Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67d2a3277e | ||
|
|
81cea3256a | ||
|
|
07f269e7f3 | ||
|
|
ce69644d53 | ||
|
|
bc37edbe9a | ||
|
|
7451d5e125 | ||
|
|
58e48a995b | ||
|
|
7b41ffa08e | ||
|
|
e0c97c5471 | ||
|
|
76ebf395e6 | ||
|
|
67dfe91890 | ||
|
|
b53016b449 | ||
|
|
be722f708d | ||
|
|
43b3a7730e | ||
|
|
df1a512a80 | ||
|
|
a55253d242 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Admin UI for Plane",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -37,7 +37,7 @@
|
||||
"lucide-react": "catalog:",
|
||||
"mobx": "catalog:",
|
||||
"mobx-react": "catalog:",
|
||||
"next-themes": "^0.2.1",
|
||||
"next-themes": "0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "7.51.5",
|
||||
|
||||
@@ -32,7 +32,4 @@ python manage.py create_bucket
|
||||
# Clear Cache before starting to remove stale values
|
||||
python manage.py clear_cache
|
||||
|
||||
# Collect static files
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"description": "API server powering Plane's backend"
|
||||
|
||||
@@ -36,7 +36,6 @@ INSTALLED_APPS = [
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.staticfiles",
|
||||
# Inhouse apps
|
||||
"plane.analytics",
|
||||
"plane.app",
|
||||
@@ -59,7 +58,6 @@ INSTALLED_APPS = [
|
||||
MIDDLEWARE = [
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"plane.authentication.middleware.session.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
@@ -21,7 +21,7 @@ celery==5.4.0
|
||||
django_celery_beat==2.6.0
|
||||
django-celery-results==2.5.1
|
||||
# file serve
|
||||
whitenoise==6.11.0
|
||||
whitenoise==6.6.0
|
||||
# fake data
|
||||
faker==25.0.0
|
||||
# filters
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "A realtime collaborative server powers Plane's rich text editor",
|
||||
"main": "./dist/start.mjs",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CollaborationController } from "./collaboration.controller";
|
||||
import { DocumentController } from "./document.controller";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { PdfExportController } from "./pdf-export.controller";
|
||||
|
||||
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController];
|
||||
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController, PdfExportController];
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { Effect, Schema, Cause } from "effect";
|
||||
import { Controller, Post } from "@plane/decorators";
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
import {
|
||||
PdfExportRequestBody,
|
||||
PdfValidationError,
|
||||
PdfAuthenticationError,
|
||||
PdfContentFetchError,
|
||||
PdfGenerationError,
|
||||
PdfTimeoutError,
|
||||
} from "@/schema/pdf-export";
|
||||
import { PdfExportService, exportToPdf } from "@/services/pdf-export";
|
||||
import type { PdfExportInput } from "@/services/pdf-export";
|
||||
|
||||
type HttpErrorResponse = { status: number; error: string };
|
||||
|
||||
@Controller("/pdf-export")
|
||||
export class PdfExportController {
|
||||
/**
|
||||
* Parses and validates the request, returning a typed input object
|
||||
*/
|
||||
private parseRequest(
|
||||
req: Request,
|
||||
requestId: string
|
||||
): Effect.Effect<PdfExportInput, PdfValidationError | PdfAuthenticationError> {
|
||||
return Effect.gen(function* () {
|
||||
const cookie = req.headers.cookie || "";
|
||||
if (!cookie) {
|
||||
return yield* Effect.fail(
|
||||
new PdfAuthenticationError({
|
||||
message: "Authentication required",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const body = yield* Schema.decodeUnknown(PdfExportRequestBody)(req.body).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new PdfValidationError({
|
||||
message: "Invalid request body",
|
||||
cause,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Get baseUrl from request body or fall back to origin header
|
||||
const baseUrl = body.baseUrl || req.headers.origin || "";
|
||||
|
||||
return {
|
||||
pageId: body.pageId,
|
||||
workspaceSlug: body.workspaceSlug,
|
||||
projectId: body.projectId,
|
||||
title: body.title,
|
||||
author: body.author,
|
||||
subject: body.subject,
|
||||
pageSize: body.pageSize,
|
||||
pageOrientation: body.pageOrientation,
|
||||
fileName: body.fileName,
|
||||
noAssets: body.noAssets,
|
||||
baseUrl,
|
||||
apiBaseUrl: body.apiBaseUrl,
|
||||
cookie,
|
||||
requestId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Post("/")
|
||||
async exportToPdf(req: Request, res: Response) {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
const effect = Effect.gen(this, function* () {
|
||||
// Parse request
|
||||
const input = yield* this.parseRequest(req, requestId);
|
||||
|
||||
// Delegate to service (fat model)
|
||||
return yield* exportToPdf(input);
|
||||
}).pipe(
|
||||
// Log errors before catching them - serialize error properly
|
||||
Effect.tapError((error) => {
|
||||
const errorInfo =
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message, stack: error.stack }
|
||||
: error;
|
||||
return Effect.logError("PDF_EXPORT: Export failed", { requestId, error: errorInfo });
|
||||
}),
|
||||
// Map tagged errors to HTTP responses using catchTags
|
||||
Effect.catchTags({
|
||||
PdfValidationError: (e: PdfValidationError): Effect.Effect<HttpErrorResponse> =>
|
||||
Effect.succeed({ status: 400, error: e.message }),
|
||||
PdfAuthenticationError: (e: PdfAuthenticationError): Effect.Effect<HttpErrorResponse> =>
|
||||
Effect.succeed({ status: 401, error: e.message }),
|
||||
PdfContentFetchError: (e: PdfContentFetchError): Effect.Effect<HttpErrorResponse> =>
|
||||
Effect.succeed({ status: e.message.includes("not found") ? 404 : 502, error: e.message }),
|
||||
PdfTimeoutError: (e: PdfTimeoutError): Effect.Effect<HttpErrorResponse> =>
|
||||
Effect.succeed({ status: 504, error: e.message }),
|
||||
PdfGenerationError: (e: PdfGenerationError): Effect.Effect<HttpErrorResponse> =>
|
||||
Effect.succeed({ status: 500, error: e.message }),
|
||||
}),
|
||||
// Handle unexpected defects
|
||||
Effect.catchAllDefect((defect) => {
|
||||
const appError = new AppError(Cause.pretty(Cause.die(defect)), {
|
||||
context: { requestId, operation: "exportToPdf" },
|
||||
});
|
||||
logger.error("PDF_EXPORT: Unexpected failure", appError);
|
||||
return Effect.succeed({ status: 500, error: "Failed to generate PDF" });
|
||||
})
|
||||
);
|
||||
|
||||
const result = await Effect.runPromise(Effect.provide(effect, PdfExportService.Default));
|
||||
|
||||
// Check if result is an error response
|
||||
if ("error" in result && "status" in result) {
|
||||
return res.status(result.status).json({ message: result.error });
|
||||
}
|
||||
|
||||
// Success - send PDF
|
||||
const { pdfBuffer, outputFileName } = result;
|
||||
|
||||
// Sanitize filename for Content-Disposition header to prevent header injection
|
||||
const sanitizedFileName = outputFileName
|
||||
.replace(/["\\\r\n]/g, "") // Remove quotes, backslashes, and CRLF
|
||||
.replace(/[^\x20-\x7E]/g, "_"); // Replace non-ASCII with underscore
|
||||
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${sanitizedFileName}"; filename*=UTF-8''${encodeURIComponent(outputFileName)}`
|
||||
);
|
||||
res.setHeader("Content-Length", pdfBuffer.length);
|
||||
return res.send(pdfBuffer);
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,32 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP
|
||||
try {
|
||||
const service = getPageService(context.documentType, context);
|
||||
// fetch details
|
||||
const response = await service.fetchDescriptionBinary(pageId);
|
||||
const response = (await service.fetchDescriptionBinary(pageId)) as Buffer;
|
||||
const binaryData = new Uint8Array(response);
|
||||
// if binary data is empty, convert HTML to binary data
|
||||
if (binaryData.byteLength === 0) {
|
||||
const pageDetails = await service.fetchDetails(pageId);
|
||||
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "<p></p>");
|
||||
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(
|
||||
pageDetails.description_html ?? "<p></p>",
|
||||
pageDetails.name
|
||||
);
|
||||
if (convertedBinaryData) {
|
||||
// save the converted binary data back to the database
|
||||
try {
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
|
||||
convertedBinaryData,
|
||||
true
|
||||
);
|
||||
const payload = {
|
||||
description_binary: contentBinaryEncoded,
|
||||
description_html: contentHTML,
|
||||
description: contentJSON,
|
||||
};
|
||||
await service.updateDescriptionBinary(pageId, payload);
|
||||
} catch (e) {
|
||||
const error = new AppError(e);
|
||||
logger.error("Failed to save binary after first convertion from html:", error);
|
||||
}
|
||||
return convertedBinaryData;
|
||||
}
|
||||
}
|
||||
@@ -52,8 +71,10 @@ const storeDocument = async ({
|
||||
try {
|
||||
const service = getPageService(context.documentType, context);
|
||||
// convert binary data to all formats
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData(pageBinaryData);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
|
||||
pageBinaryData,
|
||||
true
|
||||
);
|
||||
// create payload
|
||||
const payload = {
|
||||
description_binary: contentBinaryEncoded,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Database } from "./database";
|
||||
import { ForceCloseHandler } from "./force-close-handler";
|
||||
import { Logger } from "./logger";
|
||||
import { Redis } from "./redis";
|
||||
import { TitleSyncExtension } from "./title-sync";
|
||||
|
||||
export const getExtensions = () => [new Logger(), new Database(), new Redis()];
|
||||
export const getExtensions = () => [
|
||||
new Logger(),
|
||||
new Database(),
|
||||
new Redis(),
|
||||
new TitleSyncExtension(),
|
||||
new ForceCloseHandler(), // Must be after Redis to receive broadcasts
|
||||
];
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// hocuspocus
|
||||
import type { Extension, Hocuspocus, Document } from "@hocuspocus/server";
|
||||
import { TiptapTransformer } from "@hocuspocus/transformer";
|
||||
import type { AnyExtension, JSONContent } from "@tiptap/core";
|
||||
import type * as Y from "yjs";
|
||||
// editor extensions
|
||||
import {
|
||||
TITLE_EDITOR_EXTENSIONS,
|
||||
createRealtimeEvent,
|
||||
extractTextFromHTML,
|
||||
generateTitleProsemirrorJson,
|
||||
} from "@plane/editor";
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
// helpers
|
||||
import { getPageService } from "@/services/page/handler";
|
||||
import type { HocusPocusServerContext, OnLoadDocumentPayloadWithContext } from "@/types";
|
||||
import { broadcastMessageToPage } from "@/utils/broadcast-message";
|
||||
import { TitleUpdateManager } from "./title-update/title-update-manager";
|
||||
|
||||
/**
|
||||
* Hocuspocus extension for synchronizing document titles
|
||||
*/
|
||||
export class TitleSyncExtension implements Extension {
|
||||
// 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();
|
||||
// Store minimal data needed for each document's title observer (prevents closure memory leaks)
|
||||
private titleObserverData: Map<
|
||||
string,
|
||||
{
|
||||
parentId?: string | null;
|
||||
userId: string;
|
||||
workspaceSlug: string | null;
|
||||
instance: Hocuspocus;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
/**
|
||||
* Handle document loading - migrate old titles if needed
|
||||
*/
|
||||
async onLoadDocument({ context, document, documentName }: OnLoadDocumentPayloadWithContext) {
|
||||
try {
|
||||
// initially for on demand migration of old titles to a new title field
|
||||
// in the yjs binary
|
||||
if (document.isEmpty("title")) {
|
||||
const service = getPageService(context.documentType, context);
|
||||
const pageDetails = await service.fetchDetails(documentName);
|
||||
const title = pageDetails.name;
|
||||
if (title == null) return;
|
||||
const titleJson = (generateTitleProsemirrorJson as (text: string) => JSONContent)(title);
|
||||
const titleField = TiptapTransformer.toYdoc(titleJson, "title", TITLE_EDITOR_EXTENSIONS as AnyExtension[]);
|
||||
document.merge(titleField);
|
||||
}
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "onLoadDocument", documentName },
|
||||
});
|
||||
logger.error("Error loading document title", appError);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
}) {
|
||||
// Create a title update manager for this document
|
||||
const updateManager = new TitleUpdateManager(documentName, context);
|
||||
|
||||
// Store the manager
|
||||
this.titleUpdateManagers.set(documentName, updateManager);
|
||||
|
||||
// Store minimal data needed for the observer (prevents closure memory leak)
|
||||
this.titleObserverData.set(documentName, {
|
||||
userId: context.userId,
|
||||
workspaceSlug: context.workspaceSlug,
|
||||
instance: instance,
|
||||
});
|
||||
|
||||
// Create observer using bound method to avoid closure capturing heavy objects
|
||||
const titleObserver = this.handleTitleChange.bind(this, documentName);
|
||||
|
||||
// Observe the title field
|
||||
document.getXmlFragment("title").observeDeep(titleObserver);
|
||||
this.titleObservers.set(documentName, titleObserver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle title changes for a document
|
||||
* This is a separate method to avoid closure memory leaks
|
||||
*/
|
||||
private handleTitleChange(documentName: string, events: Y.YEvent<any>[]) {
|
||||
let title = "";
|
||||
events.forEach((event) => {
|
||||
title = extractTextFromHTML(event.currentTarget.toJSON() as string);
|
||||
});
|
||||
|
||||
// Get the manager for this document
|
||||
const manager = this.titleUpdateManagers.get(documentName);
|
||||
|
||||
// Get the stored data for this document
|
||||
const data = this.titleObserverData.get(documentName);
|
||||
|
||||
// Broadcast to parent page if it exists
|
||||
if (data?.parentId && data.workspaceSlug && data.instance) {
|
||||
const event = createRealtimeEvent({
|
||||
user_id: data.userId,
|
||||
workspace_slug: data.workspaceSlug,
|
||||
action: "property_updated",
|
||||
page_id: documentName,
|
||||
data: { name: title },
|
||||
descendants_ids: [],
|
||||
});
|
||||
|
||||
// Use the instance from stored data (guaranteed to be set)
|
||||
broadcastMessageToPage(data.instance, data.parentId, event);
|
||||
}
|
||||
|
||||
// Schedule the title update
|
||||
if (manager) {
|
||||
manager.scheduleUpdate(title);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, document }: { documentName: string; document?: Document }) {
|
||||
// Clean up observer when document is unloaded
|
||||
const observer = this.titleObservers.get(documentName);
|
||||
if (observer) {
|
||||
// unregister observer from Y.js document to prevent memory leak
|
||||
if (document) {
|
||||
try {
|
||||
document.getXmlFragment("title").unobserveDeep(observer);
|
||||
} catch (error) {
|
||||
logger.error("Failed to unobserve title field", new AppError(error, { context: { documentName } }));
|
||||
}
|
||||
}
|
||||
this.titleObservers.delete(documentName);
|
||||
}
|
||||
|
||||
// Clean up the observer data map to prevent memory leak
|
||||
this.titleObserverData.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,277 @@
|
||||
import { logger } from "@plane/logger";
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
logger.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 {
|
||||
logger.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,90 @@
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { getPageService } from "@/services/page/handler";
|
||||
import type { HocusPocusServerContext } from "@/types";
|
||||
import { DebounceManager } from "./debounce";
|
||||
|
||||
/**
|
||||
* 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 debounceManager: DebounceManager;
|
||||
private lastTitle: string | null = null;
|
||||
|
||||
/**
|
||||
* Create a new TitleUpdateManager instance
|
||||
*/
|
||||
constructor(documentName: string, context: HocusPocusServerContext, wait: number = 5000) {
|
||||
this.documentName = documentName;
|
||||
this.context = context;
|
||||
|
||||
// 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> {
|
||||
const service = getPageService(this.context.documentType, this.context);
|
||||
if (!service.updatePageProperties) {
|
||||
logger.warn(`No updateTitle method found for document ${this.documentName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await service.updatePageProperties(this.documentName, {
|
||||
data: { name: title },
|
||||
abortSignal: signal,
|
||||
});
|
||||
|
||||
// Clear last title only if it matches what we just updated
|
||||
if (this.lastTitle === title) {
|
||||
this.lastTitle = null;
|
||||
}
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "updateTitle", documentName: this.documentName },
|
||||
});
|
||||
logger.error("Error updating title", appError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,225 @@
|
||||
/**
|
||||
* PDF Export Color Constants
|
||||
*
|
||||
* These colors are mapped from the editor CSS variables and tailwind-config tokens
|
||||
* to ensure PDF exports match the editor's appearance.
|
||||
*
|
||||
* Source mappings:
|
||||
* - Editor colors: packages/editor/src/styles/variables.css
|
||||
* - Tailwind tokens: packages/tailwind-config/variables.css
|
||||
*/
|
||||
|
||||
// Editor text colors (from variables.css :root)
|
||||
export const EDITOR_TEXT_COLORS = {
|
||||
gray: "#5c5e63",
|
||||
peach: "#ff5b59",
|
||||
pink: "#f65385",
|
||||
orange: "#fd9038",
|
||||
green: "#0fc27b",
|
||||
"light-blue": "#17bee9",
|
||||
"dark-blue": "#266df0",
|
||||
purple: "#9162f9",
|
||||
} as const;
|
||||
|
||||
// Editor background colors - Light theme (from variables.css [data-theme*="light"])
|
||||
export const EDITOR_BACKGROUND_COLORS_LIGHT = {
|
||||
gray: "#d6d6d8",
|
||||
peach: "#ffd5d7",
|
||||
pink: "#fdd4e3",
|
||||
orange: "#ffe3cd",
|
||||
green: "#c3f0de",
|
||||
"light-blue": "#c5eff9",
|
||||
"dark-blue": "#c9dafb",
|
||||
purple: "#e3d8fd",
|
||||
} as const;
|
||||
|
||||
// Editor background colors - Dark theme (from variables.css [data-theme*="dark"])
|
||||
export const EDITOR_BACKGROUND_COLORS_DARK = {
|
||||
gray: "#404144",
|
||||
peach: "#593032",
|
||||
pink: "#562e3d",
|
||||
orange: "#583e2a",
|
||||
green: "#1d4a3b",
|
||||
"light-blue": "#1f495c",
|
||||
"dark-blue": "#223558",
|
||||
purple: "#3d325a",
|
||||
} as const;
|
||||
|
||||
// Use light theme colors by default for PDF exports
|
||||
export const EDITOR_BACKGROUND_COLORS = EDITOR_BACKGROUND_COLORS_LIGHT;
|
||||
|
||||
// Color key type
|
||||
export type EditorColorKey = keyof typeof EDITOR_TEXT_COLORS;
|
||||
|
||||
/**
|
||||
* Maps a color key to its text color hex value
|
||||
*/
|
||||
export const getTextColorHex = (colorKey: string): string | null => {
|
||||
if (colorKey in EDITOR_TEXT_COLORS) {
|
||||
return EDITOR_TEXT_COLORS[colorKey as EditorColorKey];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps a color key to its background color hex value
|
||||
*/
|
||||
export const getBackgroundColorHex = (colorKey: string): string | null => {
|
||||
if (colorKey in EDITOR_BACKGROUND_COLORS) {
|
||||
return EDITOR_BACKGROUND_COLORS[colorKey as EditorColorKey];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a value is a CSS variable reference (e.g., "var(--editor-colors-gray-text)")
|
||||
*/
|
||||
export const isCssVariable = (value: string): boolean => {
|
||||
return value.startsWith("var(");
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the color key from a CSS variable reference
|
||||
* e.g., "var(--editor-colors-gray-text)" -> "gray"
|
||||
* e.g., "var(--editor-colors-light-blue-background)" -> "light-blue"
|
||||
*/
|
||||
export const extractColorKeyFromCssVariable = (cssVar: string): string | null => {
|
||||
// Match patterns like: var(--editor-colors-{color}-text) or var(--editor-colors-{color}-background)
|
||||
const match = cssVar.match(/var\(--editor-colors-([\w-]+)-(text|background)\)/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a color value to a hex color for PDF rendering
|
||||
* Handles both direct hex values and CSS variable references
|
||||
*/
|
||||
export const resolveColorForPdf = (value: string | null | undefined, type: "text" | "background"): string | null => {
|
||||
if (!value) return null;
|
||||
|
||||
// If it's already a hex color, return it
|
||||
if (value.startsWith("#")) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// If it's a CSS variable, extract the key and get the hex value
|
||||
if (isCssVariable(value)) {
|
||||
const colorKey = extractColorKeyFromCssVariable(value);
|
||||
if (colorKey) {
|
||||
return type === "text" ? getTextColorHex(colorKey) : getBackgroundColorHex(colorKey);
|
||||
}
|
||||
}
|
||||
|
||||
// If it's just a color key (e.g., "gray", "peach"), get the hex value
|
||||
if (type === "text") {
|
||||
return getTextColorHex(value);
|
||||
}
|
||||
return getBackgroundColorHex(value);
|
||||
};
|
||||
|
||||
// Semantic colors from tailwind-config (light theme)
|
||||
// These are derived from the CSS variables in packages/tailwind-config/variables.css
|
||||
|
||||
// Neutral colors (light theme)
|
||||
export const NEUTRAL_COLORS = {
|
||||
white: "#ffffff",
|
||||
100: "#fafafa", // oklch(0.9848 0.0003 230.66) ≈ #fafafa
|
||||
200: "#f5f5f5", // oklch(0.9696 0.0007 230.67) ≈ #f5f5f5
|
||||
300: "#f0f0f0", // oklch(0.9543 0.001 230.67) ≈ #f0f0f0
|
||||
400: "#ebebeb", // oklch(0.9389 0.0014 230.68) ≈ #ebebeb
|
||||
500: "#e5e5e5", // oklch(0.9235 0.001733 230.6853) ≈ #e5e5e5
|
||||
600: "#d9d9d9", // oklch(0.8925 0.0024 230.7) ≈ #d9d9d9
|
||||
700: "#cccccc", // oklch(0.8612 0.0032 230.71) ≈ #cccccc
|
||||
800: "#8c8c8c", // oklch(0.6668 0.0079 230.82) ≈ #8c8c8c
|
||||
900: "#7a7a7a", // oklch(0.6161 0.009153 230.867) ≈ #7a7a7a
|
||||
1000: "#636363", // oklch(0.5288 0.0083 230.88) ≈ #636363
|
||||
1100: "#4d4d4d", // oklch(0.4377 0.0066 230.87) ≈ #4d4d4d
|
||||
1200: "#1f1f1f", // oklch(0.2378 0.0029 230.83) ≈ #1f1f1f
|
||||
black: "#0f0f0f", // oklch(0.1472 0.0034 230.83) ≈ #0f0f0f
|
||||
} as const;
|
||||
|
||||
// Brand colors (light theme accent)
|
||||
export const BRAND_COLORS = {
|
||||
default: "#3f76ff", // oklch(0.4799 0.1158 242.91) - primary accent blue
|
||||
100: "#f5f8ff",
|
||||
200: "#e8f0ff",
|
||||
300: "#d1e1ff",
|
||||
400: "#b3d0ff",
|
||||
500: "#8ab8ff",
|
||||
600: "#5c9aff",
|
||||
700: "#3f76ff",
|
||||
900: "#2952b3",
|
||||
1000: "#1e3d80",
|
||||
1100: "#142b5c",
|
||||
1200: "#0d1f40",
|
||||
} as const;
|
||||
|
||||
// Semantic text colors
|
||||
export const TEXT_COLORS = {
|
||||
primary: NEUTRAL_COLORS[1200], // --txt-primary
|
||||
secondary: NEUTRAL_COLORS[1100], // --txt-secondary
|
||||
tertiary: NEUTRAL_COLORS[1000], // --txt-tertiary
|
||||
placeholder: NEUTRAL_COLORS[900], // --txt-placeholder
|
||||
disabled: NEUTRAL_COLORS[800], // --txt-disabled
|
||||
accentPrimary: BRAND_COLORS.default, // --txt-accent-primary
|
||||
linkPrimary: BRAND_COLORS.default, // --txt-link-primary
|
||||
} as const;
|
||||
|
||||
// Semantic background colors
|
||||
export const BACKGROUND_COLORS = {
|
||||
canvas: NEUTRAL_COLORS[300], // --bg-canvas
|
||||
surface1: NEUTRAL_COLORS.white, // --bg-surface-1
|
||||
surface2: NEUTRAL_COLORS[100], // --bg-surface-2
|
||||
layer1: NEUTRAL_COLORS[200], // --bg-layer-1
|
||||
layer2: NEUTRAL_COLORS.white, // --bg-layer-2
|
||||
layer3: NEUTRAL_COLORS[300], // --bg-layer-3
|
||||
accentSubtle: "#f5f8ff", // --bg-accent-subtle (brand-100)
|
||||
} as const;
|
||||
|
||||
// Semantic border colors
|
||||
export const BORDER_COLORS = {
|
||||
subtle: NEUTRAL_COLORS[400], // --border-subtle
|
||||
subtle1: NEUTRAL_COLORS[500], // --border-subtle-1
|
||||
strong: NEUTRAL_COLORS[600], // --border-strong
|
||||
strong1: NEUTRAL_COLORS[700], // --border-strong-1
|
||||
accentStrong: BRAND_COLORS.default, // --border-accent-strong
|
||||
} as const;
|
||||
|
||||
// Code/inline code colors
|
||||
export const CODE_COLORS = {
|
||||
background: NEUTRAL_COLORS[200], // Similar to bg-layer-1
|
||||
text: "#dc2626", // Red for inline code text (matches editor)
|
||||
blockText: NEUTRAL_COLORS[1200], // Regular text for code blocks
|
||||
} as const;
|
||||
|
||||
// Link colors
|
||||
export const LINK_COLORS = {
|
||||
primary: BRAND_COLORS.default,
|
||||
hover: BRAND_COLORS[900],
|
||||
} as const;
|
||||
|
||||
// Mention colors (from pi-chat-editor mention styles: bg-accent-primary/20 text-accent-primary)
|
||||
export const MENTION_COLORS = {
|
||||
background: "#e0e9ff", // accent-primary with ~20% opacity on white
|
||||
text: BRAND_COLORS.default,
|
||||
} as const;
|
||||
|
||||
// Success/Green colors
|
||||
export const SUCCESS_COLORS = {
|
||||
primary: "#10b981",
|
||||
subtle: "#d1fae5",
|
||||
} as const;
|
||||
|
||||
// Warning/Amber colors
|
||||
export const WARNING_COLORS = {
|
||||
primary: "#f59e0b",
|
||||
subtle: "#fef3c7",
|
||||
} as const;
|
||||
|
||||
// Danger/Red colors
|
||||
export const DANGER_COLORS = {
|
||||
primary: "#ef4444",
|
||||
subtle: "#fee2e2",
|
||||
} as const;
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Circle, Path, Rect, Svg } from "@react-pdf/renderer";
|
||||
|
||||
type IconProps = {
|
||||
size?: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
// Lightbulb icon for callouts (default)
|
||||
export const LightbulbIcon = ({ size = 16, color = "#ffffff" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M9 21h6M12 3a6 6 0 0 0-6 6c0 2.22 1.21 4.16 3 5.19V17a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-2.81c1.79-1.03 3-2.97 3-5.19a6 6 0 0 0-6-6z"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Document/file icon for page embeds
|
||||
export const DocumentIcon = ({ size = 12, color = "#1e40af" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path d="M14 2v6h6" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M16 13H8M16 17H8M10 9H8" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Link icon for page links and external links
|
||||
export const LinkIcon = ({ size = 12, color = "#2563eb" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Paperclip icon for attachments (default)
|
||||
export const PaperclipIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Image icon for image attachments
|
||||
export const ImageIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Rect x={3} y={3} width={18} height={18} rx={2} ry={2} fill="none" stroke={color} strokeWidth={2} />
|
||||
<Circle cx={8.5} cy={8.5} r={1.5} fill={color} />
|
||||
<Path d="M21 15l-5-5L5 21" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Video icon for video attachments
|
||||
export const VideoIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Rect x={2} y={4} width={15} height={16} rx={2} ry={2} fill="none" stroke={color} strokeWidth={2} />
|
||||
<Path d="M17 10l5-3v10l-5-3z" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Music/audio icon
|
||||
export const MusicIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path d="M9 18V5l12-2v13" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
<Circle cx={6} cy={18} r={3} fill="none" stroke={color} strokeWidth={2} />
|
||||
<Circle cx={18} cy={16} r={3} fill="none" stroke={color} strokeWidth={2} />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// File-text icon for PDFs and documents
|
||||
export const FileTextIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Table/spreadsheet icon
|
||||
export const TableIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Rect x={3} y={3} width={18} height={18} rx={2} fill="none" stroke={color} strokeWidth={2} />
|
||||
<Path d="M3 9h18M3 15h18M9 3v18M15 3v18" fill="none" stroke={color} strokeWidth={2} />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Presentation icon
|
||||
export const PresentationIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Rect x={2} y={3} width={20} height={14} rx={2} fill="none" stroke={color} strokeWidth={2} />
|
||||
<Path d="M8 21l4-4 4 4M12 17v-4" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Archive/zip icon
|
||||
export const ArchiveIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M21 8v13a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Path
|
||||
d="M23 3H1v5h22V3zM10 12h4"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Globe icon for external embeds (rich cards)
|
||||
export const GlobeIcon = ({ size = 12, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} fill="none" stroke={color} strokeWidth={2} />
|
||||
<Path
|
||||
d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Clipboard icon for whiteboards
|
||||
export const ClipboardIcon = ({ size = 12, color = "#6b7280" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Rect x={8} y={2} width={8} height={4} rx={1} fill="none" stroke={color} strokeWidth={2} />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Ruler/diagram icon for diagrams
|
||||
export const DiagramIcon = ({ size = 12, color = "#6b7280" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M14 3v4a1 1 0 0 0 1 1h4"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Path d="M9 9h1M9 13h6M9 17h6" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Work item / task icon
|
||||
export const TaskIcon = ({ size = 14, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Rect x={3} y={3} width={18} height={18} rx={2} fill="none" stroke={color} strokeWidth={2} />
|
||||
<Path d="M9 12l2 2 4-4" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Checkmark icon for checked task items
|
||||
export const CheckIcon = ({ size = 10, color = "#ffffff" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path d="M20 6L9 17l-5-5" fill="none" stroke={color} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Helper to get file icon component based on file type
|
||||
export const getFileIcon = (fileType: string, size = 16, color = "#374151") => {
|
||||
if (fileType.startsWith("image/")) return <ImageIcon size={size} color={color} />;
|
||||
if (fileType.startsWith("video/")) return <VideoIcon size={size} color={color} />;
|
||||
if (fileType.startsWith("audio/")) return <MusicIcon size={size} color={color} />;
|
||||
if (fileType.includes("pdf")) return <FileTextIcon size={size} color="#dc2626" />;
|
||||
if (fileType.includes("spreadsheet") || fileType.includes("excel")) return <TableIcon size={size} color="#16a34a" />;
|
||||
if (fileType.includes("document") || fileType.includes("word")) return <FileTextIcon size={size} color="#2563eb" />;
|
||||
if (fileType.includes("presentation") || fileType.includes("powerpoint"))
|
||||
return <PresentationIcon size={size} color="#ea580c" />;
|
||||
if (fileType.includes("zip") || fileType.includes("archive")) return <ArchiveIcon size={size} color={color} />;
|
||||
return <PaperclipIcon size={size} color={color} />;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
export { createPdfDocument, renderPlaneDocToPdfBlob, renderPlaneDocToPdfBuffer } from "./plane-pdf-exporter";
|
||||
export { createKeyGenerator, nodeRenderers, renderNode } from "./node-renderers";
|
||||
export { markRenderers, applyMarks } from "./mark-renderers";
|
||||
export { pdfStyles } from "./styles";
|
||||
export type {
|
||||
KeyGenerator,
|
||||
MarkRendererRegistry,
|
||||
NodeRendererRegistry,
|
||||
PDFExportMetadata,
|
||||
PDFExportOptions,
|
||||
PDFMarkRenderer,
|
||||
PDFNodeRenderer,
|
||||
PDFRenderContext,
|
||||
PDFUserMention,
|
||||
TipTapDocument,
|
||||
TipTapMark,
|
||||
TipTapNode,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import {
|
||||
BACKGROUND_COLORS,
|
||||
CODE_COLORS,
|
||||
EDITOR_BACKGROUND_COLORS,
|
||||
EDITOR_TEXT_COLORS,
|
||||
LINK_COLORS,
|
||||
resolveColorForPdf,
|
||||
} from "./colors";
|
||||
import type { MarkRendererRegistry, TipTapMark } from "./types";
|
||||
|
||||
export const markRenderers: MarkRendererRegistry = {
|
||||
bold: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
fontWeight: "bold",
|
||||
}),
|
||||
|
||||
italic: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
fontStyle: "italic",
|
||||
}),
|
||||
|
||||
underline: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
textDecoration: "underline",
|
||||
}),
|
||||
|
||||
strike: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
textDecoration: "line-through",
|
||||
}),
|
||||
|
||||
code: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
fontFamily: "Courier",
|
||||
fontSize: 10,
|
||||
backgroundColor: BACKGROUND_COLORS.layer1,
|
||||
color: CODE_COLORS.text,
|
||||
}),
|
||||
|
||||
link: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
color: LINK_COLORS.primary,
|
||||
textDecoration: "underline",
|
||||
}),
|
||||
|
||||
textStyle: (mark: TipTapMark, style: Style): Style => {
|
||||
const attrs = mark.attrs || {};
|
||||
const newStyle: Style = { ...style };
|
||||
|
||||
if (attrs.color && typeof attrs.color === "string") {
|
||||
newStyle.color = attrs.color;
|
||||
}
|
||||
|
||||
if (attrs.backgroundColor && typeof attrs.backgroundColor === "string") {
|
||||
newStyle.backgroundColor = attrs.backgroundColor;
|
||||
}
|
||||
|
||||
return newStyle;
|
||||
},
|
||||
|
||||
highlight: (mark: TipTapMark, style: Style): Style => {
|
||||
const attrs = mark.attrs || {};
|
||||
return {
|
||||
...style,
|
||||
backgroundColor: (attrs.color as string) || EDITOR_BACKGROUND_COLORS.purple,
|
||||
};
|
||||
},
|
||||
|
||||
subscript: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
fontSize: 8,
|
||||
}),
|
||||
|
||||
superscript: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
fontSize: 8,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Custom color mark handler
|
||||
* Handles the customColor extension which stores colors as data-text-color and data-background-color attributes
|
||||
* The colors can be either:
|
||||
* 1. Color keys like "gray", "peach", "pink", etc. (from COLORS_LIST)
|
||||
* 2. Direct hex values for custom colors
|
||||
* 3. CSS variable references like "var(--editor-colors-gray-text)"
|
||||
*/
|
||||
customColor: (mark: TipTapMark, style: Style): Style => {
|
||||
const attrs = mark.attrs || {};
|
||||
const newStyle: Style = { ...style };
|
||||
|
||||
// Handle text color (stored in 'color' attribute)
|
||||
const textColor = attrs.color as string | undefined;
|
||||
if (textColor) {
|
||||
const resolvedColor = resolveColorForPdf(textColor, "text");
|
||||
if (resolvedColor) {
|
||||
newStyle.color = resolvedColor;
|
||||
} else if (textColor.startsWith("#") || textColor.startsWith("rgb")) {
|
||||
// Direct color value
|
||||
newStyle.color = textColor;
|
||||
} else if (textColor in EDITOR_TEXT_COLORS) {
|
||||
// Color key lookup
|
||||
newStyle.color = EDITOR_TEXT_COLORS[textColor as keyof typeof EDITOR_TEXT_COLORS];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle background color (stored in 'backgroundColor' attribute)
|
||||
const backgroundColor = attrs.backgroundColor as string | undefined;
|
||||
if (backgroundColor) {
|
||||
const resolvedColor = resolveColorForPdf(backgroundColor, "background");
|
||||
if (resolvedColor) {
|
||||
newStyle.backgroundColor = resolvedColor;
|
||||
} else if (backgroundColor.startsWith("#") || backgroundColor.startsWith("rgb")) {
|
||||
// Direct color value
|
||||
newStyle.backgroundColor = backgroundColor;
|
||||
} else if (backgroundColor in EDITOR_BACKGROUND_COLORS) {
|
||||
// Color key lookup
|
||||
newStyle.backgroundColor = EDITOR_BACKGROUND_COLORS[backgroundColor as keyof typeof EDITOR_BACKGROUND_COLORS];
|
||||
}
|
||||
}
|
||||
|
||||
return newStyle;
|
||||
},
|
||||
};
|
||||
|
||||
export const applyMarks = (marks: TipTapMark[] | undefined, baseStyle: Style = {}): Style => {
|
||||
if (!marks || marks.length === 0) {
|
||||
return baseStyle;
|
||||
}
|
||||
|
||||
return marks.reduce((style, mark) => {
|
||||
const renderer = markRenderers[mark.type];
|
||||
if (renderer) {
|
||||
return renderer(mark, style);
|
||||
}
|
||||
return style;
|
||||
}, baseStyle);
|
||||
};
|
||||
@@ -0,0 +1,438 @@
|
||||
import { Image, Link, Text, View } from "@react-pdf/renderer";
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ReactElement } from "react";
|
||||
import { CORE_EXTENSIONS } from "@plane/editor";
|
||||
import { BACKGROUND_COLORS, EDITOR_BACKGROUND_COLORS, resolveColorForPdf, TEXT_COLORS } from "./colors";
|
||||
import { CheckIcon, ClipboardIcon, DocumentIcon, GlobeIcon, LightbulbIcon, LinkIcon } from "./icons";
|
||||
import { applyMarks } from "./mark-renderers";
|
||||
import { pdfStyles } from "./styles";
|
||||
import type { KeyGenerator, NodeRendererRegistry, PDFExportMetadata, PDFRenderContext, TipTapNode } from "./types";
|
||||
|
||||
const getCalloutIcon = (node: TipTapNode, color: string): ReactElement => {
|
||||
const logoInUse = node.attrs?.["data-logo-in-use"] as string | undefined;
|
||||
const iconName = node.attrs?.["data-icon-name"] as string | undefined;
|
||||
const iconColor = (node.attrs?.["data-icon-color"] as string) || color;
|
||||
|
||||
if (logoInUse === "emoji") {
|
||||
// react-pdf doesn't support emoji rendering (PDF fonts lack emoji glyphs)
|
||||
// Fall back to a generic icon instead
|
||||
return <LightbulbIcon size={16} color={iconColor} />;
|
||||
}
|
||||
|
||||
if (iconName) {
|
||||
switch (iconName) {
|
||||
case "FileText":
|
||||
case "File":
|
||||
return <DocumentIcon size={16} color={iconColor} />;
|
||||
case "Link":
|
||||
return <LinkIcon size={16} color={iconColor} />;
|
||||
case "Globe":
|
||||
return <GlobeIcon size={16} color={iconColor} />;
|
||||
case "Clipboard":
|
||||
return <ClipboardIcon size={16} color={iconColor} />;
|
||||
case "CheckSquare":
|
||||
case "Check":
|
||||
return <CheckIcon size={16} color={iconColor} />;
|
||||
case "Lightbulb":
|
||||
default:
|
||||
return <LightbulbIcon size={16} color={iconColor} />;
|
||||
}
|
||||
}
|
||||
|
||||
return <LightbulbIcon size={16} color={color} />;
|
||||
};
|
||||
|
||||
export const createKeyGenerator = (): KeyGenerator => {
|
||||
let counter = 0;
|
||||
return () => `node-${counter++}`;
|
||||
};
|
||||
|
||||
const renderTextWithMarks = (node: TipTapNode, getKey: KeyGenerator): ReactElement => {
|
||||
const style = applyMarks(node.marks, {});
|
||||
const hasLink = node.marks?.find((m) => m.type === "link");
|
||||
|
||||
if (hasLink) {
|
||||
const href = (hasLink.attrs?.href as string) || "#";
|
||||
return (
|
||||
<Link key={getKey()} src={href} style={{ ...pdfStyles.link, ...style }}>
|
||||
{node.text || ""}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text key={getKey()} style={style}>
|
||||
{node.text || ""}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const getTextAlignStyle = (textAlign: string | null | undefined): Style => {
|
||||
if (!textAlign) return {};
|
||||
return {
|
||||
textAlign: textAlign as "left" | "right" | "center" | "justify",
|
||||
};
|
||||
};
|
||||
|
||||
const getFlexAlignStyle = (textAlign: string | null | undefined): Style => {
|
||||
if (!textAlign) return {};
|
||||
if (textAlign === "right") return { alignItems: "flex-end" };
|
||||
if (textAlign === "center") return { alignItems: "center" };
|
||||
return {};
|
||||
};
|
||||
|
||||
export const nodeRenderers: NodeRendererRegistry = {
|
||||
doc: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||
<View key={ctx.getKey()}>{children}</View>
|
||||
),
|
||||
|
||||
text: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement =>
|
||||
renderTextWithMarks(node, ctx.getKey),
|
||||
|
||||
paragraph: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const textAlign = node.attrs?.textAlign as string | null;
|
||||
const background = node.attrs?.backgroundColor as string | undefined;
|
||||
const alignStyle = getTextAlignStyle(textAlign);
|
||||
const flexStyle = getFlexAlignStyle(textAlign);
|
||||
const resolvedBgColor =
|
||||
background && background !== "default" ? resolveColorForPdf(background, "background") : null;
|
||||
const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {};
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.paragraphWrapper, flexStyle, bgStyle]}>
|
||||
<Text style={[pdfStyles.paragraph, alignStyle, bgStyle]}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
heading: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const level = (node.attrs?.level as number) || 1;
|
||||
const styleKey = `heading${level}` as keyof typeof pdfStyles;
|
||||
const style = pdfStyles[styleKey] || pdfStyles.heading1;
|
||||
const textAlign = node.attrs?.textAlign as string | null;
|
||||
const alignStyle = getTextAlignStyle(textAlign);
|
||||
const flexStyle = getFlexAlignStyle(textAlign);
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={flexStyle}>
|
||||
<Text style={[style, alignStyle]}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
blockquote: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||
<View key={ctx.getKey()} style={pdfStyles.blockquote} wrap={false}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
|
||||
codeBlock: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const codeContent = node.content?.map((c) => c.text || "").join("") || "";
|
||||
return (
|
||||
<View key={ctx.getKey()} style={pdfStyles.codeBlock} wrap={false}>
|
||||
<Text>{codeContent}</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
bulletList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const nestingLevel = (node.attrs?._nestingLevel as number) || 0;
|
||||
const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {};
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.bulletList, indentStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
orderedList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const nestingLevel = (node.attrs?._nestingLevel as number) || 0;
|
||||
const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {};
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.orderedList, indentStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
listItem: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const isOrdered = node.attrs?._parentType === "orderedList";
|
||||
const index = (node.attrs?._listItemIndex as number) || 0;
|
||||
|
||||
const bullet = isOrdered ? `${index}.` : "•";
|
||||
|
||||
const textAlign = node.attrs?._textAlign as string | null;
|
||||
const flexStyle = getFlexAlignStyle(textAlign);
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.listItem, flexStyle]} wrap={false}>
|
||||
<View style={pdfStyles.listItemBullet}>
|
||||
<Text>{bullet}</Text>
|
||||
</View>
|
||||
<View style={pdfStyles.listItemContent}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
taskList: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||
<View key={ctx.getKey()} style={pdfStyles.taskList}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
|
||||
taskItem: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const checked = node.attrs?.checked === true;
|
||||
return (
|
||||
<View key={ctx.getKey()} style={pdfStyles.taskItem} wrap={false}>
|
||||
<View style={checked ? [pdfStyles.taskCheckbox, pdfStyles.taskCheckboxChecked] : pdfStyles.taskCheckbox}>
|
||||
{checked && <CheckIcon size={8} color="#ffffff" />}
|
||||
</View>
|
||||
<View style={pdfStyles.listItemContent}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
table: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||
<View key={ctx.getKey()} style={pdfStyles.table}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
|
||||
tableRow: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const isHeader = node.attrs?._isHeader === true;
|
||||
return (
|
||||
<View key={ctx.getKey()} style={isHeader ? pdfStyles.tableHeaderRow : pdfStyles.tableRow} wrap={false}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
tableHeader: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const colwidth = node.attrs?.colwidth as number[] | undefined;
|
||||
const background = node.attrs?.background as string | undefined;
|
||||
const width = colwidth?.[0];
|
||||
const widthStyle = width ? { width, flex: undefined } : {};
|
||||
const resolvedBgColor = background ? resolveColorForPdf(background, "background") : null;
|
||||
const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {};
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.tableHeaderCell, widthStyle, bgStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
tableCell: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const colwidth = node.attrs?.colwidth as number[] | undefined;
|
||||
const background = node.attrs?.background as string | undefined;
|
||||
const width = colwidth?.[0];
|
||||
const widthStyle = width ? { width, flex: undefined } : {};
|
||||
const resolvedBgColor = background ? resolveColorForPdf(background, "background") : null;
|
||||
const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {};
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.tableCell, widthStyle, bgStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
horizontalRule: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||
<View key={ctx.getKey()} style={pdfStyles.horizontalRule} />
|
||||
),
|
||||
|
||||
hardBreak: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||
<Text key={ctx.getKey()}>{"\n"}</Text>
|
||||
),
|
||||
|
||||
image: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
if (ctx.metadata?.noAssets) {
|
||||
return <View key={ctx.getKey()} />;
|
||||
}
|
||||
|
||||
const src = (node.attrs?.src as string) || "";
|
||||
const width = node.attrs?.width as number | undefined;
|
||||
const alignment = (node.attrs?.alignment as string) || "left";
|
||||
|
||||
if (!src) {
|
||||
return <View key={ctx.getKey()} />;
|
||||
}
|
||||
|
||||
const alignmentStyle =
|
||||
alignment === "center"
|
||||
? { alignItems: "center" as const }
|
||||
: alignment === "right"
|
||||
? { alignItems: "flex-end" as const }
|
||||
: { alignItems: "flex-start" as const };
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[{ width: "100%" }, alignmentStyle]}>
|
||||
<Image
|
||||
src={src}
|
||||
style={[pdfStyles.image, width ? { width, maxHeight: 500 } : { maxWidth: 400, maxHeight: 500 }]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
imageComponent: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
if (ctx.metadata?.noAssets) {
|
||||
return <View key={ctx.getKey()} />;
|
||||
}
|
||||
|
||||
const assetId = (node.attrs?.src as string) || "";
|
||||
const rawWidth = node.attrs?.width;
|
||||
const width = typeof rawWidth === "string" ? parseInt(rawWidth, 10) : (rawWidth as number | undefined);
|
||||
const alignment = (node.attrs?.alignment as string) || "left";
|
||||
|
||||
if (!assetId) {
|
||||
return <View key={ctx.getKey()} />;
|
||||
}
|
||||
|
||||
let resolvedSrc = assetId;
|
||||
if (ctx.metadata?.resolvedImageUrls && ctx.metadata.resolvedImageUrls[assetId]) {
|
||||
resolvedSrc = ctx.metadata.resolvedImageUrls[assetId];
|
||||
}
|
||||
|
||||
const alignmentStyle =
|
||||
alignment === "center"
|
||||
? { alignItems: "center" as const }
|
||||
: alignment === "right"
|
||||
? { alignItems: "flex-end" as const }
|
||||
: { alignItems: "flex-start" as const };
|
||||
|
||||
if (!resolvedSrc.startsWith("http") && !resolvedSrc.startsWith("data:")) {
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.imagePlaceholder, alignmentStyle]}>
|
||||
<Text style={pdfStyles.imagePlaceholderText}>[Image: {assetId.slice(0, 8)}...]</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const imageStyle = width && !isNaN(width) ? { width, maxHeight: 500 } : { maxWidth: 400, maxHeight: 500 };
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[{ width: "100%" }, alignmentStyle]}>
|
||||
<Image src={resolvedSrc} style={[pdfStyles.image, imageStyle]} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
calloutComponent: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const backgroundKey = (node.attrs?.["data-background"] as string) || "gray";
|
||||
const backgroundColor =
|
||||
EDITOR_BACKGROUND_COLORS[backgroundKey as keyof typeof EDITOR_BACKGROUND_COLORS] || BACKGROUND_COLORS.layer3;
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.callout, { backgroundColor }]}>
|
||||
<View style={pdfStyles.calloutIconContainer}>{getCalloutIcon(node, TEXT_COLORS.primary)}</View>
|
||||
<View style={[pdfStyles.calloutContent, { color: TEXT_COLORS.primary }]}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
mention: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const id = (node.attrs?.id as string) || "";
|
||||
const entityIdentifier = (node.attrs?.entity_identifier as string) || "";
|
||||
const entityName = (node.attrs?.entity_name as string) || "";
|
||||
|
||||
let displayText = entityName || id || entityIdentifier;
|
||||
|
||||
if (ctx.metadata && (entityName === "user_mention" || entityName === "user")) {
|
||||
const userMention = ctx.metadata.userMentions?.find((u) => u.id === entityIdentifier || u.id === id);
|
||||
if (userMention) {
|
||||
displayText = userMention.display_name;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Text key={ctx.getKey()} style={pdfStyles.mention}>
|
||||
@{displayText}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
type InternalRenderContext = {
|
||||
parentType?: string;
|
||||
nestingLevel: number;
|
||||
listItemIndex: number;
|
||||
textAlign?: string | null;
|
||||
pdfContext: PDFRenderContext;
|
||||
};
|
||||
|
||||
const renderNodeWithContext = (node: TipTapNode, context: InternalRenderContext): ReactElement => {
|
||||
const { parentType, nestingLevel, listItemIndex, textAlign, pdfContext } = context;
|
||||
|
||||
const isListContainer = node.type === CORE_EXTENSIONS.BULLET_LIST || node.type === CORE_EXTENSIONS.ORDERED_LIST;
|
||||
|
||||
let childTextAlign = textAlign;
|
||||
if (node.type === CORE_EXTENSIONS.PARAGRAPH && node.attrs?.textAlign) {
|
||||
childTextAlign = node.attrs.textAlign as string;
|
||||
}
|
||||
|
||||
const nodeWithContext = {
|
||||
...node,
|
||||
attrs: {
|
||||
...node.attrs,
|
||||
_parentType: parentType,
|
||||
_nestingLevel: nestingLevel,
|
||||
_listItemIndex: listItemIndex,
|
||||
_textAlign: childTextAlign,
|
||||
_isHeader: node.content?.some((child) => child.type === CORE_EXTENSIONS.TABLE_HEADER),
|
||||
},
|
||||
};
|
||||
|
||||
let childNestingLevel = nestingLevel;
|
||||
if (isListContainer && parentType === CORE_EXTENSIONS.LIST_ITEM) {
|
||||
childNestingLevel = nestingLevel + 1;
|
||||
}
|
||||
|
||||
let currentListItemIndex = 0;
|
||||
const children: ReactElement[] =
|
||||
node.content?.map((child) => {
|
||||
const childContext: InternalRenderContext = {
|
||||
parentType: node.type,
|
||||
nestingLevel: childNestingLevel,
|
||||
listItemIndex: 0,
|
||||
textAlign: childTextAlign,
|
||||
pdfContext,
|
||||
};
|
||||
|
||||
if (isListContainer && child.type === CORE_EXTENSIONS.LIST_ITEM) {
|
||||
currentListItemIndex++;
|
||||
childContext.listItemIndex = currentListItemIndex;
|
||||
}
|
||||
|
||||
return renderNodeWithContext(child, childContext);
|
||||
}) || [];
|
||||
|
||||
const renderer = nodeRenderers[node.type];
|
||||
if (renderer) {
|
||||
return renderer(nodeWithContext, children, pdfContext);
|
||||
}
|
||||
|
||||
if (children.length > 0) {
|
||||
return <View key={pdfContext.getKey()}>{children}</View>;
|
||||
}
|
||||
|
||||
return <View key={pdfContext.getKey()} />;
|
||||
};
|
||||
|
||||
export const renderNode = (
|
||||
node: TipTapNode,
|
||||
parentType?: string,
|
||||
_index?: number,
|
||||
metadata?: PDFExportMetadata,
|
||||
getKey?: KeyGenerator
|
||||
): ReactElement => {
|
||||
const keyGen = getKey ?? createKeyGenerator();
|
||||
|
||||
return renderNodeWithContext(node, {
|
||||
parentType,
|
||||
nestingLevel: 0,
|
||||
listItemIndex: 0,
|
||||
pdfContext: { getKey: keyGen, metadata },
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createRequire } from "module";
|
||||
import path from "path";
|
||||
import { Document, Font, Page, pdf, Text } from "@react-pdf/renderer";
|
||||
import { createKeyGenerator, renderNode } from "./node-renderers";
|
||||
import { pdfStyles } from "./styles";
|
||||
import type { PDFExportOptions, TipTapDocument } from "./types";
|
||||
|
||||
// Use createRequire for ESM compatibility to resolve font file paths
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Resolve local font file paths from @fontsource/inter package
|
||||
const interFontDir = path.dirname(require.resolve("@fontsource/inter/package.json"));
|
||||
|
||||
Font.register({
|
||||
family: "Inter",
|
||||
fonts: [
|
||||
{
|
||||
src: path.join(interFontDir, "files/inter-latin-400-normal.woff"),
|
||||
fontWeight: 400,
|
||||
},
|
||||
{
|
||||
src: path.join(interFontDir, "files/inter-latin-400-italic.woff"),
|
||||
fontWeight: 400,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
{
|
||||
src: path.join(interFontDir, "files/inter-latin-600-normal.woff"),
|
||||
fontWeight: 600,
|
||||
},
|
||||
{
|
||||
src: path.join(interFontDir, "files/inter-latin-600-italic.woff"),
|
||||
fontWeight: 600,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
{
|
||||
src: path.join(interFontDir, "files/inter-latin-700-normal.woff"),
|
||||
fontWeight: 700,
|
||||
},
|
||||
{
|
||||
src: path.join(interFontDir, "files/inter-latin-700-italic.woff"),
|
||||
fontWeight: 700,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const createPdfDocument = (doc: TipTapDocument, options: PDFExportOptions = {}) => {
|
||||
const { title, author, subject, pageSize = "A4", pageOrientation = "portrait", metadata, noAssets } = options;
|
||||
|
||||
// Merge noAssets into metadata for use in node renderers
|
||||
const mergedMetadata = { ...metadata, noAssets };
|
||||
|
||||
const content = doc.content || [];
|
||||
const getKey = createKeyGenerator();
|
||||
const renderedContent = content.map((node, index) => renderNode(node, "doc", index, mergedMetadata, getKey));
|
||||
|
||||
return (
|
||||
<Document title={title} author={author} subject={subject}>
|
||||
<Page size={pageSize} orientation={pageOrientation} style={pdfStyles.page}>
|
||||
{title && <Text style={pdfStyles.title}>{title}</Text>}
|
||||
{renderedContent}
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
|
||||
export const renderPlaneDocToPdfBuffer = async (
|
||||
doc: TipTapDocument,
|
||||
options: PDFExportOptions = {}
|
||||
): Promise<Buffer> => {
|
||||
const pdfDocument = createPdfDocument(doc, options);
|
||||
const pdfInstance = pdf(pdfDocument);
|
||||
const blob = await pdfInstance.toBlob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
};
|
||||
|
||||
export const renderPlaneDocToPdfBlob = async (doc: TipTapDocument, options: PDFExportOptions = {}): Promise<Blob> => {
|
||||
const pdfDocument = createPdfDocument(doc, options);
|
||||
const pdfInstance = pdf(pdfDocument);
|
||||
return await pdfInstance.toBlob();
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
import { StyleSheet } from "@react-pdf/renderer";
|
||||
import {
|
||||
BACKGROUND_COLORS,
|
||||
BORDER_COLORS,
|
||||
BRAND_COLORS,
|
||||
CODE_COLORS,
|
||||
LINK_COLORS,
|
||||
MENTION_COLORS,
|
||||
NEUTRAL_COLORS,
|
||||
TEXT_COLORS,
|
||||
} from "./colors";
|
||||
|
||||
export const pdfStyles = StyleSheet.create({
|
||||
page: {
|
||||
padding: 40,
|
||||
fontFamily: "Inter",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
color: TEXT_COLORS.primary,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
marginBottom: 20,
|
||||
color: TEXT_COLORS.primary,
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
color: TEXT_COLORS.primary,
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
marginTop: 14,
|
||||
marginBottom: 6,
|
||||
color: TEXT_COLORS.primary,
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
marginTop: 12,
|
||||
marginBottom: 4,
|
||||
color: TEXT_COLORS.primary,
|
||||
},
|
||||
heading4: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
marginTop: 10,
|
||||
marginBottom: 4,
|
||||
color: TEXT_COLORS.secondary,
|
||||
},
|
||||
heading5: {
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
marginTop: 8,
|
||||
marginBottom: 4,
|
||||
color: TEXT_COLORS.secondary,
|
||||
},
|
||||
heading6: {
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
marginTop: 6,
|
||||
marginBottom: 4,
|
||||
color: TEXT_COLORS.tertiary,
|
||||
},
|
||||
paragraph: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
paragraphWrapper: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
blockquote: {
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: BORDER_COLORS.strong, // Matches .ProseMirror blockquote border-strong
|
||||
paddingLeft: 12,
|
||||
marginLeft: 0,
|
||||
marginVertical: 8,
|
||||
fontStyle: "normal", // Matches editor: font-style: normal
|
||||
fontWeight: 400, // Matches editor: font-weight: 400
|
||||
color: TEXT_COLORS.primary,
|
||||
breakInside: "avoid",
|
||||
},
|
||||
codeBlock: {
|
||||
backgroundColor: BACKGROUND_COLORS.layer1, // bg-layer-1 equivalent
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
fontFamily: "Courier",
|
||||
fontSize: 10,
|
||||
marginVertical: 8,
|
||||
color: TEXT_COLORS.primary,
|
||||
breakInside: "avoid",
|
||||
},
|
||||
codeInline: {
|
||||
backgroundColor: BACKGROUND_COLORS.layer1,
|
||||
padding: 2,
|
||||
paddingHorizontal: 4,
|
||||
borderRadius: 2,
|
||||
fontFamily: "Courier",
|
||||
fontSize: 10,
|
||||
color: CODE_COLORS.text, // Red for inline code
|
||||
},
|
||||
bulletList: {
|
||||
marginVertical: 8,
|
||||
paddingLeft: 0,
|
||||
},
|
||||
orderedList: {
|
||||
marginVertical: 8,
|
||||
paddingLeft: 0,
|
||||
},
|
||||
listItem: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: 6,
|
||||
marginBottom: 4,
|
||||
paddingRight: 10,
|
||||
breakInside: "avoid",
|
||||
},
|
||||
listItemBullet: {},
|
||||
listItemContent: {
|
||||
flex: 1,
|
||||
},
|
||||
taskList: {
|
||||
marginVertical: 8,
|
||||
},
|
||||
taskItem: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: 6,
|
||||
marginBottom: 4,
|
||||
alignItems: "flex-start",
|
||||
paddingRight: 10,
|
||||
breakInside: "avoid",
|
||||
},
|
||||
taskCheckbox: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: BORDER_COLORS.strong, // Matches editor: border-strong
|
||||
borderRadius: 2,
|
||||
marginTop: 2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
taskCheckboxChecked: {
|
||||
backgroundColor: BRAND_COLORS.default, // --background-color-accent-primary
|
||||
borderColor: BRAND_COLORS.default, // --border-color-accent-strong
|
||||
},
|
||||
table: {
|
||||
marginVertical: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: BORDER_COLORS.subtle1, // border-subtle-1
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: "row",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: BORDER_COLORS.subtle1,
|
||||
breakInside: "avoid",
|
||||
},
|
||||
tableHeaderRow: {
|
||||
backgroundColor: BACKGROUND_COLORS.surface2, // Slightly different from white
|
||||
flexDirection: "row",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: BORDER_COLORS.subtle1,
|
||||
},
|
||||
tableCell: {
|
||||
padding: 8,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: BORDER_COLORS.subtle1,
|
||||
flex: 1,
|
||||
},
|
||||
tableHeaderCell: {
|
||||
padding: 8,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: BORDER_COLORS.subtle1,
|
||||
flex: 1,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
horizontalRule: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: BORDER_COLORS.subtle1, // Matches div[data-type="horizontalRule"] border-subtle-1
|
||||
marginVertical: 16,
|
||||
},
|
||||
image: {
|
||||
maxWidth: "100%",
|
||||
marginVertical: 8,
|
||||
},
|
||||
imagePlaceholder: {
|
||||
backgroundColor: BACKGROUND_COLORS.layer1,
|
||||
padding: 16,
|
||||
borderRadius: 4,
|
||||
marginVertical: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: BORDER_COLORS.subtle,
|
||||
borderStyle: "dashed",
|
||||
},
|
||||
imagePlaceholderText: {
|
||||
color: TEXT_COLORS.tertiary,
|
||||
fontSize: 10,
|
||||
},
|
||||
callout: {
|
||||
backgroundColor: BACKGROUND_COLORS.layer3, // bg-layer-3 (default callout background)
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
marginVertical: 8,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
breakInside: "avoid",
|
||||
},
|
||||
calloutIconContainer: {
|
||||
marginRight: 10,
|
||||
marginTop: 2,
|
||||
},
|
||||
calloutContent: {
|
||||
flex: 1,
|
||||
color: TEXT_COLORS.primary, // text-primary
|
||||
},
|
||||
mention: {
|
||||
backgroundColor: MENTION_COLORS.background, // bg-accent-primary/20 equivalent
|
||||
color: MENTION_COLORS.text, // text-accent-primary
|
||||
padding: 2,
|
||||
paddingHorizontal: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
link: {
|
||||
color: LINK_COLORS.primary, // --txt-link-primary
|
||||
textDecoration: "underline",
|
||||
},
|
||||
bold: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
italic: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
underline: {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
strike: {
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
|
||||
export type TipTapMark = {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TipTapNode = {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
content?: TipTapNode[];
|
||||
text?: string;
|
||||
marks?: TipTapMark[];
|
||||
};
|
||||
|
||||
export type TipTapDocument = {
|
||||
type: "doc";
|
||||
content?: TipTapNode[];
|
||||
};
|
||||
|
||||
export type KeyGenerator = () => string;
|
||||
|
||||
export type PDFRenderContext = {
|
||||
getKey: KeyGenerator;
|
||||
metadata?: PDFExportMetadata;
|
||||
};
|
||||
|
||||
export type PDFNodeRenderer = (
|
||||
node: TipTapNode,
|
||||
children: React.ReactElement[],
|
||||
context: PDFRenderContext
|
||||
) => React.ReactElement;
|
||||
|
||||
export type PDFMarkRenderer = (mark: TipTapMark, currentStyle: Style) => Style;
|
||||
|
||||
export type NodeRendererRegistry = Record<string, PDFNodeRenderer>;
|
||||
|
||||
export type MarkRendererRegistry = Record<string, PDFMarkRenderer>;
|
||||
|
||||
export type PDFExportOptions = {
|
||||
title?: string;
|
||||
author?: string;
|
||||
subject?: string;
|
||||
pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID";
|
||||
pageOrientation?: "portrait" | "landscape";
|
||||
metadata?: PDFExportMetadata;
|
||||
/** When true, images and other assets are excluded from the PDF */
|
||||
noAssets?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Metadata for resolving entity references in PDF export
|
||||
*/
|
||||
export type PDFExportMetadata = {
|
||||
/** User mentions (user_mention in mention node) */
|
||||
userMentions?: PDFUserMention[];
|
||||
/** Resolved image URLs: Map of asset ID to presigned URL */
|
||||
resolvedImageUrls?: Record<string, string>;
|
||||
/** When true, images and other assets are excluded from the PDF */
|
||||
noAssets?: boolean;
|
||||
};
|
||||
|
||||
export type PDFUserMention = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Schema } from "effect";
|
||||
|
||||
export const PdfExportRequestBody = Schema.Struct({
|
||||
pageId: Schema.NonEmptyTrimmedString,
|
||||
workspaceSlug: Schema.NonEmptyTrimmedString,
|
||||
projectId: Schema.optional(Schema.NonEmptyTrimmedString),
|
||||
title: Schema.optional(Schema.String),
|
||||
author: Schema.optional(Schema.String),
|
||||
subject: Schema.optional(Schema.String),
|
||||
pageSize: Schema.optional(Schema.Literal("A4", "A3", "A2", "LETTER", "LEGAL", "TABLOID")),
|
||||
pageOrientation: Schema.optional(Schema.Literal("portrait", "landscape")),
|
||||
fileName: Schema.optional(Schema.String),
|
||||
noAssets: Schema.optional(Schema.Boolean),
|
||||
baseUrl: Schema.optional(Schema.String),
|
||||
// API base URL for asset resolution (e.g., "https://plane.example.com/api" or "https://api.plane.example.com")
|
||||
// Used to generate correct presigned URLs for images in self-hosted environments
|
||||
apiBaseUrl: Schema.optional(Schema.String),
|
||||
});
|
||||
|
||||
export type TPdfExportRequestBody = Schema.Schema.Type<typeof PdfExportRequestBody>;
|
||||
|
||||
export class PdfValidationError extends Schema.TaggedError<PdfValidationError>()("PdfValidationError", {
|
||||
message: Schema.NonEmptyTrimmedString,
|
||||
cause: Schema.optional(Schema.Unknown),
|
||||
}) {}
|
||||
|
||||
export class PdfAuthenticationError extends Schema.TaggedError<PdfAuthenticationError>()("PdfAuthenticationError", {
|
||||
message: Schema.NonEmptyTrimmedString,
|
||||
}) {}
|
||||
|
||||
export class PdfContentFetchError extends Schema.TaggedError<PdfContentFetchError>()("PdfContentFetchError", {
|
||||
message: Schema.NonEmptyTrimmedString,
|
||||
cause: Schema.optional(Schema.Unknown),
|
||||
}) {}
|
||||
|
||||
export class PdfMetadataFetchError extends Schema.TaggedError<PdfMetadataFetchError>()("PdfMetadataFetchError", {
|
||||
message: Schema.NonEmptyTrimmedString,
|
||||
source: Schema.Literal("user-mentions"),
|
||||
cause: Schema.optional(Schema.Unknown),
|
||||
}) {}
|
||||
|
||||
export class PdfImageProcessingError extends Schema.TaggedError<PdfImageProcessingError>()("PdfImageProcessingError", {
|
||||
message: Schema.NonEmptyTrimmedString,
|
||||
assetId: Schema.NonEmptyTrimmedString,
|
||||
cause: Schema.optional(Schema.Unknown),
|
||||
}) {}
|
||||
|
||||
export class PdfGenerationError extends Schema.TaggedError<PdfGenerationError>()("PdfGenerationError", {
|
||||
message: Schema.NonEmptyTrimmedString,
|
||||
cause: Schema.optional(Schema.Unknown),
|
||||
}) {}
|
||||
|
||||
export class PdfTimeoutError extends Schema.TaggedError<PdfTimeoutError>()("PdfTimeoutError", {
|
||||
message: Schema.NonEmptyTrimmedString,
|
||||
operation: Schema.NonEmptyTrimmedString,
|
||||
}) {}
|
||||
|
||||
export type PdfExportError =
|
||||
| PdfValidationError
|
||||
| PdfAuthenticationError
|
||||
| PdfContentFetchError
|
||||
| PdfMetadataFetchError
|
||||
| PdfImageProcessingError
|
||||
| PdfGenerationError
|
||||
| PdfTimeoutError;
|
||||
@@ -1,9 +1,32 @@
|
||||
import type { AxiosError } from "axios";
|
||||
import { logger } from "@plane/logger";
|
||||
import type { TPage } from "@plane/types";
|
||||
// services
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is an Axios error with a response
|
||||
*/
|
||||
function isAxiosErrorWithResponse(
|
||||
error: unknown
|
||||
): error is AxiosError & { response: NonNullable<AxiosError["response"]> } {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"isAxiosError" in error &&
|
||||
(error as AxiosError).isAxiosError === true &&
|
||||
"response" in error &&
|
||||
(error as AxiosError).response !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export type TUserMention = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
|
||||
export type TPageDescriptionPayload = {
|
||||
description_binary: string;
|
||||
description_html: string;
|
||||
@@ -116,4 +139,134 @@ export abstract class PageCoreService extends APIService {
|
||||
throw appError;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user mentions for a page
|
||||
* @param pageId - The page ID
|
||||
* @returns Array of user mentions
|
||||
*/
|
||||
async fetchUserMentions(pageId: string): Promise<TUserMention[]> {
|
||||
try {
|
||||
const response = await this.get(`${this.basePath}/pages/${pageId}/user-mentions/`, {
|
||||
headers: this.getHeader(),
|
||||
});
|
||||
return (response?.data as TUserMention[]) || [];
|
||||
} catch (error) {
|
||||
logger.warn("Failed to fetch user mentions", {
|
||||
pageId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an image asset ID to its actual URL (presigned URL)
|
||||
* @param workspaceSlug - The workspace slug
|
||||
* @param assetId - The asset UUID
|
||||
* @param projectId - Optional project ID for project-specific assets
|
||||
* @param apiBaseUrl - Optional API base URL for generating correct presigned URLs
|
||||
* @returns The resolved URL or null if resolution fails
|
||||
*/
|
||||
async resolveImageAssetUrl(
|
||||
workspaceSlug: string,
|
||||
assetId: string,
|
||||
projectId?: string | null,
|
||||
apiBaseUrl?: string
|
||||
): Promise<string | null> {
|
||||
const assetPath = projectId
|
||||
? `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/?disposition=inline`
|
||||
: `/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/?disposition=inline`;
|
||||
|
||||
try {
|
||||
logger.debug("Resolving image asset URL", {
|
||||
assetId,
|
||||
workspaceSlug,
|
||||
projectId: projectId ?? "(workspace-level)",
|
||||
assetPath,
|
||||
apiBaseUrl: apiBaseUrl ?? "(default)",
|
||||
});
|
||||
|
||||
// If apiBaseUrl is provided and non-empty, use fetch directly to that URL
|
||||
// This ensures the API sees the public host and generates correct presigned URLs
|
||||
if (apiBaseUrl && apiBaseUrl.trim() !== "") {
|
||||
const fullUrl = `${apiBaseUrl.replace(/\/$/, "")}${assetPath}`;
|
||||
const response = await fetch(fullUrl, {
|
||||
method: "GET",
|
||||
headers: this.getHeader(),
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
if (response.status === 302 || response.status === 301) {
|
||||
const resolvedUrl = response.headers.get("location");
|
||||
logger.debug("Image asset URL resolved (via apiBaseUrl)", {
|
||||
assetId,
|
||||
resolvedUrl: resolvedUrl ? `${resolvedUrl.substring(0, 80)}...` : null,
|
||||
});
|
||||
return resolvedUrl;
|
||||
}
|
||||
logger.warn("Unexpected response status when resolving asset URL", {
|
||||
assetId,
|
||||
status: response.status,
|
||||
fullUrl,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fallback to axios-based request using internal API_BASE_URL
|
||||
const path = projectId
|
||||
? `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/?disposition=inline`
|
||||
: `/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/?disposition=inline`;
|
||||
const response = await this.get(path, {
|
||||
headers: this.getHeader(),
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status: number) => status >= 200 && status < 400,
|
||||
});
|
||||
// If we get a 302, the Location header contains the presigned URL
|
||||
if (response.status === 302 || response.status === 301) {
|
||||
const resolvedUrl = response.headers?.location || null;
|
||||
logger.debug("Image asset URL resolved", {
|
||||
assetId,
|
||||
resolvedUrl: resolvedUrl ? `${resolvedUrl.substring(0, 80)}...` : null,
|
||||
});
|
||||
return resolvedUrl;
|
||||
}
|
||||
logger.warn("Unexpected response status when resolving asset URL", {
|
||||
assetId,
|
||||
status: response.status,
|
||||
apiPath: path,
|
||||
});
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Axios throws on 3xx when maxRedirects is 0, so we need to handle the redirect from the error
|
||||
if (isAxiosErrorWithResponse(error)) {
|
||||
const { status, headers } = error.response;
|
||||
if (status === 302 || status === 301) {
|
||||
const resolvedUrl = (headers?.location as string) || null;
|
||||
logger.debug("Image asset URL resolved (from redirect error)", {
|
||||
assetId,
|
||||
resolvedUrl: resolvedUrl ? `${resolvedUrl.substring(0, 80)}...` : null,
|
||||
});
|
||||
return resolvedUrl;
|
||||
}
|
||||
logger.error("Failed to resolve image asset URL", {
|
||||
assetId,
|
||||
workspaceSlug,
|
||||
projectId: projectId ?? "(workspace-level)",
|
||||
assetPath,
|
||||
status,
|
||||
error: error.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
logger.error("Failed to resolve image asset URL", {
|
||||
assetId,
|
||||
workspaceSlug,
|
||||
projectId: projectId ?? "(workspace-level)",
|
||||
assetPath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Effect, Duration, Schedule, pipe } from "effect";
|
||||
import { PdfTimeoutError } from "@/schema/pdf-export";
|
||||
|
||||
/**
|
||||
* Wraps an effect with timeout and exponential backoff retry logic.
|
||||
* Preserves the environment type R for proper dependency injection.
|
||||
*/
|
||||
export const withTimeoutAndRetry =
|
||||
(operation: string, { timeoutMs = 5000, maxRetries = 2 }: { timeoutMs?: number; maxRetries?: number } = {}) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | PdfTimeoutError, R> =>
|
||||
effect.pipe(
|
||||
Effect.timeoutFail({
|
||||
duration: Duration.millis(timeoutMs),
|
||||
onTimeout: () =>
|
||||
new PdfTimeoutError({
|
||||
message: `Operation "${operation}" timed out after ${timeoutMs}ms`,
|
||||
operation,
|
||||
}),
|
||||
}),
|
||||
Effect.retry(
|
||||
pipe(
|
||||
Schedule.exponential(Duration.millis(200)),
|
||||
Schedule.compose(Schedule.recurs(maxRetries)),
|
||||
Schedule.tapInput((error: E | PdfTimeoutError) =>
|
||||
Effect.logWarning("PDF_EXPORT: Retrying operation", { operation, error })
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Recovers from any error with a default fallback value.
|
||||
* Logs the error before recovering.
|
||||
*/
|
||||
export const recoverWithDefault =
|
||||
<A>(fallback: A) =>
|
||||
<E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, never, R> =>
|
||||
effect.pipe(
|
||||
Effect.tapError((error) => Effect.logWarning("PDF_EXPORT: Operation failed, using fallback", { error })),
|
||||
Effect.catchAll(() => Effect.succeed(fallback))
|
||||
);
|
||||
|
||||
/**
|
||||
* Wraps a promise-returning function with proper Effect error handling
|
||||
*/
|
||||
export const tryAsync = <A, E>(fn: () => Promise<A>, onError: (cause: unknown) => E): Effect.Effect<A, E> =>
|
||||
Effect.tryPromise({
|
||||
try: fn,
|
||||
catch: onError,
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export { PdfExportService, exportToPdf } from "./pdf-export.service";
|
||||
export * from "./effect-utils";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,383 @@
|
||||
import { Effect } from "effect";
|
||||
import sharp from "sharp";
|
||||
import { getAllDocumentFormatsFromDocumentEditorBinaryData } from "@plane/editor/lib";
|
||||
import type { PDFExportMetadata, TipTapDocument } from "@/lib/pdf";
|
||||
import { renderPlaneDocToPdfBuffer } from "@/lib/pdf";
|
||||
import { getPageService } from "@/services/page/handler";
|
||||
import type { TDocumentTypes } from "@/types";
|
||||
import {
|
||||
PdfContentFetchError,
|
||||
PdfGenerationError,
|
||||
PdfImageProcessingError,
|
||||
PdfTimeoutError,
|
||||
} from "@/schema/pdf-export";
|
||||
import { withTimeoutAndRetry, recoverWithDefault, tryAsync } from "./effect-utils";
|
||||
import type { PdfExportInput, PdfExportResult, PageContent, MetadataResult } from "./types";
|
||||
|
||||
const IMAGE_CONCURRENCY = 4;
|
||||
const IMAGE_TIMEOUT_MS = 8000;
|
||||
const CONTENT_FETCH_TIMEOUT_MS = 7000;
|
||||
const PDF_RENDER_TIMEOUT_MS = 15000;
|
||||
const IMAGE_MAX_DIMENSION = 1200;
|
||||
|
||||
type TipTapNode = {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
content?: TipTapNode[];
|
||||
};
|
||||
|
||||
/**
|
||||
* PDF Export Service
|
||||
*/
|
||||
export class PdfExportService extends Effect.Service<PdfExportService>()("PdfExportService", {
|
||||
sync: () => ({
|
||||
/**
|
||||
* Determines document type
|
||||
*/
|
||||
getDocumentType: (_input: PdfExportInput): TDocumentTypes => {
|
||||
return "project_page";
|
||||
},
|
||||
|
||||
/**
|
||||
* Extracts image asset IDs from document content
|
||||
*/
|
||||
extractImageAssetIds: (doc: TipTapNode): string[] => {
|
||||
const assetIds: string[] = [];
|
||||
|
||||
const traverse = (node: TipTapNode) => {
|
||||
if ((node.type === "imageComponent" || node.type === "image") && node.attrs?.src) {
|
||||
const src = node.attrs.src as string;
|
||||
if (src && !src.startsWith("http") && !src.startsWith("data:")) {
|
||||
assetIds.push(src);
|
||||
}
|
||||
}
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
traverse(doc);
|
||||
return [...new Set(assetIds)];
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches page content (description binary) and parses it
|
||||
*/
|
||||
fetchPageContent: (
|
||||
pageService: ReturnType<typeof getPageService>,
|
||||
pageId: string,
|
||||
requestId: string
|
||||
): Effect.Effect<PageContent, PdfContentFetchError | PdfTimeoutError> =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logDebug("PDF_EXPORT: Fetching page content", { requestId, pageId });
|
||||
|
||||
const descriptionBinary = yield* tryAsync(
|
||||
() => pageService.fetchDescriptionBinary(pageId),
|
||||
(cause) =>
|
||||
new PdfContentFetchError({
|
||||
message: "Failed to fetch page content",
|
||||
cause,
|
||||
})
|
||||
).pipe(
|
||||
withTimeoutAndRetry("fetch page content", {
|
||||
timeoutMs: CONTENT_FETCH_TIMEOUT_MS,
|
||||
maxRetries: 3,
|
||||
})
|
||||
);
|
||||
|
||||
if (!descriptionBinary) {
|
||||
return yield* Effect.fail(
|
||||
new PdfContentFetchError({
|
||||
message: "Page content not found",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const binaryData = new Uint8Array(descriptionBinary);
|
||||
const { contentJSON, titleHTML } = getAllDocumentFormatsFromDocumentEditorBinaryData(binaryData, true);
|
||||
|
||||
return {
|
||||
contentJSON: contentJSON as TipTapDocument,
|
||||
titleHTML: titleHTML || null,
|
||||
descriptionBinary,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Fetches user mentions for the page
|
||||
*/
|
||||
fetchUserMentions: (
|
||||
pageService: ReturnType<typeof getPageService>,
|
||||
pageId: string,
|
||||
requestId: string
|
||||
): Effect.Effect<MetadataResult> =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logDebug("PDF_EXPORT: Fetching user mentions", { requestId });
|
||||
|
||||
const userMentionsRaw = yield* tryAsync(
|
||||
async () => {
|
||||
if (pageService.fetchUserMentions) {
|
||||
return await pageService.fetchUserMentions(pageId);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
() => []
|
||||
).pipe(recoverWithDefault([] as Array<{ id: string; display_name: string; avatar_url?: string }>));
|
||||
|
||||
return {
|
||||
userMentions: userMentionsRaw.map((u) => ({
|
||||
id: u.id,
|
||||
display_name: u.display_name,
|
||||
avatar_url: u.avatar_url,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Resolves and processes images for PDF embedding
|
||||
*/
|
||||
processImages: (
|
||||
pageService: ReturnType<typeof getPageService>,
|
||||
workspaceSlug: string,
|
||||
projectId: string | undefined,
|
||||
assetIds: string[],
|
||||
requestId: string,
|
||||
apiBaseUrl?: string,
|
||||
baseUrl?: string
|
||||
): Effect.Effect<Record<string, string>> =>
|
||||
Effect.gen(function* () {
|
||||
if (assetIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
yield* Effect.logDebug("PDF_EXPORT: Processing images", {
|
||||
requestId,
|
||||
count: assetIds.length,
|
||||
});
|
||||
|
||||
// Resolve URLs first - pass apiBaseUrl (or baseUrl as fallback) for correct presigned URL generation
|
||||
const effectiveApiBaseUrl = apiBaseUrl && apiBaseUrl.trim() !== "" ? apiBaseUrl : baseUrl;
|
||||
const resolvedUrlMap = yield* tryAsync(
|
||||
async () => {
|
||||
const urlMap = new Map<string, string>();
|
||||
for (const assetId of assetIds) {
|
||||
const url = await pageService.resolveImageAssetUrl?.(
|
||||
workspaceSlug,
|
||||
assetId,
|
||||
projectId,
|
||||
effectiveApiBaseUrl
|
||||
);
|
||||
if (url) urlMap.set(assetId, url);
|
||||
}
|
||||
return urlMap;
|
||||
},
|
||||
() => new Map<string, string>()
|
||||
).pipe(recoverWithDefault(new Map<string, string>()));
|
||||
|
||||
if (resolvedUrlMap.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Process each image
|
||||
const processSingleImage = ([assetId, url]: [string, string]) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* tryAsync(
|
||||
() => fetch(url),
|
||||
(cause) =>
|
||||
new PdfImageProcessingError({
|
||||
message: "Failed to fetch image",
|
||||
assetId,
|
||||
cause,
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return yield* Effect.fail(
|
||||
new PdfImageProcessingError({
|
||||
message: `Image fetch returned ${response.status}`,
|
||||
assetId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const arrayBuffer = yield* tryAsync(
|
||||
() => response.arrayBuffer(),
|
||||
(cause) =>
|
||||
new PdfImageProcessingError({
|
||||
message: "Failed to read image body",
|
||||
assetId,
|
||||
cause,
|
||||
})
|
||||
);
|
||||
|
||||
const processedBuffer = yield* tryAsync(
|
||||
() =>
|
||||
sharp(Buffer.from(arrayBuffer))
|
||||
.rotate()
|
||||
.flatten({ background: { r: 255, g: 255, b: 255 } })
|
||||
.resize(IMAGE_MAX_DIMENSION, IMAGE_MAX_DIMENSION, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 85 })
|
||||
.toBuffer(),
|
||||
(cause) =>
|
||||
new PdfImageProcessingError({
|
||||
message: "Failed to process image",
|
||||
assetId,
|
||||
cause,
|
||||
})
|
||||
);
|
||||
|
||||
const base64 = processedBuffer.toString("base64");
|
||||
return [assetId, `data:image/jpeg;base64,${base64}`] as const;
|
||||
}).pipe(
|
||||
withTimeoutAndRetry(`process image ${assetId}`, {
|
||||
timeoutMs: IMAGE_TIMEOUT_MS,
|
||||
maxRetries: 1,
|
||||
}),
|
||||
Effect.tapError((error) =>
|
||||
Effect.logWarning("PDF_EXPORT: Image processing failed", {
|
||||
requestId,
|
||||
assetId,
|
||||
error,
|
||||
})
|
||||
),
|
||||
Effect.catchAll(() => Effect.succeed(null as readonly [string, string] | null))
|
||||
);
|
||||
|
||||
const entries = Array.from(resolvedUrlMap.entries());
|
||||
const pairs = yield* Effect.forEach(entries, processSingleImage, {
|
||||
concurrency: IMAGE_CONCURRENCY,
|
||||
});
|
||||
|
||||
const filtered = pairs.filter((p): p is readonly [string, string] => p !== null);
|
||||
return Object.fromEntries(filtered);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Renders document to PDF buffer
|
||||
*/
|
||||
renderPdf: (
|
||||
contentJSON: TipTapDocument,
|
||||
metadata: PDFExportMetadata,
|
||||
options: {
|
||||
title?: string;
|
||||
author?: string;
|
||||
subject?: string;
|
||||
pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID";
|
||||
pageOrientation?: "portrait" | "landscape";
|
||||
noAssets?: boolean;
|
||||
},
|
||||
requestId: string
|
||||
): Effect.Effect<Buffer, PdfGenerationError | PdfTimeoutError> =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logDebug("PDF_EXPORT: Rendering PDF", { requestId });
|
||||
|
||||
const pdfBuffer = yield* tryAsync(
|
||||
() =>
|
||||
renderPlaneDocToPdfBuffer(contentJSON, {
|
||||
title: options.title,
|
||||
author: options.author,
|
||||
subject: options.subject,
|
||||
pageSize: options.pageSize,
|
||||
pageOrientation: options.pageOrientation,
|
||||
metadata,
|
||||
noAssets: options.noAssets,
|
||||
}),
|
||||
(cause) =>
|
||||
new PdfGenerationError({
|
||||
message: "Failed to render PDF",
|
||||
cause,
|
||||
})
|
||||
).pipe(withTimeoutAndRetry("render PDF", { timeoutMs: PDF_RENDER_TIMEOUT_MS, maxRetries: 0 }));
|
||||
|
||||
yield* Effect.logInfo("PDF_EXPORT: PDF rendered successfully", {
|
||||
requestId,
|
||||
size: pdfBuffer.length,
|
||||
});
|
||||
|
||||
return pdfBuffer;
|
||||
}),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Main export pipeline - orchestrates the entire PDF export process
|
||||
* Separate function to avoid circular dependency in service definition
|
||||
*/
|
||||
export const exportToPdf = (
|
||||
input: PdfExportInput
|
||||
): Effect.Effect<PdfExportResult, PdfContentFetchError | PdfGenerationError | PdfTimeoutError, PdfExportService> =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* PdfExportService;
|
||||
const { requestId, pageId, workspaceSlug, projectId, noAssets } = input;
|
||||
|
||||
yield* Effect.logInfo("PDF_EXPORT: Starting export", { requestId, pageId, workspaceSlug });
|
||||
|
||||
// Create page service
|
||||
const documentType = service.getDocumentType(input);
|
||||
const pageService = getPageService(documentType, {
|
||||
workspaceSlug,
|
||||
projectId: projectId || null,
|
||||
cookie: input.cookie,
|
||||
documentType,
|
||||
userId: "",
|
||||
});
|
||||
|
||||
// Fetch content
|
||||
const content = yield* service.fetchPageContent(pageService, pageId, requestId);
|
||||
|
||||
// Extract image asset IDs
|
||||
const imageAssetIds = service.extractImageAssetIds(content.contentJSON as TipTapNode);
|
||||
|
||||
// Fetch user mentions
|
||||
let metadata = yield* service.fetchUserMentions(pageService, pageId, requestId);
|
||||
|
||||
// Process images if needed
|
||||
if (!noAssets && imageAssetIds.length > 0) {
|
||||
const resolvedImages = yield* service.processImages(
|
||||
pageService,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
imageAssetIds,
|
||||
requestId,
|
||||
input.apiBaseUrl,
|
||||
input.baseUrl
|
||||
);
|
||||
metadata = { ...metadata, resolvedImageUrls: resolvedImages };
|
||||
}
|
||||
|
||||
yield* Effect.logDebug("PDF_EXPORT: Metadata prepared", {
|
||||
requestId,
|
||||
userMentions: metadata.userMentions?.length ?? 0,
|
||||
resolvedImages: Object.keys(metadata.resolvedImageUrls ?? {}).length,
|
||||
});
|
||||
|
||||
// Render PDF
|
||||
const documentTitle = input.title || content.titleHTML || undefined;
|
||||
const pdfBuffer = yield* service.renderPdf(
|
||||
content.contentJSON,
|
||||
metadata,
|
||||
{
|
||||
title: documentTitle,
|
||||
author: input.author,
|
||||
subject: input.subject,
|
||||
pageSize: input.pageSize,
|
||||
pageOrientation: input.pageOrientation,
|
||||
noAssets,
|
||||
},
|
||||
requestId
|
||||
);
|
||||
|
||||
yield* Effect.logInfo("PDF_EXPORT: Export complete", {
|
||||
requestId,
|
||||
pageId,
|
||||
size: pdfBuffer.length,
|
||||
});
|
||||
|
||||
return {
|
||||
pdfBuffer,
|
||||
outputFileName: input.fileName || `page-${pageId}.pdf`,
|
||||
pageId,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { TipTapDocument, PDFUserMention } from "@/lib/pdf";
|
||||
|
||||
export interface PdfExportInput {
|
||||
readonly pageId: string;
|
||||
readonly workspaceSlug: string;
|
||||
readonly projectId?: string;
|
||||
readonly title?: string;
|
||||
readonly author?: string;
|
||||
readonly subject?: string;
|
||||
readonly pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID";
|
||||
readonly pageOrientation?: "portrait" | "landscape";
|
||||
readonly fileName?: string;
|
||||
readonly noAssets?: boolean;
|
||||
readonly baseUrl?: string;
|
||||
/** API base URL for asset resolution (e.g., "https://plane.example.com/api") */
|
||||
readonly apiBaseUrl?: string;
|
||||
readonly cookie: string;
|
||||
readonly requestId: string;
|
||||
}
|
||||
|
||||
export interface PdfExportResult {
|
||||
readonly pdfBuffer: Buffer;
|
||||
readonly outputFileName: string;
|
||||
readonly pageId: string;
|
||||
}
|
||||
|
||||
export interface PageContent {
|
||||
readonly contentJSON: TipTapDocument;
|
||||
readonly titleHTML: string | null;
|
||||
readonly descriptionBinary: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata - includes user mentions
|
||||
*/
|
||||
export interface MetadataResult {
|
||||
readonly userMentions: PDFUserMention[];
|
||||
readonly resolvedImageUrls?: Record<string, string>;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
export const generateTitleProsemirrorJson = (text: string) => {
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
...(text
|
||||
? {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./document";
|
||||
@@ -0,0 +1,727 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
import { renderPlaneDocToPdfBuffer } from "@/lib/pdf";
|
||||
import type { TipTapDocument, PDFExportMetadata } from "@/lib/pdf";
|
||||
|
||||
const PDF_HEADER = "%PDF-";
|
||||
|
||||
/**
|
||||
* Helper to extract text content from a PDF buffer
|
||||
*/
|
||||
async function extractPdfText(buffer: Buffer): Promise<string> {
|
||||
const uint8 = new Uint8Array(buffer);
|
||||
const parser = new PDFParse(uint8);
|
||||
const result = await parser.getText();
|
||||
return result.pages.map((p) => p.text).join("\n");
|
||||
}
|
||||
|
||||
describe("PDF Rendering Integration", () => {
|
||||
describe("renderPlaneDocToPdfBuffer", () => {
|
||||
it("should render empty document to valid PDF", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||
|
||||
expect(buffer).toBeInstanceOf(Buffer);
|
||||
expect(buffer.length).toBeGreaterThan(0);
|
||||
expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER);
|
||||
});
|
||||
|
||||
it("should render document with title and verify content", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Hello World" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc, {
|
||||
title: "Test Document",
|
||||
});
|
||||
|
||||
expect(buffer).toBeInstanceOf(Buffer);
|
||||
expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER);
|
||||
|
||||
const text = await extractPdfText(buffer);
|
||||
expect(text).toContain("Hello World");
|
||||
// Title is rendered in PDF content when provided
|
||||
expect(text).toContain("Test Document");
|
||||
});
|
||||
|
||||
it("should render heading nodes and verify text", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: "text", text: "Main Heading" }],
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 2 },
|
||||
content: [{ type: "text", text: "Subheading" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||
const text = await extractPdfText(buffer);
|
||||
|
||||
expect(text).toContain("Main Heading");
|
||||
expect(text).toContain("Subheading");
|
||||
});
|
||||
|
||||
it("should render paragraph with text and verify content", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "This is a test paragraph with some content." }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||
const text = await extractPdfText(buffer);
|
||||
|
||||
expect(text).toContain("This is a test paragraph with some content.");
|
||||
});
|
||||
|
||||
it("should render bullet list with all items", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "First item" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Second item" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Third item" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||
const text = await extractPdfText(buffer);
|
||||
|
||||
expect(text).toContain("First item");
|
||||
expect(text).toContain("Second item");
|
||||
expect(text).toContain("Third item");
|
||||
// Bullet points should be present
|
||||
expect(text).toContain("•");
|
||||
});
|
||||
|
||||
it("should render ordered list with numbers", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "orderedList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Step one" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Step two" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||
const text = await extractPdfText(buffer);
|
||||
|
||||
expect(text).toContain("Step one");
|
||||
expect(text).toContain("Step two");
|
||||
// Numbers should be present
|
||||
expect(text).toMatch(/1\./);
|
||||
expect(text).toMatch(/2\./);
|
||||
});
|
||||
|
||||
it("should render task list with task text", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "taskList",
|
||||
content: [
|
||||
{
|
||||
type: "taskItem",
|
||||
attrs: { checked: true },
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Completed task" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "taskItem",
|
||||
attrs: { checked: false },
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Pending task" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||
const text = await extractPdfText(buffer);
|
||||
|
||||
expect(text).toContain("Completed task");
|
||||
expect(text).toContain("Pending task");
|
||||
});
|
||||
|
||||
it("should render code block with code content", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "codeBlock",
|
||||
content: [
|
||||
{ type: "text", text: "const greeting = 'Hello';\n" },
|
||||
{ type: "text", text: "console.log(greeting);" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||
const text = await extractPdfText(buffer);
|
||||
|
||||
expect(text).toContain("const greeting");
|
||||
expect(text).toContain("console.log");
|
||||
});
|
||||
|
||||
it("should render blockquote with quoted text", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "blockquote",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "This is a quoted text." }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||
const text = await extractPdfText(buffer);
|
||||
|
||||
expect(text).toContain("This is a quoted text.");
|
||||
});
|
||||
|
||||
it("should render table with all cell content", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{
|
||||
type: "tableHeader",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Header 1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tableHeader",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Header 2" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{
|
||||
type: "tableCell",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Cell 1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tableCell",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Cell 2" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||
const text = await extractPdfText(buffer);
|
||||
|
||||
expect(text).toContain("Header 1");
|
||||
expect(text).toContain("Header 2");
|
||||
expect(text).toContain("Cell 1");
|
||||
expect(text).toContain("Cell 2");
|
||||
});
|
||||
|
||||
it("should render horizontal rule with surrounding text", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Before rule" }],
|
||||
},
|
||||
{ type: "horizontalRule" },
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "After rule" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||
const text = await extractPdfText(buffer);
|
||||
|
||||
expect(text).toContain("Before rule");
|
||||
expect(text).toContain("After rule");
|
||||
});
|
||||
|
||||
it("should render text with marks (bold, italic) preserving content", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "Normal " },
|
||||
{
|
||||
type: "text",
|
||||
text: "bold",
|
||||
marks: [{ type: "bold" }],
|
||||
},
|
||||
{ type: "text", text: " and " },
|
||||
{
|
||||
type: "text",
|
||||
text: "italic",
|
||||
marks: [{ type: "italic" }],
|
||||
},
|
||||
{ type: "text", text: " text." },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||
const text = await extractPdfText(buffer);
|
||||
|
||||
expect(text).toContain("Normal");
|
||||
expect(text).toContain("bold");
|
||||
expect(text).toContain("italic");
|
||||
expect(text).toContain("text.");
|
||||
});
|
||||
|
||||
it("should render link marks with link text", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "Click " },
|
||||
{
|
||||
type: "text",
|
||||
text: "here",
|
||||
marks: [{ type: "link", attrs: { href: "https://example.com" } }],
|
||||
},
|
||||
{ type: "text", text: " to visit." },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||
const text = await extractPdfText(buffer);
|
||||
|
||||
expect(text).toContain("Click");
|
||||
expect(text).toContain("here");
|
||||
expect(text).toContain("to visit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("page options", () => {
|
||||
it("should support different page sizes and verify content renders", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Page size test content" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const a4Buffer = await renderPlaneDocToPdfBuffer(doc, { pageSize: "A4" });
|
||||
const letterBuffer = await renderPlaneDocToPdfBuffer(doc, { pageSize: "LETTER" });
|
||||
|
||||
const a4Text = await extractPdfText(a4Buffer);
|
||||
const letterText = await extractPdfText(letterBuffer);
|
||||
|
||||
expect(a4Text).toContain("Page size test content");
|
||||
expect(letterText).toContain("Page size test content");
|
||||
// Different page sizes should produce different PDF sizes
|
||||
expect(a4Buffer.length).not.toBe(letterBuffer.length);
|
||||
});
|
||||
|
||||
it("should support landscape orientation and verify content", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Landscape content here" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const portraitBuffer = await renderPlaneDocToPdfBuffer(doc, { pageOrientation: "portrait" });
|
||||
const landscapeBuffer = await renderPlaneDocToPdfBuffer(doc, { pageOrientation: "landscape" });
|
||||
|
||||
const portraitText = await extractPdfText(portraitBuffer);
|
||||
const landscapeText = await extractPdfText(landscapeBuffer);
|
||||
|
||||
expect(portraitText).toContain("Landscape content here");
|
||||
expect(landscapeText).toContain("Landscape content here");
|
||||
expect(portraitBuffer.length).not.toBe(landscapeBuffer.length);
|
||||
});
|
||||
|
||||
it("should include author metadata in PDF", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Document content" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc, {
|
||||
author: "Test Author",
|
||||
});
|
||||
|
||||
// Verify PDF is valid and contains content
|
||||
expect(buffer).toBeInstanceOf(Buffer);
|
||||
expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER);
|
||||
// Author metadata is embedded in PDF info dict (checked via raw bytes)
|
||||
const pdfString = buffer.toString("latin1");
|
||||
expect(pdfString).toContain("/Author");
|
||||
});
|
||||
|
||||
it("should include subject metadata in PDF", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Document content" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc, {
|
||||
subject: "Technical Documentation",
|
||||
});
|
||||
|
||||
// Verify PDF is valid
|
||||
expect(buffer).toBeInstanceOf(Buffer);
|
||||
expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER);
|
||||
// Subject metadata is embedded in PDF info dict
|
||||
const pdfString = buffer.toString("latin1");
|
||||
expect(pdfString).toContain("/Subject");
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata rendering", () => {
|
||||
it("should render user mentions with resolved display name", async () => {
|
||||
const metadata: PDFExportMetadata = {
|
||||
userMentions: [{ id: "user-123", display_name: "John Doe" }],
|
||||
};
|
||||
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "Hello " },
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
entity_name: "user_mention",
|
||||
entity_identifier: "user-123",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc, { metadata });
|
||||
const text = await extractPdfText(buffer);
|
||||
|
||||
expect(text).toContain("Hello");
|
||||
expect(text).toContain("John Doe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("complex documents", () => {
|
||||
it("should render a full document with mixed content and verify all sections", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: "text", text: "Project Overview" }],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "This document describes the " },
|
||||
{ type: "text", text: "key features", marks: [{ type: "bold" }] },
|
||||
{ type: "text", text: " of the project." },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 2 },
|
||||
content: [{ type: "text", text: "Features" }],
|
||||
},
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Feature A - Core functionality" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Feature B - Advanced options" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 2 },
|
||||
content: [{ type: "text", text: "Code Example" }],
|
||||
},
|
||||
{
|
||||
type: "codeBlock",
|
||||
content: [{ type: "text", text: "function hello() {\n return 'world';\n}" }],
|
||||
},
|
||||
{
|
||||
type: "blockquote",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Important: Review before deployment." }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "horizontalRule" },
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "End of document." }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc, {
|
||||
title: "Project Overview",
|
||||
author: "Development Team",
|
||||
subject: "Technical Documentation",
|
||||
});
|
||||
|
||||
const text = await extractPdfText(buffer);
|
||||
|
||||
// Verify metadata is embedded in PDF
|
||||
const pdfString = buffer.toString("latin1");
|
||||
expect(pdfString).toContain("/Title");
|
||||
expect(pdfString).toContain("/Author");
|
||||
expect(pdfString).toContain("/Subject");
|
||||
|
||||
// Verify all content sections are present
|
||||
expect(text).toContain("Project Overview");
|
||||
expect(text).toContain("This document describes the");
|
||||
expect(text).toContain("key features");
|
||||
expect(text).toContain("Features");
|
||||
expect(text).toContain("Feature A - Core functionality");
|
||||
expect(text).toContain("Feature B - Advanced options");
|
||||
expect(text).toContain("Code Example");
|
||||
expect(text).toContain("function hello");
|
||||
expect(text).toContain("Important: Review before deployment");
|
||||
expect(text).toContain("End of document");
|
||||
});
|
||||
|
||||
it("should render deeply nested lists with all levels", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Level 1" }],
|
||||
},
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Level 2" }],
|
||||
},
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Level 3" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||
const text = await extractPdfText(buffer);
|
||||
|
||||
expect(text).toContain("Level 1");
|
||||
expect(text).toContain("Level 2");
|
||||
expect(text).toContain("Level 3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("noAssets option", () => {
|
||||
it("should render text but skip images when noAssets is true", async () => {
|
||||
const doc: TipTapDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
attrs: { src: "https://example.com/image.png" },
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Text after image" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await renderPlaneDocToPdfBuffer(doc, { noAssets: true });
|
||||
const text = await extractPdfText(buffer);
|
||||
|
||||
expect(text).toContain("Text after image");
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, assert } from "vitest";
|
||||
import { Effect, Duration, Either } from "effect";
|
||||
import { withTimeoutAndRetry, recoverWithDefault, tryAsync } from "@/services/pdf-export/effect-utils";
|
||||
import { PdfTimeoutError } from "@/schema/pdf-export";
|
||||
|
||||
describe("effect-utils", () => {
|
||||
describe("withTimeoutAndRetry", () => {
|
||||
it("should succeed when effect completes within timeout", async () => {
|
||||
const effect = Effect.succeed("success");
|
||||
const wrapped = withTimeoutAndRetry("test-operation")(effect);
|
||||
|
||||
const result = await Effect.runPromise(wrapped);
|
||||
expect(result).toBe("success");
|
||||
});
|
||||
|
||||
it("should fail with PdfTimeoutError when effect exceeds timeout", async () => {
|
||||
const slowEffect = Effect.gen(function* () {
|
||||
yield* Effect.sleep(Duration.millis(500));
|
||||
return "success";
|
||||
});
|
||||
|
||||
const wrapped = withTimeoutAndRetry("test-operation", {
|
||||
timeoutMs: 50,
|
||||
maxRetries: 0,
|
||||
})(slowEffect);
|
||||
|
||||
const result = await Effect.runPromise(Effect.either(wrapped));
|
||||
|
||||
assert(Either.isLeft(result), "Expected Left but got Right");
|
||||
expect(result.left).toBeInstanceOf(PdfTimeoutError);
|
||||
expect((result.left as PdfTimeoutError).operation).toBe("test-operation");
|
||||
});
|
||||
|
||||
it("should retry on failure up to maxRetries times", async () => {
|
||||
const attemptCounter = { count: 0 };
|
||||
|
||||
const flakyEffect = Effect.gen(function* () {
|
||||
attemptCounter.count++;
|
||||
if (attemptCounter.count < 3) {
|
||||
return yield* Effect.fail(new Error("transient failure"));
|
||||
}
|
||||
return "success";
|
||||
});
|
||||
|
||||
const wrapped = withTimeoutAndRetry("test-operation", {
|
||||
timeoutMs: 5000,
|
||||
maxRetries: 3,
|
||||
})(flakyEffect);
|
||||
|
||||
const result = await Effect.runPromise(wrapped);
|
||||
|
||||
expect(result).toBe("success");
|
||||
expect(attemptCounter.count).toBe(3);
|
||||
});
|
||||
|
||||
it("should fail after exhausting retries", async () => {
|
||||
const effect = Effect.fail(new Error("permanent failure"));
|
||||
|
||||
const wrapped = withTimeoutAndRetry("test-operation", {
|
||||
timeoutMs: 5000,
|
||||
maxRetries: 2,
|
||||
})(effect);
|
||||
|
||||
const result = await Effect.runPromise(Effect.either(wrapped));
|
||||
|
||||
expect(result._tag).toBe("Left");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverWithDefault", () => {
|
||||
it("should return success value when effect succeeds", async () => {
|
||||
const effect = Effect.succeed("success");
|
||||
const wrapped = recoverWithDefault("fallback")(effect);
|
||||
|
||||
const result = await Effect.runPromise(wrapped);
|
||||
expect(result).toBe("success");
|
||||
});
|
||||
|
||||
it("should return fallback value when effect fails", async () => {
|
||||
const effect = Effect.fail(new Error("failure"));
|
||||
const wrapped = recoverWithDefault("fallback")(effect);
|
||||
|
||||
const result = await Effect.runPromise(wrapped);
|
||||
expect(result).toBe("fallback");
|
||||
});
|
||||
|
||||
it("should log warning when recovering from error", async () => {
|
||||
const logs: string[] = [];
|
||||
|
||||
const effect = Effect.fail(new Error("test error")).pipe(
|
||||
recoverWithDefault("fallback"),
|
||||
Effect.tap(() => Effect.sync(() => logs.push("after recovery")))
|
||||
);
|
||||
|
||||
const result = await Effect.runPromise(effect);
|
||||
|
||||
expect(result).toBe("fallback");
|
||||
expect(logs).toContain("after recovery");
|
||||
});
|
||||
|
||||
it("should work with complex fallback objects", async () => {
|
||||
const fallback = { items: [], count: 0, metadata: { version: 1 } };
|
||||
|
||||
const effect = Effect.fail(new Error("failure"));
|
||||
const wrapped = recoverWithDefault(fallback)(effect);
|
||||
|
||||
const result = await Effect.runPromise(wrapped);
|
||||
expect(result).toEqual(fallback);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryAsync", () => {
|
||||
it("should wrap successful promise", async () => {
|
||||
const effect = tryAsync(
|
||||
() => Promise.resolve("success"),
|
||||
(err) => new Error(`wrapped: ${err}`)
|
||||
);
|
||||
|
||||
const result = await Effect.runPromise(effect);
|
||||
expect(result).toBe("success");
|
||||
});
|
||||
|
||||
it("should wrap rejected promise with custom error", async () => {
|
||||
const effect = tryAsync(
|
||||
() => Promise.reject(new Error("original")),
|
||||
(err) => new Error(`wrapped: ${(err as Error).message}`)
|
||||
);
|
||||
|
||||
const result = await Effect.runPromise(Effect.either(effect));
|
||||
|
||||
assert(Either.isLeft(result), "Expected Left but got Right");
|
||||
expect(result.left.message).toBe("wrapped: original");
|
||||
});
|
||||
|
||||
it("should handle synchronous throws", async () => {
|
||||
const effect = tryAsync(
|
||||
() => {
|
||||
throw new Error("sync error");
|
||||
},
|
||||
(err) => new Error(`caught: ${(err as Error).message}`)
|
||||
);
|
||||
|
||||
const result = await Effect.runPromise(Effect.either(effect));
|
||||
|
||||
assert(Either.isLeft(result), "Expected Left but got Right");
|
||||
expect(result.left.message).toBe("caught: sync error");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
"noImplicitOverride": false,
|
||||
"noImplicitReturns": false,
|
||||
"noUnusedLocals": false,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
(plane_proxy) {
|
||||
request_body {
|
||||
max_size {$FILE_SIZE_LIMIT}
|
||||
}
|
||||
|
||||
handle /spaces/* {
|
||||
reverse_proxy localhost:3002
|
||||
}
|
||||
|
||||
handle /live/* {
|
||||
reverse_proxy localhost:3005
|
||||
}
|
||||
handle /api/* {
|
||||
reverse_proxy localhost:3004
|
||||
}
|
||||
|
||||
handle /auth/* {
|
||||
reverse_proxy localhost:3004
|
||||
}
|
||||
|
||||
handle_path /god-mode* {
|
||||
root * /app/admin
|
||||
try_files {path} {path}/ /index.html
|
||||
file_server
|
||||
}
|
||||
handle_path /* {
|
||||
root * /app/web
|
||||
try_files {path} {path}/ /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
{$CERT_EMAIL}
|
||||
acme_ca {$CERT_ACME_CA:https://acme-v02.api.letsencrypt.org/directory}
|
||||
{$CERT_ACME_DNS}
|
||||
servers {
|
||||
max_header_size 25MB
|
||||
client_ip_headers X-Forwarded-For X-Real-IP
|
||||
trusted_proxies static {$TRUSTED_PROXIES:0.0.0.0/0}
|
||||
}
|
||||
}
|
||||
|
||||
{$SITE_ADDRESS} {
|
||||
import plane_proxy
|
||||
}
|
||||
@@ -15,9 +15,8 @@
|
||||
|
||||
reverse_proxy /auth/* api:8000
|
||||
|
||||
reverse_proxy /static/* api:8000
|
||||
|
||||
reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000
|
||||
reverse_proxy /{$BUCKET_NAME} plane-minio:9000
|
||||
|
||||
reverse_proxy /* web:3000
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
@@ -40,7 +40,7 @@
|
||||
"mobx": "catalog:",
|
||||
"mobx-react": "catalog:",
|
||||
"mobx-utils": "catalog:",
|
||||
"next-themes": "^0.2.1",
|
||||
"next-themes": "0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-dropzone": "^14.2.3",
|
||||
|
||||
@@ -9,8 +9,6 @@ import { Breadcrumbs, Header } from "@plane/ui";
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
// hooks
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
// local imports
|
||||
import { StarUsOnGitHubLink } from "./star-us-link";
|
||||
|
||||
export const WorkspaceDashboardHeader = observer(function WorkspaceDashboardHeader() {
|
||||
// plane hooks
|
||||
@@ -45,7 +43,6 @@ export const WorkspaceDashboardHeader = observer(function WorkspaceDashboardHead
|
||||
<Shapes size={16} />
|
||||
<div className="hidden text-xs font-medium sm:hidden md:block">{t("home.manage_widgets")}</div>
|
||||
</Button>
|
||||
<StarUsOnGitHubLink />
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
</>
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { PageAccessIcon } from "@/components/common/page-access-icon";
|
||||
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
|
||||
import { PageHeaderActions } from "@/components/pages/header/actions";
|
||||
import { PageSyncingBadge } from "@/components/pages/header/syncing-badge";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
@@ -95,6 +96,7 @@ export const PageDetailsHeader = observer(function PageDetailsHeader() {
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
<PageSyncingBadge syncStatus={page.isSyncingWithServer} />
|
||||
<PageDetailsHeaderExtraActions page={page} storeType={storeType} />
|
||||
<PageHeaderActions page={page} storeType={storeType} />
|
||||
</Header.RightItem>
|
||||
|
||||
@@ -1,32 +1,26 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Outlet } from "react-router";
|
||||
// constants
|
||||
import { WORKSPACE_SETTINGS_ACCESS } from "@plane/constants";
|
||||
import type { EUserWorkspaceRoles } from "@plane/types";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { getWorkspaceActivePath, pathnameToAccessKey } from "@/components/settings/helper";
|
||||
import { SettingsMobileNav } from "@/components/settings/mobile";
|
||||
// plane imports
|
||||
import { WORKSPACE_SETTINGS_ACCESS } from "@plane/constants";
|
||||
import type { EUserWorkspaceRoles } from "@plane/types";
|
||||
// plane web components
|
||||
import { WorkspaceSettingsRightSidebar } from "@/plane-web/components/workspace/right-sidebar";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local components
|
||||
import { WorkspaceSettingsSidebar } from "./sidebar";
|
||||
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
const WorkspaceSettingLayout = observer(function WorkspaceSettingLayout({ params }: Route.ComponentProps) {
|
||||
// router
|
||||
const { workspaceSlug } = params;
|
||||
function WorkspaceSettingLayout() {
|
||||
// store hooks
|
||||
const { workspaceUserInfo, getWorkspaceRoleByWorkspaceSlug } = useUserPermissions();
|
||||
// next hooks
|
||||
const pathname = usePathname();
|
||||
// derived values
|
||||
const { accessKey } = pathnameToAccessKey(pathname);
|
||||
const userWorkspaceRole = getWorkspaceRoleByWorkspaceSlug(workspaceSlug);
|
||||
const { workspaceSlug, accessKey } = pathnameToAccessKey(pathname);
|
||||
const userWorkspaceRole = getWorkspaceRoleByWorkspaceSlug(workspaceSlug.toString());
|
||||
|
||||
let isAuthorized: boolean | string = false;
|
||||
if (pathname && workspaceSlug && userWorkspaceRole) {
|
||||
@@ -48,12 +42,11 @@ const WorkspaceSettingLayout = observer(function WorkspaceSettingLayout({ params
|
||||
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
|
||||
<Outlet />
|
||||
</div>
|
||||
<WorkspaceSettingsRightSidebar workspaceSlug={workspaceSlug} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default WorkspaceSettingLayout;
|
||||
export default observer(WorkspaceSettingLayout);
|
||||
|
||||
+9
-15
@@ -28,10 +28,10 @@ import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web components
|
||||
import { BillingActionsButton } from "@/plane-web/components/workspace/billing/billing-actions-button";
|
||||
import { SendWorkspaceInvitationModal, MembersActivityButton } from "@/plane-web/components/workspace/members";
|
||||
import { SendWorkspaceInvitationModal } from "@/plane-web/components/workspace/members/invite-modal";
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsPage({ params }: Route.ComponentProps) {
|
||||
function WorkspaceMembersSettingsPage({ params }: Route.ComponentProps) {
|
||||
// states
|
||||
const [inviteModal, setInviteModal] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
@@ -70,27 +70,23 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP
|
||||
title: "Success!",
|
||||
message: t("workspace_settings.settings.members.invitations_sent_successfully"),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
let message = undefined;
|
||||
if (error instanceof Error) {
|
||||
const err = error as Error & { error?: string };
|
||||
message = err.error;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
captureError({
|
||||
eventName: MEMBER_TRACKER_EVENTS.invite,
|
||||
payload: {
|
||||
emails: data.emails.map((email) => email.email),
|
||||
},
|
||||
error: error as Error,
|
||||
error: err,
|
||||
});
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: `${message ?? t("something_went_wrong_please_try_again")}`,
|
||||
message: `${err.error ?? t("something_went_wrong_please_try_again")}`,
|
||||
});
|
||||
|
||||
throw error;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,7 +137,6 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP
|
||||
className="w-full max-w-[234px] border-none bg-transparent text-sm outline-none placeholder:text-custom-text-400"
|
||||
placeholder={`${t("search")}...`}
|
||||
value={searchQuery}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
@@ -151,7 +146,6 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP
|
||||
handleUpdate={handleRoleFilterUpdate}
|
||||
memberType="workspace"
|
||||
/>
|
||||
<MembersActivityButton workspaceSlug={workspaceSlug} />
|
||||
{canPerformWorkspaceAdminActions && (
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -169,6 +163,6 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP
|
||||
</section>
|
||||
</SettingsContentWrapper>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default WorkspaceMembersSettingsPage;
|
||||
export default observer(WorkspaceMembersSettingsPage);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { useQuickActionsFactory } from "@/components/common/quick-actions-factory";
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./modal";
|
||||
export * from "./use-end-cycle";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const useEndCycle = (isCurrentCycle: boolean) => ({
|
||||
isEndCycleModalOpen: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
setEndCycleModalOpen: (value: boolean) => {},
|
||||
endCycleContextMenu: undefined,
|
||||
});
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Pen, Trash } from "lucide-react";
|
||||
import { Trash } from "lucide-react";
|
||||
import { PROJECT_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// components
|
||||
import { ProIcon } from "@/components/common/pro-icon";
|
||||
|
||||
type TEstimateListItem = {
|
||||
estimateId: string;
|
||||
@@ -21,22 +17,6 @@ export const EstimateListItemButtons = observer(function EstimateListItemButtons
|
||||
if (!isAdmin || !isEditable) return <></>;
|
||||
return (
|
||||
<div className="relative flex items-center gap-1">
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
<div className="relative flex items-center gap-2">
|
||||
<div>Upgrade</div>
|
||||
<ProIcon className="w-3 h-3" />
|
||||
</div>
|
||||
}
|
||||
position="top"
|
||||
>
|
||||
<button
|
||||
className="relative flex-shrink-0 w-6 h-6 flex justify-center items-center rounded cursor-pointer transition-colors overflow-hidden hover:bg-custom-background-80"
|
||||
data-ph-element={PROJECT_SETTINGS_TRACKER_ELEMENTS.ESTIMATES_LIST_ITEM}
|
||||
>
|
||||
<Pen size={12} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
className="relative flex-shrink-0 w-6 h-6 flex justify-center items-center rounded cursor-pointer transition-colors overflow-hidden hover:bg-custom-background-80"
|
||||
onClick={() => onDeleteClick && onDeleteClick(estimateId)}
|
||||
|
||||
@@ -12,10 +12,12 @@ import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
||||
import { InboxIcon } from "@plane/propel/icons";
|
||||
import useSWR from "swr";
|
||||
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
|
||||
// local imports
|
||||
import { StarUsOnGitHubLink } from "@/app/(all)/[workspaceSlug]/(projects)/star-us-link";
|
||||
|
||||
export const TopNavigationRoot = observer(function TopNavigationRoot() {
|
||||
// router
|
||||
const { workspaceSlug, projectId, workItem } = useParams();
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
// store hooks
|
||||
@@ -70,6 +72,7 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
|
||||
/>
|
||||
</Tooltip>
|
||||
<HelpMenuRoot />
|
||||
<StarUsOnGitHubLink />
|
||||
<div className="flex items-center justify-center size-8 hover:bg-custom-background-80 rounded-md">
|
||||
<UserMenuRoot size="xs" />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { EIssueLayoutTypes, IProjectView } from "@plane/types";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import type { TWorkspaceLayoutProps } from "@/components/views/helper";
|
||||
|
||||
export type TLayoutSelectionProps = {
|
||||
@@ -15,6 +18,68 @@ export function WorkspaceAdditionalLayouts(props: TWorkspaceLayoutProps) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export type TMenuItemsFactoryProps = {
|
||||
isOwner: boolean;
|
||||
isAdmin: boolean;
|
||||
setDeleteViewModal: (open: boolean) => void;
|
||||
setCreateUpdateViewModal: (open: boolean) => void;
|
||||
handleOpenInNewTab: () => void;
|
||||
handleCopyText: () => void;
|
||||
isLocked: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId?: string;
|
||||
viewId: string;
|
||||
};
|
||||
|
||||
export const useMenuItemsFactory = (props: TMenuItemsFactoryProps) => {
|
||||
const { isOwner, isAdmin, setDeleteViewModal, setCreateUpdateViewModal, handleOpenInNewTab, handleCopyText } = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const editMenuItem = () => ({
|
||||
key: "edit",
|
||||
action: () => setCreateUpdateViewModal(true),
|
||||
title: t("edit"),
|
||||
icon: Pencil,
|
||||
shouldRender: isOwner,
|
||||
});
|
||||
|
||||
const openInNewTabMenuItem = () => ({
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: t("open_in_new_tab"),
|
||||
icon: ExternalLink,
|
||||
});
|
||||
|
||||
const copyLinkMenuItem = () => ({
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: t("copy_link"),
|
||||
icon: Link,
|
||||
});
|
||||
|
||||
const deleteMenuItem = () => ({
|
||||
key: "delete",
|
||||
action: () => setDeleteViewModal(true),
|
||||
title: t("delete"),
|
||||
icon: Trash2,
|
||||
shouldRender: isOwner || isAdmin,
|
||||
});
|
||||
|
||||
return {
|
||||
editMenuItem,
|
||||
openInNewTabMenuItem,
|
||||
copyLinkMenuItem,
|
||||
deleteMenuItem,
|
||||
};
|
||||
};
|
||||
|
||||
export const useViewMenuItems = (props: TMenuItemsFactoryProps): TContextMenuItem[] => {
|
||||
const factory = useMenuItemsFactory(props);
|
||||
|
||||
return [factory.editMenuItem(), factory.openInNewTabMenuItem(), factory.copyLinkMenuItem(), factory.deleteMenuItem()];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function AdditionalHeaderItems(view: IProjectView) {
|
||||
return <></>;
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./invite-modal";
|
||||
export * from "./members-activity-button";
|
||||
@@ -1,6 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const MembersActivityButton = observer(function MembersActivityButton(props: { workspaceSlug: string }) {
|
||||
return <></>;
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./root";
|
||||
@@ -1,10 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
type TWorkspaceSettingsRightSidebarProps = { workspaceSlug: string };
|
||||
|
||||
export const WorkspaceSettingsRightSidebar = observer(function WorkspaceSettingsRightSidebar(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
props: TWorkspaceSettingsRightSidebarProps
|
||||
) {
|
||||
return <></>;
|
||||
});
|
||||
@@ -17,9 +17,8 @@ import { cn } from "@plane/utils";
|
||||
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { useWorkspaceNavigationPreferences } from "@/hooks/use-navigation-preferences";
|
||||
// local imports
|
||||
import { UpgradeBadge } from "../upgrade-badge";
|
||||
import { getSidebarNavigationItemIcon } from "./helper";
|
||||
@@ -50,33 +49,16 @@ export const ExtendedSidebarItem = observer(function ExtendedSidebarItem(props:
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { getNavigationPreferences, updateSidebarPreference } = useWorkspace();
|
||||
const { toggleExtendedSidebar } = useAppTheme();
|
||||
const { data } = useUser();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { preferences: workspacePreferences, toggleWorkspaceItem } = useWorkspaceNavigationPreferences();
|
||||
|
||||
// derived values
|
||||
const sidebarPreference = getNavigationPreferences(workspaceSlug.toString());
|
||||
const isPinned = sidebarPreference?.[item.key]?.is_pinned;
|
||||
const isPinned = workspacePreferences.items[item.key]?.is_pinned ?? false;
|
||||
|
||||
const handleLinkClick = () => toggleExtendedSidebar(true);
|
||||
|
||||
const itemHref =
|
||||
item.key === "your_work"
|
||||
? `/${workspaceSlug.toString()}${item.href}${data?.id}`
|
||||
: `/${workspaceSlug.toString()}${item.href}`;
|
||||
const isActive = itemHref === pathname;
|
||||
|
||||
const pinNavigationItem = (workspaceSlug: string, key: string) => {
|
||||
updateSidebarPreference(workspaceSlug, key, { is_pinned: true });
|
||||
};
|
||||
|
||||
const unPinNavigationItem = (workspaceSlug: string, key: string) => {
|
||||
updateSidebarPreference(workspaceSlug, key, { is_pinned: false });
|
||||
};
|
||||
|
||||
const icon = getSidebarNavigationItemIcon(item.key);
|
||||
|
||||
useEffect(() => {
|
||||
const element = navigationIemRef.current;
|
||||
const dragHandleElement = dragHandleRef.current;
|
||||
@@ -146,6 +128,22 @@ export const ExtendedSidebarItem = observer(function ExtendedSidebarItem(props:
|
||||
);
|
||||
}, [isLastChild, handleOnNavigationItemDrop, disableDrag, disableDrop, item.key]);
|
||||
|
||||
const itemHref =
|
||||
item.key === "your_work"
|
||||
? `/${workspaceSlug.toString()}${item.href}${data?.id}`
|
||||
: `/${workspaceSlug.toString()}${item.href}`;
|
||||
const isActive = itemHref === pathname;
|
||||
|
||||
const pinNavigationItem = (key: string) => {
|
||||
toggleWorkspaceItem(key, true);
|
||||
};
|
||||
|
||||
const unPinNavigationItem = (key: string) => {
|
||||
toggleWorkspaceItem(key, false);
|
||||
};
|
||||
|
||||
const icon = getSidebarNavigationItemIcon(item.key);
|
||||
|
||||
if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) {
|
||||
return null;
|
||||
}
|
||||
@@ -153,7 +151,9 @@ export const ExtendedSidebarItem = observer(function ExtendedSidebarItem(props:
|
||||
return (
|
||||
<div
|
||||
id={`sidebar-${item.key}`}
|
||||
className={cn("relative", { "bg-custom-sidebar-background-80 opacity-60": isDragging })}
|
||||
className={cn("relative", {
|
||||
"bg-custom-sidebar-background-80 opacity-60": isDragging,
|
||||
})}
|
||||
ref={navigationIemRef}
|
||||
>
|
||||
<DropIndicator classNames="absolute top-0" isVisible={instruction === "DRAG_OVER"} />
|
||||
@@ -167,15 +167,16 @@ export const ExtendedSidebarItem = observer(function ExtendedSidebarItem(props:
|
||||
<Tooltip
|
||||
// isMobile={isMobile}
|
||||
tooltipContent={t("drag_to_rearrange")}
|
||||
position="top-end"
|
||||
position="top-start"
|
||||
disabled={isDragging}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center justify-center absolute top-1/2 -left-3 -translate-y-1/2 rounded text-custom-sidebar-text-400 cursor-grab",
|
||||
"flex items-center justify-center absolute top-1/2 -left-3 -translate-y-1/2 rounded text-custom-sidebar-text-400 cursor-grab group-hover/project-item:opacity-100 opacity-0",
|
||||
{
|
||||
"cursor-grabbing": isDragging,
|
||||
"opacity-100": isDragging,
|
||||
}
|
||||
)}
|
||||
ref={dragHandleRef}
|
||||
@@ -201,14 +202,14 @@ export const ExtendedSidebarItem = observer(function ExtendedSidebarItem(props:
|
||||
<Tooltip tooltipContent="Unpin">
|
||||
<PinOff
|
||||
className="size-3.5 flex-shrink-0 hover:text-custom-text-300 outline-none text-custom-text-400"
|
||||
onClick={() => unPinNavigationItem(workspaceSlug.toString(), item.key)}
|
||||
onClick={() => unPinNavigationItem(item.key)}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip tooltipContent="Pin">
|
||||
<Pin
|
||||
className="size-3.5 flex-shrink-0 hover:text-custom-text-300 outline-none text-custom-text-400"
|
||||
onClick={() => pinNavigationItem(workspaceSlug.toString(), item.key)}
|
||||
onClick={() => pinNavigationItem(item.key)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Timer } from "lucide-react";
|
||||
// plane imports
|
||||
import { CycleIcon, IntakeIcon, ModuleIcon, PageIcon, ViewsIcon } from "@plane/propel/icons";
|
||||
import type { IProject } from "@plane/types";
|
||||
@@ -17,7 +16,6 @@ export type TProperties = {
|
||||
};
|
||||
|
||||
type TProjectBaseFeatureKeys = "cycles" | "modules" | "views" | "pages" | "inbox";
|
||||
type TProjectOtherFeatureKeys = "is_time_tracking_enabled";
|
||||
|
||||
type TBaseFeatureList = {
|
||||
[key in TProjectBaseFeatureKeys]: TProperties;
|
||||
@@ -71,22 +69,6 @@ export const PROJECT_BASE_FEATURES_LIST: TBaseFeatureList = {
|
||||
},
|
||||
};
|
||||
|
||||
type TOtherFeatureList = {
|
||||
[key in TProjectOtherFeatureKeys]: TProperties;
|
||||
};
|
||||
|
||||
export const PROJECT_OTHER_FEATURES_LIST: TOtherFeatureList = {
|
||||
is_time_tracking_enabled: {
|
||||
key: "time_tracking",
|
||||
property: "is_time_tracking_enabled",
|
||||
title: "Time Tracking",
|
||||
description: "Log time, see timesheets, and download full CSVs for your entire workspace.",
|
||||
icon: <Timer className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
|
||||
isPro: true,
|
||||
isEnabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
type TProjectFeatures = {
|
||||
project_features: {
|
||||
key: string;
|
||||
@@ -94,12 +76,6 @@ type TProjectFeatures = {
|
||||
description: string;
|
||||
featureList: TBaseFeatureList;
|
||||
};
|
||||
project_others: {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
featureList: TOtherFeatureList;
|
||||
};
|
||||
};
|
||||
|
||||
export const PROJECT_FEATURES_LIST: TProjectFeatures = {
|
||||
@@ -109,10 +85,4 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
|
||||
description: "Toggle these on or off this project.",
|
||||
featureList: PROJECT_BASE_FEATURES_LIST,
|
||||
},
|
||||
project_others: {
|
||||
key: "work_management",
|
||||
title: "Work management",
|
||||
description: "Available only on some plans as indicated by the label next to the feature below.",
|
||||
featureList: PROJECT_OTHER_FEATURES_LIST,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Pencil, ExternalLink, Link, Trash2, ArchiveRestoreIcon } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ArchiveIcon } from "@plane/propel/icons";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
|
||||
/**
|
||||
* Unified factory for creating menu items across all entities (cycles, modules, views, epics)
|
||||
*/
|
||||
export const useQuickActionsFactory = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
// Common menu items
|
||||
createEditMenuItem: (handler: () => void, shouldRender: boolean = true): TContextMenuItem => ({
|
||||
key: "edit",
|
||||
title: t("edit"),
|
||||
icon: Pencil,
|
||||
action: handler,
|
||||
shouldRender,
|
||||
}),
|
||||
|
||||
createOpenInNewTabMenuItem: (handler: () => void): TContextMenuItem => ({
|
||||
key: "open-new-tab",
|
||||
title: t("open_in_new_tab"),
|
||||
icon: ExternalLink,
|
||||
action: handler,
|
||||
}),
|
||||
|
||||
createCopyLinkMenuItem: (handler: () => void): TContextMenuItem => ({
|
||||
key: "copy-link",
|
||||
title: t("copy_link"),
|
||||
icon: Link,
|
||||
action: handler,
|
||||
}),
|
||||
|
||||
createArchiveMenuItem: (
|
||||
handler: () => void,
|
||||
opts: { shouldRender?: boolean; disabled?: boolean; description?: string }
|
||||
): TContextMenuItem => ({
|
||||
key: "archive",
|
||||
title: t("archive"),
|
||||
icon: ArchiveIcon,
|
||||
action: handler,
|
||||
className: "items-start",
|
||||
iconClassName: "mt-1",
|
||||
description: opts.description,
|
||||
disabled: opts.disabled,
|
||||
shouldRender: opts.shouldRender,
|
||||
}),
|
||||
|
||||
createRestoreMenuItem: (handler: () => void, shouldRender: boolean = true): TContextMenuItem => ({
|
||||
key: "restore",
|
||||
title: t("restore"),
|
||||
icon: ArchiveRestoreIcon,
|
||||
action: handler,
|
||||
shouldRender,
|
||||
}),
|
||||
|
||||
createDeleteMenuItem: (handler: () => void, shouldRender: boolean = true): TContextMenuItem => ({
|
||||
key: "delete",
|
||||
title: t("delete"),
|
||||
icon: Trash2,
|
||||
action: handler,
|
||||
shouldRender,
|
||||
}),
|
||||
|
||||
// Layout-level actions (for work item list views)
|
||||
createOpenInNewTab: (handler: () => void): TContextMenuItem => ({
|
||||
key: "open-in-new-tab",
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
action: handler,
|
||||
}),
|
||||
|
||||
createCopyLayoutLinkMenuItem: (handler: () => void): TContextMenuItem => ({
|
||||
key: "copy-link",
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
action: handler,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -1,145 +0,0 @@
|
||||
// types
|
||||
import type { ICycle, IModule, IProjectView, IWorkspaceView } from "@plane/types";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
// hooks
|
||||
import { useQuickActionsFactory } from "@/plane-web/components/common/quick-actions-factory";
|
||||
|
||||
// Types
|
||||
interface UseCycleMenuItemsProps {
|
||||
cycleDetails: ICycle | undefined;
|
||||
isEditingAllowed: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycleId: string;
|
||||
handleEdit: () => void;
|
||||
handleArchive: () => void;
|
||||
handleRestore: () => void;
|
||||
handleDelete: () => void;
|
||||
handleCopyLink: () => void;
|
||||
handleOpenInNewTab: () => void;
|
||||
}
|
||||
|
||||
interface UseModuleMenuItemsProps {
|
||||
moduleDetails: IModule | undefined;
|
||||
isEditingAllowed: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
moduleId: string;
|
||||
handleEdit: () => void;
|
||||
handleArchive: () => void;
|
||||
handleRestore: () => void;
|
||||
handleDelete: () => void;
|
||||
handleCopyLink: () => void;
|
||||
handleOpenInNewTab: () => void;
|
||||
}
|
||||
|
||||
interface UseViewMenuItemsProps {
|
||||
isOwner: boolean;
|
||||
isAdmin: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId?: string;
|
||||
view: IProjectView | IWorkspaceView;
|
||||
handleEdit: () => void;
|
||||
handleDelete: () => void;
|
||||
handleCopyLink: () => void;
|
||||
handleOpenInNewTab: () => void;
|
||||
}
|
||||
|
||||
interface UseLayoutMenuItemsProps {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
storeType: "PROJECT" | "EPIC";
|
||||
handleCopyLink: () => void;
|
||||
handleOpenInNewTab: () => void;
|
||||
}
|
||||
|
||||
type MenuResult = {
|
||||
items: TContextMenuItem[];
|
||||
modals: JSX.Element | null;
|
||||
};
|
||||
|
||||
export const useCycleMenuItems = (props: UseCycleMenuItemsProps): MenuResult => {
|
||||
const factory = useQuickActionsFactory();
|
||||
const { cycleDetails, isEditingAllowed, ...handlers } = props;
|
||||
|
||||
const isArchived = !!cycleDetails?.archived_at;
|
||||
const isCompleted = cycleDetails?.status?.toLowerCase() === "completed";
|
||||
|
||||
// Assemble final menu items - order defined here
|
||||
const items = [
|
||||
factory.createEditMenuItem(handlers.handleEdit, isEditingAllowed && !isCompleted && !isArchived),
|
||||
factory.createOpenInNewTabMenuItem(handlers.handleOpenInNewTab),
|
||||
factory.createCopyLinkMenuItem(handlers.handleCopyLink),
|
||||
factory.createArchiveMenuItem(handlers.handleArchive, {
|
||||
shouldRender: isEditingAllowed && !isArchived,
|
||||
disabled: !isCompleted,
|
||||
description: isCompleted ? undefined : "Only completed cycles can be archived",
|
||||
}),
|
||||
factory.createRestoreMenuItem(handlers.handleRestore, isEditingAllowed && isArchived),
|
||||
factory.createDeleteMenuItem(handlers.handleDelete, isEditingAllowed && !isCompleted && !isArchived),
|
||||
].filter((item) => item.shouldRender !== false);
|
||||
|
||||
return { items, modals: null };
|
||||
};
|
||||
|
||||
export const useModuleMenuItems = (props: UseModuleMenuItemsProps): MenuResult => {
|
||||
const factory = useQuickActionsFactory();
|
||||
const { moduleDetails, isEditingAllowed, ...handlers } = props;
|
||||
|
||||
const isArchived = !!moduleDetails?.archived_at;
|
||||
const moduleState = moduleDetails?.status?.toLocaleLowerCase();
|
||||
const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState);
|
||||
|
||||
// Assemble final menu items - order defined here
|
||||
const items = [
|
||||
factory.createEditMenuItem(handlers.handleEdit, isEditingAllowed && !isArchived),
|
||||
factory.createOpenInNewTabMenuItem(handlers.handleOpenInNewTab),
|
||||
factory.createCopyLinkMenuItem(handlers.handleCopyLink),
|
||||
factory.createArchiveMenuItem(handlers.handleArchive, {
|
||||
shouldRender: isEditingAllowed && !isArchived,
|
||||
disabled: !isInArchivableGroup,
|
||||
description: isInArchivableGroup ? undefined : "Only completed or cancelled modules can be archived",
|
||||
}),
|
||||
factory.createRestoreMenuItem(handlers.handleRestore, isEditingAllowed && isArchived),
|
||||
factory.createDeleteMenuItem(handlers.handleDelete, isEditingAllowed && !isArchived),
|
||||
].filter((item) => item.shouldRender !== false);
|
||||
|
||||
return { items, modals: null };
|
||||
};
|
||||
|
||||
export const useViewMenuItems = (props: UseViewMenuItemsProps): MenuResult => {
|
||||
const factory = useQuickActionsFactory();
|
||||
const { workspaceSlug, isOwner, isAdmin, projectId, view, ...handlers } = props;
|
||||
|
||||
if (!view) return { items: [], modals: null };
|
||||
|
||||
// Assemble final menu items - order defined here
|
||||
const items = [
|
||||
factory.createEditMenuItem(handlers.handleEdit, isOwner),
|
||||
factory.createOpenInNewTabMenuItem(handlers.handleOpenInNewTab),
|
||||
factory.createCopyLinkMenuItem(handlers.handleCopyLink),
|
||||
factory.createDeleteMenuItem(handlers.handleDelete, isOwner || isAdmin),
|
||||
].filter((item) => item.shouldRender !== false);
|
||||
|
||||
return { items, modals: null };
|
||||
};
|
||||
|
||||
export const useLayoutMenuItems = (props: UseLayoutMenuItemsProps): MenuResult => {
|
||||
const factory = useQuickActionsFactory();
|
||||
const { ...handlers } = props;
|
||||
|
||||
// Assemble final menu items - order defined here
|
||||
const items = [
|
||||
factory.createOpenInNewTab(handlers.handleOpenInNewTab),
|
||||
factory.createCopyLayoutLinkMenuItem(handlers.handleCopyLink),
|
||||
].filter((item) => item.shouldRender !== false);
|
||||
|
||||
return { items, modals: null };
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const useIntakeHeaderMenuItems = (props: {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
handleCopyLink: () => void;
|
||||
}): MenuResult => ({ items: [], modals: null });
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
// icons
|
||||
import { ArchiveRestoreIcon, ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react";
|
||||
// ui
|
||||
import {
|
||||
CYCLE_TRACKER_EVENTS,
|
||||
@@ -9,17 +11,18 @@ import {
|
||||
CYCLE_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ArchiveIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { ContextMenu, CustomMenu } from "@plane/ui";
|
||||
import { copyUrlToClipboard, cn } from "@plane/utils";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useCycleMenuItems } from "@/components/common/quick-actions-helper";
|
||||
import { captureClick, captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useEndCycle, EndCycleModal } from "@/plane-web/components/cycles";
|
||||
// local imports
|
||||
import { ArchiveCycleModal } from "./archived-cycles/modal";
|
||||
import { CycleDeleteModal } from "./delete-modal";
|
||||
@@ -47,6 +50,12 @@ export const CycleQuickActions = observer(function CycleQuickActions(props: Prop
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
const isArchived = !!cycleDetails?.archived_at;
|
||||
const isCompleted = cycleDetails?.status?.toLowerCase() === "completed";
|
||||
const isCurrentCycle = cycleDetails?.status?.toLowerCase() === "current";
|
||||
const transferrableIssuesCount = cycleDetails
|
||||
? cycleDetails.total_issues - (cycleDetails.cancelled_issues + cycleDetails.completed_issues)
|
||||
: 0;
|
||||
// auth
|
||||
const isEditingAllowed = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
@@ -55,6 +64,8 @@ export const CycleQuickActions = observer(function CycleQuickActions(props: Prop
|
||||
projectId
|
||||
);
|
||||
|
||||
const { isEndCycleModalOpen, setEndCycleModalOpen, endCycleContextMenu } = useEndCycle(isCurrentCycle);
|
||||
|
||||
const cycleLink = `${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`;
|
||||
const handleCopyText = () =>
|
||||
copyUrlToClipboard(cycleLink).then(() => {
|
||||
@@ -66,6 +77,12 @@ export const CycleQuickActions = observer(function CycleQuickActions(props: Prop
|
||||
});
|
||||
const handleOpenInNewTab = () => window.open(`/${cycleLink}`, "_blank");
|
||||
|
||||
const handleEditCycle = () => {
|
||||
setUpdateModal(true);
|
||||
};
|
||||
|
||||
const handleArchiveCycle = () => setArchiveCycleModal(true);
|
||||
|
||||
const handleRestoreCycle = async () =>
|
||||
await restoreCycle(workspaceSlug, projectId, cycleId)
|
||||
.then(() => {
|
||||
@@ -96,22 +113,60 @@ export const CycleQuickActions = observer(function CycleQuickActions(props: Prop
|
||||
});
|
||||
});
|
||||
|
||||
const menuResult = useCycleMenuItems({
|
||||
cycleDetails: cycleDetails ?? undefined,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cycleId,
|
||||
isEditingAllowed,
|
||||
handleEdit: () => setUpdateModal(true),
|
||||
handleArchive: () => setArchiveCycleModal(true),
|
||||
handleRestore: handleRestoreCycle,
|
||||
handleDelete: () => setDeleteModal(true),
|
||||
handleCopyLink: handleCopyText,
|
||||
handleOpenInNewTab,
|
||||
});
|
||||
const handleDeleteCycle = () => {
|
||||
setDeleteModal(true);
|
||||
};
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = Array.isArray(menuResult) ? menuResult : menuResult.items;
|
||||
const additionalModals = Array.isArray(menuResult) ? null : menuResult.modals;
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
title: t("edit"),
|
||||
icon: Pencil,
|
||||
action: handleEditCycle,
|
||||
shouldRender: isEditingAllowed && !isCompleted && !isArchived,
|
||||
},
|
||||
{
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: t("open_in_new_tab"),
|
||||
icon: ExternalLink,
|
||||
shouldRender: !isArchived,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: t("copy_link"),
|
||||
icon: LinkIcon,
|
||||
shouldRender: !isArchived,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
action: handleArchiveCycle,
|
||||
title: t("archive"),
|
||||
description: isCompleted ? undefined : t("project_cycles.only_completed_cycles_can_be_archived"),
|
||||
icon: ArchiveIcon,
|
||||
className: "items-start",
|
||||
iconClassName: "mt-1",
|
||||
shouldRender: isEditingAllowed && !isArchived,
|
||||
disabled: !isCompleted,
|
||||
},
|
||||
{
|
||||
key: "restore",
|
||||
action: handleRestoreCycle,
|
||||
title: t("restore"),
|
||||
icon: ArchiveRestoreIcon,
|
||||
shouldRender: isEditingAllowed && isArchived,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: handleDeleteCycle,
|
||||
title: t("delete"),
|
||||
icon: Trash2,
|
||||
shouldRender: isEditingAllowed && !isCompleted && !isArchived,
|
||||
},
|
||||
];
|
||||
|
||||
if (endCycleContextMenu) MENU_ITEMS.splice(3, 0, endCycleContextMenu);
|
||||
|
||||
const CONTEXT_MENU_ITEMS = MENU_ITEMS.map(function CONTEXT_MENU_ITEMS(item) {
|
||||
return {
|
||||
@@ -151,7 +206,17 @@ export const CycleQuickActions = observer(function CycleQuickActions(props: Prop
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
{additionalModals}
|
||||
{isCurrentCycle && (
|
||||
<EndCycleModal
|
||||
isOpen={isEndCycleModalOpen}
|
||||
handleClose={() => setEndCycleModalOpen(false)}
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
transferrableIssuesCount={transferrableIssuesCount}
|
||||
cycleName={cycleDetails.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
|
||||
|
||||
@@ -72,7 +72,6 @@ export const LiteTextEditor = React.forwardRef(function LiteTextEditor(
|
||||
placeholder = t("issue.comments.placeholder"),
|
||||
disabledExtensions: additionalDisabledExtensions = [],
|
||||
editorClassName = "",
|
||||
showPlaceholderOnEmpty = true,
|
||||
...rest
|
||||
} = props;
|
||||
// states
|
||||
@@ -155,7 +154,6 @@ export const LiteTextEditor = React.forwardRef(function LiteTextEditor(
|
||||
}),
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
showPlaceholderOnEmpty={showPlaceholderOnEmpty}
|
||||
containerClassName={cn(containerClassName, "relative", {
|
||||
"p-2": !editable,
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { FC } from "react";
|
||||
import { Info } from "lucide-react";
|
||||
// plane imports
|
||||
import { EEstimateSystem, ESTIMATE_SYSTEMS } from "@plane/constants";
|
||||
@@ -32,29 +31,32 @@ export function EstimateCreateStageOne(props: TEstimateCreateStageOne) {
|
||||
<div className="space-y-6">
|
||||
<div className="sm:flex sm:items-center sm:space-x-10 sm:space-y-0 gap-2 mb-2">
|
||||
<RadioInput
|
||||
options={Object.keys(ESTIMATE_SYSTEMS).map((system) => {
|
||||
const currentSystem = system as TEstimateSystemKeys;
|
||||
const isEnabled = isEstimateSystemEnabled(currentSystem);
|
||||
return {
|
||||
label: !ESTIMATE_SYSTEMS[currentSystem]?.is_available ? (
|
||||
<div className="relative flex items-center gap-2 cursor-no-drop text-custom-text-300">
|
||||
{t(ESTIMATE_SYSTEMS[currentSystem]?.i18n_name)}
|
||||
<Tooltip tooltipContent={t("common.coming_soon")}>
|
||||
<Info size={12} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : !isEnabled ? (
|
||||
<div className="relative flex items-center gap-2 cursor-no-drop text-custom-text-300">
|
||||
{t(ESTIMATE_SYSTEMS[currentSystem]?.i18n_name)}
|
||||
<UpgradeBadge />
|
||||
</div>
|
||||
) : (
|
||||
<div>{t(ESTIMATE_SYSTEMS[currentSystem]?.i18n_name)}</div>
|
||||
),
|
||||
value: system,
|
||||
disabled: !isEnabled,
|
||||
};
|
||||
})}
|
||||
options={Object.keys(ESTIMATE_SYSTEMS)
|
||||
.map((system) => {
|
||||
const currentSystem = system as TEstimateSystemKeys;
|
||||
const isEnabled = isEstimateSystemEnabled(currentSystem);
|
||||
if (!isEnabled) return null;
|
||||
return {
|
||||
label: !ESTIMATE_SYSTEMS[currentSystem]?.is_available ? (
|
||||
<div className="relative flex items-center gap-2 cursor-no-drop text-custom-text-300">
|
||||
{t(ESTIMATE_SYSTEMS[currentSystem]?.i18n_name)}
|
||||
<Tooltip tooltipContent={t("common.coming_soon")}>
|
||||
<Info size={12} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : !isEnabled ? (
|
||||
<div className="relative flex items-center gap-2 cursor-no-drop text-custom-text-300">
|
||||
{t(ESTIMATE_SYSTEMS[currentSystem]?.i18n_name)}
|
||||
<UpgradeBadge />
|
||||
</div>
|
||||
) : (
|
||||
<div>{t(ESTIMATE_SYSTEMS[currentSystem]?.i18n_name)}</div>
|
||||
),
|
||||
value: system,
|
||||
disabled: !isEnabled,
|
||||
};
|
||||
})
|
||||
.filter((option) => option !== null)}
|
||||
name="estimate-radio-input"
|
||||
label={t("project_settings.estimates.create.choose_estimate_system")}
|
||||
labelClassName="text-sm font-medium text-custom-text-200 mb-1.5"
|
||||
@@ -99,7 +101,7 @@ export function EstimateCreateStageOne(props: TEstimateCreateStageOne) {
|
||||
<p className="text-xs text-custom-text-300">
|
||||
{currentEstimateSystem.templates[name]?.values
|
||||
?.map((template) =>
|
||||
estimateSystem === EEstimateSystem.TIME
|
||||
estimateSystem === (EEstimateSystem.TIME as TEstimateSystemKeys)
|
||||
? convertMinutesToHoursMinutesString(Number(template.value)).trim()
|
||||
: template.value
|
||||
)
|
||||
|
||||
@@ -2,24 +2,24 @@ import { useState } from "react";
|
||||
import { intersection } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Info } from "lucide-react";
|
||||
// import { Info } from "lucide-react";
|
||||
import {
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
EXPORTERS_LIST,
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
// ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
WORKSPACE_SETTINGS_TRACKER_EVENTS,
|
||||
WORKSPACE_SETTINGS_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
// import { Tooltip } from "@plane/propel/tooltip";
|
||||
// import { EIssuesStoreType } from "@plane/types";
|
||||
import type { TWorkItemFilterExpression } from "@plane/types";
|
||||
import { CustomSearchSelect, CustomSelect } from "@plane/ui";
|
||||
import { WorkspaceLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/workspace-level";
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
|
||||
// import { WorkspaceLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/workspace-level";
|
||||
// import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
@@ -37,15 +37,15 @@ type FormData = {
|
||||
filters: TWorkItemFilterExpression;
|
||||
};
|
||||
|
||||
const initialWorkItemFilters = {
|
||||
richFilters: {},
|
||||
displayFilters: {},
|
||||
displayProperties: {},
|
||||
kanbanFilters: {
|
||||
group_by: [],
|
||||
sub_group_by: [],
|
||||
},
|
||||
};
|
||||
// const initialWorkItemFilters = {
|
||||
// richFilters: {},
|
||||
// displayFilters: {},
|
||||
// displayProperties: {},
|
||||
// kanbanFilters: {
|
||||
// group_by: [],
|
||||
// sub_group_by: [],
|
||||
// },
|
||||
// };
|
||||
|
||||
const projectExportService = new ProjectExportService();
|
||||
|
||||
@@ -101,52 +101,57 @@ export const ExportForm = observer(function ExportForm(props: Props) {
|
||||
multiple: formData.project.length > 1,
|
||||
rich_filters: formData.filters,
|
||||
};
|
||||
await projectExportService
|
||||
.csvExport(workspaceSlug, payload)
|
||||
.then(() => {
|
||||
mutateServices();
|
||||
setExportLoading(false);
|
||||
captureSuccess({
|
||||
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.csv_exported,
|
||||
payload: {
|
||||
provider: formData.provider.provider,
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("workspace_settings.settings.exports.modal.toasts.success.title"),
|
||||
message: t("workspace_settings.settings.exports.modal.toasts.success.message", {
|
||||
entity:
|
||||
formData.provider.provider === "csv"
|
||||
? "CSV"
|
||||
: formData.provider.provider === "xlsx"
|
||||
? "Excel"
|
||||
: formData.provider.provider === "json"
|
||||
? "JSON"
|
||||
: "",
|
||||
}),
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
setExportLoading(false);
|
||||
captureError({
|
||||
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.csv_exported,
|
||||
payload: {
|
||||
provider: formData.provider.provider,
|
||||
},
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("workspace_settings.settings.exports.modal.toasts.error.message"),
|
||||
});
|
||||
try {
|
||||
await projectExportService.csvExport(workspaceSlug, payload);
|
||||
mutateServices();
|
||||
setExportLoading(false);
|
||||
captureSuccess({
|
||||
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.csv_exported,
|
||||
payload: {
|
||||
provider: formData.provider.provider,
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("workspace_settings.settings.exports.modal.toasts.success.title"),
|
||||
message: t("workspace_settings.settings.exports.modal.toasts.success.message", {
|
||||
entity:
|
||||
formData.provider.provider === "csv"
|
||||
? "CSV"
|
||||
: formData.provider.provider === "xlsx"
|
||||
? "Excel"
|
||||
: formData.provider.provider === "json"
|
||||
? "JSON"
|
||||
: "",
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
setExportLoading(false);
|
||||
captureError({
|
||||
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.csv_exported,
|
||||
payload: {
|
||||
provider: formData.provider.provider,
|
||||
},
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("workspace_settings.settings.exports.modal.toasts.error.message"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setExportLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(ExportCSVToMail)} className="flex flex-col gap-4 mt-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(ExportCSVToMail)(e);
|
||||
}}
|
||||
className="flex flex-col gap-4 mt-4"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
{/* Project Selector */}
|
||||
<div className="w-1/2">
|
||||
@@ -210,7 +215,7 @@ export const ExportForm = observer(function ExportForm(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
{/* Rich Filters */}
|
||||
<div className="w-full">
|
||||
{/* <div className="w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="text-sm font-medium text-custom-text-200 leading-tight">{t("common.filters")}</div>
|
||||
<Tooltip
|
||||
@@ -251,7 +256,7 @@ export const ExportForm = observer(function ExportForm(props: Props) {
|
||||
</WorkspaceLevelWorkItemFiltersHOC>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="primary"
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { copyUrlToClipboard, cn } from "@plane/utils";
|
||||
import { useLayoutMenuItems } from "@/components/common/quick-actions-helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
storeType: "PROJECT" | "EPIC";
|
||||
};
|
||||
|
||||
export const LayoutQuickActions: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, storeType } = props;
|
||||
|
||||
const layoutLink = `${workspaceSlug}/projects/${projectId}/${storeType === "EPIC" ? "epics" : "issues"}`;
|
||||
|
||||
const handleCopyLink = () =>
|
||||
copyUrlToClipboard(layoutLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link copied",
|
||||
message: `${storeType === "EPIC" ? "Epics" : "Work items"} link copied to clipboard.`,
|
||||
});
|
||||
});
|
||||
|
||||
const handleOpenInNewTab = () => window.open(`/${layoutLink}`, "_blank");
|
||||
|
||||
const menuResult = useLayoutMenuItems({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
storeType,
|
||||
handleCopyLink,
|
||||
handleOpenInNewTab,
|
||||
});
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = Array.isArray(menuResult) ? menuResult : menuResult.items;
|
||||
const additionalModals = Array.isArray(menuResult) ? null : menuResult.modals;
|
||||
|
||||
return (
|
||||
<>
|
||||
{additionalModals}
|
||||
<CustomMenu
|
||||
ellipsis
|
||||
placement="bottom-end"
|
||||
closeOnSelect
|
||||
maxHeight="lg"
|
||||
className="flex-shrink-0 flex items-center justify-center size-[26px] bg-custom-background-80/70 rounded"
|
||||
>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={item.action}
|
||||
className={cn("flex items-center gap-2", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className="h-3 w-3" />}
|
||||
<span>{item.title}</span>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
// icons
|
||||
import { ArchiveRestoreIcon, ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react";
|
||||
// plane imports
|
||||
import {
|
||||
EUserPermissions,
|
||||
@@ -9,12 +11,13 @@ import {
|
||||
MODULE_TRACKER_EVENTS,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { ArchiveIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { ContextMenu, CustomMenu } from "@plane/ui";
|
||||
import { copyUrlToClipboard, cn } from "@plane/utils";
|
||||
// components
|
||||
import { useModuleMenuItems } from "@/components/common/quick-actions-helper";
|
||||
import { ArchiveModuleModal, CreateUpdateModuleModal, DeleteModuleModal } from "@/components/modules";
|
||||
// helpers
|
||||
import { captureClick, captureSuccess, captureError } from "@/helpers/event-tracker.helper";
|
||||
@@ -47,6 +50,7 @@ export const ModuleQuickActions = observer(function ModuleQuickActions(props: Pr
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
const isArchived = !!moduleDetails?.archived_at;
|
||||
// auth
|
||||
const isEditingAllowed = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
@@ -55,6 +59,9 @@ export const ModuleQuickActions = observer(function ModuleQuickActions(props: Pr
|
||||
projectId
|
||||
);
|
||||
|
||||
const moduleState = moduleDetails?.status?.toLocaleLowerCase();
|
||||
const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState);
|
||||
|
||||
const moduleLink = `${workspaceSlug}/projects/${projectId}/modules/${moduleId}`;
|
||||
const handleCopyText = () =>
|
||||
copyUrlToClipboard(moduleLink).then(() => {
|
||||
@@ -66,6 +73,12 @@ export const ModuleQuickActions = observer(function ModuleQuickActions(props: Pr
|
||||
});
|
||||
const handleOpenInNewTab = () => window.open(`/${moduleLink}`, "_blank");
|
||||
|
||||
const handleEditModule = () => {
|
||||
setEditModal(true);
|
||||
};
|
||||
|
||||
const handleArchiveModule = () => setArchiveModuleModal(true);
|
||||
|
||||
const handleRestoreModule = async () =>
|
||||
await restoreModule(workspaceSlug, projectId, moduleId)
|
||||
.then(() => {
|
||||
@@ -93,34 +106,71 @@ export const ModuleQuickActions = observer(function ModuleQuickActions(props: Pr
|
||||
});
|
||||
});
|
||||
|
||||
// Use unified menu hook from plane-web (resolves to CE or EE)
|
||||
const menuResult = useModuleMenuItems({
|
||||
moduleDetails: moduleDetails ?? undefined,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
moduleId,
|
||||
isEditingAllowed,
|
||||
handleEdit: () => setEditModal(true),
|
||||
handleArchive: () => setArchiveModuleModal(true),
|
||||
handleRestore: handleRestoreModule,
|
||||
handleDelete: () => setDeleteModal(true),
|
||||
handleCopyLink: handleCopyText,
|
||||
handleOpenInNewTab,
|
||||
});
|
||||
const handleDeleteModule = () => {
|
||||
setDeleteModal(true);
|
||||
};
|
||||
|
||||
// Handle both CE (array) and EE (object) return types
|
||||
const MENU_ITEMS: TContextMenuItem[] = Array.isArray(menuResult) ? menuResult : menuResult.items;
|
||||
const additionalModals = Array.isArray(menuResult) ? null : menuResult.modals;
|
||||
|
||||
const CONTEXT_MENU_ITEMS: TContextMenuItem[] = MENU_ITEMS.map((item) => ({
|
||||
...item,
|
||||
action: () => {
|
||||
captureClick({
|
||||
elementName: MODULE_TRACKER_ELEMENTS.CONTEXT_MENU,
|
||||
});
|
||||
item.action();
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
title: t("edit"),
|
||||
icon: Pencil,
|
||||
action: handleEditModule,
|
||||
shouldRender: isEditingAllowed && !isArchived,
|
||||
},
|
||||
}));
|
||||
{
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: t("open_in_new_tab"),
|
||||
icon: ExternalLink,
|
||||
shouldRender: !isArchived,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: t("copy_link"),
|
||||
icon: LinkIcon,
|
||||
shouldRender: !isArchived,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
action: handleArchiveModule,
|
||||
title: t("archive"),
|
||||
description: isInArchivableGroup ? undefined : t("project_module.quick_actions.archive_module_description"),
|
||||
icon: ArchiveIcon,
|
||||
className: "items-start",
|
||||
iconClassName: "mt-1",
|
||||
shouldRender: isEditingAllowed && !isArchived,
|
||||
disabled: !isInArchivableGroup,
|
||||
},
|
||||
{
|
||||
key: "restore",
|
||||
action: handleRestoreModule,
|
||||
title: t("restore"),
|
||||
icon: ArchiveRestoreIcon,
|
||||
shouldRender: isEditingAllowed && isArchived,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: handleDeleteModule,
|
||||
title: t("delete"),
|
||||
icon: Trash2,
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
];
|
||||
|
||||
const CONTEXT_MENU_ITEMS = MENU_ITEMS.map(function CONTEXT_MENU_ITEMS(item) {
|
||||
return {
|
||||
...item,
|
||||
|
||||
onClick: () => {
|
||||
captureClick({
|
||||
elementName: MODULE_TRACKER_ELEMENTS.CONTEXT_MENU,
|
||||
});
|
||||
item.action();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -141,7 +191,6 @@ export const ModuleQuickActions = observer(function ModuleQuickActions(props: Pr
|
||||
handleClose={() => setArchiveModuleModal(false)}
|
||||
/>
|
||||
<DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
|
||||
{additionalModals}
|
||||
</div>
|
||||
)}
|
||||
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
|
||||
export const ContentLimitBanner: React.FC<Props> = ({ className, onDismiss }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 bg-custom-background-80 border-b border-custom-border-200 px-4 py-2.5 text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200 mx-auto">
|
||||
<span className="text-amber-500">
|
||||
<TriangleAlert />
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
Content limit reached and live sync is off. Create a new page or use nested pages to continue syncing.
|
||||
</span>
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="ml-auto text-custom-text-300 hover:text-custom-text-200"
|
||||
aria-label="Dismiss content limit warning"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
|
||||
import { CollaborativeDocumentEditorWithRef } from "@plane/editor";
|
||||
import type {
|
||||
CollaborationState,
|
||||
EditorRefApi,
|
||||
EditorTitleRefApi,
|
||||
TAIMenuProps,
|
||||
TDisplayConfig,
|
||||
TFileHandler,
|
||||
@@ -26,6 +27,8 @@ import { useUser } from "@/hooks/store/user";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||
// plane web imports
|
||||
import type { TCustomEventHandlers } from "@/hooks/use-realtime-page-events";
|
||||
import { useRealtimePageEvents } from "@/hooks/use-realtime-page-events";
|
||||
import { EditorAIMenu } from "@/plane-web/components/pages";
|
||||
import type { TExtendedEditorExtensionsConfig } from "@/plane-web/hooks/pages";
|
||||
import type { EPageStoreType } from "@/plane-web/hooks/store";
|
||||
@@ -51,7 +54,6 @@ type Props = {
|
||||
config: TEditorBodyConfig;
|
||||
editorReady: boolean;
|
||||
editorForwardRef: React.RefObject<EditorRefApi>;
|
||||
handleConnectionStatus: Dispatch<SetStateAction<boolean>>;
|
||||
handleEditorReady: (status: boolean) => void;
|
||||
handleOpenNavigationPane: () => void;
|
||||
handlers: TEditorBodyHandlers;
|
||||
@@ -61,14 +63,16 @@ type Props = {
|
||||
projectId?: string;
|
||||
workspaceSlug: string;
|
||||
storeType: EPageStoreType;
|
||||
customRealtimeEventHandlers?: TCustomEventHandlers;
|
||||
extendedEditorProps: TExtendedEditorExtensionsConfig;
|
||||
isFetchingFallbackBinary?: boolean;
|
||||
onCollaborationStateChange?: (state: CollaborationState) => void;
|
||||
};
|
||||
|
||||
export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
||||
const {
|
||||
config,
|
||||
editorForwardRef,
|
||||
handleConnectionStatus,
|
||||
handleEditorReady,
|
||||
handleOpenNavigationPane,
|
||||
handlers,
|
||||
@@ -79,7 +83,11 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
extendedEditorProps,
|
||||
isFetchingFallbackBinary,
|
||||
onCollaborationStateChange,
|
||||
} = props;
|
||||
// refs
|
||||
const titleEditorRef = useRef<EditorTitleRefApi>(null);
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
@@ -87,10 +95,9 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
||||
// derived values
|
||||
const {
|
||||
id: pageId,
|
||||
name: pageTitle,
|
||||
isContentEditable,
|
||||
updateTitle,
|
||||
editor: { editorRef, updateAssetsList },
|
||||
setSyncingStatus,
|
||||
} = page;
|
||||
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
|
||||
// use editor mention
|
||||
@@ -123,6 +130,24 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
||||
[fontSize, fontStyle, isFullWidth]
|
||||
);
|
||||
|
||||
// Use the new hook to handle page events
|
||||
const { updatePageProperties } = useRealtimePageEvents({
|
||||
storeType,
|
||||
page,
|
||||
getUserDetails,
|
||||
handlers,
|
||||
});
|
||||
|
||||
// Set syncing status when page changes and reset collaboration state
|
||||
useEffect(() => {
|
||||
setSyncingStatus("syncing");
|
||||
onCollaborationStateChange?.({
|
||||
stage: { kind: "connecting" },
|
||||
isServerSynced: false,
|
||||
isServerDisconnected: false,
|
||||
});
|
||||
}, [pageId, setSyncingStatus, onCollaborationStateChange]);
|
||||
|
||||
const getAIMenu = useCallback(
|
||||
({ isOpen, onClose }: TAIMenuProps) => (
|
||||
<EditorAIMenu
|
||||
@@ -136,20 +161,25 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
||||
[editorRef, workspaceId, workspaceSlug]
|
||||
);
|
||||
|
||||
const handleServerConnect = useCallback(() => {
|
||||
handleConnectionStatus(false);
|
||||
}, [handleConnectionStatus]);
|
||||
|
||||
const handleServerError = useCallback(() => {
|
||||
handleConnectionStatus(true);
|
||||
}, [handleConnectionStatus]);
|
||||
|
||||
const serverHandler: TServerHandler = useMemo(
|
||||
() => ({
|
||||
onConnect: handleServerConnect,
|
||||
onServerError: handleServerError,
|
||||
onStateChange: (state) => {
|
||||
// Pass full state to parent
|
||||
onCollaborationStateChange?.(state);
|
||||
|
||||
// Map collaboration stage to UI syncing status
|
||||
// Stage → UI mapping: disconnected → error | synced → synced | all others → syncing
|
||||
if (state.stage.kind === "disconnected") {
|
||||
setSyncingStatus("error");
|
||||
} else if (state.stage.kind === "synced") {
|
||||
setSyncingStatus("synced");
|
||||
} else {
|
||||
// initial, connecting, awaiting-sync, reconnecting → show as syncing
|
||||
setSyncingStatus("syncing");
|
||||
}
|
||||
},
|
||||
}),
|
||||
[handleServerConnect, handleServerError]
|
||||
[setSyncingStatus, onCollaborationStateChange]
|
||||
);
|
||||
|
||||
const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => {
|
||||
@@ -194,7 +224,9 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
||||
}
|
||||
);
|
||||
|
||||
if (pageId === undefined || !realtimeConfig) return <PageContentLoader className={blockWidthClassName} />;
|
||||
const isPageLoading = pageId === undefined || !realtimeConfig;
|
||||
|
||||
if (isPageLoading) return <PageContentLoader className={blockWidthClassName} />;
|
||||
|
||||
return (
|
||||
<Row
|
||||
@@ -225,12 +257,6 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
||||
<div className="page-header-container group/page-header">
|
||||
<div className={blockWidthClassName}>
|
||||
<PageEditorHeaderRoot page={page} projectId={projectId} />
|
||||
<PageEditorTitle
|
||||
editorRef={editorRef}
|
||||
readOnly={!isContentEditable}
|
||||
title={pageTitle}
|
||||
updateTitle={updateTitle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CollaborativeDocumentEditorWithRef
|
||||
@@ -239,6 +265,7 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
||||
fileHandler={config.fileHandler}
|
||||
handleEditorReady={handleEditorReady}
|
||||
ref={editorForwardRef}
|
||||
titleRef={titleEditorRef}
|
||||
containerClassName="h-full p-0 pb-64"
|
||||
displayConfig={displayConfig}
|
||||
getEditorMetaData={getEditorMetaData}
|
||||
@@ -251,6 +278,7 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
||||
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||
getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }),
|
||||
}}
|
||||
updatePageProperties={updatePageProperties}
|
||||
realtimeConfig={realtimeConfig}
|
||||
serverHandler={serverHandler}
|
||||
user={userConfig}
|
||||
@@ -261,6 +289,7 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
||||
}}
|
||||
onAssetChange={updateAssetsList}
|
||||
extendedEditorProps={extendedEditorProps}
|
||||
isFetchingFallbackBinary={isFetchingFallbackBinary}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import type { CollaborationState, EditorRefApi } from "@plane/editor";
|
||||
import type { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types";
|
||||
// hooks
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePageFallback } from "@/hooks/use-page-fallback";
|
||||
// plane web import
|
||||
import type { PageUpdateHandler, TCustomEventHandlers } from "@/hooks/use-realtime-page-events";
|
||||
import { PageModals } from "@/plane-web/components/pages";
|
||||
import { usePagesPaneExtensions, useExtendedEditorProps } from "@/plane-web/hooks/pages";
|
||||
import type { EPageStoreType } from "@/plane-web/hooks/store";
|
||||
@@ -16,6 +16,7 @@ import type { TPageInstance } from "@/store/pages/base-page";
|
||||
import { PageNavigationPaneRoot } from "../navigation-pane";
|
||||
import { PageVersionsOverlay } from "../version";
|
||||
import { PagesVersionEditor } from "../version/editor";
|
||||
import { ContentLimitBanner } from "./content-limit-banner";
|
||||
import { PageEditorBody } from "./editor-body";
|
||||
import type { TEditorBodyConfig, TEditorBodyHandlers } from "./editor-body";
|
||||
import { PageEditorToolbarRoot } from "./toolbar";
|
||||
@@ -23,7 +24,7 @@ import { PageEditorToolbarRoot } from "./toolbar";
|
||||
export type TPageRootHandlers = {
|
||||
create: (payload: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
|
||||
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
|
||||
fetchDescriptionBinary: () => Promise<any>;
|
||||
fetchDescriptionBinary: () => Promise<ArrayBuffer>;
|
||||
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
|
||||
restoreVersion: (pageId: string, versionId: string) => Promise<void>;
|
||||
updateDescription: (document: TDocumentPayload) => Promise<void>;
|
||||
@@ -39,27 +40,37 @@ type TPageRootProps = {
|
||||
webhookConnectionParams: TWebhookConnectionQueryParams;
|
||||
projectId?: string;
|
||||
workspaceSlug: string;
|
||||
customRealtimeEventHandlers?: TCustomEventHandlers;
|
||||
};
|
||||
|
||||
export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
|
||||
const { config, handlers, page, projectId, storeType, webhookConnectionParams, workspaceSlug } = props;
|
||||
export const PageRoot = observer((props: TPageRootProps) => {
|
||||
const {
|
||||
config,
|
||||
handlers,
|
||||
page,
|
||||
projectId,
|
||||
storeType,
|
||||
webhookConnectionParams,
|
||||
workspaceSlug,
|
||||
customRealtimeEventHandlers,
|
||||
} = props;
|
||||
// states
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
|
||||
const [collaborationState, setCollaborationState] = useState<CollaborationState | null>(null);
|
||||
const [showContentTooLargeBanner, setShowContentTooLargeBanner] = useState(false);
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// derived values
|
||||
const {
|
||||
isContentEditable,
|
||||
editor: { setEditorRef },
|
||||
} = page;
|
||||
// page fallback
|
||||
usePageFallback({
|
||||
const { isFetchingFallbackBinary } = usePageFallback({
|
||||
editorRef,
|
||||
fetchPageDescription: handlers.fetchDescriptionBinary,
|
||||
hasConnectionFailed,
|
||||
page,
|
||||
collaborationState,
|
||||
updatePageDescription: handlers.updateDescription,
|
||||
});
|
||||
|
||||
@@ -91,6 +102,24 @@ export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
|
||||
editorRef,
|
||||
});
|
||||
|
||||
// Type-safe error handler for content too large errors
|
||||
const errorHandler: PageUpdateHandler<"error"> = (params) => {
|
||||
const { data } = params;
|
||||
|
||||
// Check if it's content too large error
|
||||
if (data.error_code === "content_too_large") {
|
||||
setShowContentTooLargeBanner(true);
|
||||
}
|
||||
|
||||
// Call original error handler if exists
|
||||
customRealtimeEventHandlers?.error?.(params);
|
||||
};
|
||||
|
||||
const mergedCustomEventHandlers: TCustomEventHandlers = {
|
||||
...customRealtimeEventHandlers,
|
||||
error: errorHandler,
|
||||
};
|
||||
|
||||
// Get extended editor extensions configuration
|
||||
const extendedEditorProps = useExtendedEditorProps({
|
||||
workspaceSlug,
|
||||
@@ -134,11 +163,12 @@ export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
|
||||
isNavigationPaneOpen={isNavigationPaneOpen}
|
||||
page={page}
|
||||
/>
|
||||
{showContentTooLargeBanner && <ContentLimitBanner className="px-page-x" />}
|
||||
<PageEditorBody
|
||||
config={config}
|
||||
customRealtimeEventHandlers={mergedCustomEventHandlers}
|
||||
editorReady={editorReady}
|
||||
editorForwardRef={editorRef}
|
||||
handleConnectionStatus={setHasConnectionFailed}
|
||||
handleEditorReady={handleEditorReady}
|
||||
handleOpenNavigationPane={handleOpenNavigationPane}
|
||||
handlers={handlers}
|
||||
@@ -149,6 +179,8 @@ export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
|
||||
webhookConnectionParams={webhookConnectionParams}
|
||||
workspaceSlug={workspaceSlug}
|
||||
extendedEditorProps={extendedEditorProps}
|
||||
isFetchingFallbackBinary={isFetchingFallbackBinary}
|
||||
onCollaborationStateChange={setCollaborationState}
|
||||
/>
|
||||
</div>
|
||||
<PageNavigationPaneRoot
|
||||
|
||||
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
|
||||
import { ArrowUpToLine, Clipboard, History } from "lucide-react";
|
||||
// plane imports
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { Switch } from "@plane/propel/switch";
|
||||
import { copyTextToClipboard } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
@@ -50,7 +50,7 @@ export const PageOptionsDropdown = observer(function PageOptionsDropdown(props:
|
||||
customContent: (
|
||||
<>
|
||||
Full width
|
||||
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
|
||||
<Switch value={isFullWidth} onChange={() => {}} />
|
||||
</>
|
||||
),
|
||||
className: "flex items-center justify-between gap-2",
|
||||
@@ -61,7 +61,7 @@ export const PageOptionsDropdown = observer(function PageOptionsDropdown(props:
|
||||
customContent: (
|
||||
<>
|
||||
Sticky toolbar
|
||||
<ToggleSwitch value={isStickyToolbarEnabled} onChange={() => {}} />
|
||||
<Switch value={isStickyToolbarEnabled} onChange={() => {}} />
|
||||
</>
|
||||
),
|
||||
className: "flex items-center justify-between gap-2",
|
||||
@@ -71,13 +71,12 @@ export const PageOptionsDropdown = observer(function PageOptionsDropdown(props:
|
||||
key: "copy-markdown",
|
||||
action: () => {
|
||||
if (!editorRef) return;
|
||||
copyTextToClipboard(editorRef.getMarkDown()).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Markdown copied to clipboard.",
|
||||
})
|
||||
);
|
||||
editorRef.copyMarkdownToClipboard();
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Markdown copied to clipboard.",
|
||||
});
|
||||
},
|
||||
title: "Copy markdown",
|
||||
icon: Clipboard,
|
||||
@@ -127,18 +126,19 @@ export const PageOptionsDropdown = observer(function PageOptionsDropdown(props:
|
||||
isOpen={isExportModalOpen}
|
||||
onClose={() => setIsExportModalOpen(false)}
|
||||
pageTitle={name ?? ""}
|
||||
pageId={page.id ?? ""}
|
||||
/>
|
||||
<PageActions
|
||||
extraOptions={EXTRA_MENU_OPTIONS}
|
||||
optionsOrder={[
|
||||
"full-screen",
|
||||
"sticky-toolbar",
|
||||
"copy-markdown",
|
||||
"version-history",
|
||||
"make-a-copy",
|
||||
"toggle-access",
|
||||
"archive-restore",
|
||||
"delete",
|
||||
"version-history",
|
||||
"copy-markdown",
|
||||
"toggle-access",
|
||||
"export",
|
||||
]}
|
||||
page={page}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { CloudOff } from "lucide-react";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
syncStatus: "syncing" | "synced" | "error";
|
||||
};
|
||||
|
||||
export const PageSyncingBadge = ({ syncStatus }: Props) => {
|
||||
const [prevSyncStatus, setPrevSyncStatus] = useState<"syncing" | "synced" | "error" | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(syncStatus !== "synced");
|
||||
|
||||
useEffect(() => {
|
||||
// Only handle transitions when there's a change
|
||||
if (prevSyncStatus !== syncStatus) {
|
||||
if (syncStatus === "synced") {
|
||||
// Delay hiding to allow exit animation to complete
|
||||
setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
}, 300); // match animation duration
|
||||
} else {
|
||||
setIsVisible(true);
|
||||
}
|
||||
setPrevSyncStatus(syncStatus);
|
||||
}
|
||||
}, [syncStatus, prevSyncStatus]);
|
||||
|
||||
if (!isVisible || syncStatus === "synced") return null;
|
||||
|
||||
const badgeContent = {
|
||||
syncing: {
|
||||
label: "Syncing...",
|
||||
tooltipHeading: "Syncing...",
|
||||
tooltipContent: "Your changes are being synced with the server. You can continue making changes.",
|
||||
bgColor: "bg-custom-primary-100/20",
|
||||
textColor: "text-custom-primary-100",
|
||||
pulseColor: "bg-custom-primary-100",
|
||||
pulseBgColor: "bg-custom-primary-100/30",
|
||||
icon: null,
|
||||
},
|
||||
error: {
|
||||
label: "Connection lost",
|
||||
tooltipHeading: "Connection lost",
|
||||
tooltipContent:
|
||||
"We're having trouble connecting to the websocket server. Your changes will be synced and saved every 10 seconds.",
|
||||
bgColor: "bg-red-500/20",
|
||||
textColor: "text-red-500",
|
||||
icon: <CloudOff className="size-3" />,
|
||||
},
|
||||
};
|
||||
|
||||
// This way we guarantee badgeContent is defined
|
||||
const content = badgeContent[syncStatus];
|
||||
|
||||
return (
|
||||
<Tooltip tooltipHeading={content.tooltipHeading} tooltipContent={content.tooltipContent}>
|
||||
<div
|
||||
className={`flex-shrink-0 h-6 flex items-center gap-1.5 px-2 rounded ${content.textColor} ${content.bgColor} animate-quickFadeIn`}
|
||||
>
|
||||
{syncStatus === "syncing" ? (
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="absolute -inset-0.5 rounded-full bg-custom-primary-100/30 animate-ping" />
|
||||
<div className="relative h-1.5 w-1.5 rounded-full bg-custom-primary-100" />
|
||||
</div>
|
||||
) : (
|
||||
content.icon
|
||||
)}
|
||||
<span className="text-xs font-medium">{content.label}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import type { PageProps } from "@react-pdf/renderer";
|
||||
import { pdf } from "@react-pdf/renderer";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useParams } from "react-router";
|
||||
// plane editor
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
// plane constants
|
||||
import { API_URL } from "@plane/constants";
|
||||
// plane ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { CustomSelect, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// components
|
||||
import { PDFDocument } from "@/components/editor/pdf";
|
||||
// services
|
||||
import { liveService } from "@/services/live.service";
|
||||
// hooks
|
||||
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||
|
||||
@@ -19,10 +19,11 @@ type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
pageTitle: string;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
type TExportFormats = "pdf" | "markdown";
|
||||
type TPageFormats = Exclude<PageProps["size"], undefined>;
|
||||
type TPageFormats = "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID";
|
||||
type TContentVariety = "everything" | "no-assets";
|
||||
|
||||
type TFormValues = {
|
||||
@@ -96,7 +97,7 @@ const defaultValues: TFormValues = {
|
||||
};
|
||||
|
||||
export function ExportPageModal(props: Props) {
|
||||
const { editorRef, isOpen, onClose, pageTitle } = props;
|
||||
const { editorRef, isOpen, onClose, pageTitle, pageId } = props;
|
||||
// states
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
// params
|
||||
@@ -105,8 +106,8 @@ export function ExportPageModal(props: Props) {
|
||||
const { control, reset, watch } = useForm<TFormValues>({
|
||||
defaultValues,
|
||||
});
|
||||
// parse editor content
|
||||
const { replaceCustomComponentsFromHTMLContent, replaceCustomComponentsFromMarkdownContent } = useParseEditorContent({
|
||||
// parse editor content (used for markdown export)
|
||||
const { replaceCustomComponentsFromMarkdownContent } = useParseEditorContent({
|
||||
projectId,
|
||||
workspaceSlug: workspaceSlug ?? "",
|
||||
});
|
||||
@@ -138,20 +139,22 @@ export function ExportPageModal(props: Props) {
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// handle export as a PDF
|
||||
// handle export as a PDF via live server
|
||||
const handleExportAsPDF = async () => {
|
||||
try {
|
||||
const pageContent = `<h1 class="page-title">${pageTitle}</h1>${editorRef?.getDocument().html ?? "<p></p>"}`;
|
||||
const parsedPageContent = await replaceCustomComponentsFromHTMLContent({
|
||||
htmlContent: pageContent,
|
||||
noAssets: selectedContentVariety === "no-assets",
|
||||
});
|
||||
if (!workspaceSlug) throw new Error("Workspace slug is required");
|
||||
|
||||
const blob = await pdf(<PDFDocument content={parsedPageContent} pageFormat={selectedPageFormat} />).toBlob();
|
||||
initiateDownload(blob, `${fileName}-${selectedPageFormat.toString().toLowerCase()}.pdf`);
|
||||
} catch (error) {
|
||||
throw new Error(`Error in exporting as a PDF: ${error}`);
|
||||
}
|
||||
const blob = await liveService.exportToPdf({
|
||||
pageId,
|
||||
workspaceSlug: workspaceSlug.toString(),
|
||||
projectId: projectId?.toString(),
|
||||
title: pageTitle,
|
||||
pageSize: selectedPageFormat,
|
||||
fileName: `${fileName}-${selectedPageFormat.toString().toLowerCase()}.pdf`,
|
||||
noAssets: selectedContentVariety === "no-assets",
|
||||
apiBaseUrl: API_URL,
|
||||
});
|
||||
|
||||
initiateDownload(blob, `${fileName}-${selectedPageFormat.toString().toLowerCase()}.pdf`);
|
||||
};
|
||||
// handle export as markdown
|
||||
const handleExportAsMarkdown = async () => {
|
||||
@@ -200,10 +203,10 @@ export function ExportPageModal(props: Props) {
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.SM}>
|
||||
<div>
|
||||
<div className="p-5 space-y-5">
|
||||
<h3 className="text-xl font-medium text-custom-text-200">Export page</h3>
|
||||
<h3 className="text-18 font-medium text-secondary">Export page</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h6 className="flex-shrink-0 text-sm text-custom-text-200">Export format</h6>
|
||||
<h6 className="flex-shrink-0 text-13 text-secondary">Export format</h6>
|
||||
<Controller
|
||||
control={control}
|
||||
name="export_format"
|
||||
@@ -226,7 +229,7 @@ export function ExportPageModal(props: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h6 className="flex-shrink-0 text-sm text-custom-text-200">Include content</h6>
|
||||
<h6 className="flex-shrink-0 text-13 text-secondary">Include content</h6>
|
||||
<Controller
|
||||
control={control}
|
||||
name="content_variety"
|
||||
@@ -250,7 +253,7 @@ export function ExportPageModal(props: Props) {
|
||||
</div>
|
||||
{isPDFSelected && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h6 className="flex-shrink-0 text-sm text-custom-text-200">Page format</h6>
|
||||
<h6 className="flex-shrink-0 text-13 text-secondary">Page format</h6>
|
||||
<Controller
|
||||
control={control}
|
||||
name="page_format"
|
||||
@@ -275,11 +278,11 @@ export function ExportPageModal(props: Props) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-subtle">
|
||||
<Button variant="secondary" size="lg" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" loading={isExporting} onClick={handleExport}>
|
||||
<Button variant="primary" size="lg" loading={isExporting} onClick={handleExport}>
|
||||
{isExporting ? "Exporting" : "Export"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export const ProjectFeaturesList = observer(function ProjectFeaturesList(props:
|
||||
// derived values
|
||||
const currentProjectDetails = getProjectById(projectId);
|
||||
|
||||
const handleSubmit = async (featureKey: string, featureProperty: string) => {
|
||||
const handleSubmit = (featureKey: string, featureProperty: string) => {
|
||||
if (!workspaceSlug || !projectId || !currentProjectDetails) return;
|
||||
|
||||
// making the request to update the project feature
|
||||
@@ -52,13 +52,14 @@ export const ProjectFeaturesList = observer(function ProjectFeaturesList(props:
|
||||
message: () => "Something went wrong while updating project feature. Please try again.",
|
||||
},
|
||||
});
|
||||
updateProjectPromise.then(() => {
|
||||
void updateProjectPromise.then(() => {
|
||||
captureSuccess({
|
||||
eventName: PROJECT_TRACKER_EVENTS.feature_toggled,
|
||||
payload: {
|
||||
feature_key: featureKey,
|
||||
},
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ import type { TContextMenuItem } from "@plane/ui";
|
||||
import { ContextMenu, CustomMenu } from "@plane/ui";
|
||||
import { copyUrlToClipboard, cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { useViewMenuItems } from "@/components/common/quick-actions-helper";
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useViewMenuItems } from "@/plane-web/components/views/helper";
|
||||
import { PublishViewModal, useViewPublish } from "@/plane-web/components/views/publish";
|
||||
// local imports
|
||||
import { DeleteProjectViewModal } from "./delete-view-modal";
|
||||
@@ -54,22 +54,19 @@ export const ViewQuickActions = observer(function ViewQuickActions(props: Props)
|
||||
});
|
||||
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
|
||||
|
||||
const menuResult = useViewMenuItems({
|
||||
const MENU_ITEMS: TContextMenuItem[] = useViewMenuItems({
|
||||
isOwner,
|
||||
isAdmin,
|
||||
setDeleteViewModal,
|
||||
setCreateUpdateViewModal,
|
||||
handleOpenInNewTab,
|
||||
handleCopyText,
|
||||
isLocked: view.is_locked,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
view,
|
||||
handleEdit: () => setCreateUpdateViewModal(true),
|
||||
handleDelete: () => setDeleteViewModal(true),
|
||||
handleCopyLink: handleCopyText,
|
||||
handleOpenInNewTab,
|
||||
viewId: view.id,
|
||||
});
|
||||
|
||||
// Handle both CE (array) and EE (object) return types
|
||||
const MENU_ITEMS: TContextMenuItem[] = Array.isArray(menuResult) ? menuResult : menuResult.items;
|
||||
const additionalModals = Array.isArray(menuResult) ? null : menuResult.modals;
|
||||
|
||||
if (publishContextMenu) MENU_ITEMS.splice(2, 0, publishContextMenu);
|
||||
|
||||
const CONTEXT_MENU_ITEMS = MENU_ITEMS.map(function CONTEXT_MENU_ITEMS(item) {
|
||||
@@ -94,7 +91,6 @@ export const ViewQuickActions = observer(function ViewQuickActions(props: Props)
|
||||
/>
|
||||
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
|
||||
<PublishViewModal isOpen={isPublishModalOpen} onClose={() => setPublishModalOpen(false)} view={view} />
|
||||
{additionalModals}
|
||||
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
|
||||
<CustomMenu ellipsis placement="bottom-end" closeOnSelect buttonClassName={customClassName}>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
|
||||
@@ -4,13 +4,14 @@ import { observer } from "mobx-react";
|
||||
import { EUserPermissions, EUserPermissionsLevel, GLOBAL_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IWorkspaceView } from "@plane/types";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { copyUrlToClipboard, cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { useViewMenuItems } from "@/components/common/quick-actions-helper";
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useViewMenuItems } from "@/plane-web/components/views/helper";
|
||||
// local imports
|
||||
import { DeleteGlobalViewModal } from "./delete-view-modal";
|
||||
import { CreateUpdateWorkspaceViewModal } from "./modal";
|
||||
@@ -43,15 +44,16 @@ export const WorkspaceViewQuickActions = observer(function WorkspaceViewQuickAct
|
||||
});
|
||||
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
|
||||
|
||||
const MENU_ITEMS = useViewMenuItems({
|
||||
const MENU_ITEMS: TContextMenuItem[] = useViewMenuItems({
|
||||
isOwner,
|
||||
isAdmin,
|
||||
handleDelete: () => setDeleteViewModal(true),
|
||||
handleEdit: () => setUpdateViewModal(true),
|
||||
setDeleteViewModal,
|
||||
setCreateUpdateViewModal: setUpdateViewModal,
|
||||
handleOpenInNewTab,
|
||||
handleCopyLink: handleCopyText,
|
||||
handleCopyText,
|
||||
isLocked: view.is_locked,
|
||||
workspaceSlug,
|
||||
view,
|
||||
viewId: view.id,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -64,7 +66,7 @@ export const WorkspaceViewQuickActions = observer(function WorkspaceViewQuickAct
|
||||
closeOnSelect
|
||||
buttonClassName="flex-shrink-0 flex items-center justify-center size-[26px] bg-custom-background-80/70 rounded"
|
||||
>
|
||||
{MENU_ITEMS.items.map((item) => {
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
|
||||
@@ -1,34 +1,54 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { EditorRefApi, CollaborationState } from "@plane/editor";
|
||||
// plane editor
|
||||
import { convertBinaryDataToBase64String, getBinaryDataFromDocumentEditorHTMLString } from "@plane/editor";
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
// plane types
|
||||
import type { TDocumentPayload } from "@plane/types";
|
||||
// hooks
|
||||
import useAutoSave from "@/hooks/use-auto-save";
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type TArgs = {
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
fetchPageDescription: () => Promise<ArrayBuffer>;
|
||||
hasConnectionFailed: boolean;
|
||||
collaborationState: CollaborationState | null;
|
||||
updatePageDescription: (data: TDocumentPayload) => Promise<void>;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const usePageFallback = (args: TArgs) => {
|
||||
const { editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription } = args;
|
||||
const { editorRef, fetchPageDescription, collaborationState, updatePageDescription, page } = args;
|
||||
const hasShownFallbackToast = useRef(false);
|
||||
|
||||
const [isFetchingFallbackBinary, setIsFetchingFallbackBinary] = useState(false);
|
||||
|
||||
// Derive connection failure from collaboration state
|
||||
const hasConnectionFailed = collaborationState?.stage.kind === "disconnected";
|
||||
|
||||
const handleUpdateDescription = useCallback(async () => {
|
||||
if (!hasConnectionFailed) return;
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
// Show toast notification when fallback mechanism kicks in (only once)
|
||||
if (!hasShownFallbackToast.current) {
|
||||
console.warn("Websocket Connection lost, your changes are being saved using backup mechanism.");
|
||||
hasShownFallbackToast.current = true;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsFetchingFallbackBinary(true);
|
||||
|
||||
const latestEncodedDescription = await fetchPageDescription();
|
||||
let latestDecodedDescription: Uint8Array;
|
||||
if (latestEncodedDescription && latestEncodedDescription.byteLength > 0) {
|
||||
latestDecodedDescription = new Uint8Array(latestEncodedDescription);
|
||||
} else {
|
||||
latestDecodedDescription = getBinaryDataFromDocumentEditorHTMLString("<p></p>");
|
||||
const pageDescriptionHtml = page.description_html;
|
||||
latestDecodedDescription = getBinaryDataFromDocumentEditorHTMLString(
|
||||
pageDescriptionHtml ?? "<p></p>",
|
||||
page.name
|
||||
);
|
||||
}
|
||||
|
||||
editor.setProviderDocument(latestDecodedDescription);
|
||||
@@ -41,16 +61,23 @@ export const usePageFallback = (args: TArgs) => {
|
||||
description_html: html,
|
||||
description: json,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in updating description using fallback logic:", error);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsFetchingFallbackBinary(false);
|
||||
}
|
||||
}, [editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription]);
|
||||
}, [editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription, page.description_html, page.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasConnectionFailed) {
|
||||
handleUpdateDescription();
|
||||
} else {
|
||||
// Reset toast flag when connection is restored
|
||||
hasShownFallbackToast.current = false;
|
||||
}
|
||||
}, [handleUpdateDescription, hasConnectionFailed]);
|
||||
|
||||
useAutoSave(handleUpdateDescription);
|
||||
|
||||
return { isFetchingFallbackBinary };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { LIVE_URL } from "@plane/constants";
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export class LiveService extends APIService {
|
||||
constructor() {
|
||||
super(LIVE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports a page to PDF via the live server
|
||||
* @param params - PDF export parameters
|
||||
* @returns Blob of the generated PDF
|
||||
*/
|
||||
async exportToPdf(params: {
|
||||
pageId: string;
|
||||
workspaceSlug: string;
|
||||
projectId?: string;
|
||||
title?: string;
|
||||
pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID";
|
||||
pageOrientation?: "portrait" | "landscape";
|
||||
fileName?: string;
|
||||
noAssets?: boolean;
|
||||
/** API base URL for asset resolution (e.g., "https://plane.example.com/api") */
|
||||
apiBaseUrl?: string;
|
||||
}): Promise<Blob> {
|
||||
const response = await this.post(
|
||||
`/pdf-export`,
|
||||
{
|
||||
...params,
|
||||
baseUrl: window.location.origin,
|
||||
},
|
||||
{
|
||||
withCredentials: true,
|
||||
responseType: "blob",
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const liveService = new LiveService();
|
||||
@@ -13,6 +13,7 @@ import { PageEditorInstance } from "./page-editor-info";
|
||||
export type TBasePage = TPage & {
|
||||
// observables
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
isSyncingWithServer: "syncing" | "synced" | "error";
|
||||
// computed
|
||||
asJSON: TPage | undefined;
|
||||
isCurrentUserOwner: boolean;
|
||||
@@ -35,6 +36,7 @@ export type TBasePage = TPage & {
|
||||
removePageFromFavorites: () => Promise<void>;
|
||||
duplicate: () => Promise<TPage | undefined>;
|
||||
mutateProperties: (data: Partial<TPage>, shouldUpdateName?: boolean) => void;
|
||||
setSyncingStatus: (status: "syncing" | "synced" | "error") => void;
|
||||
// sub-store
|
||||
editor: PageEditorInstance;
|
||||
};
|
||||
@@ -73,6 +75,7 @@ export type TPageInstance = TBasePage &
|
||||
export class BasePage extends ExtendedBasePage implements TBasePage {
|
||||
// loaders
|
||||
isSubmitting: TNameDescriptionLoader = "saved";
|
||||
isSyncingWithServer: "syncing" | "synced" | "error" = "syncing";
|
||||
// page properties
|
||||
id: string | undefined;
|
||||
name: string | undefined;
|
||||
@@ -155,6 +158,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
||||
created_at: observable.ref,
|
||||
updated_at: observable.ref,
|
||||
deleted_at: observable.ref,
|
||||
isSyncingWithServer: observable.ref,
|
||||
// helpers
|
||||
oldName: observable.ref,
|
||||
setIsSubmitting: action,
|
||||
@@ -535,4 +539,10 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
||||
set(this, key, value);
|
||||
});
|
||||
};
|
||||
|
||||
setSyncingStatus = (status: "syncing" | "synced" | "error") => {
|
||||
runInAction(() => {
|
||||
this.isSyncingWithServer = status;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
@@ -52,7 +52,7 @@
|
||||
"mobx": "catalog:",
|
||||
"mobx-react": "catalog:",
|
||||
"mobx-utils": "catalog:",
|
||||
"next-themes": "^0.2.1",
|
||||
"next-themes": "0.4.6",
|
||||
"posthog-js": "^1.255.1",
|
||||
"react": "catalog:",
|
||||
"react-color": "^2.19.3",
|
||||
|
||||
@@ -31,14 +31,12 @@ COPY --from=node /usr/local/lib /usr/local/lib
|
||||
COPY --from=node /usr/local/include /usr/local/include
|
||||
COPY --from=node /usr/local/bin /usr/local/bin
|
||||
|
||||
COPY --from=web-img /app /app/web
|
||||
COPY --from=web-img /usr/share/nginx/html /app/web
|
||||
COPY --from=space-img /app /app/space
|
||||
COPY --from=admin-img /app /app/admin
|
||||
COPY --from=admin-img /usr/share/nginx/html/god-mode /app/admin
|
||||
COPY --from=live-img /app /app/live
|
||||
|
||||
RUN rm -rf /app/web/apps/web/.next/cache && \
|
||||
rm -rf /app/space/apps/space/.next/cache && \
|
||||
rm -rf /app/admin/apps/admin/.next/cache
|
||||
RUN rm -rf /app/space/apps/space/.next/cache
|
||||
|
||||
COPY --from=proxy-img /usr/bin/caddy /usr/bin/caddy
|
||||
COPY dist/Caddyfile /app/proxy/Caddyfile
|
||||
|
||||
@@ -95,7 +95,7 @@ update_env_file(){
|
||||
|
||||
build_dist_files(){
|
||||
cp ./variables.env $DIST_DIR/plane.env
|
||||
cp ../../../apps/proxy/Caddyfile.ce $DIST_DIR/Caddyfile
|
||||
cp ../../../apps/proxy/Caddyfile.aio.ce $DIST_DIR/Caddyfile
|
||||
|
||||
echo "" >> $DIST_DIR/plane.env
|
||||
echo "" >> $DIST_DIR/plane.env
|
||||
@@ -108,16 +108,6 @@ build_dist_files(){
|
||||
update_env_file $DIST_DIR/plane.env "API_BASE_URL" "http://localhost:3004"
|
||||
update_env_file $DIST_DIR/plane.env "SITE_ADDRESS" ":80"
|
||||
|
||||
# remove this line containing `plane-minio:9000`
|
||||
remove_line $DIST_DIR/Caddyfile "plane-minio:9000" ""
|
||||
|
||||
# in caddyfile, update `reverse_proxy /spaces/* space:3000` to `reverse_proxy /spaces/* space:3002`
|
||||
string_replace $DIST_DIR/Caddyfile "web:3000" "localhost:3001"
|
||||
string_replace $DIST_DIR/Caddyfile "space:3000" "localhost:3002"
|
||||
string_replace $DIST_DIR/Caddyfile "admin:3000" "localhost:3003"
|
||||
string_replace $DIST_DIR/Caddyfile "api:8000" "localhost:3004"
|
||||
string_replace $DIST_DIR/Caddyfile "live:3000" "localhost:3005"
|
||||
|
||||
|
||||
# print docker build command
|
||||
echo "------------------------------------------------"
|
||||
|
||||
@@ -17,20 +17,9 @@ stderr_logfile_backups=5
|
||||
priority=10
|
||||
|
||||
|
||||
[program:web]
|
||||
command=sh -c "node /app/web/apps/web/server.js"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/app/logs/access/web.log
|
||||
stderr_logfile=/app/logs/error/web.err.log
|
||||
# stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=50MB
|
||||
stderr_logfile_backups=5
|
||||
environment=PORT=3001,HOSTNAME=0.0.0.0
|
||||
priority=15
|
||||
|
||||
[program:space]
|
||||
command=sh -c "node /app/space/apps/space/server.js"
|
||||
directory=/app/space/apps/space/build/server
|
||||
command=sh -c "npx react-router-serve index.js"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/app/logs/access/space.log
|
||||
@@ -41,18 +30,6 @@ stderr_logfile_backups=5
|
||||
environment=PORT=3002,HOSTNAME=0.0.0.0
|
||||
priority=15
|
||||
|
||||
[program:admin]
|
||||
command=sh -c "node /app/admin/apps/admin/server.js"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/app/logs/access/admin.log
|
||||
stderr_logfile=/app/logs/error/admin.err.log
|
||||
# stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=50MB
|
||||
stderr_logfile_backups=5
|
||||
environment=PORT=3003,HOSTNAME=0.0.0.0
|
||||
priority=15
|
||||
|
||||
[program:api]
|
||||
directory=/app/backend
|
||||
command=sh -c "./bin/docker-entrypoint-api.sh"
|
||||
@@ -96,7 +73,7 @@ priority=20
|
||||
|
||||
|
||||
[program:live]
|
||||
command=sh -c "node /app/live/apps/live/dist/start.js"
|
||||
command=sh -c "node /app/live/apps/live"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/app/logs/access/live.log
|
||||
|
||||
+2
-3
@@ -2,7 +2,7 @@
|
||||
"name": "plane",
|
||||
"description": "Open-source project management that unlocks customer value",
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -68,8 +68,7 @@
|
||||
"prosemirror-view": "1.40.0",
|
||||
"@types/express": "4.17.23",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"next": "16.0.7"
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@sentry/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/codemods",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
@@ -44,13 +44,16 @@
|
||||
"@tiptap/extension-blockquote": "^2.22.3",
|
||||
"@tiptap/extension-character-count": "^2.22.3",
|
||||
"@tiptap/extension-collaboration": "^2.22.3",
|
||||
"@tiptap/extension-document": "^2.22.3",
|
||||
"@tiptap/extension-emoji": "^2.22.3",
|
||||
"@tiptap/extension-heading": "^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": "^2.22.3",
|
||||
"@tiptap/extension-text-align": "^2.22.3",
|
||||
"@tiptap/extension-text-style": "^2.22.3",
|
||||
"@tiptap/extension-underline": "^2.22.3",
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// contexts
|
||||
import { CollaborationProvider, useCollaboration } from "@/contexts/collaboration-context";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
|
||||
// constants
|
||||
import { DocumentEditorSideEffects } from "@/plane-editor/components/document-editor-side-effects";
|
||||
// types
|
||||
import type { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
|
||||
|
||||
function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
|
||||
// Inner component that has access to collaboration context
|
||||
const CollaborativeDocumentEditorInner: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
|
||||
const {
|
||||
aiHandler,
|
||||
bubbleMenuEnabled = true,
|
||||
@@ -41,15 +42,20 @@ function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
|
||||
onEditorFocus,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
tabIndex,
|
||||
user,
|
||||
extendedDocumentEditorProps,
|
||||
titleRef,
|
||||
updatePageProperties,
|
||||
isFetchingFallbackBinary,
|
||||
} = props;
|
||||
|
||||
// use document editor
|
||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
|
||||
// Get non-null provider from context
|
||||
const { provider, state, actions } = useCollaboration();
|
||||
|
||||
// Editor initialization with guaranteed non-null provider
|
||||
const { editor, titleEditor } = useCollaborativeEditor({
|
||||
provider,
|
||||
disabledExtensions,
|
||||
editable,
|
||||
editorClassName,
|
||||
@@ -70,11 +76,11 @@ function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
|
||||
onEditorFocus,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
tabIndex,
|
||||
titleRef,
|
||||
updatePageProperties,
|
||||
user,
|
||||
extendedDocumentEditorProps,
|
||||
actions,
|
||||
});
|
||||
|
||||
const editorContainerClassNames = getEditorClassNames({
|
||||
@@ -83,37 +89,71 @@ function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
// Show loader ONLY when cache is known empty and server hasn't synced yet
|
||||
const shouldShowSyncLoader = state.isCacheReady && !state.hasCachedContent && !state.isServerSynced;
|
||||
const shouldWaitForFallbackBinary = isFetchingFallbackBinary && !state.hasCachedContent && state.isServerDisconnected;
|
||||
const isLoading = shouldShowSyncLoader || shouldWaitForFallbackBinary;
|
||||
|
||||
// Gate content rendering on isDocReady to prevent empty editor flash
|
||||
const showContentSkeleton = !state.isDocReady;
|
||||
|
||||
if (!editor || !titleEditor) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentEditorSideEffects editor={editor} id={id} extendedEditorProps={extendedEditorProps} />
|
||||
<PageRenderer
|
||||
aiHandler={aiHandler}
|
||||
bubbleMenuEnabled={bubbleMenuEnabled}
|
||||
displayConfig={displayConfig}
|
||||
documentLoaderClassName={documentLoaderClassName}
|
||||
editor={editor}
|
||||
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
|
||||
extendedEditorProps={extendedEditorProps}
|
||||
id={id}
|
||||
isTouchDevice={!!isTouchDevice}
|
||||
isLoading={!hasServerSynced && !hasServerConnectionFailed}
|
||||
tabIndex={tabIndex}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
disabledExtensions={disabledExtensions}
|
||||
extendedDocumentEditorProps={extendedDocumentEditorProps}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"transition-opacity duration-200",
|
||||
showContentSkeleton && !isLoading && "opacity-0 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<PageRenderer
|
||||
aiHandler={aiHandler}
|
||||
bubbleMenuEnabled={bubbleMenuEnabled}
|
||||
displayConfig={displayConfig}
|
||||
documentLoaderClassName={documentLoaderClassName}
|
||||
disabledExtensions={disabledExtensions}
|
||||
extendedDocumentEditorProps={extendedDocumentEditorProps}
|
||||
editor={editor}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
titleEditor={titleEditor}
|
||||
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
|
||||
extendedEditorProps={extendedEditorProps}
|
||||
id={id}
|
||||
isLoading={isLoading}
|
||||
isTouchDevice={!!isTouchDevice}
|
||||
tabIndex={tabIndex}
|
||||
provider={provider}
|
||||
state={state}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const CollaborativeDocumentEditorWithRef = React.forwardRef(function CollaborativeDocumentEditorWithRef(
|
||||
props: ICollaborativeDocumentEditorProps,
|
||||
ref: React.ForwardedRef<EditorRefApi>
|
||||
) {
|
||||
return <CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />;
|
||||
});
|
||||
// Outer component that provides collaboration context
|
||||
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
|
||||
const { id, realtimeConfig, serverHandler, user } = props;
|
||||
|
||||
const token = useMemo(() => JSON.stringify(user), [user]);
|
||||
|
||||
return (
|
||||
<CollaborationProvider
|
||||
docId={id}
|
||||
serverUrl={realtimeConfig.url}
|
||||
authToken={token}
|
||||
onStateChange={serverHandler?.onStateChange}
|
||||
>
|
||||
<CollaborativeDocumentEditorInner {...props} />
|
||||
</CollaborationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditorProps>(
|
||||
(props, ref) => (
|
||||
<CollaborativeDocumentEditor key={props.id} {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi>} />
|
||||
)
|
||||
);
|
||||
|
||||
CollaborativeDocumentEditorWithRef.displayName = "CollaborativeDocumentEditorWithRef";
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { DocumentContentLoader, EditorContainer, EditorContentWrapper } from "@/components/editors";
|
||||
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
|
||||
import { BlockMenu, EditorBubbleMenu } from "@/components/menus";
|
||||
// types
|
||||
import type { TCollabValue } from "@/contexts";
|
||||
import type {
|
||||
ICollaborativeDocumentEditorPropsExtended,
|
||||
IEditorProps,
|
||||
@@ -20,6 +22,7 @@ type Props = {
|
||||
displayConfig: TDisplayConfig;
|
||||
documentLoaderClassName?: string;
|
||||
editor: Editor;
|
||||
titleEditor?: Editor;
|
||||
editorContainerClassName: string;
|
||||
extendedDocumentEditorProps?: ICollaborativeDocumentEditorPropsExtended;
|
||||
extendedEditorProps: IEditorPropsExtended;
|
||||
@@ -28,11 +31,12 @@ type Props = {
|
||||
isLoading?: boolean;
|
||||
isTouchDevice: boolean;
|
||||
tabIndex?: number;
|
||||
provider?: HocuspocusProvider;
|
||||
state?: TCollabValue["state"];
|
||||
};
|
||||
|
||||
export function PageRenderer(props: Props) {
|
||||
const {
|
||||
aiHandler,
|
||||
bubbleMenuEnabled,
|
||||
disabledExtensions,
|
||||
displayConfig,
|
||||
@@ -45,8 +49,10 @@ export function PageRenderer(props: Props) {
|
||||
isLoading,
|
||||
isTouchDevice,
|
||||
tabIndex,
|
||||
titleEditor,
|
||||
provider,
|
||||
state,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("frame-renderer flex-grow w-full", {
|
||||
@@ -56,33 +62,54 @@ export function PageRenderer(props: Props) {
|
||||
{isLoading ? (
|
||||
<DocumentContentLoader className={documentLoaderClassName} />
|
||||
) : (
|
||||
<EditorContainer
|
||||
displayConfig={displayConfig}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
id={id}
|
||||
isTouchDevice={isTouchDevice}
|
||||
>
|
||||
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
||||
{editor.isEditable && !isTouchDevice && (
|
||||
<div>
|
||||
{bubbleMenuEnabled && (
|
||||
<EditorBubbleMenu
|
||||
disabledExtensions={disabledExtensions}
|
||||
editor={editor}
|
||||
extendedEditorProps={extendedEditorProps}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
<>
|
||||
{titleEditor && (
|
||||
<div className="relative w-full py-3">
|
||||
<EditorContainer
|
||||
editor={titleEditor}
|
||||
id={id + "-title"}
|
||||
isTouchDevice={isTouchDevice}
|
||||
editorContainerClassName="page-title-editor bg-transparent py-3 border-none"
|
||||
displayConfig={displayConfig}
|
||||
>
|
||||
<EditorContentWrapper
|
||||
editor={titleEditor}
|
||||
id={id + "-title"}
|
||||
tabIndex={tabIndex}
|
||||
className="no-scrollbar placeholder-custom-text-400 bg-transparent tracking-[-2%] font-bold text-[2rem] leading-[2.375rem] w-full outline-none p-0 border-none resize-none rounded-none"
|
||||
/>
|
||||
)}
|
||||
<BlockMenu
|
||||
editor={editor}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
disabledExtensions={disabledExtensions}
|
||||
/>
|
||||
<AIFeaturesMenu menu={aiHandler?.menu} />
|
||||
</EditorContainer>
|
||||
</div>
|
||||
)}
|
||||
</EditorContainer>
|
||||
<EditorContainer
|
||||
displayConfig={displayConfig}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
id={id}
|
||||
isTouchDevice={isTouchDevice}
|
||||
provider={provider}
|
||||
state={state}
|
||||
>
|
||||
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
||||
{editor.isEditable && !isTouchDevice && (
|
||||
<div>
|
||||
{bubbleMenuEnabled && (
|
||||
<EditorBubbleMenu
|
||||
editor={editor}
|
||||
disabledExtensions={disabledExtensions}
|
||||
extendedEditorProps={extendedEditorProps}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
/>
|
||||
)}
|
||||
<BlockMenu
|
||||
editor={editor}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
disabledExtensions={disabledExtensions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</EditorContainer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// components
|
||||
import type { TCollabValue } from "@/contexts";
|
||||
import { LinkContainer } from "@/plane-editor/components/link-container";
|
||||
// plugins
|
||||
import { nodeHighlightPluginKey } from "@/plugins/highlight";
|
||||
// types
|
||||
import type { TDisplayConfig } from "@/types";
|
||||
|
||||
@@ -18,12 +22,85 @@ type Props = {
|
||||
editorContainerClassName: string;
|
||||
id: string;
|
||||
isTouchDevice: boolean;
|
||||
provider?: HocuspocusProvider | undefined;
|
||||
state?: TCollabValue["state"];
|
||||
};
|
||||
|
||||
export function EditorContainer(props: Props) {
|
||||
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice } = props;
|
||||
export const EditorContainer: FC<Props> = (props) => {
|
||||
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice, provider, state } = props;
|
||||
// refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const hasScrolledOnce = useRef(false);
|
||||
const scrollToNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
if (!editor) return false;
|
||||
|
||||
const doc = editor.state.doc;
|
||||
let pos: number | null = null;
|
||||
|
||||
doc.descendants((node, position) => {
|
||||
if (node.attrs && node.attrs.id === nodeId) {
|
||||
pos = position;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (pos === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodePosition = pos;
|
||||
const tr = editor.state.tr.setMeta(nodeHighlightPluginKey, { nodeId });
|
||||
editor.view.dispatch(tr);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const domNode = editor.view.nodeDOM(nodePosition);
|
||||
if (domNode instanceof HTMLElement) {
|
||||
domNode.scrollIntoView({ behavior: "instant", block: "center" });
|
||||
}
|
||||
});
|
||||
|
||||
editor.once("focus", () => {
|
||||
const clearTr = editor.state.tr.setMeta(nodeHighlightPluginKey, { nodeId: null });
|
||||
editor.view.dispatch(clearTr);
|
||||
});
|
||||
|
||||
hasScrolledOnce.current = true;
|
||||
return true;
|
||||
},
|
||||
|
||||
[editor]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const nodeId = window.location.href.split("#")[1];
|
||||
|
||||
const handleSynced = () => scrollToNode(nodeId);
|
||||
|
||||
if (nodeId && !hasScrolledOnce.current) {
|
||||
if (provider && state) {
|
||||
const { hasCachedContent } = state;
|
||||
// If the provider is synced or the cached content is available and the server is disconnected, scroll to the node
|
||||
if (hasCachedContent) {
|
||||
const hasScrolled = handleSynced();
|
||||
if (!hasScrolled) {
|
||||
provider.on("synced", handleSynced);
|
||||
}
|
||||
} else if (provider.isSynced) {
|
||||
handleSynced();
|
||||
} else {
|
||||
provider.on("synced", handleSynced);
|
||||
}
|
||||
} else {
|
||||
handleSynced();
|
||||
}
|
||||
return () => {
|
||||
if (provider) {
|
||||
provider.off("synced", handleSynced);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [scrollToNode, provider, state]);
|
||||
|
||||
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
@@ -88,7 +165,6 @@ export function EditorContainer(props: Props) {
|
||||
`editor-container cursor-text relative line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
|
||||
{
|
||||
"active-editor": editor?.isFocused && editor?.isEditable,
|
||||
"wide-layout": displayConfig.wideLayout,
|
||||
},
|
||||
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
|
||||
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
|
||||
@@ -100,4 +176,4 @@ export function EditorContainer(props: Props) {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,19 +3,24 @@ import type { Editor } from "@tiptap/react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
editor: Editor | null;
|
||||
id: string;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export function EditorContentWrapper(props: Props) {
|
||||
const { editor, children, tabIndex, id } = props;
|
||||
export const EditorContentWrapper: FC<Props> = (props) => {
|
||||
const { editor, className, children, tabIndex, id } = props;
|
||||
|
||||
return (
|
||||
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
|
||||
<div
|
||||
tabIndex={tabIndex}
|
||||
onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}
|
||||
className={className}
|
||||
>
|
||||
<EditorContent editor={editor} id={id} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,7 +41,6 @@ export function EditorWrapper(props: Props) {
|
||||
handleEditorReady,
|
||||
autofocus,
|
||||
placeholder,
|
||||
showPlaceholderOnEmpty,
|
||||
tabIndex,
|
||||
value,
|
||||
} = props;
|
||||
@@ -68,7 +67,6 @@ export function EditorWrapper(props: Props) {
|
||||
handleEditorReady,
|
||||
autofocus,
|
||||
placeholder,
|
||||
showPlaceholderOnEmpty,
|
||||
tabIndex,
|
||||
value,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { createContext, useContext } from "react";
|
||||
// hooks
|
||||
import { useYjsSetup } from "@/hooks/use-yjs-setup";
|
||||
|
||||
export type TCollabValue = NonNullable<ReturnType<typeof useYjsSetup>>;
|
||||
|
||||
const CollabContext = createContext<TCollabValue | null>(null);
|
||||
|
||||
type CollabProviderProps = Parameters<typeof useYjsSetup>[0] & {
|
||||
fallback?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function CollaborationProvider({ fallback = null, children, ...args }: CollabProviderProps) {
|
||||
const setup = useYjsSetup(args);
|
||||
|
||||
// Only wait for provider setup, not content ready
|
||||
// Consumers can check state.isDocReady to gate content rendering
|
||||
if (!setup) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <CollabContext.Provider value={setup}>{children}</CollabContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCollaboration(): TCollabValue {
|
||||
const ctx = useContext(CollabContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useCollaboration must be used inside <CollaborationProvider>");
|
||||
}
|
||||
return ctx; // guaranteed non-null
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./collaboration-context";
|
||||
@@ -47,7 +47,6 @@ type TArguments = Pick<
|
||||
| "isTouchDevice"
|
||||
| "mentionHandler"
|
||||
| "placeholder"
|
||||
| "showPlaceholderOnEmpty"
|
||||
| "tabIndex"
|
||||
| "extendedEditorProps"
|
||||
> & {
|
||||
@@ -66,7 +65,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
isTouchDevice = false,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
showPlaceholderOnEmpty,
|
||||
tabIndex,
|
||||
editable,
|
||||
extendedEditorProps,
|
||||
@@ -110,7 +108,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
TableCell,
|
||||
TableRow,
|
||||
CustomMentionExtension(mentionHandler),
|
||||
CustomPlaceholderExtension({ placeholder, showPlaceholderOnEmpty }),
|
||||
CustomPlaceholderExtension({ placeholder }),
|
||||
CharacterCount,
|
||||
CustomColorExtension,
|
||||
CustomTextAlignExtension,
|
||||
|
||||
@@ -6,11 +6,10 @@ import type { IEditorProps } from "@/types";
|
||||
|
||||
type TArgs = {
|
||||
placeholder: IEditorProps["placeholder"];
|
||||
showPlaceholderOnEmpty: IEditorProps["showPlaceholderOnEmpty"];
|
||||
};
|
||||
|
||||
export const CustomPlaceholderExtension = (args: TArgs) => {
|
||||
const { placeholder, showPlaceholderOnEmpty = false } = args;
|
||||
const { placeholder } = args;
|
||||
|
||||
return Placeholder.configure({
|
||||
placeholder: ({ editor, node }) => {
|
||||
@@ -30,13 +29,6 @@ export const CustomPlaceholderExtension = (args: TArgs) => {
|
||||
|
||||
if (shouldHidePlaceholder) return "";
|
||||
|
||||
if (showPlaceholderOnEmpty) {
|
||||
const isDocumentEmpty = editor.state.doc.textContent.length === 0;
|
||||
if (!isDocumentEmpty) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholder) {
|
||||
if (typeof placeholder === "string") return placeholder;
|
||||
else return placeholder(editor.isFocused, editor.getHTML());
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { AnyExtension, Extensions } from "@tiptap/core";
|
||||
import Document from "@tiptap/extension-document";
|
||||
import Heading from "@tiptap/extension-heading";
|
||||
import Text from "@tiptap/extension-text";
|
||||
|
||||
export const TitleExtensions: Extensions = [
|
||||
Document.extend({
|
||||
content: "heading",
|
||||
}),
|
||||
Heading.configure({
|
||||
levels: [1],
|
||||
}) as AnyExtension,
|
||||
Text,
|
||||
];
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Buffer } from "buffer";
|
||||
import type { Extensions, JSONContent } from "@tiptap/core";
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
|
||||
@@ -9,10 +10,13 @@ import {
|
||||
CoreEditorExtensionsWithoutProps,
|
||||
DocumentEditorExtensionsWithoutProps,
|
||||
} from "@/extensions/core-without-props";
|
||||
import { TitleExtensions } from "@/extensions/title-extension";
|
||||
import { sanitizeHTML } from "@plane/utils";
|
||||
|
||||
// editor extension configs
|
||||
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
|
||||
export const TITLE_EDITOR_EXTENSIONS: Extensions = TitleExtensions;
|
||||
// editor schemas
|
||||
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
@@ -45,9 +49,10 @@ export const convertBinaryDataToBase64String = (document: Uint8Array): string =>
|
||||
/**
|
||||
* @description this function decodes base64 string to binary data
|
||||
* @param {string} document
|
||||
* @returns {ArrayBuffer}
|
||||
* @returns {Buffer<ArrayBuffer>}
|
||||
*/
|
||||
export const convertBase64StringToBinaryData = (document: string): ArrayBuffer => Buffer.from(document, "base64");
|
||||
export const convertBase64StringToBinaryData = (document: string): Buffer<ArrayBuffer> =>
|
||||
Buffer.from(document, "base64");
|
||||
|
||||
/**
|
||||
* @description this function generates the binary equivalent of html content for the rich text editor
|
||||
@@ -64,16 +69,49 @@ export const getBinaryDataFromRichTextEditorHTMLString = (descriptionHTML: strin
|
||||
return encodedData;
|
||||
};
|
||||
|
||||
export const generateTitleProsemirrorJson = (text: string): JSONContent => {
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
...(text
|
||||
? {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @description this function generates the binary equivalent of html content for the document editor
|
||||
* @param {string} descriptionHTML
|
||||
* @param {string} descriptionHTML - The HTML content to convert
|
||||
* @param {string} [title] - Optional title to append to the document
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export const getBinaryDataFromDocumentEditorHTMLString = (descriptionHTML: string): Uint8Array => {
|
||||
export const getBinaryDataFromDocumentEditorHTMLString = (descriptionHTML: string, title?: string): Uint8Array => {
|
||||
// convert HTML to JSON
|
||||
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
|
||||
// convert JSON to Y.Doc format
|
||||
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
|
||||
|
||||
// If title is provided, merge it into the document
|
||||
if (title != null) {
|
||||
const titleJSON = generateTitleProsemirrorJson(title);
|
||||
const titleField = prosemirrorJSONToYDoc(documentEditorSchema, titleJSON, "title");
|
||||
// Encode the title YDoc to updates and apply them to the main document
|
||||
const titleUpdates = Y.encodeStateAsUpdate(titleField);
|
||||
Y.applyUpdate(transformedData, titleUpdates);
|
||||
}
|
||||
|
||||
// convert Y.Doc to Uint8Array format
|
||||
const encodedData = Y.encodeStateAsUpdate(transformedData);
|
||||
return encodedData;
|
||||
@@ -114,11 +152,13 @@ export const getAllDocumentFormatsFromRichTextEditorBinaryData = (
|
||||
* @returns
|
||||
*/
|
||||
export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
|
||||
description: Uint8Array
|
||||
description: Uint8Array,
|
||||
updateTitle: boolean
|
||||
): {
|
||||
contentBinaryEncoded: string;
|
||||
contentJSON: object;
|
||||
contentHTML: string;
|
||||
titleHTML?: string;
|
||||
} => {
|
||||
// encode binary description data
|
||||
const base64Data = convertBinaryDataToBase64String(description);
|
||||
@@ -130,11 +170,24 @@ export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
|
||||
// convert to HTML
|
||||
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
return {
|
||||
contentBinaryEncoded: base64Data,
|
||||
contentJSON,
|
||||
contentHTML,
|
||||
};
|
||||
if (updateTitle) {
|
||||
const title = yDoc.getXmlFragment("title");
|
||||
const titleJSON = yXmlFragmentToProseMirrorRootNode(title, documentEditorSchema).toJSON();
|
||||
const titleHTML = extractTextFromHTML(generateHTML(titleJSON, DOCUMENT_EDITOR_EXTENSIONS));
|
||||
|
||||
return {
|
||||
contentBinaryEncoded: base64Data,
|
||||
contentJSON,
|
||||
contentHTML,
|
||||
titleHTML,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
contentBinaryEncoded: base64Data,
|
||||
contentJSON,
|
||||
contentHTML,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
type TConvertHTMLDocumentToAllFormatsArgs = {
|
||||
@@ -170,8 +223,10 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF
|
||||
// Convert HTML to binary format for document editor
|
||||
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
|
||||
// Generate all document formats from the binary data
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
|
||||
contentBinary,
|
||||
false
|
||||
);
|
||||
allFormats = {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
@@ -183,3 +238,11 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF
|
||||
|
||||
return allFormats;
|
||||
};
|
||||
|
||||
export const extractTextFromHTML = (html: string): string => {
|
||||
// Use DOMPurify to safely extract text and remove all HTML tags
|
||||
// This is more secure than regex as it handles edge cases and prevents injection
|
||||
// Note: sanitizeHTML trims whitespace, which is acceptable for title extraction
|
||||
const sanitizedText = sanitizeHTML(html); // sanitize the string to remove all HTML tags
|
||||
return sanitizedText.trim() || ""; // trim the string to remove leading and trailing whitespaces
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { Extensions } from "@tiptap/core";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
// react
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
// extensions
|
||||
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
|
||||
// hooks
|
||||
@@ -9,10 +11,29 @@ import { useEditor } from "@/hooks/use-editor";
|
||||
// plane editor extensions
|
||||
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import type { TCollaborativeEditorHookProps } from "@/types";
|
||||
import type {
|
||||
TCollaborativeEditorHookProps,
|
||||
ICollaborativeDocumentEditorProps,
|
||||
IEditorPropsExtended,
|
||||
IEditorProps,
|
||||
TEditorHookProps,
|
||||
EditorTitleRefApi,
|
||||
} from "@/types";
|
||||
// local imports
|
||||
import { useEditorNavigation } from "./use-editor-navigation";
|
||||
import { useTitleEditor } from "./use-title-editor";
|
||||
|
||||
export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => {
|
||||
type UseCollaborativeEditorArgs = Omit<TCollaborativeEditorHookProps, "realtimeConfig" | "serverHandler" | "user"> & {
|
||||
provider: HocuspocusProvider;
|
||||
user: TCollaborativeEditorHookProps["user"];
|
||||
actions: {
|
||||
signalForcedClose: (value: boolean) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export const useCollaborativeEditor = (props: UseCollaborativeEditorArgs) => {
|
||||
const {
|
||||
provider,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onTransaction,
|
||||
@@ -24,71 +45,26 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
||||
extensions = [],
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
getEditorMetaData,
|
||||
forwardedRef,
|
||||
getEditorMetaData,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
dragDropEnabled = true,
|
||||
isTouchDevice,
|
||||
mentionHandler,
|
||||
onEditorFocus,
|
||||
placeholder,
|
||||
showPlaceholderOnEmpty,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
tabIndex,
|
||||
titleRef,
|
||||
updatePageProperties,
|
||||
user,
|
||||
} = props;
|
||||
// states
|
||||
const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false);
|
||||
const [hasServerSynced, setHasServerSynced] = useState(false);
|
||||
// initialize Hocuspocus provider
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
new HocuspocusProvider({
|
||||
name: id,
|
||||
// using user id as a token to verify the user on the server
|
||||
token: JSON.stringify(user),
|
||||
url: realtimeConfig.url,
|
||||
onAuthenticationFailed: () => {
|
||||
serverHandler?.onServerError?.();
|
||||
setHasServerConnectionFailed(true);
|
||||
},
|
||||
onConnect: () => serverHandler?.onConnect?.(),
|
||||
onClose: (data) => {
|
||||
if (data.event.code === 1006) {
|
||||
serverHandler?.onServerError?.();
|
||||
setHasServerConnectionFailed(true);
|
||||
}
|
||||
},
|
||||
onSynced: () => setHasServerSynced(true),
|
||||
}),
|
||||
[id, realtimeConfig, serverHandler, user]
|
||||
);
|
||||
|
||||
const localProvider = useMemo(
|
||||
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
|
||||
[id, provider]
|
||||
);
|
||||
const { mainNavigationExtension, titleNavigationExtension, setMainEditor, setTitleEditor } = useEditorNavigation();
|
||||
|
||||
// destroy and disconnect all providers connection on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
provider?.destroy();
|
||||
localProvider?.destroy();
|
||||
},
|
||||
[provider, localProvider]
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
disabledExtensions,
|
||||
extendedEditorProps,
|
||||
id,
|
||||
editable,
|
||||
editorProps,
|
||||
editorClassName,
|
||||
enableHistory: false,
|
||||
extensions: [
|
||||
// Memoize extensions to avoid unnecessary editor recreations
|
||||
const editorExtensions = useMemo(
|
||||
() => [
|
||||
SideMenuExtension({
|
||||
aiEnabled: !disabledExtensions?.includes("ai"),
|
||||
dragDropEnabled,
|
||||
@@ -96,6 +72,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
||||
HeadingListExtension,
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
field: "default",
|
||||
}),
|
||||
...extensions,
|
||||
...DocumentEditorAdditionalExtensions({
|
||||
@@ -107,27 +84,120 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
||||
provider,
|
||||
userDetails: user,
|
||||
}),
|
||||
mainNavigationExtension,
|
||||
],
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
getEditorMetaData,
|
||||
handleEditorReady,
|
||||
isTouchDevice,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onEditorFocus,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
showPlaceholderOnEmpty,
|
||||
provider,
|
||||
tabIndex,
|
||||
});
|
||||
[
|
||||
provider,
|
||||
disabledExtensions,
|
||||
dragDropEnabled,
|
||||
extensions,
|
||||
extendedEditorProps,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
editable,
|
||||
user,
|
||||
mainNavigationExtension,
|
||||
]
|
||||
);
|
||||
|
||||
// Editor configuration
|
||||
const editorConfig = useMemo<TEditorHookProps>(
|
||||
() => ({
|
||||
disabledExtensions,
|
||||
extendedEditorProps,
|
||||
id,
|
||||
editable,
|
||||
editorProps,
|
||||
editorClassName,
|
||||
enableHistory: false,
|
||||
extensions: editorExtensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
getEditorMetaData,
|
||||
handleEditorReady,
|
||||
isTouchDevice,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onEditorFocus,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
provider,
|
||||
tabIndex,
|
||||
}),
|
||||
[
|
||||
provider,
|
||||
disabledExtensions,
|
||||
extendedEditorProps,
|
||||
id,
|
||||
editable,
|
||||
editorProps,
|
||||
editorClassName,
|
||||
editorExtensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
getEditorMetaData,
|
||||
handleEditorReady,
|
||||
isTouchDevice,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onEditorFocus,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
]
|
||||
);
|
||||
|
||||
const editor = useEditor(editorConfig);
|
||||
|
||||
const titleExtensions = useMemo(
|
||||
() => [
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
field: "title",
|
||||
}),
|
||||
titleNavigationExtension,
|
||||
],
|
||||
[provider, titleNavigationExtension]
|
||||
);
|
||||
|
||||
const titleEditorConfig = useMemo<{
|
||||
id: string;
|
||||
editable: boolean;
|
||||
provider: HocuspocusProvider;
|
||||
titleRef?: React.MutableRefObject<EditorTitleRefApi | null>;
|
||||
updatePageProperties?: ICollaborativeDocumentEditorProps["updatePageProperties"];
|
||||
extensions: Extensions;
|
||||
extendedEditorProps?: IEditorPropsExtended;
|
||||
getEditorMetaData?: IEditorProps["getEditorMetaData"];
|
||||
}>(
|
||||
() => ({
|
||||
id,
|
||||
editable,
|
||||
provider,
|
||||
titleRef,
|
||||
updatePageProperties,
|
||||
extensions: titleExtensions,
|
||||
extendedEditorProps,
|
||||
getEditorMetaData,
|
||||
}),
|
||||
[provider, id, editable, titleRef, updatePageProperties, titleExtensions, extendedEditorProps, getEditorMetaData]
|
||||
);
|
||||
|
||||
const titleEditor = useTitleEditor(titleEditorConfig as Parameters<typeof useTitleEditor>[0]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && titleEditor) {
|
||||
setMainEditor(editor);
|
||||
setTitleEditor(titleEditor);
|
||||
}
|
||||
}, [editor, titleEditor, setMainEditor, setTitleEditor]);
|
||||
|
||||
return {
|
||||
editor,
|
||||
hasServerConnectionFailed,
|
||||
hasServerSynced,
|
||||
titleEditor,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Creates a title editor extension that enables keyboard navigation to the main editor
|
||||
*
|
||||
* @param getMainEditor Function to get the main editor instance
|
||||
* @returns A Tiptap extension with keyboard shortcuts
|
||||
*/
|
||||
export const createTitleNavigationExtension = (getMainEditor: () => Editor | null) =>
|
||||
Extension.create({
|
||||
name: "titleEditorNavigation",
|
||||
priority: 10,
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// Arrow down at end of title - Move to main editor
|
||||
ArrowDown: () => {
|
||||
const mainEditor = getMainEditor();
|
||||
if (!mainEditor) return false;
|
||||
|
||||
// If cursor is at the end of the title
|
||||
mainEditor.commands.focus("start");
|
||||
return true;
|
||||
},
|
||||
|
||||
// Right arrow at end of title - Move to main editor
|
||||
ArrowRight: ({ editor: titleEditor }) => {
|
||||
const mainEditor = getMainEditor();
|
||||
if (!mainEditor) return false;
|
||||
|
||||
const { from, to } = titleEditor.state.selection;
|
||||
const documentLength = titleEditor.state.doc.content.size;
|
||||
|
||||
// If cursor is at the end of the title
|
||||
if (from === to && to === documentLength - 1) {
|
||||
mainEditor.commands.focus("start");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Enter - Create new line in main editor and focus
|
||||
Enter: () => {
|
||||
const mainEditor = getMainEditor();
|
||||
if (!mainEditor) return false;
|
||||
|
||||
// Focus at the start of the main editor
|
||||
mainEditor.chain().focus().insertContentAt(0, { type: "paragraph" }).run();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a main editor extension that enables keyboard navigation to the title editor
|
||||
*
|
||||
* @param getTitleEditor Function to get the title editor instance
|
||||
* @returns A Tiptap extension with keyboard shortcuts
|
||||
*/
|
||||
export const createMainNavigationExtension = (getTitleEditor: () => Editor | null) =>
|
||||
Extension.create({
|
||||
name: "mainEditorNavigation",
|
||||
priority: 10,
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// Arrow up at start of main editor - Move to title editor
|
||||
ArrowUp: ({ editor: mainEditor }) => {
|
||||
const titleEditor = getTitleEditor();
|
||||
if (!titleEditor) return false;
|
||||
|
||||
const { from, to } = mainEditor.state.selection;
|
||||
|
||||
// If cursor is at the start of the main editor
|
||||
if (from === 1 && to === 1) {
|
||||
titleEditor.commands.focus("end");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Left arrow at start of main editor - Move to title editor
|
||||
ArrowLeft: ({ editor: mainEditor }) => {
|
||||
const titleEditor = getTitleEditor();
|
||||
if (!titleEditor) return false;
|
||||
|
||||
const { from, to } = mainEditor.state.selection;
|
||||
|
||||
// If cursor is at the absolute start of the main editor
|
||||
if (from === 1 && to === 1) {
|
||||
titleEditor.commands.focus("end");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Backspace - Special handling for first paragraph
|
||||
Backspace: ({ editor }) => {
|
||||
const titleEditor = getTitleEditor();
|
||||
if (!titleEditor) return false;
|
||||
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
|
||||
// Only handle when cursor is at position 1 with empty selection
|
||||
if (from === 1 && to === 1 && empty) {
|
||||
const firstNode = editor.state.doc.firstChild;
|
||||
|
||||
// If first node is a paragraph
|
||||
if (firstNode && firstNode.type.name === "paragraph") {
|
||||
// If paragraph is already empty, delete it and focus title editor
|
||||
if (firstNode.content.size === 0) {
|
||||
editor.commands.deleteNode("paragraph");
|
||||
// Use setTimeout to ensure the node is deleted before changing focus
|
||||
setTimeout(() => titleEditor.commands.focus("end"), 0);
|
||||
return true;
|
||||
}
|
||||
// If paragraph is not empty, just move focus to title editor
|
||||
else {
|
||||
titleEditor.commands.focus("end");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Hook to manage navigation between title and main editors
|
||||
*
|
||||
* Creates extension factories for keyboard navigation between editors
|
||||
* and maintains references to both editors
|
||||
*
|
||||
* @returns Object with editor setters and extensions
|
||||
*/
|
||||
export const useEditorNavigation = () => {
|
||||
// Create refs to store editor instances
|
||||
const titleEditorRef = useRef<Editor | null>(null);
|
||||
const mainEditorRef = useRef<Editor | null>(null);
|
||||
|
||||
// Create stable getter functions
|
||||
const getTitleEditor = useCallback(() => titleEditorRef.current, []);
|
||||
const getMainEditor = useCallback(() => mainEditorRef.current, []);
|
||||
|
||||
// Create stable setter functions
|
||||
const setTitleEditor = useCallback((editor: Editor | null) => {
|
||||
titleEditorRef.current = editor;
|
||||
}, []);
|
||||
|
||||
const setMainEditor = useCallback((editor: Editor | null) => {
|
||||
mainEditorRef.current = editor;
|
||||
}, []);
|
||||
|
||||
// Create extension factories that access editor refs
|
||||
const titleNavigationExtension = createTitleNavigationExtension(getMainEditor);
|
||||
const mainNavigationExtension = createMainNavigationExtension(getTitleEditor);
|
||||
|
||||
return {
|
||||
setTitleEditor,
|
||||
setMainEditor,
|
||||
titleNavigationExtension,
|
||||
mainNavigationExtension,
|
||||
};
|
||||
};
|
||||
@@ -40,7 +40,6 @@ export const useEditor = (props: TEditorHookProps) => {
|
||||
onEditorFocus,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
showPlaceholderOnEmpty,
|
||||
tabIndex,
|
||||
provider,
|
||||
value,
|
||||
@@ -71,7 +70,6 @@ export const useEditor = (props: TEditorHookProps) => {
|
||||
isTouchDevice,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
showPlaceholderOnEmpty,
|
||||
tabIndex,
|
||||
provider,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { Extensions } from "@tiptap/core";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { useImperativeHandle } from "react";
|
||||
// constants
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
// extensions
|
||||
import { TitleExtensions } from "@/extensions/title-extension";
|
||||
// helpers
|
||||
import { getEditorRefHelpers } from "@/helpers/editor-ref";
|
||||
// types
|
||||
import type { IEditorPropsExtended, IEditorProps } from "@/types";
|
||||
import type { EditorTitleRefApi, ICollaborativeDocumentEditorProps } from "@/types/editor";
|
||||
|
||||
type Props = {
|
||||
editable?: boolean;
|
||||
provider: HocuspocusProvider;
|
||||
titleRef?: React.MutableRefObject<EditorTitleRefApi | null>;
|
||||
extensions?: Extensions;
|
||||
initialValue?: string;
|
||||
field?: string;
|
||||
placeholder?: string;
|
||||
updatePageProperties?: ICollaborativeDocumentEditorProps["updatePageProperties"];
|
||||
id: string;
|
||||
extendedEditorProps?: IEditorPropsExtended;
|
||||
getEditorMetaData?: IEditorProps["getEditorMetaData"];
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that creates a title editor with collaboration features
|
||||
* Uses the same Y.Doc as the main editor but a different field
|
||||
*/
|
||||
export const useTitleEditor = (props: Props) => {
|
||||
const {
|
||||
editable = true,
|
||||
id,
|
||||
initialValue = "",
|
||||
extensions,
|
||||
provider,
|
||||
updatePageProperties,
|
||||
titleRef,
|
||||
getEditorMetaData,
|
||||
} = props;
|
||||
|
||||
// Force editor recreation when Y.Doc changes (provider.document.guid)
|
||||
const docKey = provider?.document?.guid ?? id;
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
onUpdate: ({ editor }) => {
|
||||
updatePageProperties?.(id, "property_updated", { name: editor?.getText() });
|
||||
},
|
||||
editable,
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: false,
|
||||
extensions: [
|
||||
...TitleExtensions,
|
||||
...(extensions ?? []),
|
||||
Placeholder.configure({
|
||||
placeholder: () => "Untitled",
|
||||
includeChildren: true,
|
||||
showOnlyWhenEditable: false,
|
||||
}),
|
||||
],
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<h1></h1>",
|
||||
},
|
||||
[editable, initialValue, docKey]
|
||||
);
|
||||
|
||||
useImperativeHandle(titleRef, () => ({
|
||||
...getEditorRefHelpers({
|
||||
editor,
|
||||
provider,
|
||||
getEditorMetaData: getEditorMetaData ?? (() => ({ file_assets: [], user_mentions: [] })),
|
||||
}),
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editor
|
||||
?.chain()
|
||||
.setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true)
|
||||
.setMeta(CORE_EDITOR_META.INTENTIONAL_DELETION, true)
|
||||
.clearContent(emitUpdate)
|
||||
.run();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editor?.commands.setContent(content, false);
|
||||
},
|
||||
}));
|
||||
|
||||
return editor;
|
||||
};
|
||||
@@ -0,0 +1,369 @@
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
// react
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
// indexeddb
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
// yjs
|
||||
import type * as Y from "yjs";
|
||||
// types
|
||||
import type { CollaborationState, CollabStage, CollaborationError } from "@/types/collaboration";
|
||||
|
||||
// Helper to check if a close code indicates a forced close
|
||||
const isForcedCloseCode = (code: number | undefined): boolean => {
|
||||
if (!code) return false;
|
||||
// All custom close codes (4000-4003) are treated as forced closes
|
||||
return code >= 4000 && code <= 4003;
|
||||
};
|
||||
|
||||
type UseYjsSetupArgs = {
|
||||
docId: string;
|
||||
serverUrl: string;
|
||||
authToken: string;
|
||||
onStateChange?: (state: CollaborationState) => void;
|
||||
options?: {
|
||||
maxConnectionAttempts?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_RETRIES = 3;
|
||||
|
||||
export const useYjsSetup = ({ docId, serverUrl, authToken, onStateChange }: UseYjsSetupArgs) => {
|
||||
// Current collaboration stage
|
||||
const [stage, setStage] = useState<CollabStage>({ kind: "initial" });
|
||||
|
||||
// Cache readiness state
|
||||
const [hasCachedContent, setHasCachedContent] = useState(false);
|
||||
const [isCacheReady, setIsCacheReady] = useState(false);
|
||||
|
||||
// Provider and Y.Doc in state (nullable until effect runs)
|
||||
const [yjsSession, setYjsSession] = useState<{ provider: HocuspocusProvider; ydoc: Y.Doc } | null>(null);
|
||||
|
||||
// Use refs for values that need to be mutated from callbacks
|
||||
const retryCountRef = useRef(0);
|
||||
const forcedCloseSignalRef = useRef(false);
|
||||
const isDisposedRef = useRef(false);
|
||||
const stageRef = useRef<CollabStage>({ kind: "initial" });
|
||||
const lastReconnectTimeRef = useRef(0);
|
||||
|
||||
// Create/destroy provider in effect (not during render)
|
||||
useEffect(() => {
|
||||
// Reset refs when creating new provider (e.g., document switch)
|
||||
retryCountRef.current = 0;
|
||||
isDisposedRef.current = false;
|
||||
forcedCloseSignalRef.current = false;
|
||||
stageRef.current = { kind: "initial" };
|
||||
|
||||
const provider = new HocuspocusProvider({
|
||||
name: docId,
|
||||
token: authToken,
|
||||
url: serverUrl,
|
||||
onAuthenticationFailed: () => {
|
||||
if (isDisposedRef.current) return;
|
||||
const error: CollaborationError = { type: "auth-failed", message: "Authentication failed" };
|
||||
const newStage = { kind: "disconnected" as const, error };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
},
|
||||
onConnect: () => {
|
||||
if (isDisposedRef.current) {
|
||||
provider?.disconnect();
|
||||
return;
|
||||
}
|
||||
retryCountRef.current = 0;
|
||||
// After successful connection, transition to awaiting-sync (onSynced will move to synced)
|
||||
const newStage = { kind: "awaiting-sync" as const };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
},
|
||||
onStatus: ({ status: providerStatus }) => {
|
||||
if (isDisposedRef.current) return;
|
||||
if (providerStatus === "connecting") {
|
||||
// Derive whether this is initial connect or reconnection from retry count
|
||||
const isReconnecting = retryCountRef.current > 0;
|
||||
setStage(isReconnecting ? { kind: "reconnecting", attempt: retryCountRef.current } : { kind: "connecting" });
|
||||
} else if (providerStatus === "disconnected") {
|
||||
// Do not transition here; let handleClose decide the final stage
|
||||
} else if (providerStatus === "connected") {
|
||||
// Connection succeeded, move to awaiting-sync
|
||||
const newStage = { kind: "awaiting-sync" as const };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
}
|
||||
},
|
||||
onSynced: () => {
|
||||
if (isDisposedRef.current) return;
|
||||
retryCountRef.current = 0;
|
||||
// Document sync complete
|
||||
const newStage = { kind: "synced" as const };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
},
|
||||
});
|
||||
|
||||
const pauseProvider = () => {
|
||||
const wsProvider = provider.configuration.websocketProvider;
|
||||
if (wsProvider) {
|
||||
try {
|
||||
wsProvider.shouldConnect = false;
|
||||
wsProvider.disconnect();
|
||||
} catch (error) {
|
||||
console.error(`Error pausing websocketProvider:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const permanentlyStopProvider = () => {
|
||||
isDisposedRef.current = true;
|
||||
|
||||
const wsProvider = provider.configuration.websocketProvider;
|
||||
if (wsProvider) {
|
||||
try {
|
||||
wsProvider.shouldConnect = false;
|
||||
wsProvider.disconnect();
|
||||
wsProvider.destroy();
|
||||
} catch (error) {
|
||||
console.error(`Error tearing down websocketProvider:`, error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
provider.destroy();
|
||||
} catch (error) {
|
||||
console.error(`Error destroying provider:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (closeEvent: { event?: { code?: number; reason?: string } }) => {
|
||||
if (isDisposedRef.current) return;
|
||||
|
||||
const closeCode = closeEvent.event?.code;
|
||||
const wsProvider = provider.configuration.websocketProvider;
|
||||
const shouldConnect = wsProvider.shouldConnect;
|
||||
const isForcedClose = isForcedCloseCode(closeCode) || forcedCloseSignalRef.current || shouldConnect === false;
|
||||
|
||||
if (isForcedClose) {
|
||||
// Determine if this is a manual disconnect or a permanent error
|
||||
const isManualDisconnect = shouldConnect === false;
|
||||
|
||||
const error: CollaborationError = {
|
||||
type: "forced-close",
|
||||
code: closeCode || 0,
|
||||
message: isManualDisconnect ? "Manually disconnected" : "Server forced connection close",
|
||||
};
|
||||
const newStage = { kind: "disconnected" as const, error };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
|
||||
retryCountRef.current = 0;
|
||||
forcedCloseSignalRef.current = false;
|
||||
|
||||
// Only pause if it's a real forced close (not manual disconnect)
|
||||
// Manual disconnect leaves it as is (shouldConnect=false already set if manual)
|
||||
if (!isManualDisconnect) {
|
||||
pauseProvider();
|
||||
}
|
||||
} else {
|
||||
// Transient connection loss: attempt reconnection
|
||||
retryCountRef.current++;
|
||||
|
||||
if (retryCountRef.current >= DEFAULT_MAX_RETRIES) {
|
||||
// Exceeded max retry attempts
|
||||
const error: CollaborationError = {
|
||||
type: "max-retries",
|
||||
message: `Failed to connect after ${DEFAULT_MAX_RETRIES} attempts`,
|
||||
};
|
||||
const newStage = { kind: "disconnected" as const, error };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
|
||||
pauseProvider();
|
||||
} else {
|
||||
// Still have retries left, move to reconnecting
|
||||
const newStage = { kind: "reconnecting" as const, attempt: retryCountRef.current };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
provider.on("close", handleClose);
|
||||
|
||||
setYjsSession({ provider, ydoc: provider.document as Y.Doc });
|
||||
|
||||
// Handle page visibility changes (sleep/wake, tab switching)
|
||||
const handleVisibilityChange = (event?: Event) => {
|
||||
if (isDisposedRef.current) return;
|
||||
|
||||
const isVisible = document.visibilityState === "visible";
|
||||
const isFocus = event?.type === "focus";
|
||||
|
||||
if (isVisible || isFocus) {
|
||||
// Throttle reconnection attempts to avoid double-firing (visibility + focus)
|
||||
const now = Date.now();
|
||||
if (now - lastReconnectTimeRef.current < 1000) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wsProvider = provider.configuration.websocketProvider;
|
||||
if (!wsProvider) return;
|
||||
|
||||
const ws = wsProvider.webSocket;
|
||||
const isStale = ws?.readyState === WebSocket.CLOSED || ws?.readyState === WebSocket.CLOSING;
|
||||
|
||||
// If disconnected or stale, re-enable reconnection and force reconnect
|
||||
if (isStale || stageRef.current.kind === "disconnected") {
|
||||
lastReconnectTimeRef.current = now;
|
||||
|
||||
// Re-enable connection on tab focus (even if manually disconnected before sleep)
|
||||
wsProvider.shouldConnect = true;
|
||||
|
||||
// Reset retry count for fresh reconnection attempt
|
||||
retryCountRef.current = 0;
|
||||
|
||||
// Move to connecting state
|
||||
const newStage = { kind: "connecting" as const };
|
||||
stageRef.current = newStage;
|
||||
setStage(newStage);
|
||||
|
||||
wsProvider.disconnect();
|
||||
wsProvider.connect();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle online/offline events
|
||||
const handleOnline = () => {
|
||||
if (isDisposedRef.current) return;
|
||||
|
||||
const wsProvider = provider.configuration.websocketProvider;
|
||||
if (wsProvider) {
|
||||
wsProvider.shouldConnect = true;
|
||||
wsProvider.disconnect();
|
||||
wsProvider.connect();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.addEventListener("focus", handleVisibilityChange);
|
||||
window.addEventListener("online", handleOnline);
|
||||
|
||||
return () => {
|
||||
try {
|
||||
provider.off("close", handleClose);
|
||||
} catch (error) {
|
||||
console.error(`Error unregistering close handler:`, error);
|
||||
}
|
||||
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("focus", handleVisibilityChange);
|
||||
window.removeEventListener("online", handleOnline);
|
||||
|
||||
permanentlyStopProvider();
|
||||
};
|
||||
}, [docId, serverUrl, authToken]);
|
||||
|
||||
// IndexedDB persistence lifecycle
|
||||
useEffect(() => {
|
||||
if (!yjsSession) return;
|
||||
|
||||
const idbPersistence = new IndexeddbPersistence(docId, yjsSession.provider.document);
|
||||
|
||||
const onIdbSynced = () => {
|
||||
const yFragment = idbPersistence.doc.getXmlFragment("default");
|
||||
const docLength = yFragment?.length ?? 0;
|
||||
setIsCacheReady(true);
|
||||
setHasCachedContent(docLength > 0);
|
||||
};
|
||||
|
||||
idbPersistence.on("synced", onIdbSynced);
|
||||
|
||||
return () => {
|
||||
idbPersistence.off("synced", onIdbSynced);
|
||||
try {
|
||||
idbPersistence.destroy();
|
||||
} catch (error) {
|
||||
console.error(`Error destroying local provider:`, error);
|
||||
}
|
||||
};
|
||||
}, [docId, yjsSession]);
|
||||
|
||||
// Observe Y.Doc content changes to update hasCachedContent (catches fallback scenario)
|
||||
useEffect(() => {
|
||||
if (!yjsSession || !isCacheReady) return;
|
||||
|
||||
const fragment = yjsSession.ydoc.getXmlFragment("default");
|
||||
let lastHasContent = false;
|
||||
|
||||
const updateCachedContentFlag = () => {
|
||||
const len = fragment?.length ?? 0;
|
||||
const hasContent = len > 0;
|
||||
|
||||
// Only update state if the boolean value actually changed
|
||||
if (hasContent !== lastHasContent) {
|
||||
lastHasContent = hasContent;
|
||||
setHasCachedContent(hasContent);
|
||||
}
|
||||
};
|
||||
// Initial check (handles fallback content loaded before this effect runs)
|
||||
updateCachedContentFlag();
|
||||
|
||||
// Use observeDeep to catch nested changes (keystrokes modify Y.XmlText inside Y.XmlElement)
|
||||
fragment.observeDeep(updateCachedContentFlag);
|
||||
|
||||
return () => {
|
||||
try {
|
||||
fragment.unobserveDeep(updateCachedContentFlag);
|
||||
} catch (error) {
|
||||
console.error("Error unobserving fragment:", error);
|
||||
}
|
||||
};
|
||||
}, [yjsSession, isCacheReady]);
|
||||
|
||||
// Notify state changes callback (use ref to avoid dependency on handler)
|
||||
const stateChangeCallbackRef = useRef(onStateChange);
|
||||
stateChangeCallbackRef.current = onStateChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!stateChangeCallbackRef.current) return;
|
||||
|
||||
const isServerSynced = stage.kind === "synced";
|
||||
const isServerDisconnected = stage.kind === "disconnected";
|
||||
|
||||
const state: CollaborationState = {
|
||||
stage,
|
||||
isServerSynced,
|
||||
isServerDisconnected,
|
||||
};
|
||||
|
||||
stateChangeCallbackRef.current(state);
|
||||
}, [stage]);
|
||||
|
||||
// Derived values for convenience
|
||||
const isServerSynced = stage.kind === "synced";
|
||||
const isServerDisconnected = stage.kind === "disconnected";
|
||||
const isDocReady = isServerSynced || isServerDisconnected || (isCacheReady && hasCachedContent);
|
||||
|
||||
const signalForcedClose = useCallback((value: boolean) => {
|
||||
forcedCloseSignalRef.current = value;
|
||||
}, []);
|
||||
|
||||
// Don't return anything until provider is ready - guarantees non-null provider
|
||||
if (!yjsSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: yjsSession.provider,
|
||||
ydoc: yjsSession.ydoc,
|
||||
state: {
|
||||
stage,
|
||||
hasCachedContent,
|
||||
isCacheReady,
|
||||
isServerSynced,
|
||||
isServerDisconnected,
|
||||
isDocReady,
|
||||
},
|
||||
actions: {
|
||||
signalForcedClose,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
|
||||
type NodeHighlightState = {
|
||||
highlightedNodeId: string | null;
|
||||
decorations: DecorationSet;
|
||||
};
|
||||
|
||||
type NodeHighlightMeta = {
|
||||
nodeId?: string | null;
|
||||
};
|
||||
|
||||
export const nodeHighlightPluginKey = new PluginKey<NodeHighlightState>("nodeHighlight");
|
||||
|
||||
const buildDecorations = (doc: Parameters<typeof DecorationSet.create>[0], highlightedNodeId: string | null) => {
|
||||
if (!highlightedNodeId) {
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
const decorations: Decoration[] = [];
|
||||
const highlightClassNames = ["bg-custom-primary-100/20", "transition-all", "duration-300", "rounded"];
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
// Check if this node has the id we're looking for
|
||||
if (node.attrs && node.attrs.id === highlightedNodeId) {
|
||||
const decorationAttrs: Record<string, string> = {
|
||||
"data-node-highlighted": "true",
|
||||
class: highlightClassNames.join(" "),
|
||||
};
|
||||
|
||||
// For text nodes, highlight the inline content
|
||||
if (node.isText) {
|
||||
decorations.push(
|
||||
Decoration.inline(pos, pos + node.nodeSize, decorationAttrs, {
|
||||
inclusiveStart: true,
|
||||
inclusiveEnd: true,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// For block nodes, add a node decoration
|
||||
decorations.push(Decoration.node(pos, pos + node.nodeSize, decorationAttrs));
|
||||
}
|
||||
|
||||
return false; // Stop searching once we found the node
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
};
|
||||
|
||||
export const NodeHighlightPlugin = () =>
|
||||
new Plugin<NodeHighlightState>({
|
||||
key: nodeHighlightPluginKey,
|
||||
state: {
|
||||
init: () => ({
|
||||
highlightedNodeId: null,
|
||||
decorations: DecorationSet.empty,
|
||||
}),
|
||||
apply: (tr, value, _oldState, newState) => {
|
||||
let highlightedNodeId = value.highlightedNodeId;
|
||||
let decorations = value.decorations;
|
||||
|
||||
const meta = tr.getMeta(nodeHighlightPluginKey) as NodeHighlightMeta | undefined;
|
||||
let shouldRecalculate = tr.docChanged;
|
||||
|
||||
if (meta) {
|
||||
if (meta.nodeId !== undefined) {
|
||||
highlightedNodeId = typeof meta.nodeId === "string" && meta.nodeId.length > 0 ? meta.nodeId : null;
|
||||
shouldRecalculate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRecalculate) {
|
||||
decorations = buildDecorations(newState.doc, highlightedNodeId);
|
||||
} else if (tr.docChanged) {
|
||||
decorations = decorations.map(tr.mapping, newState.doc);
|
||||
}
|
||||
|
||||
return {
|
||||
highlightedNodeId,
|
||||
decorations,
|
||||
};
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return nodeHighlightPluginKey.getState(state)?.decorations ?? DecorationSet.empty;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,37 @@
|
||||
export type CollaborationError =
|
||||
| { type: "auth-failed"; message: string }
|
||||
| { type: "network-error"; message: string }
|
||||
| { type: "forced-close"; code: number; message: string }
|
||||
| { type: "max-retries"; message: string };
|
||||
|
||||
/**
|
||||
* Single-stage state machine for collaboration lifecycle.
|
||||
* Stages represent the sequential progression: initial → connecting → awaiting-sync → synced
|
||||
*
|
||||
* Invariants:
|
||||
* - "awaiting-sync" only occurs when connection is successful and sync is pending
|
||||
* - "synced" occurs only after connection success and onSynced callback
|
||||
* - "reconnecting" with attempt > 0 when retrying after a connection drop
|
||||
* - "disconnected" is terminal (connection failed or forced close)
|
||||
*/
|
||||
export type CollabStage =
|
||||
| { kind: "initial" }
|
||||
| { kind: "connecting" }
|
||||
| { kind: "awaiting-sync" }
|
||||
| { kind: "synced" }
|
||||
| { kind: "reconnecting"; attempt: number }
|
||||
| { kind: "disconnected"; error: CollaborationError };
|
||||
|
||||
/**
|
||||
* Public collaboration state exposed to consumers.
|
||||
* Contains the current stage and derived booleans for convenience.
|
||||
*/
|
||||
export type CollaborationState = {
|
||||
stage: CollabStage;
|
||||
isServerSynced: boolean;
|
||||
isServerDisconnected: boolean;
|
||||
};
|
||||
|
||||
export type TServerHandler = {
|
||||
onConnect?: () => void;
|
||||
onServerError?: () => void;
|
||||
onStateChange: (state: CollaborationState) => void;
|
||||
};
|
||||
|
||||
@@ -27,6 +27,8 @@ import type {
|
||||
TRealtimeConfig,
|
||||
TServerHandler,
|
||||
TUserDetails,
|
||||
TExtendedEditorRefApi,
|
||||
EventToPayloadMap,
|
||||
} from "@/types";
|
||||
|
||||
export type TEditorCommands =
|
||||
@@ -97,7 +99,7 @@ export type TDocumentInfo = {
|
||||
words: number;
|
||||
};
|
||||
|
||||
export type EditorRefApi = {
|
||||
export type CoreEditorRefApi = {
|
||||
blur: () => void;
|
||||
clearEditor: (emitUpdate?: boolean) => void;
|
||||
createSelectionAtCursorPosition: () => void;
|
||||
@@ -138,6 +140,10 @@ export type EditorRefApi = {
|
||||
undo: () => void;
|
||||
};
|
||||
|
||||
export type EditorRefApi = CoreEditorRefApi & TExtendedEditorRefApi;
|
||||
|
||||
export type EditorTitleRefApi = EditorRefApi;
|
||||
|
||||
// editor props
|
||||
export type IEditorProps = {
|
||||
autofocus?: boolean;
|
||||
@@ -164,7 +170,6 @@ export type IEditorProps = {
|
||||
onEnterKeyPress?: (e?: any) => void;
|
||||
onTransaction?: () => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
showPlaceholderOnEmpty?: boolean;
|
||||
tabIndex?: number;
|
||||
value?: string | null;
|
||||
extendedEditorProps: IEditorPropsExtended;
|
||||
@@ -186,6 +191,15 @@ export type ICollaborativeDocumentEditorProps = Omit<IEditorProps, "initialValue
|
||||
serverHandler?: TServerHandler;
|
||||
user: TUserDetails;
|
||||
extendedDocumentEditorProps?: ICollaborativeDocumentEditorPropsExtended;
|
||||
updatePageProperties?: <T extends keyof EventToPayloadMap>(
|
||||
pageIds: string | string[],
|
||||
actionType: T,
|
||||
data: EventToPayloadMap[T],
|
||||
performAction?: boolean
|
||||
) => void;
|
||||
pageRestorationInProgress?: boolean;
|
||||
titleRef?: React.MutableRefObject<EditorTitleRefApi | null>;
|
||||
isFetchingFallbackBinary?: boolean;
|
||||
};
|
||||
|
||||
export type IDocumentEditorProps = Omit<IEditorProps, "initialValue" | "onEnterKeyPress" | "value"> & {
|
||||
|
||||
@@ -29,7 +29,6 @@ export type TEditorHookProps = TCoreHookProps &
|
||||
| "onChange"
|
||||
| "onTransaction"
|
||||
| "placeholder"
|
||||
| "showPlaceholderOnEmpty"
|
||||
| "tabIndex"
|
||||
| "value"
|
||||
> & {
|
||||
@@ -51,10 +50,12 @@ export type TCollaborativeEditorHookProps = TCoreHookProps &
|
||||
| "onChange"
|
||||
| "onTransaction"
|
||||
| "placeholder"
|
||||
| "showPlaceholderOnEmpty"
|
||||
| "tabIndex"
|
||||
> &
|
||||
Pick<
|
||||
ICollaborativeDocumentEditorProps,
|
||||
"dragDropEnabled" | "extendedDocumentEditorProps" | "realtimeConfig" | "serverHandler" | "user"
|
||||
>;
|
||||
> & {
|
||||
titleRef?: ICollaborativeDocumentEditorProps["titleRef"];
|
||||
updatePageProperties?: ICollaborativeDocumentEditorProps["updatePageProperties"];
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user