fix: title sync done

This commit is contained in:
Palanikannan M
2025-04-03 18:44:53 +05:30
parent bdb14d519d
commit 8becfffed2
14 changed files with 136 additions and 218 deletions

View File

@@ -1,4 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

View File

@@ -2,6 +2,7 @@ import { PageService } from "@/core/services/page.service";
import { transformHTMLToBinary } from "./transformers";
import { getAllDocumentFormatsFromBinaryData } from "@/core/helpers/page";
import { logger } from "@plane/logger";
import { HocusPocusServerContext } from "@/core/types/common";
const pageService = new PageService();
@@ -9,13 +10,15 @@ const pageService = new PageService();
* Fetches the binary description data for a project page
* Falls back to HTML transformation if binary is not available
*/
export const fetchPageDescriptionBinary = async (
params: URLSearchParams,
pageId: string,
cookie: string | undefined
) => {
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
export const fetchPageDescriptionBinary = async ({
pageId,
context,
}: {
pageId: string;
context: HocusPocusServerContext;
}) => {
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return null;
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
@@ -35,24 +38,21 @@ export const fetchPageDescriptionBinary = async (
* Updates the description of a project page
*/
export const updatePageDescription = async ({
params,
context,
pageId,
updatedDescription,
cookie,
state: updatedDescription,
title,
}: {
params: URLSearchParams | undefined;
context: HocusPocusServerContext;
pageId: string;
updatedDescription: Uint8Array;
cookie: string | undefined;
state: Uint8Array;
title: string;
}) => {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
}
const workspaceSlug = params?.get("workspaceSlug")?.toString();
const projectId = params?.get("projectId")?.toString();
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
@@ -67,17 +67,14 @@ export const updatePageDescription = async ({
};
export const fetchProjectPageTitle = async ({
workspaceSlug,
projectId,
context,
pageId,
cookie,
}: {
workspaceSlug: string;
projectId: string;
context: HocusPocusServerContext;
pageId: string;
cookie: string | undefined;
}) => {
if (!workspaceSlug || !cookie) return;
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return;
try {
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
@@ -89,20 +86,17 @@ export const fetchProjectPageTitle = async ({
};
export const updateProjectPageTitle = async ({
workspaceSlug,
projectId,
context,
pageId,
title,
cookie,
abortSignal,
}: {
workspaceSlug: string;
projectId: string;
context: HocusPocusServerContext;
pageId: string;
title: string;
cookie: string | undefined;
abortSignal?: AbortSignal;
}) => {
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return;
const payload = {

View File

@@ -19,32 +19,19 @@ export const projectPageHandler: DocumentHandler = {
/**
* Fetch project page description
*/
fetch: async ({ pageId, params, context }: DocumentFetchParams) => {
const { cookie } = context;
return await fetchPageDescriptionBinary(params, pageId, cookie);
},
fetch: fetchPageDescriptionBinary,
/**
* Store project page description
*/
store: async ({ pageId, state, params, context, title }: DocumentStoreParams) => {
const { cookie } = context;
await updatePageDescription({ params, pageId, updatedDescription: state, cookie, title });
},
store: updatePageDescription,
/**
* Fetch project page title
*/
fetchTitle: async ({ workspaceSlug, projectId, pageId, cookie }) => {
return await fetchProjectPageTitle({ workspaceSlug, projectId, pageId, cookie });
},
fetchTitle: fetchProjectPageTitle,
/**
* Store project page title
*/
updateTitle: async ({ workspaceSlug, projectId, pageId, title, cookie, abortSignal }) => {
await updateProjectPageTitle({ workspaceSlug, projectId, pageId, title, cookie, abortSignal });
},
updateTitle: updateProjectPageTitle,
};
// Define the project page handler definition

View File

@@ -16,15 +16,11 @@ export const createDatabaseExtension = () => {
const handleFetch = async ({
context,
documentName: pageId,
requestParameters,
}: {
context: HocusPocusServerContext;
documentName: TDocumentTypes;
requestParameters: URLSearchParams;
}) => {
const { documentType } = context;
const params = requestParameters;
let fetchedData = null;
fetchedData = await catchAsync(
async () => {
@@ -39,11 +35,10 @@ const handleFetch = async ({
});
}
const documentHandler = getDocumentHandler(documentType);
const documentHandler = getDocumentHandler(context);
fetchedData = await documentHandler.fetch({
context: context as HocusPocusServerContext,
pageId,
params,
});
if (!fetchedData) {
@@ -70,7 +65,6 @@ const handleStore = async ({
context,
state,
documentName: pageId,
requestParameters,
document,
}: Partial<storePayload> & {
context: HocusPocusServerContext;
@@ -93,7 +87,6 @@ const handleStore = async ({
title = extractTextFromHTML(document?.getXmlFragment("title")?.toJSON());
}
const { documentType } = context as HocusPocusServerContext;
const params = requestParameters;
if (!documentType) {
handleError(null, {
errorType: "bad-request",
@@ -105,12 +98,11 @@ const handleStore = async ({
});
}
const documentHandler = getDocumentHandler(documentType);
const documentHandler = getDocumentHandler(context);
await documentHandler.store({
context: context as HocusPocusServerContext,
pageId,
state,
params,
title,
});
},

View File

@@ -4,6 +4,7 @@ import { Logger } from "@hocuspocus/extension-logger";
import { setupRedisExtension } from "@/core/extensions/redis";
import { createDatabaseExtension } from "@/core/extensions/database";
import { logger } from "@plane/logger";
import { TitleSyncExtension } from "./title-sync";
export const getExtensions = async (): Promise<Extension[]> => {
const extensions: Extension[] = [
@@ -16,6 +17,9 @@ export const getExtensions = async (): Promise<Extension[]> => {
createDatabaseExtension(),
];
const titleSyncExtension = new TitleSyncExtension();
extensions.push(titleSyncExtension);
// Add Redis extensions if Redis is available
const redisExtensions = await setupRedisExtension();
extensions.push(...redisExtensions);

View File

@@ -1,5 +1,5 @@
// hocuspocus
import { Extension, afterLoadDocumentPayload, Hocuspocus, Document } from "@hocuspocus/server";
import { Extension, Hocuspocus, Document } from "@hocuspocus/server";
import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs";
// types
@@ -18,38 +18,22 @@ import { TitleUpdateManager } from "./title-update/title-update-manager";
*/
export class TitleSyncExtension implements Extension {
instance!: Hocuspocus;
// Maps document names to their observers and update managers
private titleObservers: Map<string, (events: Y.YEvent<any>[]) => void> = new Map();
private titleUpdateManagers: Map<string, TitleUpdateManager> = new Map();
/**
* Handle document loading - migrate old titles if needed
*/
async onLoadDocument({
context,
document,
requestParameters,
}: {
context: HocusPocusServerContext;
document: Document;
requestParameters: URLSearchParams;
}) {
async onLoadDocument({ context, document }: { context: HocusPocusServerContext; document: Document }) {
try {
// initially for on demand migration of old titles to a new title field
// in the yjs binary
if (document.isEmpty("title")) {
const typedContext = context as HocusPocusServerContext;
const workspaceSlug = requestParameters.get("workspaceSlug")?.toString();
const projectId = requestParameters.get("projectId")?.toString();
const documentHandler = getDocumentHandler(typedContext.documentType);
const { workspaceSlug, projectId } = context;
const documentHandler = getDocumentHandler(context);
if (!workspaceSlug || !projectId) return;
const title = await documentHandler.fetchTitle({
workspaceSlug,
projectId,
context,
pageId: document.name,
cookie: typedContext.cookie,
});
if (title == null) return;
const titleField = TiptapTransformer.toYdoc(
@@ -66,46 +50,47 @@ export class TitleSyncExtension implements Extension {
/**
* Set up title synchronization for a document after it's loaded
*/
async afterLoadDocument({ document, documentName, context, requestParameters }: afterLoadDocumentPayload) {
const workspaceSlug = requestParameters.get("workspaceSlug")?.toString();
const projectId = requestParameters.get("projectId")?.toString();
async afterLoadDocument({
document,
documentName,
context,
}: {
document: Document;
documentName: string;
context: HocusPocusServerContext;
}) {
const { workspaceSlug, projectId } = context;
// Exit if we don't have the required information
if (!workspaceSlug || !projectId) return;
const documentHandler = getDocumentHandler(context.documentType);
const documentHandler = getDocumentHandler(context);
// Create a title update manager for this document
const updateManager = new TitleUpdateManager(
documentName,
workspaceSlug,
projectId,
context.cookie,
documentHandler
);
const updateManager = new TitleUpdateManager(documentName, documentHandler, context);
// Store the manager
this.titleUpdateManagers.set(documentName, updateManager);
// Set up observer for title field
const titleObserver = (events: Y.YEvent<any>[]) => {
let title = "";
events.forEach((event) => {
title = extractTextFromHTML(event.currentTarget.toJSON());
});
// Schedule an update with the manager
const manager = this.titleUpdateManagers.get(documentName);
if (manager) {
manager.scheduleUpdate(title);
}
};
// Observe the title field
document.getXmlFragment("title").observeDeep(titleObserver);
this.titleObservers.set(documentName, titleObserver);
}
/**
* Force save title before unloading the document
*/
@@ -118,7 +103,7 @@ export class TitleSyncExtension implements Extension {
this.titleUpdateManagers.delete(documentName);
}
}
/**
* Remove observers after document unload
*/
@@ -126,10 +111,9 @@ export class TitleSyncExtension implements Extension {
// Clean up observer when document is unloaded
const observer = this.titleObservers.get(documentName);
if (observer) {
console.log(`Removing title observer for ${documentName}`);
this.titleObservers.delete(documentName);
}
// Ensure manager is cleaned up if beforeUnloadDocument somehow didn't run
if (this.titleUpdateManagers.has(documentName)) {
const manager = this.titleUpdateManagers.get(documentName)!;

View File

@@ -28,7 +28,7 @@ export const createDebounceState = (): DebounceState => ({
export interface DebounceOptions {
/** The wait time in milliseconds */
wait: number;
/** Optional logging prefix for debug messages */
logPrefix?: string;
}
@@ -41,7 +41,7 @@ export class DebounceManager {
private state: DebounceState;
private wait: number;
private logPrefix: string;
/**
* Creates a new DebounceManager
* @param options Debounce configuration options
@@ -49,9 +49,9 @@ export class DebounceManager {
constructor(options: DebounceOptions) {
this.state = createDebounceState();
this.wait = options.wait;
this.logPrefix = options.logPrefix || '';
this.logPrefix = options.logPrefix || "";
}
/**
* Schedule a debounced function call
* @param func The function to call
@@ -60,56 +60,56 @@ export class DebounceManager {
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) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Operation in progress, storing new args and starting new timer`);
}
// 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) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Already scheduled, updating args and restarting timer`);
}
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);
if (this.logPrefix) {
console.log(`${this.logPrefix}: Scheduled execution with wait time ${this.wait}ms`);
}
}
/**
* 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)) {
// If an operation is already in progress, abort it if the debounce period has completed
@@ -118,104 +118,104 @@ export class DebounceManager {
console.log(`${this.logPrefix}: Timer expired while operation in progress - will abort current operation`);
}
}
// 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 => {
this.performFunction(func).catch((error) => {
console.error(`${this.logPrefix}: Error in execution:`, error);
});
}
/**
* Perform the actual function call, handling any in-progress operations
*/
private async performFunction(func: (...args: any[]) => Promise<void>): Promise<void> {
const args = this.state.lastArgs;
if (!args) return;
// Store the args we're about to use
const currentArgs = [...args];
// If another operation is in progress, abort it
await this.abortOngoingOperation();
// Mark that we're starting a new operation
this.state.inProgress = true;
this.state.abortController = new AbortController();
try {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Starting operation`);
}
// 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);
if (this.logPrefix) {
console.log(`${this.logPrefix}: Completed operation`);
}
// 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;
if (this.logPrefix) {
console.log(`${this.logPrefix}: Args have not changed during operation, clearing lastArgs`);
}
// 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
// If lastArgs have changed during this operation, the timer should already be running
// but let's make sure it is
if (!this.state.timerId) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Args changed during operation, ensuring timer is running`);
}
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
if (error instanceof Error && error.name === "AbortError") {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Operation was aborted, another operation should be starting`);
}
// Nothing to do here, the new operation will be triggered by the timer expiration
} else {
console.error(`${this.logPrefix}: Error during operation:`, error);
// On error (not abort), make sure we have a timer running to retry
if (!this.state.timerId && this.state.lastArgs) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Rescheduling failed operation`);
}
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
@@ -226,7 +226,7 @@ export class DebounceManager {
this.state.abortController = null;
}
}
/**
* Abort any ongoing operation
*/
@@ -235,35 +235,32 @@ export class DebounceManager {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Aborting in-progress operation`);
}
this.state.abortController.abort();
// Small delay to ensure the abort has had time to propagate
await new Promise(resolve => setTimeout(resolve, 20));
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) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Force resetting in-progress state after abort`);
}
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
);
return this.state.lastCallTime === undefined || time - this.state.lastCallTime >= this.wait;
}
/**
* Calculate how much longer we should wait
*/
@@ -271,7 +268,7 @@ export class DebounceManager {
const timeSinceLastCall = time - (this.state.lastCallTime || 0);
return Math.max(0, this.wait - timeSinceLastCall);
}
/**
* Force immediate execution
*/
@@ -279,16 +276,16 @@ export class DebounceManager {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Force immediate execution`);
}
// 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);
@@ -302,7 +299,7 @@ export class DebounceManager {
}
}
}
/**
* Cancel any pending operations without executing
*/
@@ -310,27 +307,27 @@ export class DebounceManager {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Cancelling pending operations`);
}
// 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
*/
@@ -341,4 +338,5 @@ export class DebounceManager {
}
return true;
}
}
}

View File

@@ -1,5 +1,6 @@
import { DocumentHandler } from "@/core/types/document-handler";
import { DebounceManager } from "./debounce";
import { HocusPocusServerContext } from "@/core/types/common";
/**
* Manages title update operations for a single document
@@ -7,28 +8,22 @@ import { DebounceManager } from "./debounce";
*/
export class TitleUpdateManager {
private documentName: string;
private workspaceSlug: string;
private projectId: string;
private cookie: string;
private documentHandler: DocumentHandler;
private debounceManager: DebounceManager;
private lastTitle: string | null = null;
private context: HocusPocusServerContext;
/**
* Create a new TitleUpdateManager instance
*/
constructor(
documentName: string,
workspaceSlug: string,
projectId: string,
cookie: string,
documentHandler: any,
documentHandler: DocumentHandler,
context: HocusPocusServerContext,
wait: number = 5000
) {
this.context = context;
this.documentName = documentName;
this.workspaceSlug = workspaceSlug;
this.projectId = projectId;
this.cookie = cookie;
this.documentHandler = documentHandler;
// Set up debounce manager with logging
@@ -61,14 +56,12 @@ export class TitleUpdateManager {
try {
console.log(`Starting title update for ${this.documentName} with: "${title}"`);
await this.documentHandler.updateTitle(
this.workspaceSlug,
this.projectId,
this.documentName,
await this.documentHandler.updateTitle({
context: this.context,
pageId: this.documentName,
title,
this.cookie,
signal
);
abortSignal: signal,
});
console.log(`Completed title update for ${this.documentName} with: "${title}"`);

View File

@@ -13,17 +13,7 @@ initializeDocumentHandlers();
* @param additionalContext Optional additional context criteria
* @returns The appropriate document handler
*/
export function getDocumentHandler(
documentType: string,
additionalContext: Omit<HocusPocusServerContext, "documentType">
): DocumentHandler {
// Create a context object with all criteria
const context: HocusPocusServerContext = {
documentType: documentType as any,
...additionalContext,
};
// Use the factory to get the appropriate handler
export function getDocumentHandler(context: HocusPocusServerContext): DocumentHandler {
return handlerFactory.getHandler(context);
}

View File

@@ -6,7 +6,6 @@ import { handleAuthentication } from "@/core/lib/authentication";
// extensions
import { getExtensions } from "@/core/extensions/index";
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
import { TitleSyncExtension } from "./extensions/title-sync";
// editor types
import { TUserDetails } from "@plane/editor";
// types
@@ -70,6 +69,7 @@ export const getHocusPocusServer = async () => {
context.cookie = cookie ?? requestParameters.get("cookie");
context.userId = userId;
context.workspaceSlug = requestParameters.get("workspaceSlug")?.toString() as string;
context.projectId = requestParameters.get("projectId")?.toString() as string;
return await handleAuthentication({
cookie: context.cookie,
@@ -95,7 +95,7 @@ export const getHocusPocusServer = async () => {
{ extra: { operation: "stateless", payload } }
);
},
extensions: [...extensions, new TitleSyncExtension()],
debounce: 10000,
extensions,
debounce: 1000,
});
};

View File

@@ -44,11 +44,8 @@ export class PageService extends APIService {
cookie: string,
abortSignal?: AbortSignal
): Promise<any> {
console.log("aaya update call", data);
// Early abort check
if (abortSignal?.aborted) {
console.log(`Title update was already aborted before starting: ${pageId}`);
throw new DOMException("Aborted", "AbortError");
}
@@ -57,7 +54,6 @@ export class PageService extends APIService {
const abortPromise = new Promise((_, reject) => {
if (abortSignal) {
abortListener = () => {
console.log(`Title update aborted during execution: ${pageId}`);
reject(new DOMException("Aborted", "AbortError"));
};
abortSignal.addEventListener("abort", abortListener);
@@ -65,12 +61,6 @@ export class PageService extends APIService {
});
try {
// The simulated delay that can be aborted
// await Promise.race([
// new Promise((resolve) => setTimeout(resolve, 10000)),
// abortPromise
// ]);
// The actual API call that can be aborted
return await Promise.race([
this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, data, {
@@ -83,7 +73,6 @@ export class PageService extends APIService {
.catch((error) => {
// Special handling for aborted fetch requests
if (error.name === "AbortError") {
console.log(`Fetch request for title update was aborted: ${pageId}`);
throw new DOMException("Aborted", "AbortError");
}
throw error;

View File

@@ -6,7 +6,6 @@ import { HocusPocusServerContext } from "@/core/types/common";
export interface DocumentFetchParams {
context: HocusPocusServerContext;
pageId: string;
params: URLSearchParams;
}
/**
@@ -16,7 +15,6 @@ export interface DocumentStoreParams {
context: HocusPocusServerContext;
pageId: string;
state: Uint8Array;
params: URLSearchParams | undefined;
title: string;
}
@@ -37,22 +35,15 @@ export interface DocumentHandler {
/**
* Fetch title
*/
fetchTitle: (params: {
workspaceSlug: string;
projectId: string;
pageId: string;
cookie: string;
}) => Promise<string | undefined>;
fetchTitle: (params: { pageId: string; context: HocusPocusServerContext }) => Promise<string | undefined>;
/**
* Update title
*/
updateTitle?: (params: {
workspaceSlug: string;
projectId: string;
context: HocusPocusServerContext;
pageId: string;
title: string;
cookie: string;
abortSignal?: AbortSignal;
}) => Promise<void>;
}

View File

@@ -21,7 +21,7 @@ export const PageRenderer = (props: IPageRenderer) => {
props;
return (
<div className="frame-renderer flex-grow w-full">
<div className={"frame-renderer flex-grow w-full space-y-4 document-editor-container"}>
{titleEditor && (
<div className="relative w-full py-3">
<EditorContainer

View File

@@ -46,7 +46,7 @@ export const LinkViewContainer: FC<LinkViewContainerProps> = ({ editor, containe
const handleLinkHover = useCallback(
(event: MouseEvent) => {
if (!editor || editorState.linkExtensionStorage.isBubbleMenuOpen) return;
if (!editor || editorState?.linkExtensionStorage?.isBubbleMenuOpen) return;
// Find the closest anchor tag from the event target
const target = (event.target as HTMLElement)?.closest("a");
@@ -109,7 +109,7 @@ export const LinkViewContainer: FC<LinkViewContainerProps> = ({ editor, containe
// Close link view when bubble menu opens
useEffect(() => {
if (editorState.linkExtensionStorage.isBubbleMenuOpen && isOpen) {
if (editorState?.linkExtensionStorage?.isBubbleMenuOpen && isOpen) {
setIsOpen(false);
}
}, [editorState.linkExtensionStorage, isOpen]);