mirror of
https://github.com/makeplane/plane.git
synced 2025-12-16 20:07:56 +01:00
[WIKI-844] fix: realtime sync post vite migration with title editor sync and indexed db access (#8294)
* fix: robust way to handle socket connection and read from indexeddb cache when reqd * fix: realtime sync working with failure handling * fix: title editor added * merge preview into fix/realtime-sync * check * page renderer props * lint errors * lint errors * lint errors * sanitize html * sanitize html * format fix * fix lint
This commit is contained in:
@@ -27,6 +27,17 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP
|
|||||||
const pageDetails = await service.fetchDetails(pageId);
|
const pageDetails = await service.fetchDetails(pageId);
|
||||||
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "<p></p>");
|
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "<p></p>");
|
||||||
if (convertedBinaryData) {
|
if (convertedBinaryData) {
|
||||||
|
// save the converted binary data back to the database
|
||||||
|
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
|
||||||
|
convertedBinaryData,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const payload = {
|
||||||
|
description_binary: contentBinaryEncoded,
|
||||||
|
description_html: contentHTML,
|
||||||
|
description: contentJSON,
|
||||||
|
};
|
||||||
|
await service.updateDescriptionBinary(pageId, payload);
|
||||||
return convertedBinaryData;
|
return convertedBinaryData;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,8 +63,10 @@ const storeDocument = async ({
|
|||||||
try {
|
try {
|
||||||
const service = getPageService(context.documentType, context);
|
const service = getPageService(context.documentType, context);
|
||||||
// convert binary data to all formats
|
// convert binary data to all formats
|
||||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
|
||||||
getAllDocumentFormatsFromDocumentEditorBinaryData(pageBinaryData);
|
pageBinaryData,
|
||||||
|
true
|
||||||
|
);
|
||||||
// create payload
|
// create payload
|
||||||
const payload = {
|
const payload = {
|
||||||
description_binary: contentBinaryEncoded,
|
description_binary: contentBinaryEncoded,
|
||||||
|
|||||||
175
apps/live/src/extensions/title-sync.ts
Normal file
175
apps/live/src/extensions/title-sync.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
// hocuspocus
|
||||||
|
import type { Extension, Hocuspocus, Document } from "@hocuspocus/server";
|
||||||
|
import { TiptapTransformer } from "@hocuspocus/transformer";
|
||||||
|
import type * as Y from "yjs";
|
||||||
|
// editor extensions
|
||||||
|
import { TITLE_EDITOR_EXTENSIONS, createRealtimeEvent } 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 { generateTitleProsemirrorJson } from "@/utils";
|
||||||
|
import { broadcastMessageToPage } from "@/utils/broadcast-message";
|
||||||
|
import { TitleUpdateManager } from "./title-update/title-update-manager";
|
||||||
|
import { extractTextFromHTML } from "./title-update/title-utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 title = await service.fe
|
||||||
|
const title = (await service.fetchDetails?.(documentName)).name;
|
||||||
|
if (title == null) return;
|
||||||
|
const titleField = TiptapTransformer.toYdoc(
|
||||||
|
generateTitleProsemirrorJson(title),
|
||||||
|
"title",
|
||||||
|
// editor
|
||||||
|
TITLE_EDITOR_EXTENSIONS as any
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
277
apps/live/src/extensions/title-update/debounce.ts
Normal file
277
apps/live/src/extensions/title-update/debounce.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/live/src/extensions/title-update/title-utils.ts
Normal file
11
apps/live/src/extensions/title-update/title-utils.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { sanitizeHTML } from "@plane/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to extract text from HTML content
|
||||||
|
*/
|
||||||
|
export const extractTextFromHTML = (html: string): string => {
|
||||||
|
// Use sanitizeHTML 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
|
||||||
|
return sanitizeHTML(html) || "";
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
|||||||
import { PageAccessIcon } from "@/components/common/page-access-icon";
|
import { PageAccessIcon } from "@/components/common/page-access-icon";
|
||||||
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
|
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
|
||||||
import { PageHeaderActions } from "@/components/pages/header/actions";
|
import { PageHeaderActions } from "@/components/pages/header/actions";
|
||||||
|
import { PageSyncingBadge } from "@/components/pages/header/syncing-badge";
|
||||||
// hooks
|
// hooks
|
||||||
import { useProject } from "@/hooks/store/use-project";
|
import { useProject } from "@/hooks/store/use-project";
|
||||||
import { useAppRouter } from "@/hooks/use-app-router";
|
import { useAppRouter } from "@/hooks/use-app-router";
|
||||||
@@ -95,6 +96,7 @@ export const PageDetailsHeader = observer(function PageDetailsHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</Header.LeftItem>
|
</Header.LeftItem>
|
||||||
<Header.RightItem>
|
<Header.RightItem>
|
||||||
|
<PageSyncingBadge syncStatus={page.isSyncingWithServer} />
|
||||||
<PageDetailsHeaderExtraActions page={page} storeType={storeType} />
|
<PageDetailsHeaderExtraActions page={page} storeType={storeType} />
|
||||||
<PageHeaderActions page={page} storeType={storeType} />
|
<PageHeaderActions page={page} storeType={storeType} />
|
||||||
</Header.RightItem>
|
</Header.RightItem>
|
||||||
|
|||||||
@@ -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, useEffect, useMemo, useRef } from "react";
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
|
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
|
||||||
import { CollaborativeDocumentEditorWithRef } from "@plane/editor";
|
import { CollaborativeDocumentEditorWithRef } from "@plane/editor";
|
||||||
import type {
|
import type {
|
||||||
|
CollaborationState,
|
||||||
EditorRefApi,
|
EditorRefApi,
|
||||||
|
EditorTitleRefApi,
|
||||||
TAIMenuProps,
|
TAIMenuProps,
|
||||||
TDisplayConfig,
|
TDisplayConfig,
|
||||||
TFileHandler,
|
TFileHandler,
|
||||||
@@ -26,6 +27,8 @@ import { useUser } from "@/hooks/store/user";
|
|||||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||||
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||||
// plane web imports
|
// 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 { EditorAIMenu } from "@/plane-web/components/pages";
|
||||||
import type { TExtendedEditorExtensionsConfig } from "@/plane-web/hooks/pages";
|
import type { TExtendedEditorExtensionsConfig } from "@/plane-web/hooks/pages";
|
||||||
import type { EPageStoreType } from "@/plane-web/hooks/store";
|
import type { EPageStoreType } from "@/plane-web/hooks/store";
|
||||||
@@ -51,7 +54,6 @@ type Props = {
|
|||||||
config: TEditorBodyConfig;
|
config: TEditorBodyConfig;
|
||||||
editorReady: boolean;
|
editorReady: boolean;
|
||||||
editorForwardRef: React.RefObject<EditorRefApi>;
|
editorForwardRef: React.RefObject<EditorRefApi>;
|
||||||
handleConnectionStatus: Dispatch<SetStateAction<boolean>>;
|
|
||||||
handleEditorReady: (status: boolean) => void;
|
handleEditorReady: (status: boolean) => void;
|
||||||
handleOpenNavigationPane: () => void;
|
handleOpenNavigationPane: () => void;
|
||||||
handlers: TEditorBodyHandlers;
|
handlers: TEditorBodyHandlers;
|
||||||
@@ -61,14 +63,16 @@ type Props = {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
storeType: EPageStoreType;
|
storeType: EPageStoreType;
|
||||||
|
customRealtimeEventHandlers?: TCustomEventHandlers;
|
||||||
extendedEditorProps: TExtendedEditorExtensionsConfig;
|
extendedEditorProps: TExtendedEditorExtensionsConfig;
|
||||||
|
isFetchingFallbackBinary?: boolean;
|
||||||
|
onCollaborationStateChange?: (state: CollaborationState) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
||||||
const {
|
const {
|
||||||
config,
|
config,
|
||||||
editorForwardRef,
|
editorForwardRef,
|
||||||
handleConnectionStatus,
|
|
||||||
handleEditorReady,
|
handleEditorReady,
|
||||||
handleOpenNavigationPane,
|
handleOpenNavigationPane,
|
||||||
handlers,
|
handlers,
|
||||||
@@ -79,7 +83,11 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
|||||||
projectId,
|
projectId,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
extendedEditorProps,
|
extendedEditorProps,
|
||||||
|
isFetchingFallbackBinary,
|
||||||
|
onCollaborationStateChange,
|
||||||
} = props;
|
} = props;
|
||||||
|
// refs
|
||||||
|
const titleEditorRef = useRef<EditorTitleRefApi>(null);
|
||||||
// store hooks
|
// store hooks
|
||||||
const { data: currentUser } = useUser();
|
const { data: currentUser } = useUser();
|
||||||
const { getWorkspaceBySlug } = useWorkspace();
|
const { getWorkspaceBySlug } = useWorkspace();
|
||||||
@@ -87,10 +95,9 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
|||||||
// derived values
|
// derived values
|
||||||
const {
|
const {
|
||||||
id: pageId,
|
id: pageId,
|
||||||
name: pageTitle,
|
|
||||||
isContentEditable,
|
isContentEditable,
|
||||||
updateTitle,
|
|
||||||
editor: { editorRef, updateAssetsList },
|
editor: { editorRef, updateAssetsList },
|
||||||
|
setSyncingStatus,
|
||||||
} = page;
|
} = page;
|
||||||
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
|
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
|
||||||
// use editor mention
|
// use editor mention
|
||||||
@@ -123,6 +130,24 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
|||||||
[fontSize, fontStyle, isFullWidth]
|
[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(
|
const getAIMenu = useCallback(
|
||||||
({ isOpen, onClose }: TAIMenuProps) => (
|
({ isOpen, onClose }: TAIMenuProps) => (
|
||||||
<EditorAIMenu
|
<EditorAIMenu
|
||||||
@@ -136,20 +161,25 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
|||||||
[editorRef, workspaceId, workspaceSlug]
|
[editorRef, workspaceId, workspaceSlug]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleServerConnect = useCallback(() => {
|
|
||||||
handleConnectionStatus(false);
|
|
||||||
}, [handleConnectionStatus]);
|
|
||||||
|
|
||||||
const handleServerError = useCallback(() => {
|
|
||||||
handleConnectionStatus(true);
|
|
||||||
}, [handleConnectionStatus]);
|
|
||||||
|
|
||||||
const serverHandler: TServerHandler = useMemo(
|
const serverHandler: TServerHandler = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
onConnect: handleServerConnect,
|
onStateChange: (state) => {
|
||||||
onServerError: handleServerError,
|
// 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(() => {
|
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 (
|
return (
|
||||||
<Row
|
<Row
|
||||||
@@ -225,12 +257,6 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
|||||||
<div className="page-header-container group/page-header">
|
<div className="page-header-container group/page-header">
|
||||||
<div className={blockWidthClassName}>
|
<div className={blockWidthClassName}>
|
||||||
<PageEditorHeaderRoot page={page} projectId={projectId} />
|
<PageEditorHeaderRoot page={page} projectId={projectId} />
|
||||||
<PageEditorTitle
|
|
||||||
editorRef={editorRef}
|
|
||||||
readOnly={!isContentEditable}
|
|
||||||
title={pageTitle}
|
|
||||||
updateTitle={updateTitle}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CollaborativeDocumentEditorWithRef
|
<CollaborativeDocumentEditorWithRef
|
||||||
@@ -239,6 +265,7 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
|||||||
fileHandler={config.fileHandler}
|
fileHandler={config.fileHandler}
|
||||||
handleEditorReady={handleEditorReady}
|
handleEditorReady={handleEditorReady}
|
||||||
ref={editorForwardRef}
|
ref={editorForwardRef}
|
||||||
|
titleRef={titleEditorRef}
|
||||||
containerClassName="h-full p-0 pb-64"
|
containerClassName="h-full p-0 pb-64"
|
||||||
displayConfig={displayConfig}
|
displayConfig={displayConfig}
|
||||||
getEditorMetaData={getEditorMetaData}
|
getEditorMetaData={getEditorMetaData}
|
||||||
@@ -251,6 +278,7 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
|||||||
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||||
getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }),
|
getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }),
|
||||||
}}
|
}}
|
||||||
|
updatePageProperties={updatePageProperties}
|
||||||
realtimeConfig={realtimeConfig}
|
realtimeConfig={realtimeConfig}
|
||||||
serverHandler={serverHandler}
|
serverHandler={serverHandler}
|
||||||
user={userConfig}
|
user={userConfig}
|
||||||
@@ -261,6 +289,7 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
|||||||
}}
|
}}
|
||||||
onAssetChange={updateAssetsList}
|
onAssetChange={updateAssetsList}
|
||||||
extendedEditorProps={extendedEditorProps}
|
extendedEditorProps={extendedEditorProps}
|
||||||
|
isFetchingFallbackBinary={isFetchingFallbackBinary}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import type { EditorRefApi } from "@plane/editor";
|
import type { CollaborationState, EditorRefApi } from "@plane/editor";
|
||||||
import type { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types";
|
import type { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types";
|
||||||
// hooks
|
// hooks
|
||||||
import { useAppRouter } from "@/hooks/use-app-router";
|
|
||||||
import { usePageFallback } from "@/hooks/use-page-fallback";
|
import { usePageFallback } from "@/hooks/use-page-fallback";
|
||||||
// plane web import
|
// plane web import
|
||||||
|
import type { PageUpdateHandler, TCustomEventHandlers } from "@/hooks/use-realtime-page-events";
|
||||||
import { PageModals } from "@/plane-web/components/pages";
|
import { PageModals } from "@/plane-web/components/pages";
|
||||||
import { usePagesPaneExtensions, useExtendedEditorProps } from "@/plane-web/hooks/pages";
|
import { usePagesPaneExtensions, useExtendedEditorProps } from "@/plane-web/hooks/pages";
|
||||||
import type { EPageStoreType } from "@/plane-web/hooks/store";
|
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 { PageNavigationPaneRoot } from "../navigation-pane";
|
||||||
import { PageVersionsOverlay } from "../version";
|
import { PageVersionsOverlay } from "../version";
|
||||||
import { PagesVersionEditor } from "../version/editor";
|
import { PagesVersionEditor } from "../version/editor";
|
||||||
|
import { ContentLimitBanner } from "./content-limit-banner";
|
||||||
import { PageEditorBody } from "./editor-body";
|
import { PageEditorBody } from "./editor-body";
|
||||||
import type { TEditorBodyConfig, TEditorBodyHandlers } from "./editor-body";
|
import type { TEditorBodyConfig, TEditorBodyHandlers } from "./editor-body";
|
||||||
import { PageEditorToolbarRoot } from "./toolbar";
|
import { PageEditorToolbarRoot } from "./toolbar";
|
||||||
@@ -23,7 +24,7 @@ import { PageEditorToolbarRoot } from "./toolbar";
|
|||||||
export type TPageRootHandlers = {
|
export type TPageRootHandlers = {
|
||||||
create: (payload: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
|
create: (payload: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
|
||||||
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
|
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
|
||||||
fetchDescriptionBinary: () => Promise<any>;
|
fetchDescriptionBinary: () => Promise<ArrayBuffer>;
|
||||||
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
|
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
|
||||||
restoreVersion: (pageId: string, versionId: string) => Promise<void>;
|
restoreVersion: (pageId: string, versionId: string) => Promise<void>;
|
||||||
updateDescription: (document: TDocumentPayload) => Promise<void>;
|
updateDescription: (document: TDocumentPayload) => Promise<void>;
|
||||||
@@ -39,27 +40,36 @@ type TPageRootProps = {
|
|||||||
webhookConnectionParams: TWebhookConnectionQueryParams;
|
webhookConnectionParams: TWebhookConnectionQueryParams;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
|
customRealtimeEventHandlers?: TCustomEventHandlers;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
|
export const PageRoot = observer((props: TPageRootProps) => {
|
||||||
const { config, handlers, page, projectId, storeType, webhookConnectionParams, workspaceSlug } = props;
|
const {
|
||||||
|
config,
|
||||||
|
handlers,
|
||||||
|
page,
|
||||||
|
projectId,
|
||||||
|
storeType,
|
||||||
|
webhookConnectionParams,
|
||||||
|
workspaceSlug,
|
||||||
|
customRealtimeEventHandlers,
|
||||||
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [editorReady, setEditorReady] = useState(false);
|
const [editorReady, setEditorReady] = useState(false);
|
||||||
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
|
const [collaborationState, setCollaborationState] = useState<CollaborationState | null>(null);
|
||||||
|
const [showContentTooLargeBanner, setShowContentTooLargeBanner] = useState(false);
|
||||||
// refs
|
// refs
|
||||||
const editorRef = useRef<EditorRefApi>(null);
|
const editorRef = useRef<EditorRefApi>(null);
|
||||||
// router
|
|
||||||
const router = useAppRouter();
|
|
||||||
// derived values
|
// derived values
|
||||||
const {
|
const {
|
||||||
isContentEditable,
|
isContentEditable,
|
||||||
editor: { setEditorRef },
|
editor: { setEditorRef },
|
||||||
} = page;
|
} = page;
|
||||||
// page fallback
|
// page fallback
|
||||||
usePageFallback({
|
const { isFetchingFallbackBinary } = usePageFallback({
|
||||||
editorRef,
|
editorRef,
|
||||||
fetchPageDescription: handlers.fetchDescriptionBinary,
|
fetchPageDescription: handlers.fetchDescriptionBinary,
|
||||||
hasConnectionFailed,
|
collaborationState,
|
||||||
updatePageDescription: handlers.updateDescription,
|
updatePageDescription: handlers.updateDescription,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,6 +101,24 @@ export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
|
|||||||
editorRef,
|
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
|
// Get extended editor extensions configuration
|
||||||
const extendedEditorProps = useExtendedEditorProps({
|
const extendedEditorProps = useExtendedEditorProps({
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
@@ -134,11 +162,12 @@ export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
|
|||||||
isNavigationPaneOpen={isNavigationPaneOpen}
|
isNavigationPaneOpen={isNavigationPaneOpen}
|
||||||
page={page}
|
page={page}
|
||||||
/>
|
/>
|
||||||
|
{showContentTooLargeBanner && <ContentLimitBanner className="px-page-x" />}
|
||||||
<PageEditorBody
|
<PageEditorBody
|
||||||
config={config}
|
config={config}
|
||||||
|
customRealtimeEventHandlers={mergedCustomEventHandlers}
|
||||||
editorReady={editorReady}
|
editorReady={editorReady}
|
||||||
editorForwardRef={editorRef}
|
editorForwardRef={editorRef}
|
||||||
handleConnectionStatus={setHasConnectionFailed}
|
|
||||||
handleEditorReady={handleEditorReady}
|
handleEditorReady={handleEditorReady}
|
||||||
handleOpenNavigationPane={handleOpenNavigationPane}
|
handleOpenNavigationPane={handleOpenNavigationPane}
|
||||||
handlers={handlers}
|
handlers={handlers}
|
||||||
@@ -149,6 +178,8 @@ export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
|
|||||||
webhookConnectionParams={webhookConnectionParams}
|
webhookConnectionParams={webhookConnectionParams}
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
extendedEditorProps={extendedEditorProps}
|
extendedEditorProps={extendedEditorProps}
|
||||||
|
isFetchingFallbackBinary={isFetchingFallbackBinary}
|
||||||
|
onCollaborationStateChange={setCollaborationState}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PageNavigationPaneRoot
|
<PageNavigationPaneRoot
|
||||||
|
|||||||
72
apps/web/core/components/pages/header/syncing-badge.tsx
Normal file
72
apps/web/core/components/pages/header/syncing-badge.tsx
Normal file
@@ -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,7 +1,9 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { EditorRefApi, CollaborationState } from "@plane/editor";
|
||||||
// plane editor
|
// plane editor
|
||||||
import { convertBinaryDataToBase64String, getBinaryDataFromDocumentEditorHTMLString } from "@plane/editor";
|
import { convertBinaryDataToBase64String, getBinaryDataFromDocumentEditorHTMLString } from "@plane/editor";
|
||||||
import type { EditorRefApi } from "@plane/editor";
|
// plane propel
|
||||||
|
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||||
// plane types
|
// plane types
|
||||||
import type { TDocumentPayload } from "@plane/types";
|
import type { TDocumentPayload } from "@plane/types";
|
||||||
// hooks
|
// hooks
|
||||||
@@ -10,19 +12,38 @@ import useAutoSave from "@/hooks/use-auto-save";
|
|||||||
type TArgs = {
|
type TArgs = {
|
||||||
editorRef: React.RefObject<EditorRefApi>;
|
editorRef: React.RefObject<EditorRefApi>;
|
||||||
fetchPageDescription: () => Promise<ArrayBuffer>;
|
fetchPageDescription: () => Promise<ArrayBuffer>;
|
||||||
hasConnectionFailed: boolean;
|
collaborationState: CollaborationState | null;
|
||||||
updatePageDescription: (data: TDocumentPayload) => Promise<void>;
|
updatePageDescription: (data: TDocumentPayload) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePageFallback = (args: TArgs) => {
|
export const usePageFallback = (args: TArgs) => {
|
||||||
const { editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription } = args;
|
const { editorRef, fetchPageDescription, collaborationState, updatePageDescription } = 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 () => {
|
const handleUpdateDescription = useCallback(async () => {
|
||||||
if (!hasConnectionFailed) return;
|
if (!hasConnectionFailed) return;
|
||||||
const editor = editorRef.current;
|
const editor = editorRef.current;
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
|
|
||||||
|
// Show toast notification when fallback mechanism kicks in (only once)
|
||||||
|
if (!hasShownFallbackToast.current) {
|
||||||
|
// setToast({
|
||||||
|
// type: TOAST_TYPE.WARNING,
|
||||||
|
// title: "Connection lost",
|
||||||
|
// message: "Your changes are being saved using backup mechanism. ",
|
||||||
|
// });
|
||||||
|
console.log("Connection lost");
|
||||||
|
hasShownFallbackToast.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setIsFetchingFallbackBinary(true);
|
||||||
|
|
||||||
const latestEncodedDescription = await fetchPageDescription();
|
const latestEncodedDescription = await fetchPageDescription();
|
||||||
let latestDecodedDescription: Uint8Array;
|
let latestDecodedDescription: Uint8Array;
|
||||||
if (latestEncodedDescription && latestEncodedDescription.byteLength > 0) {
|
if (latestEncodedDescription && latestEncodedDescription.byteLength > 0) {
|
||||||
@@ -41,16 +62,28 @@ export const usePageFallback = (args: TArgs) => {
|
|||||||
description_html: html,
|
description_html: html,
|
||||||
description: json,
|
description: json,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Error in updating description using fallback logic:", error);
|
console.error(error);
|
||||||
|
// setToast({
|
||||||
|
// type: TOAST_TYPE.ERROR,
|
||||||
|
// title: "Error",
|
||||||
|
// message: `Failed to update description using backup mechanism, ${error?.message}`,
|
||||||
|
// });
|
||||||
|
} finally {
|
||||||
|
setIsFetchingFallbackBinary(false);
|
||||||
}
|
}
|
||||||
}, [editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription]);
|
}, [editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasConnectionFailed) {
|
if (hasConnectionFailed) {
|
||||||
handleUpdateDescription();
|
handleUpdateDescription();
|
||||||
|
} else {
|
||||||
|
// Reset toast flag when connection is restored
|
||||||
|
hasShownFallbackToast.current = false;
|
||||||
}
|
}
|
||||||
}, [handleUpdateDescription, hasConnectionFailed]);
|
}, [handleUpdateDescription, hasConnectionFailed]);
|
||||||
|
|
||||||
useAutoSave(handleUpdateDescription);
|
useAutoSave(handleUpdateDescription);
|
||||||
|
|
||||||
|
return { isFetchingFallbackBinary };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { PageEditorInstance } from "./page-editor-info";
|
|||||||
export type TBasePage = TPage & {
|
export type TBasePage = TPage & {
|
||||||
// observables
|
// observables
|
||||||
isSubmitting: TNameDescriptionLoader;
|
isSubmitting: TNameDescriptionLoader;
|
||||||
|
isSyncingWithServer: "syncing" | "synced" | "error";
|
||||||
// computed
|
// computed
|
||||||
asJSON: TPage | undefined;
|
asJSON: TPage | undefined;
|
||||||
isCurrentUserOwner: boolean;
|
isCurrentUserOwner: boolean;
|
||||||
@@ -35,6 +36,7 @@ export type TBasePage = TPage & {
|
|||||||
removePageFromFavorites: () => Promise<void>;
|
removePageFromFavorites: () => Promise<void>;
|
||||||
duplicate: () => Promise<TPage | undefined>;
|
duplicate: () => Promise<TPage | undefined>;
|
||||||
mutateProperties: (data: Partial<TPage>, shouldUpdateName?: boolean) => void;
|
mutateProperties: (data: Partial<TPage>, shouldUpdateName?: boolean) => void;
|
||||||
|
setSyncingStatus: (status: "syncing" | "synced" | "error") => void;
|
||||||
// sub-store
|
// sub-store
|
||||||
editor: PageEditorInstance;
|
editor: PageEditorInstance;
|
||||||
};
|
};
|
||||||
@@ -73,6 +75,7 @@ export type TPageInstance = TBasePage &
|
|||||||
export class BasePage extends ExtendedBasePage implements TBasePage {
|
export class BasePage extends ExtendedBasePage implements TBasePage {
|
||||||
// loaders
|
// loaders
|
||||||
isSubmitting: TNameDescriptionLoader = "saved";
|
isSubmitting: TNameDescriptionLoader = "saved";
|
||||||
|
isSyncingWithServer: "syncing" | "synced" | "error" = "syncing";
|
||||||
// page properties
|
// page properties
|
||||||
id: string | undefined;
|
id: string | undefined;
|
||||||
name: string | undefined;
|
name: string | undefined;
|
||||||
@@ -155,6 +158,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
|||||||
created_at: observable.ref,
|
created_at: observable.ref,
|
||||||
updated_at: observable.ref,
|
updated_at: observable.ref,
|
||||||
deleted_at: observable.ref,
|
deleted_at: observable.ref,
|
||||||
|
isSyncingWithServer: observable.ref,
|
||||||
// helpers
|
// helpers
|
||||||
oldName: observable.ref,
|
oldName: observable.ref,
|
||||||
setIsSubmitting: action,
|
setIsSubmitting: action,
|
||||||
@@ -535,4 +539,10 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
|||||||
set(this, key, value);
|
set(this, key, value);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
setSyncingStatus = (status: "syncing" | "synced" | "error") => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.isSyncingWithServer = status;
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,13 +44,16 @@
|
|||||||
"@tiptap/extension-blockquote": "^2.22.3",
|
"@tiptap/extension-blockquote": "^2.22.3",
|
||||||
"@tiptap/extension-character-count": "^2.22.3",
|
"@tiptap/extension-character-count": "^2.22.3",
|
||||||
"@tiptap/extension-collaboration": "^2.22.3",
|
"@tiptap/extension-collaboration": "^2.22.3",
|
||||||
|
"@tiptap/extension-document": "^2.22.3",
|
||||||
"@tiptap/extension-emoji": "^2.22.3",
|
"@tiptap/extension-emoji": "^2.22.3",
|
||||||
|
"@tiptap/extension-heading": "^2.22.3",
|
||||||
"@tiptap/extension-image": "^2.22.3",
|
"@tiptap/extension-image": "^2.22.3",
|
||||||
"@tiptap/extension-list-item": "^2.22.3",
|
"@tiptap/extension-list-item": "^2.22.3",
|
||||||
"@tiptap/extension-mention": "^2.22.3",
|
"@tiptap/extension-mention": "^2.22.3",
|
||||||
"@tiptap/extension-placeholder": "^2.22.3",
|
"@tiptap/extension-placeholder": "^2.22.3",
|
||||||
"@tiptap/extension-task-item": "^2.22.3",
|
"@tiptap/extension-task-item": "^2.22.3",
|
||||||
"@tiptap/extension-task-list": "^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-align": "^2.22.3",
|
||||||
"@tiptap/extension-text-style": "^2.22.3",
|
"@tiptap/extension-text-style": "^2.22.3",
|
||||||
"@tiptap/extension-underline": "^2.22.3",
|
"@tiptap/extension-underline": "^2.22.3",
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// components
|
// components
|
||||||
import { PageRenderer } from "@/components/editors";
|
import { PageRenderer } from "@/components/editors";
|
||||||
// constants
|
// constants
|
||||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||||
|
// contexts
|
||||||
|
import { CollaborationProvider, useCollaboration } from "@/contexts/collaboration-context";
|
||||||
// helpers
|
// helpers
|
||||||
import { getEditorClassNames } from "@/helpers/common";
|
import { getEditorClassNames } from "@/helpers/common";
|
||||||
// hooks
|
// hooks
|
||||||
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
|
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
|
||||||
// constants
|
|
||||||
import { DocumentEditorSideEffects } from "@/plane-editor/components/document-editor-side-effects";
|
|
||||||
// types
|
// types
|
||||||
import type { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/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 {
|
const {
|
||||||
aiHandler,
|
aiHandler,
|
||||||
bubbleMenuEnabled = true,
|
bubbleMenuEnabled = true,
|
||||||
@@ -41,15 +42,20 @@ function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
|
|||||||
onEditorFocus,
|
onEditorFocus,
|
||||||
onTransaction,
|
onTransaction,
|
||||||
placeholder,
|
placeholder,
|
||||||
realtimeConfig,
|
|
||||||
serverHandler,
|
|
||||||
tabIndex,
|
tabIndex,
|
||||||
user,
|
user,
|
||||||
extendedDocumentEditorProps,
|
extendedDocumentEditorProps,
|
||||||
|
titleRef,
|
||||||
|
updatePageProperties,
|
||||||
|
isFetchingFallbackBinary,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
// use document editor
|
// Get non-null provider from context
|
||||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
|
const { provider, state, actions } = useCollaboration();
|
||||||
|
|
||||||
|
// Editor initialization with guaranteed non-null provider
|
||||||
|
const { editor, titleEditor } = useCollaborativeEditor({
|
||||||
|
provider,
|
||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
editable,
|
editable,
|
||||||
editorClassName,
|
editorClassName,
|
||||||
@@ -70,11 +76,11 @@ function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
|
|||||||
onEditorFocus,
|
onEditorFocus,
|
||||||
onTransaction,
|
onTransaction,
|
||||||
placeholder,
|
placeholder,
|
||||||
realtimeConfig,
|
|
||||||
serverHandler,
|
|
||||||
tabIndex,
|
tabIndex,
|
||||||
|
titleRef,
|
||||||
|
updatePageProperties,
|
||||||
user,
|
user,
|
||||||
extendedDocumentEditorProps,
|
actions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorContainerClassNames = getEditorClassNames({
|
const editorContainerClassNames = getEditorClassNames({
|
||||||
@@ -83,37 +89,71 @@ function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
|
|||||||
containerClassName,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DocumentEditorSideEffects editor={editor} id={id} extendedEditorProps={extendedEditorProps} />
|
<div
|
||||||
<PageRenderer
|
className={cn(
|
||||||
aiHandler={aiHandler}
|
"transition-opacity duration-200",
|
||||||
bubbleMenuEnabled={bubbleMenuEnabled}
|
showContentSkeleton && !isLoading && "opacity-0 pointer-events-none"
|
||||||
displayConfig={displayConfig}
|
)}
|
||||||
documentLoaderClassName={documentLoaderClassName}
|
>
|
||||||
editor={editor}
|
<PageRenderer
|
||||||
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
|
aiHandler={aiHandler}
|
||||||
extendedEditorProps={extendedEditorProps}
|
bubbleMenuEnabled={bubbleMenuEnabled}
|
||||||
id={id}
|
displayConfig={displayConfig}
|
||||||
isTouchDevice={!!isTouchDevice}
|
documentLoaderClassName={documentLoaderClassName}
|
||||||
isLoading={!hasServerSynced && !hasServerConnectionFailed}
|
disabledExtensions={disabledExtensions}
|
||||||
tabIndex={tabIndex}
|
extendedDocumentEditorProps={extendedDocumentEditorProps}
|
||||||
flaggedExtensions={flaggedExtensions}
|
editor={editor}
|
||||||
disabledExtensions={disabledExtensions}
|
flaggedExtensions={flaggedExtensions}
|
||||||
extendedDocumentEditorProps={extendedDocumentEditorProps}
|
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(
|
// Outer component that provides collaboration context
|
||||||
props: ICollaborativeDocumentEditorProps,
|
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
|
||||||
ref: React.ForwardedRef<EditorRefApi>
|
const { id, realtimeConfig, serverHandler, user } = props;
|
||||||
) {
|
|
||||||
return <CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />;
|
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";
|
CollaborativeDocumentEditorWithRef.displayName = "CollaborativeDocumentEditorWithRef";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// components
|
// components
|
||||||
import { DocumentContentLoader, EditorContainer, EditorContentWrapper } from "@/components/editors";
|
import { DocumentContentLoader, EditorContainer, EditorContentWrapper } from "@/components/editors";
|
||||||
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
|
import { BlockMenu, EditorBubbleMenu } from "@/components/menus";
|
||||||
// types
|
// types
|
||||||
|
import type { TCollabValue } from "@/contexts";
|
||||||
import type {
|
import type {
|
||||||
ICollaborativeDocumentEditorPropsExtended,
|
ICollaborativeDocumentEditorPropsExtended,
|
||||||
IEditorProps,
|
IEditorProps,
|
||||||
@@ -20,6 +22,7 @@ type Props = {
|
|||||||
displayConfig: TDisplayConfig;
|
displayConfig: TDisplayConfig;
|
||||||
documentLoaderClassName?: string;
|
documentLoaderClassName?: string;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
|
titleEditor?: Editor;
|
||||||
editorContainerClassName: string;
|
editorContainerClassName: string;
|
||||||
extendedDocumentEditorProps?: ICollaborativeDocumentEditorPropsExtended;
|
extendedDocumentEditorProps?: ICollaborativeDocumentEditorPropsExtended;
|
||||||
extendedEditorProps: IEditorPropsExtended;
|
extendedEditorProps: IEditorPropsExtended;
|
||||||
@@ -28,11 +31,12 @@ type Props = {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isTouchDevice: boolean;
|
isTouchDevice: boolean;
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
|
provider?: HocuspocusProvider;
|
||||||
|
state?: TCollabValue["state"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PageRenderer(props: Props) {
|
export function PageRenderer(props: Props) {
|
||||||
const {
|
const {
|
||||||
aiHandler,
|
|
||||||
bubbleMenuEnabled,
|
bubbleMenuEnabled,
|
||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
displayConfig,
|
displayConfig,
|
||||||
@@ -45,8 +49,10 @@ export function PageRenderer(props: Props) {
|
|||||||
isLoading,
|
isLoading,
|
||||||
isTouchDevice,
|
isTouchDevice,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
|
titleEditor,
|
||||||
|
provider,
|
||||||
|
state,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("frame-renderer flex-grow w-full", {
|
className={cn("frame-renderer flex-grow w-full", {
|
||||||
@@ -56,33 +62,54 @@ export function PageRenderer(props: Props) {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<DocumentContentLoader className={documentLoaderClassName} />
|
<DocumentContentLoader className={documentLoaderClassName} />
|
||||||
) : (
|
) : (
|
||||||
<EditorContainer
|
<>
|
||||||
displayConfig={displayConfig}
|
{titleEditor && (
|
||||||
editor={editor}
|
<div className="relative w-full py-3">
|
||||||
editorContainerClassName={editorContainerClassName}
|
<EditorContainer
|
||||||
id={id}
|
editor={titleEditor}
|
||||||
isTouchDevice={isTouchDevice}
|
id={id + "-title"}
|
||||||
>
|
isTouchDevice={isTouchDevice}
|
||||||
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
editorContainerClassName="page-title-editor bg-transparent py-3 border-none"
|
||||||
{editor.isEditable && !isTouchDevice && (
|
displayConfig={displayConfig}
|
||||||
<div>
|
>
|
||||||
{bubbleMenuEnabled && (
|
<EditorContentWrapper
|
||||||
<EditorBubbleMenu
|
editor={titleEditor}
|
||||||
disabledExtensions={disabledExtensions}
|
id={id + "-title"}
|
||||||
editor={editor}
|
tabIndex={tabIndex}
|
||||||
extendedEditorProps={extendedEditorProps}
|
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"
|
||||||
flaggedExtensions={flaggedExtensions}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</EditorContainer>
|
||||||
<BlockMenu
|
|
||||||
editor={editor}
|
|
||||||
flaggedExtensions={flaggedExtensions}
|
|
||||||
disabledExtensions={disabledExtensions}
|
|
||||||
/>
|
|
||||||
<AIFeaturesMenu menu={aiHandler?.menu} />
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
import type { FC, ReactNode } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
import { useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
// plane utils
|
// plane utils
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// constants
|
// constants
|
||||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||||
// components
|
// components
|
||||||
|
import type { TCollabValue } from "@/contexts";
|
||||||
import { LinkContainer } from "@/plane-editor/components/link-container";
|
import { LinkContainer } from "@/plane-editor/components/link-container";
|
||||||
|
// plugins
|
||||||
|
import { nodeHighlightPluginKey } from "@/plugins/highlight";
|
||||||
// types
|
// types
|
||||||
import type { TDisplayConfig } from "@/types";
|
import type { TDisplayConfig } from "@/types";
|
||||||
|
|
||||||
@@ -18,12 +22,85 @@ type Props = {
|
|||||||
editorContainerClassName: string;
|
editorContainerClassName: string;
|
||||||
id: string;
|
id: string;
|
||||||
isTouchDevice: boolean;
|
isTouchDevice: boolean;
|
||||||
|
provider?: HocuspocusProvider | undefined;
|
||||||
|
state?: TCollabValue["state"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EditorContainer(props: Props) {
|
export const EditorContainer: FC<Props> = (props) => {
|
||||||
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice } = props;
|
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice, provider, state } = props;
|
||||||
// refs
|
// refs
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
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>) => {
|
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
if (event.target !== event.currentTarget) return;
|
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}`,
|
`editor-container cursor-text relative line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
|
||||||
{
|
{
|
||||||
"active-editor": editor?.isFocused && editor?.isEditable,
|
"active-editor": editor?.isFocused && editor?.isEditable,
|
||||||
"wide-layout": displayConfig.wideLayout,
|
|
||||||
},
|
},
|
||||||
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
|
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
|
||||||
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
|
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
|
||||||
@@ -100,4 +176,4 @@ export function EditorContainer(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -3,19 +3,24 @@ import type { Editor } from "@tiptap/react";
|
|||||||
import type { FC, ReactNode } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
className?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
editor: Editor | null;
|
editor: Editor | null;
|
||||||
id: string;
|
id: string;
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EditorContentWrapper(props: Props) {
|
export const EditorContentWrapper: FC<Props> = (props) => {
|
||||||
const { editor, children, tabIndex, id } = props;
|
const { editor, className, children, tabIndex, id } = props;
|
||||||
|
|
||||||
return (
|
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} />
|
<EditorContent editor={editor} id={id} />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
32
packages/editor/src/core/contexts/collaboration-context.tsx
Normal file
32
packages/editor/src/core/contexts/collaboration-context.tsx
Normal file
@@ -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
|
||||||
|
}
|
||||||
1
packages/editor/src/core/contexts/index.ts
Normal file
1
packages/editor/src/core/contexts/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./collaboration-context";
|
||||||
14
packages/editor/src/core/extensions/title-extension.ts
Normal file
14
packages/editor/src/core/extensions/title-extension.ts
Normal file
@@ -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 { Buffer } from "buffer";
|
||||||
|
import type { Extensions } from "@tiptap/core";
|
||||||
import { getSchema } from "@tiptap/core";
|
import { getSchema } from "@tiptap/core";
|
||||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||||
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
|
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
|
||||||
@@ -9,10 +10,13 @@ import {
|
|||||||
CoreEditorExtensionsWithoutProps,
|
CoreEditorExtensionsWithoutProps,
|
||||||
DocumentEditorExtensionsWithoutProps,
|
DocumentEditorExtensionsWithoutProps,
|
||||||
} from "@/extensions/core-without-props";
|
} from "@/extensions/core-without-props";
|
||||||
|
import { TitleExtensions } from "@/extensions/title-extension";
|
||||||
|
import { sanitizeHTML } from "@plane/utils";
|
||||||
|
|
||||||
// editor extension configs
|
// editor extension configs
|
||||||
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
|
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
|
||||||
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
|
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
|
||||||
|
export const TITLE_EDITOR_EXTENSIONS: Extensions = TitleExtensions;
|
||||||
// editor schemas
|
// editor schemas
|
||||||
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
|
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
|
||||||
const documentEditorSchema = getSchema(DOCUMENT_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
|
* @description this function decodes base64 string to binary data
|
||||||
* @param {string} document
|
* @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
|
* @description this function generates the binary equivalent of html content for the rich text editor
|
||||||
@@ -114,11 +119,13 @@ export const getAllDocumentFormatsFromRichTextEditorBinaryData = (
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
|
export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
|
||||||
description: Uint8Array
|
description: Uint8Array,
|
||||||
|
updateTitle: boolean
|
||||||
): {
|
): {
|
||||||
contentBinaryEncoded: string;
|
contentBinaryEncoded: string;
|
||||||
contentJSON: object;
|
contentJSON: object;
|
||||||
contentHTML: string;
|
contentHTML: string;
|
||||||
|
titleHTML?: string;
|
||||||
} => {
|
} => {
|
||||||
// encode binary description data
|
// encode binary description data
|
||||||
const base64Data = convertBinaryDataToBase64String(description);
|
const base64Data = convertBinaryDataToBase64String(description);
|
||||||
@@ -130,11 +137,24 @@ export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
|
|||||||
// convert to HTML
|
// convert to HTML
|
||||||
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
||||||
|
|
||||||
return {
|
if (updateTitle) {
|
||||||
contentBinaryEncoded: base64Data,
|
const title = yDoc.getXmlFragment("title");
|
||||||
contentJSON,
|
const titleJSON = yXmlFragmentToProseMirrorRootNode(title, documentEditorSchema).toJSON();
|
||||||
contentHTML,
|
const titleHTML = extractTextFromHTML(generateHTML(titleJSON, DOCUMENT_EDITOR_EXTENSIONS));
|
||||||
};
|
|
||||||
|
return {
|
||||||
|
contentBinaryEncoded: base64Data,
|
||||||
|
contentJSON,
|
||||||
|
contentHTML,
|
||||||
|
titleHTML,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
contentBinaryEncoded: base64Data,
|
||||||
|
contentJSON,
|
||||||
|
contentHTML,
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
type TConvertHTMLDocumentToAllFormatsArgs = {
|
type TConvertHTMLDocumentToAllFormatsArgs = {
|
||||||
@@ -170,8 +190,10 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF
|
|||||||
// Convert HTML to binary format for document editor
|
// Convert HTML to binary format for document editor
|
||||||
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
|
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
|
||||||
// Generate all document formats from the binary data
|
// Generate all document formats from the binary data
|
||||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
|
||||||
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
|
contentBinary,
|
||||||
|
false
|
||||||
|
);
|
||||||
allFormats = {
|
allFormats = {
|
||||||
description: contentJSON,
|
description: contentJSON,
|
||||||
description_html: contentHTML,
|
description_html: contentHTML,
|
||||||
@@ -183,3 +205,10 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF
|
|||||||
|
|
||||||
return allFormats;
|
return allFormats;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const extractTextFromHTML = (html: string): string => {
|
||||||
|
// Use sanitizeHTML 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
|
||||||
|
return sanitizeHTML(html) || "";
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 Collaboration from "@tiptap/extension-collaboration";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
// react
|
||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
import type React from "react";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
// extensions
|
// extensions
|
||||||
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
|
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
|
||||||
// hooks
|
// hooks
|
||||||
@@ -9,10 +11,29 @@ import { useEditor } from "@/hooks/use-editor";
|
|||||||
// plane editor extensions
|
// plane editor extensions
|
||||||
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||||
// types
|
// 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 {
|
const {
|
||||||
|
provider,
|
||||||
onAssetChange,
|
onAssetChange,
|
||||||
onChange,
|
onChange,
|
||||||
onTransaction,
|
onTransaction,
|
||||||
@@ -24,71 +45,28 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
|||||||
extensions = [],
|
extensions = [],
|
||||||
fileHandler,
|
fileHandler,
|
||||||
flaggedExtensions,
|
flaggedExtensions,
|
||||||
getEditorMetaData,
|
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
|
getEditorMetaData,
|
||||||
handleEditorReady,
|
handleEditorReady,
|
||||||
id,
|
id,
|
||||||
|
mentionHandler,
|
||||||
dragDropEnabled = true,
|
dragDropEnabled = true,
|
||||||
isTouchDevice,
|
isTouchDevice,
|
||||||
mentionHandler,
|
|
||||||
onEditorFocus,
|
onEditorFocus,
|
||||||
placeholder,
|
placeholder,
|
||||||
showPlaceholderOnEmpty,
|
showPlaceholderOnEmpty,
|
||||||
realtimeConfig,
|
|
||||||
serverHandler,
|
|
||||||
tabIndex,
|
tabIndex,
|
||||||
|
titleRef,
|
||||||
|
updatePageProperties,
|
||||||
user,
|
user,
|
||||||
|
actions,
|
||||||
} = props;
|
} = 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(
|
const { mainNavigationExtension, titleNavigationExtension, setMainEditor, setTitleEditor } = useEditorNavigation();
|
||||||
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
|
|
||||||
[id, provider]
|
|
||||||
);
|
|
||||||
|
|
||||||
// destroy and disconnect all providers connection on unmount
|
// Memoize extensions to avoid unnecessary editor recreations
|
||||||
useEffect(
|
const editorExtensions = useMemo(
|
||||||
() => () => {
|
() => [
|
||||||
provider?.destroy();
|
|
||||||
localProvider?.destroy();
|
|
||||||
},
|
|
||||||
[provider, localProvider]
|
|
||||||
);
|
|
||||||
|
|
||||||
const editor = useEditor({
|
|
||||||
disabledExtensions,
|
|
||||||
extendedEditorProps,
|
|
||||||
id,
|
|
||||||
editable,
|
|
||||||
editorProps,
|
|
||||||
editorClassName,
|
|
||||||
enableHistory: false,
|
|
||||||
extensions: [
|
|
||||||
SideMenuExtension({
|
SideMenuExtension({
|
||||||
aiEnabled: !disabledExtensions?.includes("ai"),
|
aiEnabled: !disabledExtensions?.includes("ai"),
|
||||||
dragDropEnabled,
|
dragDropEnabled,
|
||||||
@@ -96,6 +74,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
|||||||
HeadingListExtension,
|
HeadingListExtension,
|
||||||
Collaboration.configure({
|
Collaboration.configure({
|
||||||
document: provider.document,
|
document: provider.document,
|
||||||
|
field: "default",
|
||||||
}),
|
}),
|
||||||
...extensions,
|
...extensions,
|
||||||
...DocumentEditorAdditionalExtensions({
|
...DocumentEditorAdditionalExtensions({
|
||||||
@@ -107,27 +86,122 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
|||||||
provider,
|
provider,
|
||||||
userDetails: user,
|
userDetails: user,
|
||||||
}),
|
}),
|
||||||
|
mainNavigationExtension,
|
||||||
],
|
],
|
||||||
fileHandler,
|
[
|
||||||
flaggedExtensions,
|
provider,
|
||||||
forwardedRef,
|
disabledExtensions,
|
||||||
getEditorMetaData,
|
dragDropEnabled,
|
||||||
handleEditorReady,
|
extensions,
|
||||||
isTouchDevice,
|
extendedEditorProps,
|
||||||
mentionHandler,
|
fileHandler,
|
||||||
onAssetChange,
|
flaggedExtensions,
|
||||||
onChange,
|
editable,
|
||||||
onEditorFocus,
|
user,
|
||||||
onTransaction,
|
mainNavigationExtension,
|
||||||
placeholder,
|
]
|
||||||
showPlaceholderOnEmpty,
|
);
|
||||||
provider,
|
|
||||||
tabIndex,
|
// 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,
|
||||||
|
showPlaceholderOnEmpty,
|
||||||
|
provider,
|
||||||
|
tabIndex,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
provider,
|
||||||
|
disabledExtensions,
|
||||||
|
extendedEditorProps,
|
||||||
|
id,
|
||||||
|
editable,
|
||||||
|
editorProps,
|
||||||
|
editorClassName,
|
||||||
|
editorExtensions,
|
||||||
|
fileHandler,
|
||||||
|
flaggedExtensions,
|
||||||
|
forwardedRef,
|
||||||
|
getEditorMetaData,
|
||||||
|
handleEditorReady,
|
||||||
|
isTouchDevice,
|
||||||
|
mentionHandler,
|
||||||
|
onAssetChange,
|
||||||
|
onChange,
|
||||||
|
onEditorFocus,
|
||||||
|
onTransaction,
|
||||||
|
placeholder,
|
||||||
|
showPlaceholderOnEmpty,
|
||||||
|
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 {
|
return {
|
||||||
editor,
|
editor,
|
||||||
hasServerConnectionFailed,
|
titleEditor,
|
||||||
hasServerSynced,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
169
packages/editor/src/core/hooks/use-editor-navigation.ts
Normal file
169
packages/editor/src/core/hooks/use-editor-navigation.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
91
packages/editor/src/core/hooks/use-title-editor.ts
Normal file
91
packages/editor/src/core/hooks/use-title-editor.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
369
packages/editor/src/core/hooks/use-yjs-setup.ts
Normal file
369
packages/editor/src/core/hooks/use-yjs-setup.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
92
packages/editor/src/core/plugins/highlight.ts
Normal file
92
packages/editor/src/core/plugins/highlight.ts
Normal file
@@ -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 TServerHandler = {
|
export type CollaborationError =
|
||||||
onConnect?: () => void;
|
| { type: "auth-failed"; message: string }
|
||||||
onServerError?: () => void;
|
| { 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 = {
|
||||||
|
onStateChange: (state: CollaborationState) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import type {
|
|||||||
TRealtimeConfig,
|
TRealtimeConfig,
|
||||||
TServerHandler,
|
TServerHandler,
|
||||||
TUserDetails,
|
TUserDetails,
|
||||||
|
TExtendedEditorRefApi,
|
||||||
|
EventToPayloadMap,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
export type TEditorCommands =
|
export type TEditorCommands =
|
||||||
@@ -97,7 +99,7 @@ export type TDocumentInfo = {
|
|||||||
words: number;
|
words: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EditorRefApi = {
|
export type CoreEditorRefApi = {
|
||||||
blur: () => void;
|
blur: () => void;
|
||||||
clearEditor: (emitUpdate?: boolean) => void;
|
clearEditor: (emitUpdate?: boolean) => void;
|
||||||
createSelectionAtCursorPosition: () => void;
|
createSelectionAtCursorPosition: () => void;
|
||||||
@@ -139,6 +141,10 @@ export type EditorRefApi = {
|
|||||||
undo: () => void;
|
undo: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EditorRefApi = CoreEditorRefApi & TExtendedEditorRefApi;
|
||||||
|
|
||||||
|
export type EditorTitleRefApi = EditorRefApi;
|
||||||
|
|
||||||
// editor props
|
// editor props
|
||||||
export type IEditorProps = {
|
export type IEditorProps = {
|
||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
@@ -187,6 +193,15 @@ export type ICollaborativeDocumentEditorProps = Omit<IEditorProps, "initialValue
|
|||||||
serverHandler?: TServerHandler;
|
serverHandler?: TServerHandler;
|
||||||
user: TUserDetails;
|
user: TUserDetails;
|
||||||
extendedDocumentEditorProps?: ICollaborativeDocumentEditorPropsExtended;
|
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"> & {
|
export type IDocumentEditorProps = Omit<IEditorProps, "initialValue" | "onEnterKeyPress" | "value"> & {
|
||||||
|
|||||||
@@ -57,4 +57,7 @@ export type TCollaborativeEditorHookProps = TCoreHookProps &
|
|||||||
Pick<
|
Pick<
|
||||||
ICollaborativeDocumentEditorProps,
|
ICollaborativeDocumentEditorProps,
|
||||||
"dragDropEnabled" | "extendedDocumentEditorProps" | "realtimeConfig" | "serverHandler" | "user"
|
"dragDropEnabled" | "extendedDocumentEditorProps" | "realtimeConfig" | "serverHandler" | "user"
|
||||||
>;
|
> & {
|
||||||
|
titleRef?: ICollaborativeDocumentEditorProps["titleRef"];
|
||||||
|
updatePageProperties?: ICollaborativeDocumentEditorProps["updatePageProperties"];
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,3 +3,4 @@
|
|||||||
@import "./table.css";
|
@import "./table.css";
|
||||||
@import "./github-dark.css";
|
@import "./github-dark.css";
|
||||||
@import "./drag-drop.css";
|
@import "./drag-drop.css";
|
||||||
|
@import "./title-editor.css";
|
||||||
|
|||||||
49
packages/editor/src/styles/title-editor.css
Normal file
49
packages/editor/src/styles/title-editor.css
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/* Title editor styles */
|
||||||
|
.page-title-editor {
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title-editor .ProseMirror {
|
||||||
|
background-color: transparent;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: -2%;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle font sizes */
|
||||||
|
.page-title-editor.small-font .ProseMirror h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title-editor.large-font .ProseMirror h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 2.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus state */
|
||||||
|
.page-title-editor.active-editor .ProseMirror {
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder */
|
||||||
|
.page-title-editor .ProseMirror h1.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: var(--color-placeholder);
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title-editor .ProseMirror h1.is-empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: var(--color-placeholder);
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
3737
pnpm-lock.yaml
generated
3737
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user