Compare commits

..

1 Commits

Author SHA1 Message Date
Palanikannan M 202336dd9c fix: rendering node views reliably 2025-12-01 16:15:33 +05:30
48 changed files with 429 additions and 2297 deletions
+2 -15
View File
@@ -27,17 +27,6 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP
const pageDetails = await service.fetchDetails(pageId);
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "<p></p>");
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;
}
}
@@ -63,10 +52,8 @@ const storeDocument = async ({
try {
const service = getPageService(context.documentType, context);
// convert binary data to all formats
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
pageBinaryData,
true
);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(pageBinaryData);
// create payload
const payload = {
description_binary: contentBinaryEncoded,
-176
View File
@@ -1,176 +0,0 @@
// 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, {
parentId: context.parentId,
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());
});
// 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);
}
}
}
@@ -1,277 +0,0 @@
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;
}
}
@@ -1,90 +0,0 @@
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;
}
}
@@ -1,8 +0,0 @@
/**
* Utility function to extract text from HTML content
*/
export const extractTextFromHTML = (html: string): string => {
// Use a regex to extract text between tags
const textMatch = html.replace(/<[^>]*>/g, "");
return textMatch || "";
};
@@ -10,7 +10,6 @@ import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { PageAccessIcon } from "@/components/common/page-access-icon";
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
import { PageHeaderActions } from "@/components/pages/header/actions";
import { PageSyncingBadge } from "@/components/pages/header/syncing-badge";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
@@ -96,7 +95,6 @@ export const PageDetailsHeader = observer(function PageDetailsHeader() {
</div>
</Header.LeftItem>
<Header.RightItem>
<PageSyncingBadge syncStatus={page.isSyncingWithServer} />
<PageDetailsHeaderExtraActions page={page} storeType={storeType} />
<PageHeaderActions page={page} storeType={storeType} />
</Header.RightItem>
@@ -1,35 +0,0 @@
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,12 +1,11 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
// plane imports
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
import { CollaborativeDocumentEditorWithRef } from "@plane/editor";
import type {
CollaborationState,
EditorRefApi,
EditorTitleRefApi,
TAIMenuProps,
TDisplayConfig,
TFileHandler,
@@ -27,7 +26,6 @@ import { useUser } from "@/hooks/store/user";
import { usePageFilters } from "@/hooks/use-page-filters";
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
// plane web imports
import type { TCustomEventHandlers } from "@/hooks/use-realtime-page-events";
import { EditorAIMenu } from "@/plane-web/components/pages";
import type { TExtendedEditorExtensionsConfig } from "@/plane-web/hooks/pages";
import type { EPageStoreType } from "@/plane-web/hooks/store";
@@ -53,6 +51,7 @@ type Props = {
config: TEditorBodyConfig;
editorReady: boolean;
editorForwardRef: React.RefObject<EditorRefApi>;
handleConnectionStatus: Dispatch<SetStateAction<boolean>>;
handleEditorReady: (status: boolean) => void;
handleOpenNavigationPane: () => void;
handlers: TEditorBodyHandlers;
@@ -62,16 +61,14 @@ type Props = {
projectId?: string;
workspaceSlug: string;
storeType: EPageStoreType;
customRealtimeEventHandlers?: TCustomEventHandlers;
extendedEditorProps: TExtendedEditorExtensionsConfig;
isFetchingFallbackBinary?: boolean;
onCollaborationStateChange?: (state: CollaborationState) => void;
};
export const PageEditorBody = observer(function PageEditorBody(props: Props) {
const {
config,
editorForwardRef,
handleConnectionStatus,
handleEditorReady,
handleOpenNavigationPane,
handlers,
@@ -82,11 +79,7 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
projectId,
workspaceSlug,
extendedEditorProps,
isFetchingFallbackBinary,
onCollaborationStateChange,
} = props;
// refs
const titleEditorRef = useRef<EditorTitleRefApi>(null);
// store hooks
const { data: currentUser } = useUser();
const { getWorkspaceBySlug } = useWorkspace();
@@ -98,7 +91,6 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
isContentEditable,
updateTitle,
editor: { editorRef, updateAssetsList },
setSyncingStatus,
} = page;
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
// use editor mention
@@ -144,35 +136,20 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
[editorRef, workspaceId, workspaceSlug]
);
// 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 handleServerConnect = useCallback(() => {
handleConnectionStatus(false);
}, [handleConnectionStatus]);
const handleServerError = useCallback(() => {
handleConnectionStatus(true);
}, [handleConnectionStatus]);
const serverHandler: TServerHandler = useMemo(
() => ({
onStateChange: (state) => {
// Pass full state to parent
onCollaborationStateChange?.(state);
// Map collaboration stage to UI syncing status
// Stage → UI mapping: disconnected → error | synced → synced | all others → syncing
if (state.stage.kind === "disconnected") {
setSyncingStatus("error");
} else if (state.stage.kind === "synced") {
setSyncingStatus("synced");
} else {
// initial, connecting, awaiting-sync, reconnecting → show as syncing
setSyncingStatus("syncing");
}
},
onConnect: handleServerConnect,
onServerError: handleServerError,
}),
[setSyncingStatus, onCollaborationStateChange]
[handleServerConnect, handleServerError]
);
const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => {
@@ -217,9 +194,7 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
}
);
const isPageLoading = pageId === undefined || !realtimeConfig;
if (isPageLoading) return <PageContentLoader className={blockWidthClassName} />;
if (pageId === undefined || !realtimeConfig) return <PageContentLoader className={blockWidthClassName} />;
return (
<Row
@@ -250,6 +225,12 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
<div className="page-header-container group/page-header">
<div className={blockWidthClassName}>
<PageEditorHeaderRoot page={page} projectId={projectId} />
<PageEditorTitle
editorRef={editorRef}
readOnly={!isContentEditable}
title={pageTitle}
updateTitle={updateTitle}
/>
</div>
</div>
<CollaborativeDocumentEditorWithRef
@@ -258,7 +239,6 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
fileHandler={config.fileHandler}
handleEditorReady={handleEditorReady}
ref={editorForwardRef}
titleRef={titleEditorRef}
containerClassName="h-full p-0 pb-64"
displayConfig={displayConfig}
getEditorMetaData={getEditorMetaData}
@@ -281,7 +261,6 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
}}
onAssetChange={updateAssetsList}
extendedEditorProps={extendedEditorProps}
isFetchingFallbackBinary={isFetchingFallbackBinary}
/>
</div>
</Row>
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import type { CollaborationState, EditorRefApi } from "@plane/editor";
import type { EditorRefApi } from "@plane/editor";
import type { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types";
// hooks
import { useAppRouter } from "@/hooks/use-app-router";
import { usePageFallback } from "@/hooks/use-page-fallback";
// plane web import
import type { PageUpdateHandler, TCustomEventHandlers } from "@/hooks/use-realtime-page-events";
import { PageModals } from "@/plane-web/components/pages";
import { usePagesPaneExtensions, useExtendedEditorProps } from "@/plane-web/hooks/pages";
import type { EPageStoreType } from "@/plane-web/hooks/store";
@@ -16,7 +16,6 @@ import type { TPageInstance } from "@/store/pages/base-page";
import { PageNavigationPaneRoot } from "../navigation-pane";
import { PageVersionsOverlay } from "../version";
import { PagesVersionEditor } from "../version/editor";
import { ContentLimitBanner } from "./content-limit-banner";
import { PageEditorBody } from "./editor-body";
import type { TEditorBodyConfig, TEditorBodyHandlers } from "./editor-body";
import { PageEditorToolbarRoot } from "./toolbar";
@@ -24,7 +23,7 @@ import { PageEditorToolbarRoot } from "./toolbar";
export type TPageRootHandlers = {
create: (payload: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
fetchDescriptionBinary: () => Promise<ArrayBuffer>;
fetchDescriptionBinary: () => Promise<any>;
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
restoreVersion: (pageId: string, versionId: string) => Promise<void>;
updateDescription: (document: TDocumentPayload) => Promise<void>;
@@ -40,36 +39,27 @@ type TPageRootProps = {
webhookConnectionParams: TWebhookConnectionQueryParams;
projectId?: string;
workspaceSlug: string;
customRealtimeEventHandlers?: TCustomEventHandlers;
};
export const PageRoot = observer((props: TPageRootProps) => {
const {
config,
handlers,
page,
projectId,
storeType,
webhookConnectionParams,
workspaceSlug,
customRealtimeEventHandlers,
} = props;
export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
const { config, handlers, page, projectId, storeType, webhookConnectionParams, workspaceSlug } = props;
// states
const [editorReady, setEditorReady] = useState(false);
const [collaborationState, setCollaborationState] = useState<CollaborationState | null>(null);
const [showContentTooLargeBanner, setShowContentTooLargeBanner] = useState(false);
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
// router
const router = useAppRouter();
// derived values
const {
isContentEditable,
editor: { setEditorRef },
} = page;
// page fallback
const { isFetchingFallbackBinary } = usePageFallback({
usePageFallback({
editorRef,
fetchPageDescription: handlers.fetchDescriptionBinary,
collaborationState,
hasConnectionFailed,
updatePageDescription: handlers.updateDescription,
});
@@ -101,24 +91,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
editorRef,
});
// Type-safe error handler for content too large errors
const errorHandler: PageUpdateHandler<"error"> = (params) => {
const { data } = params;
// Check if it's content too large error
if (data.error_code === "content_too_large") {
setShowContentTooLargeBanner(true);
}
// Call original error handler if exists
customRealtimeEventHandlers?.error?.(params);
};
const mergedCustomEventHandlers: TCustomEventHandlers = {
...customRealtimeEventHandlers,
error: errorHandler,
};
// Get extended editor extensions configuration
const extendedEditorProps = useExtendedEditorProps({
workspaceSlug,
@@ -162,12 +134,11 @@ export const PageRoot = observer((props: TPageRootProps) => {
isNavigationPaneOpen={isNavigationPaneOpen}
page={page}
/>
{showContentTooLargeBanner && <ContentLimitBanner className="px-page-x" />}
<PageEditorBody
config={config}
customRealtimeEventHandlers={mergedCustomEventHandlers}
editorReady={editorReady}
editorForwardRef={editorRef}
handleConnectionStatus={setHasConnectionFailed}
handleEditorReady={handleEditorReady}
handleOpenNavigationPane={handleOpenNavigationPane}
handlers={handlers}
@@ -178,8 +149,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
webhookConnectionParams={webhookConnectionParams}
workspaceSlug={workspaceSlug}
extendedEditorProps={extendedEditorProps}
isFetchingFallbackBinary={isFetchingFallbackBinary}
onCollaborationStateChange={setCollaborationState}
/>
</div>
<PageNavigationPaneRoot
@@ -1,72 +0,0 @@
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>
);
};
@@ -46,7 +46,6 @@ export const useEditorMention = (args: TArgs) => {
name={user.member__display_name}
/>
),
id: user.member__id,
entity_identifier: user.member__id,
entity_name: "user_mention",
title: user.member__display_name,
+6 -37
View File
@@ -1,9 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { EditorRefApi, CollaborationState } from "@plane/editor";
import { useCallback, useEffect } from "react";
// plane editor
import { convertBinaryDataToBase64String, getBinaryDataFromDocumentEditorHTMLString } from "@plane/editor";
// plane propel
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import type { EditorRefApi } from "@plane/editor";
// plane types
import type { TDocumentPayload } from "@plane/types";
// hooks
@@ -12,37 +10,19 @@ import useAutoSave from "@/hooks/use-auto-save";
type TArgs = {
editorRef: React.RefObject<EditorRefApi>;
fetchPageDescription: () => Promise<ArrayBuffer>;
collaborationState: CollaborationState | null;
hasConnectionFailed: boolean;
updatePageDescription: (data: TDocumentPayload) => Promise<void>;
};
export const usePageFallback = (args: TArgs) => {
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 { editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription } = args;
const handleUpdateDescription = useCallback(async () => {
if (!hasConnectionFailed) return;
const editor = editorRef.current;
if (!editor) return;
// Show toast notification when fallback mechanism kicks in (only once)
if (!hasShownFallbackToast.current) {
setToast({
type: TOAST_TYPE.WARNING,
title: "Connection lost",
message: "Your changes are being saved using backup mechanism. ",
});
hasShownFallbackToast.current = true;
}
try {
setIsFetchingFallbackBinary(true);
const latestEncodedDescription = await fetchPageDescription();
let latestDecodedDescription: Uint8Array;
if (latestEncodedDescription && latestEncodedDescription.byteLength > 0) {
@@ -61,27 +41,16 @@ export const usePageFallback = (args: TArgs) => {
description_html: html,
description: json,
});
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error",
message: `Failed to update description using backup mechanism, ${error?.message}`,
});
} finally {
setIsFetchingFallbackBinary(false);
} catch (error) {
console.error("Error in updating description using fallback logic:", error);
}
}, [editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription]);
useEffect(() => {
if (hasConnectionFailed) {
handleUpdateDescription();
} else {
// Reset toast flag when connection is restored
hasShownFallbackToast.current = false;
}
}, [handleUpdateDescription, hasConnectionFailed]);
useAutoSave(handleUpdateDescription);
return { isFetchingFallbackBinary };
};
-10
View File
@@ -13,7 +13,6 @@ import { PageEditorInstance } from "./page-editor-info";
export type TBasePage = TPage & {
// observables
isSubmitting: TNameDescriptionLoader;
isSyncingWithServer: "syncing" | "synced" | "error";
// computed
asJSON: TPage | undefined;
isCurrentUserOwner: boolean;
@@ -36,7 +35,6 @@ export type TBasePage = TPage & {
removePageFromFavorites: () => Promise<void>;
duplicate: () => Promise<TPage | undefined>;
mutateProperties: (data: Partial<TPage>, shouldUpdateName?: boolean) => void;
setSyncingStatus: (status: "syncing" | "synced" | "error") => void;
// sub-store
editor: PageEditorInstance;
};
@@ -75,7 +73,6 @@ export type TPageInstance = TBasePage &
export class BasePage extends ExtendedBasePage implements TBasePage {
// loaders
isSubmitting: TNameDescriptionLoader = "saved";
isSyncingWithServer: "syncing" | "synced" | "error" = "syncing";
// page properties
id: string | undefined;
name: string | undefined;
@@ -158,7 +155,6 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
created_at: observable.ref,
updated_at: observable.ref,
deleted_at: observable.ref,
isSyncingWithServer: observable.ref,
// helpers
oldName: observable.ref,
setIsSubmitting: action,
@@ -539,10 +535,4 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
set(this, key, value);
});
};
setSyncingStatus = (status: "syncing" | "synced" | "error") => {
runInAction(() => {
this.isSyncingWithServer = status;
});
};
}
-3
View File
@@ -44,16 +44,13 @@
"@tiptap/extension-blockquote": "^2.22.3",
"@tiptap/extension-character-count": "^2.22.3",
"@tiptap/extension-collaboration": "^2.22.3",
"@tiptap/extension-document": "^3.2.0",
"@tiptap/extension-emoji": "^2.22.3",
"@tiptap/extension-heading": "^3.4.3",
"@tiptap/extension-image": "^2.22.3",
"@tiptap/extension-list-item": "^2.22.3",
"@tiptap/extension-mention": "^2.22.3",
"@tiptap/extension-placeholder": "^2.22.3",
"@tiptap/extension-task-item": "^2.22.3",
"@tiptap/extension-task-list": "^2.22.3",
"@tiptap/extension-text": "^3.2.0",
"@tiptap/extension-text-align": "^2.22.3",
"@tiptap/extension-text-style": "^2.22.3",
"@tiptap/extension-underline": "^2.22.3",
@@ -1,21 +1,20 @@
import React, { useMemo } from "react";
import React from "react";
// plane imports
import { cn } from "@plane/utils";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// contexts
import { CollaborationProvider, useCollaboration } from "@/contexts/collaboration-context";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
// constants
import { DocumentEditorSideEffects } from "@/plane-editor/components/document-editor-side-effects";
// types
import type { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
import { DocumentEditorSideEffects } from "@/plane-editor/components/document-editor-side-effects";
function CollaborativeDocumentEditorInner(props: ICollaborativeDocumentEditorProps) {
function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
const {
aiHandler,
bubbleMenuEnabled = true,
@@ -42,20 +41,15 @@ function CollaborativeDocumentEditorInner(props: ICollaborativeDocumentEditorPro
onEditorFocus,
onTransaction,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
user,
extendedDocumentEditorProps,
titleRef,
updatePageProperties,
isFetchingFallbackBinary,
} = props;
// Get non-null provider from context
const { provider, state, actions } = useCollaboration();
// Editor initialization with guaranteed non-null provider
const { editor, titleEditor } = useCollaborativeEditor({
provider,
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
disabledExtensions,
editable,
editorClassName,
@@ -76,10 +70,11 @@ function CollaborativeDocumentEditorInner(props: ICollaborativeDocumentEditorPro
onEditorFocus,
onTransaction,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
titleRef,
user,
actions,
extendedDocumentEditorProps,
});
const editorContainerClassNames = getEditorClassNames({
@@ -88,72 +83,37 @@ function CollaborativeDocumentEditorInner(props: ICollaborativeDocumentEditorPro
containerClassName,
});
// 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;
if (!editor) return null;
return (
<>
<div
className={cn(
"transition-opacity duration-200",
showContentSkeleton && !isLoading && "opacity-0 pointer-events-none"
)}
>
<DocumentEditorSideEffects editor={editor} id={id} extendedEditorProps={extendedEditorProps} />
<PageRenderer
aiHandler={aiHandler}
bubbleMenuEnabled={bubbleMenuEnabled}
displayConfig={displayConfig}
documentLoaderClassName={documentLoaderClassName}
disabledExtensions={disabledExtensions}
extendedDocumentEditorProps={extendedDocumentEditorProps}
editor={editor}
flaggedExtensions={flaggedExtensions}
titleEditor={titleEditor}
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
extendedEditorProps={extendedEditorProps}
id={id}
isLoading={isLoading}
isTouchDevice={!!isTouchDevice}
tabIndex={tabIndex}
provider={provider}
state={state}
/>
</div>
<DocumentEditorSideEffects editor={editor} id={id} extendedEditorProps={extendedEditorProps} />
<PageRenderer
aiHandler={aiHandler}
bubbleMenuEnabled={bubbleMenuEnabled}
displayConfig={displayConfig}
documentLoaderClassName={documentLoaderClassName}
editor={editor}
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
extendedEditorProps={extendedEditorProps}
id={id}
isTouchDevice={!!isTouchDevice}
isLoading={!hasServerSynced && !hasServerConnectionFailed}
tabIndex={tabIndex}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
extendedDocumentEditorProps={extendedDocumentEditorProps}
/>
</>
);
}
// Outer component that provides collaboration context
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
const { id, realtimeConfig, serverHandler, user } = props;
const token = useMemo(() => JSON.stringify(user), [user]);
return (
<CollaborationProvider
docId={id}
serverUrl={realtimeConfig.url}
authToken={token}
onStateChange={serverHandler?.onStateChange}
>
<CollaborativeDocumentEditorInner {...props} />
</CollaborationProvider>
);
};
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditorProps>(
(props, ref) => (
<CollaborativeDocumentEditor key={props.id} {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi>} />
)
);
const CollaborativeDocumentEditorWithRef = React.forwardRef(function CollaborativeDocumentEditorWithRef(
props: ICollaborativeDocumentEditorProps,
ref: React.ForwardedRef<EditorRefApi>
) {
return <CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />;
});
CollaborativeDocumentEditorWithRef.displayName = "CollaborativeDocumentEditorWithRef";
@@ -1,12 +1,10 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Editor } from "@tiptap/react";
// plane imports
import { cn } from "@plane/utils";
// components
import { DocumentContentLoader, EditorContainer, EditorContentWrapper } from "@/components/editors";
import { BlockMenu, EditorBubbleMenu } from "@/components/menus";
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
// types
import type { TCollabValue } from "@/contexts";
import type {
ICollaborativeDocumentEditorPropsExtended,
IEditorProps,
@@ -22,7 +20,6 @@ type Props = {
displayConfig: TDisplayConfig;
documentLoaderClassName?: string;
editor: Editor;
titleEditor?: Editor;
editorContainerClassName: string;
extendedDocumentEditorProps?: ICollaborativeDocumentEditorPropsExtended;
extendedEditorProps: IEditorPropsExtended;
@@ -30,13 +27,12 @@ type Props = {
id: string;
isLoading?: boolean;
isTouchDevice: boolean;
provider?: HocuspocusProvider;
state?: TCollabValue["state"];
tabIndex?: number;
};
export function PageRenderer(props: Props) {
const {
aiHandler,
bubbleMenuEnabled,
disabledExtensions,
displayConfig,
@@ -49,10 +45,8 @@ export function PageRenderer(props: Props) {
isLoading,
isTouchDevice,
tabIndex,
provider,
state,
titleEditor
} = props;
return (
<div
className={cn("frame-renderer flex-grow w-full", {
@@ -62,47 +56,33 @@ export function PageRenderer(props: Props) {
{isLoading ? (
<DocumentContentLoader className={documentLoaderClassName} />
) : (
<>
{titleEditor && (
<div className="relative w-full py-3">
<EditorContainer
editor={titleEditor}
id={id + "-title"}
isTouchDevice={isTouchDevice}
editorContainerClassName="page-title-editor bg-transparent py-3 border-none"
displayConfig={displayConfig}
>
<EditorContentWrapper
editor={titleEditor}
id={id + "-title"}
tabIndex={tabIndex}
className="no-scrollbar placeholder-custom-text-400 bg-transparent tracking-[-2%] font-bold text-[2rem] leading-[2.375rem] w-full outline-none p-0 border-none resize-none rounded-none"
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
isTouchDevice={isTouchDevice}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && !isTouchDevice && (
<div>
{bubbleMenuEnabled && (
<EditorBubbleMenu
disabledExtensions={disabledExtensions}
editor={editor}
extendedEditorProps={extendedEditorProps}
flaggedExtensions={flaggedExtensions}
/>
</EditorContainer>
)}
<BlockMenu
editor={editor}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
/>
<AIFeaturesMenu menu={aiHandler?.menu} />
</div>
)}
<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>
</>
</EditorContainer>
)}
</div>
);
@@ -1,17 +1,13 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Editor } from "@tiptap/react";
import type { FC, ReactNode } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// components
import type { TCollabValue } from "@/contexts";
import { LinkContainer } from "@/plane-editor/components/link-container";
// plugins
import { nodeHighlightPluginKey } from "@/plugins/highlight";
// types
import type { TDisplayConfig } from "@/types";
@@ -22,85 +18,12 @@ type Props = {
editorContainerClassName: string;
id: string;
isTouchDevice: boolean;
provider?: HocuspocusProvider | undefined;
state?: TCollabValue["state"];
};
export const EditorContainer: FC<Props> = (props) => {
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice, provider, state } = props;
export function EditorContainer(props: Props) {
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice } = props;
// refs
const containerRef = useRef<HTMLDivElement>(null);
const hasScrolledOnce = useRef(false);
const scrollToNode = useCallback(
(nodeId: string) => {
if (!editor) return false;
const doc = editor.state.doc;
let pos: number | null = null;
doc.descendants((node, position) => {
if (node.attrs && node.attrs.id === nodeId) {
pos = position;
return false;
}
});
if (pos === null) {
return false;
}
const nodePosition = pos;
const tr = editor.state.tr.setMeta(nodeHighlightPluginKey, { nodeId });
editor.view.dispatch(tr);
requestAnimationFrame(() => {
const domNode = editor.view.nodeDOM(nodePosition);
if (domNode instanceof HTMLElement) {
domNode.scrollIntoView({ behavior: "instant", block: "center" });
}
});
editor.once("focus", () => {
const clearTr = editor.state.tr.setMeta(nodeHighlightPluginKey, { nodeId: null });
editor.view.dispatch(clearTr);
});
hasScrolledOnce.current = true;
return true;
},
[editor]
);
useEffect(() => {
const nodeId = window.location.href.split("#")[1];
const handleSynced = () => scrollToNode(nodeId);
if (nodeId && !hasScrolledOnce.current) {
if (provider && state) {
const { hasCachedContent } = state;
// If the provider is synced or the cached content is available and the server is disconnected, scroll to the node
if (hasCachedContent) {
const hasScrolled = handleSynced();
if (!hasScrolled) {
provider.on("synced", handleSynced);
}
} else if (provider.isSynced) {
handleSynced();
} else {
provider.on("synced", handleSynced);
}
} else {
handleSynced();
}
return () => {
if (provider) {
provider.off("synced", handleSynced);
}
};
}
}, [scrollToNode, provider, state]);
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (event.target !== event.currentTarget) return;
@@ -165,6 +88,7 @@ export const EditorContainer: FC<Props> = (props) => {
`editor-container cursor-text relative line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
{
"active-editor": editor?.isFocused && editor?.isEditable,
"wide-layout": displayConfig.wideLayout,
},
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
@@ -3,22 +3,17 @@ import type { Editor } from "@tiptap/react";
import type { FC, ReactNode } from "react";
type Props = {
className?: string;
children?: ReactNode;
editor: Editor | null;
id: string;
tabIndex?: number;
};
export const EditorContentWrapper: FC<Props> = (props) => {
const { editor, className, children, tabIndex, id } = props;
export function EditorContentWrapper(props: Props) {
const { editor, children, tabIndex, id } = props;
return (
<div
tabIndex={tabIndex}
onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}
className={className}
>
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
<EditorContent editor={editor} id={id} />
{children}
</div>
@@ -69,3 +69,5 @@ export const BLOCK_NODE_TYPES = [
CORE_EXTENSIONS.CALLOUT,
CORE_EXTENSIONS.WORK_ITEM_EMBED,
];
export const INLINE_NODE_TYPES = [CORE_EXTENSIONS.MENTION];
@@ -1,32 +0,0 @@
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 +0,0 @@
export * from "./collaboration-context";
@@ -1,6 +1,6 @@
import type { NodeViewProps } from "@tiptap/react";
import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
import React, { useState } from "react";
import { useState } from "react";
// constants
import { COLORS_LIST } from "@/constants/common";
// local components
@@ -33,6 +33,7 @@ export function CustomCalloutBlock(props: CustomCalloutNodeViewProps) {
style={{
backgroundColor: activeBackgroundColor,
}}
key={`callout-block-${node.attrs.id}`}
>
<CalloutBlockLogoSelector
blockAttributes={node.attrs}
@@ -1,4 +1,5 @@
import type { Node as ProseMirrorNode } from "@tiptap/core";
import type { TBlockNodeBaseAttributes } from "../unique-id/types";
export enum ECalloutAttributeNames {
ICON_COLOR = "data-icon-color",
@@ -20,7 +21,7 @@ export type TCalloutBlockEmojiAttributes = {
[ECalloutAttributeNames.EMOJI_URL]: string | undefined;
};
export type TCalloutBlockAttributes = {
export type TCalloutBlockAttributes = TBlockNodeBaseAttributes & {
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
[ECalloutAttributeNames.BACKGROUND]: string | undefined;
[ECalloutAttributeNames.BLOCK_TYPE]: "callout-component";
@@ -1,4 +1,4 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import type { NodeViewProps } from "@tiptap/react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import ts from "highlight.js/lib/languages/typescript";
import { common, createLowlight } from "lowlight";
@@ -8,16 +8,20 @@ import { useState } from "react";
import { Tooltip } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// types
import type { TCodeBlockAttributes } from "./types";
// we just have ts support for now
const lowlight = createLowlight(common);
lowlight.register("ts", ts);
type Props = {
node: ProseMirrorNode;
export type CodeBlockNodeViewProps = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TCodeBlockAttributes;
};
};
export function CodeBlockComponent({ node }: Props) {
export function CodeBlockComponent({ node }: CodeBlockNodeViewProps) {
const [copied, setCopied] = useState(false);
const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
@@ -33,7 +37,7 @@ export function CodeBlockComponent({ node }: Props) {
};
return (
<NodeViewWrapper className="code-block relative group/code">
<NodeViewWrapper className="code-block relative group/code" key={`code-block-${node.attrs.id}`}>
<Tooltip tooltipContent="Copy code">
<button
type="button"
@@ -5,13 +5,16 @@ import { common, createLowlight } from "lowlight";
// components
import { CodeBlockLowlight } from "./code-block-lowlight";
import { CodeBlockComponent } from "./code-block-node-view";
import type { CodeBlockNodeViewProps } from "./code-block-node-view";
const lowlight = createLowlight(common);
lowlight.register("ts", ts);
export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockComponent);
return ReactNodeViewRenderer((props) => (
<CodeBlockComponent {...props} node={props.node as CodeBlockNodeViewProps["node"]} />
));
},
addKeyboardShortcuts() {
@@ -0,0 +1,5 @@
import type { TBlockNodeBaseAttributes } from "../unique-id/types";
export type TCodeBlockAttributes = TBlockNodeBaseAttributes & {
language: string | null;
};
@@ -122,7 +122,7 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) {
const shouldShowBlock = (isUploaded || imageFromFileSystem) && !failedToLoadImage;
return (
<NodeViewWrapper>
<NodeViewWrapper key={`image-block-${node.attrs.id}`}>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{shouldShowBlock && !hasDuplicationFailed ? (
<CustomImageBlock
@@ -1,6 +1,7 @@
import type { Node } from "@tiptap/core";
// types
import type { TFileHandler } from "@/types";
import type { TBlockNodeBaseAttributes } from "../unique-id/types";
export enum ECustomImageAttributeNames {
ID = "id",
@@ -32,8 +33,7 @@ export enum ECustomImageStatus {
DUPLICATION_FAILED = "duplication-failed",
}
export type TCustomImageAttributes = {
[ECustomImageAttributeNames.ID]: string | null;
export type TCustomImageAttributes = TBlockNodeBaseAttributes & {
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
[ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null;
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
@@ -1,5 +1,7 @@
import type { NodeViewProps } from "@tiptap/react";
import { NodeViewWrapper } from "@tiptap/react";
import { useMemo } from "react";
import { v4 as uuidv4 } from "uuid";
// extension config
import type { TMentionExtensionOptions } from "./extension-config";
// extension types
@@ -19,7 +21,7 @@ export function MentionNodeView(props: MentionNodeViewProps) {
} = props;
return (
<NodeViewWrapper className="mention-component inline w-fit">
<NodeViewWrapper className="mention-component inline w-fit" key={`mention-${attrs.id}`}>
{(extension.options as TMentionExtensionOptions).renderComponent({
entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER] ?? "",
entity_name: attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention",
@@ -31,11 +31,9 @@ export const MentionsListDropdown = forwardRef(function MentionsListDropdown(pro
(sectionIndex: number, itemIndex: number) => {
try {
const item = sections?.[sectionIndex]?.items?.[itemIndex];
const transactionId = uuidv4();
if (item) {
command({
...item,
id: transactionId,
});
}
} catch (error) {
@@ -1,14 +0,0 @@
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,
];
@@ -4,13 +4,12 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import type { Transaction } from "@tiptap/pm/state";
import { v4 as uuidv4 } from "uuid";
// constants
import { CORE_EXTENSIONS, BLOCK_NODE_TYPES } from "@/constants/extension";
import { CORE_EXTENSIONS, BLOCK_NODE_TYPES, INLINE_NODE_TYPES } from "@/constants/extension";
import { ADDITIONAL_BLOCK_NODE_TYPES } from "@/plane-editor/constants/extensions";
import { createUniqueIDPlugin } from "./plugin";
import { createIdsForView } from "./utils";
// plane imports
const COMBINED_BLOCK_NODE_TYPES = [...BLOCK_NODE_TYPES, ...ADDITIONAL_BLOCK_NODE_TYPES];
const COMBINED_BLOCK_NODE_TYPES = [...INLINE_NODE_TYPES, ...BLOCK_NODE_TYPES, ...ADDITIONAL_BLOCK_NODE_TYPES];
export type UniqueIDGenerationContext = {
node: ProseMirrorNode;
pos: number;
@@ -0,0 +1,7 @@
/**
* Base attributes for all block nodes that have the unique-id extension.
* All block node attribute types should extend this.
*/
export interface TBlockNodeBaseAttributes {
id?: string | null;
}
@@ -2,6 +2,7 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
// local imports
import { WorkItemEmbedExtensionConfig } from "./extension-config";
import type { TWorkItemEmbedAttributes } from "./types";
type Props = {
widgetCallback: ({
@@ -18,15 +19,18 @@ type Props = {
export function WorkItemEmbedExtension(props: Props) {
return WorkItemEmbedExtensionConfig.extend({
addNodeView() {
return ReactNodeViewRenderer((issueProps: NodeViewProps) => (
<NodeViewWrapper>
{props.widgetCallback({
issueId: issueProps.node.attrs.entity_identifier,
projectId: issueProps.node.attrs.project_identifier,
workspaceSlug: issueProps.node.attrs.workspace_identifier,
})}
</NodeViewWrapper>
));
return ReactNodeViewRenderer((issueProps: NodeViewProps) => {
const attrs = issueProps.node.attrs as TWorkItemEmbedAttributes;
return (
<NodeViewWrapper key={`work-item-embed-${attrs.id}`}>
{props.widgetCallback({
issueId: attrs.entity_identifier!,
projectId: attrs.project_identifier,
workspaceSlug: attrs.workspace_identifier,
})}
</NodeViewWrapper>
);
});
},
});
}
@@ -0,0 +1,8 @@
import type { TBlockNodeBaseAttributes } from "../unique-id/types";
export type TWorkItemEmbedAttributes = TBlockNodeBaseAttributes & {
entity_identifier: string | undefined;
project_identifier: string | undefined;
workspace_identifier: string | undefined;
entity_name: string | undefined;
};
+10 -37
View File
@@ -1,5 +1,4 @@
import { Buffer } from "buffer";
import type { Extensions } from "@tiptap/core";
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
@@ -10,12 +9,10 @@ import {
CoreEditorExtensionsWithoutProps,
DocumentEditorExtensionsWithoutProps,
} from "@/extensions/core-without-props";
import { TitleExtensions } from "@/extensions/title-extension";
// editor extension configs
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
export const TITLE_EDITOR_EXTENSIONS: Extensions = TitleExtensions;
// editor schemas
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
@@ -48,10 +45,9 @@ export const convertBinaryDataToBase64String = (document: Uint8Array): string =>
/**
* @description this function decodes base64 string to binary data
* @param {string} document
* @returns {Buffer<ArrayBuffer>}
* @returns {ArrayBuffer}
*/
export const convertBase64StringToBinaryData = (document: string): Buffer<ArrayBuffer> =>
Buffer.from(document, "base64");
export const convertBase64StringToBinaryData = (document: string): ArrayBuffer => Buffer.from(document, "base64");
/**
* @description this function generates the binary equivalent of html content for the rich text editor
@@ -118,13 +114,11 @@ export const getAllDocumentFormatsFromRichTextEditorBinaryData = (
* @returns
*/
export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
description: Uint8Array,
updateTitle: boolean
description: Uint8Array
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
titleHTML?: string;
} => {
// encode binary description data
const base64Data = convertBinaryDataToBase64String(description);
@@ -136,24 +130,11 @@ export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
if (updateTitle) {
const title = yDoc.getXmlFragment("title");
const titleJSON = yXmlFragmentToProseMirrorRootNode(title, documentEditorSchema).toJSON();
const titleHTML = extractTextFromHTML(generateHTML(titleJSON, DOCUMENT_EDITOR_EXTENSIONS));
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
titleHTML,
};
} else {
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
}
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
};
type TConvertHTMLDocumentToAllFormatsArgs = {
@@ -189,10 +170,8 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF
// Convert HTML to binary format for document editor
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
// Generate all document formats from the binary data
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
contentBinary,
false
);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
@@ -204,9 +183,3 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF
return allFormats;
};
export const extractTextFromHTML = (html: string): string => {
// Use a regex to extract text between tags
const textMatch = html.replace(/<[^>]*>/g, "");
return textMatch || "";
};
@@ -1,9 +1,7 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Extensions } from "@tiptap/core";
import { HocuspocusProvider } from "@hocuspocus/provider";
import Collaboration from "@tiptap/extension-collaboration";
// react
import type React from "react";
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { IndexeddbPersistence } from "y-indexeddb";
// extensions
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
// hooks
@@ -11,28 +9,10 @@ import { useEditor } from "@/hooks/use-editor";
// plane editor extensions
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import type {
TCollaborativeEditorHookProps,
ICollaborativeDocumentEditorProps,
IEditorPropsExtended,
TEditorHookProps,
EditorTitleRefApi,
} from "@/types";
// local imports
import { useEditorNavigation } from "./use-editor-navigation";
import { useTitleEditor } from "./use-title-editor";
import type { TCollaborativeEditorHookProps } from "@/types";
type UseCollaborativeEditorArgs = Omit<TCollaborativeEditorHookProps, "realtimeConfig" | "serverHandler" | "user"> & {
provider: HocuspocusProvider;
user: TCollaborativeEditorHookProps["user"];
actions: {
signalForcedClose: (value: boolean) => void;
};
};
export const useCollaborativeEditor = (props: UseCollaborativeEditorArgs) => {
export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => {
const {
provider,
onAssetChange,
onChange,
onTransaction,
@@ -44,27 +24,70 @@ export const useCollaborativeEditor = (props: UseCollaborativeEditorArgs) => {
extensions = [],
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
dragDropEnabled = true,
isTouchDevice,
mentionHandler,
onEditorFocus,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
titleRef,
updatePageProperties,
user,
actions,
} = 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 { mainNavigationExtension, titleNavigationExtension, setMainEditor, setTitleEditor } = useEditorNavigation();
const localProvider = useMemo(
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
[id, provider]
);
// Memoize extensions to avoid unnecessary editor recreations
const editorExtensions = useMemo(
() => [
// destroy and disconnect all providers connection on unmount
useEffect(
() => () => {
provider?.destroy();
localProvider?.destroy();
},
[provider, localProvider]
);
const editor = useEditor({
disabledExtensions,
extendedEditorProps,
id,
editable,
editorProps,
editorClassName,
enableHistory: false,
extensions: [
SideMenuExtension({
aiEnabled: !disabledExtensions?.includes("ai"),
dragDropEnabled,
@@ -72,7 +95,6 @@ export const useCollaborativeEditor = (props: UseCollaborativeEditorArgs) => {
HeadingListExtension,
Collaboration.configure({
document: provider.document,
field: "default",
}),
...extensions,
...DocumentEditorAdditionalExtensions({
@@ -84,118 +106,26 @@ export const useCollaborativeEditor = (props: UseCollaborativeEditorArgs) => {
provider,
userDetails: user,
}),
mainNavigationExtension,
],
[
provider,
disabledExtensions,
dragDropEnabled,
extensions,
extendedEditorProps,
fileHandler,
flaggedExtensions,
editable,
user,
mainNavigationExtension,
]
);
// Editor configuration
const editorConfig = useMemo<TEditorHookProps>(
() => ({
disabledExtensions,
extendedEditorProps,
id,
editable,
editorProps,
editorClassName,
enableHistory: false,
extensions: editorExtensions,
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
isTouchDevice,
mentionHandler,
onAssetChange,
onChange,
onEditorFocus,
onTransaction,
placeholder,
provider,
tabIndex,
}),
[
provider,
disabledExtensions,
extendedEditorProps,
id,
editable,
editorProps,
editorClassName,
editorExtensions,
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
isTouchDevice,
mentionHandler,
onAssetChange,
onChange,
onEditorFocus,
onTransaction,
placeholder,
tabIndex,
]
);
const editor = useEditor(editorConfig);
const titleExtensions = useMemo(
() => [
Collaboration.configure({
document: provider.document,
field: "title",
}),
titleNavigationExtension,
],
[provider, titleNavigationExtension]
);
const titleEditorConfig = useMemo<{
id: string;
editable: boolean;
provider: HocuspocusProvider;
titleRef?: React.MutableRefObject<EditorTitleRefApi | null>;
updatePageProperties?: ICollaborativeDocumentEditorProps["updatePageProperties"];
extensions: Extensions;
extendedEditorProps?: IEditorPropsExtended;
}>(
() => ({
id,
editable,
provider,
titleRef,
updatePageProperties,
extensions: titleExtensions,
extendedEditorProps,
}),
[provider, id, editable, titleRef, updatePageProperties, titleExtensions, extendedEditorProps]
);
const titleEditor = useTitleEditor(titleEditorConfig as Parameters<typeof useTitleEditor>[0]);
useEffect(() => {
if (editor && titleEditor) {
setMainEditor(editor);
setTitleEditor(titleEditor);
}
}, [editor, titleEditor, setMainEditor, setTitleEditor]);
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
isTouchDevice,
mentionHandler,
onAssetChange,
onChange,
onEditorFocus,
onTransaction,
placeholder,
provider,
tabIndex,
});
return {
editor,
titleEditor,
hasServerConnectionFailed,
hasServerSynced,
};
};
@@ -1,169 +0,0 @@
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,
};
};
@@ -1,80 +0,0 @@
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 } 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;
};
/**
* 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 } = props;
// Force editor recreation when Y.Doc changes (provider.document.guid)
const docKey = provider?.document?.guid ?? id;
const editor = useEditor(
{
onUpdate: () => {
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,
}),
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;
};
@@ -1,369 +0,0 @@
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,
},
};
};
@@ -1,92 +0,0 @@
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,37 +1,4 @@
export type CollaborationError =
| { type: "auth-failed"; message: string }
| { type: "network-error"; message: string }
| { type: "forced-close"; code: number; message: string }
| { type: "max-retries"; message: string };
/**
* Single-stage state machine for collaboration lifecycle.
* Stages represent the sequential progression: initial → connecting → awaiting-sync → synced
*
* Invariants:
* - "awaiting-sync" only occurs when connection is successful and sync is pending
* - "synced" occurs only after connection success and onSynced callback
* - "reconnecting" with attempt > 0 when retrying after a connection drop
* - "disconnected" is terminal (connection failed or forced close)
*/
export type CollabStage =
| { kind: "initial" }
| { kind: "connecting" }
| { kind: "awaiting-sync" }
| { kind: "synced" }
| { kind: "reconnecting"; attempt: number }
| { kind: "disconnected"; error: CollaborationError };
/**
* Public collaboration state exposed to consumers.
* Contains the current stage and derived booleans for convenience.
*/
export type CollaborationState = {
stage: CollabStage;
isServerSynced: boolean;
isServerDisconnected: boolean;
};
export type TServerHandler = {
onStateChange: (state: CollaborationState) => void;
onConnect?: () => void;
onServerError?: () => void;
};
+1 -16
View File
@@ -27,8 +27,6 @@ import type {
TRealtimeConfig,
TServerHandler,
TUserDetails,
TExtendedEditorRefApi,
EventToPayloadMap,
} from "@/types";
export type TEditorCommands =
@@ -99,7 +97,7 @@ export type TDocumentInfo = {
words: number;
};
export type CoreEditorRefApi = {
export type EditorRefApi = {
blur: () => void;
clearEditor: (emitUpdate?: boolean) => void;
createSelectionAtCursorPosition: () => void;
@@ -140,10 +138,6 @@ export type CoreEditorRefApi = {
undo: () => void;
};
export type EditorRefApi = CoreEditorRefApi & TExtendedEditorRefApi;
export type EditorTitleRefApi = EditorRefApi;
// editor props
export type IEditorProps = {
autofocus?: boolean;
@@ -191,15 +185,6 @@ export type ICollaborativeDocumentEditorProps = Omit<IEditorProps, "initialValue
serverHandler?: TServerHandler;
user: TUserDetails;
extendedDocumentEditorProps?: ICollaborativeDocumentEditorPropsExtended;
updatePageProperties?: <T extends keyof EventToPayloadMap>(
pageIds: string | string[],
actionType: T,
data: EventToPayloadMap[T],
performAction?: boolean
) => void;
pageRestorationInProgress?: boolean;
titleRef?: React.MutableRefObject<EditorTitleRefApi | null>;
isFetchingFallbackBinary?: boolean;
};
export type IDocumentEditorProps = Omit<IEditorProps, "initialValue" | "onEnterKeyPress" | "value"> & {
+1 -4
View File
@@ -55,7 +55,4 @@ export type TCollaborativeEditorHookProps = TCoreHookProps &
Pick<
ICollaborativeDocumentEditorProps,
"dragDropEnabled" | "extendedDocumentEditorProps" | "realtimeConfig" | "serverHandler" | "user"
> & {
titleRef?: ICollaborativeDocumentEditorProps["titleRef"];
updatePageProperties?: ICollaborativeDocumentEditorProps["updatePageProperties"];
};
>;
+1 -1
View File
@@ -5,7 +5,7 @@ export type TMentionSuggestion = {
entity_identifier: string;
entity_name: TSearchEntities;
icon: React.ReactNode;
id: string;
id?: string | null;
subTitle?: string;
title: string;
};
-1
View File
@@ -3,4 +3,3 @@
@import "./table.css";
@import "./github-dark.css";
@import "./drag-drop.css";
@import "./title-editor.css";
@@ -1,49 +0,0 @@
/* 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;
}
+171 -207
View File
@@ -186,7 +186,7 @@ importers:
version: 9.1.1(mobx@6.12.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-themes:
specifier: ^0.2.1
version: 0.2.1(next@14.2.32(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
version: 0.2.1(next@14.2.32(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react:
specifier: 'catalog:'
version: 18.3.1
@@ -446,7 +446,7 @@ importers:
version: 6.0.8(mobx@6.12.0)
next-themes:
specifier: ^0.2.1
version: 0.2.1(next@14.2.32(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
version: 0.2.1(next@14.2.32(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react:
specifier: 'catalog:'
version: 18.3.1
@@ -624,7 +624,7 @@ importers:
version: 6.0.8(mobx@6.12.0)
next-themes:
specifier: ^0.2.1
version: 0.2.1(next@14.2.32(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
version: 0.2.1(next@14.2.32(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
posthog-js:
specifier: ^1.255.1
version: 1.255.1
@@ -845,70 +845,61 @@ importers:
version: link:../utils
'@tiptap/core':
specifier: 'catalog:'
version: 2.26.3(@tiptap/pm@2.26.1)
version: 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-blockquote':
specifier: ^2.22.3
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-character-count':
specifier: ^2.22.3
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
'@tiptap/extension-collaboration':
specifier: ^2.22.3
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))
'@tiptap/extension-document':
specifier: ^3.2.0
version: 3.2.0(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))
'@tiptap/extension-emoji':
specifier: ^2.22.3
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)(@tiptap/suggestion@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1))(emojibase@16.0.0)
'@tiptap/extension-heading':
specifier: ^3.4.3
version: 3.4.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)(@tiptap/suggestion@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3))(emojibase@16.0.0)
'@tiptap/extension-image':
specifier: ^2.22.3
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-list-item':
specifier: ^2.22.3
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-mention':
specifier: ^2.22.3
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)(@tiptap/suggestion@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1))
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)(@tiptap/suggestion@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3))
'@tiptap/extension-placeholder':
specifier: ^2.22.3
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
'@tiptap/extension-task-item':
specifier: ^2.22.3
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
'@tiptap/extension-task-list':
specifier: ^2.22.3
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
'@tiptap/extension-text':
specifier: ^3.2.0
version: 3.2.0(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-text-align':
specifier: ^2.22.3
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-text-style':
specifier: ^2.22.3
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-underline':
specifier: ^2.22.3
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/html':
specifier: 'catalog:'
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
'@tiptap/pm':
specifier: ^2.22.3
version: 2.26.1
version: 2.26.3
'@tiptap/react':
specifier: ^2.22.3
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tiptap/starter-kit':
specifier: ^2.22.3
version: 2.26.1
'@tiptap/suggestion':
specifier: ^2.22.3
version: 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
buffer:
specifier: ^6.0.3
version: 6.0.3
@@ -947,7 +938,7 @@ importers:
version: 6.3.7
tiptap-markdown:
specifier: ^0.8.10
version: 0.8.10(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
version: 0.8.10(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
uuid:
specifier: 'catalog:'
version: 13.0.0
@@ -3724,8 +3715,8 @@ packages:
peerDependencies:
'@tiptap/pm': ^2.7.0
'@tiptap/extension-blockquote@2.26.1':
resolution: {integrity: sha512-viQ6AHRhjCYYipKK6ZepBzwZpkuMvO9yhRHeUZDvlSOAh8rvsUTSre0y74nu8QRYUt4a44lJJ6BpphJK7bEgYA==}
'@tiptap/extension-blockquote@2.26.2':
resolution: {integrity: sha512-SQNMX2rkWdAOYT6pM9KZ4bZK07YiCqX6wkHiKbLSZ8GMLi35dhkiSBxvY2I72q5ucIjgC9asGf8knA/2fbVypA==}
peerDependencies:
'@tiptap/core': ^2.7.0
@@ -3762,31 +3753,26 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-collaboration@2.26.1':
resolution: {integrity: sha512-ozCrGW5IAzi/18Ngdi0v/Q175D7J3ZGoTffDDyPxnTK/oksfROajoe+ZIEgoDGXPeI/I7TTlTONuqQ6LZT5r7Q==}
'@tiptap/extension-collaboration@2.26.2':
resolution: {integrity: sha512-kvlzKnkhH57YgpuDIOu/h9iOuoIRZf8Bc+yAy/ldbCz9EnT8/k4BMaixb98lrYExaQfozGxuAAOZmK0sR95j4Q==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
y-prosemirror: ^1.2.11
'@tiptap/extension-document@2.26.1':
resolution: {integrity: sha512-2P2IZp1NRAE+21mRuFBiP3X2WKfZ6kUC23NJKpn8bcOamY3obYqCt0ltGPhE4eR8n8QAl2fI/3jIgjR07dC8ow==}
'@tiptap/extension-document@2.26.2':
resolution: {integrity: sha512-s0/P3A8zxWL/h3e20xWMTT/rcwD0+57I6mT9JgNBPtvhPePy8d698G6/qFK+x+GdIyjJylfsq2BrSE9H+QhIBg==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-document@3.2.0':
resolution: {integrity: sha512-2SQoew2HtOwuORpk8l7NjKDzsCBotMsij9vRSTp4nwoa4ZFGwmlVMsNIaKq/E/GuS7oiejmUUROsiD3w1stPKw==}
peerDependencies:
'@tiptap/core': ^3.2.0
'@tiptap/extension-dropcursor@2.26.2':
resolution: {integrity: sha512-o5j4Gkurb/WBu1wP2tihYnZ8dENzmlxFWWMx++g6abEpn9xdud7VxHT5Ny7mBSBptI8uMwKT53weYC0on38n3g==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-emoji@2.26.1':
resolution: {integrity: sha512-CtK10GF80Qr4lgJ7P6W6tVThOjpq1lh8oyoBospZ+CjD4GYcY73bdl+FP0uxhZdJsMHzaqzMP5wWQ54zHsIaIg==}
'@tiptap/extension-emoji@2.26.2':
resolution: {integrity: sha512-NeYU3H2arrjx7fFPnWl4IqjTMID8k5BKyDRgWarKQ1ApJpeq+8EzTGIffflHNcrIiZxezTtx4anhsC1NL+/Gdw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
@@ -3809,16 +3795,11 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-heading@2.26.1':
resolution: {integrity: sha512-KSzL8WZV3pjJG9ke4RaU70+B5UlYR2S6olNt5UCAawM+fi11mobVztiBoC19xtpSVqIXC1AmXOqUgnuSvmE4ZA==}
'@tiptap/extension-heading@2.26.2':
resolution: {integrity: sha512-0VAr1l1QKFJ0B2l4D4wV0LRlyFYeJt0S09mz+HPF2TqKF4twmPjaGD6o5zzXWl8c4cQj1CmM8P+9an3SKRjOaA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-heading@3.4.3':
resolution: {integrity: sha512-s9X0pvkpE/Tfz/Ui5ETcmSj41MXj78UoWGfZHf3lKqtoDbmIlLEKZRBYuKUMC7812gig5AeqryPBqvEtjPSq8A==}
peerDependencies:
'@tiptap/core': ^3.4.3
'@tiptap/extension-history@2.26.1':
resolution: {integrity: sha512-m6YR1gkkauIDo3PRl0gP+7Oc4n5OqDzcjVh6LvWREmZP8nmi94hfseYbqOXUb6RPHIc0JKF02eiRifT4MSd2nw==}
peerDependencies:
@@ -3831,8 +3812,8 @@ packages:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-image@2.26.1':
resolution: {integrity: sha512-96+MaYBJebQlR/ik5W72GLUfXdEoxFs+6jsoERxbM5qEdhb7TEnodBFtWZOwgDO27kFd6rSNZuW9r5KJNtljEg==}
'@tiptap/extension-image@2.26.2':
resolution: {integrity: sha512-3gK+ETLiWGAUdyPDXDheNJ38OgQabSzZJ+1nQo9KWjI7P3LQ7/ctxLtT+hAFpxX0qMK4bnu5vZaItSXxE3ZtpQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
@@ -3841,13 +3822,13 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-list-item@2.26.1':
resolution: {integrity: sha512-quOXckC73Luc3x+Dcm88YAEBW+Crh3x5uvtQOQtn2GEG91AshrvbnhGRiYnfvEN7UhWIS+FYI5liHFcRKSUKrQ==}
'@tiptap/extension-list-item@2.26.2':
resolution: {integrity: sha512-T1dFfx1JjRRX0iyStSTwMNajMyT+OE7XEggn+DON1g+zbgA+4cJ11WQpfrfA9VM2H5QWYyKGfHFigoFcJ8rjog==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-mention@2.26.1':
resolution: {integrity: sha512-sBrlJ9nWjFx7oWCtt0hV192FgCBXva1zwImWbgXTCGPAjv0d5EoPymIfRgoeanAmuQjOHoKzzZnJ6bELTZhkGw==}
'@tiptap/extension-mention@2.26.2':
resolution: {integrity: sha512-fezYJQlewhfaIXBQ85LTDRBMsWfynb9xSsEYC8EeRhbwI5fFlWKEAi876oQiSqqJTg6NV/pda9dUn+wZkV7U4w==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
@@ -3874,39 +3855,34 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-task-item@2.26.1':
resolution: {integrity: sha512-b7JNeOsBqEd1p2oQ5N6Msz9fr2o73WR1WsYDC0WhECg07Goud2gQEkwWkQaLsvfcwuS746eMJK/nrT2pVEngYA==}
'@tiptap/extension-task-item@2.26.2':
resolution: {integrity: sha512-KFfl9QraNldIR9ayP7t+b69BvjmdGeE9Mr+u/6h6jOmUVFpnES45geuEBSYuWWpK4EeD3ZUKXo7WGEWnTLSyfQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-task-list@2.26.1':
resolution: {integrity: sha512-xR4LMpMPZ6bpkZNmFvIojmNGtdGKNlKFbpvyIOgs4qhlWskbFQQVevglHjV1R8xJLic5c+byJQaAmQdQudqGng==}
'@tiptap/extension-task-list@2.26.2':
resolution: {integrity: sha512-mQdB0BVULJiiFPGdst4JbQPm/ZGZMIeYafVu6iGSMYxdP4piMvMbY4txtLV8zN8oUEAO5zooiXXbS3OFFBDZyQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-text-align@2.26.1':
resolution: {integrity: sha512-x6mpNGELy2QtSPBoQqNgiXO9PjZoB+O2EAfXA9YRiBDSIRNOrw+7vOVpi+IgzswFmhMNgIYUVfQRud4FHUCNew==}
'@tiptap/extension-text-align@2.26.2':
resolution: {integrity: sha512-fra+weXocCINVPGjykteTiQ+p34POSWyxw+Y+KFBegw24uecHhB5NCK6aXa68tvEMLC9EdvZRHuoKRfkxBQ9rw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-text-style@2.26.1':
resolution: {integrity: sha512-t9Nc/UkrbCfnSHEUi1gvUQ2ZPzvfdYFT5TExoV2DTiUCkhG6+mecT5bTVFGW3QkPmbToL+nFhGn4ZRMDD0SP3Q==}
'@tiptap/extension-text-style@2.26.2':
resolution: {integrity: sha512-rNkV3dgT3nTISEf3Ax/DdqQsSy9p46n2fOBkD8FCtdrwsWNH5N4uUh4jI/q0exYKJUyZGvl60uXwCkZiQ3pVBA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-text@2.26.1':
resolution: {integrity: sha512-p2n8WVMd/2vckdJlol24acaTDIZAhI7qle5cM75bn01sOEZoFlSw6SwINOULrUCzNJsYb43qrLEibZb4j2LeQw==}
'@tiptap/extension-text@2.26.2':
resolution: {integrity: sha512-Rb8Le/Li+EixQNc/pGkEJpLjozTjWYP9glaYfnjPtRVw4tHcd7khVm5mer0TQjonbBUjVC1zSuXv9gifXOv6DQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-text@3.2.0':
resolution: {integrity: sha512-VDQPjVr6zd3MGh8+xPIIj+fuIhnws9tpAmP7jvBep+0tL7P2RYpN2NSj0zqeStHPcGfv9iMuXEuuMkEsi5gIZg==}
peerDependencies:
'@tiptap/core': ^3.2.0
'@tiptap/extension-underline@2.26.1':
resolution: {integrity: sha512-/fufv41WDMdf0a4xmFAxONoAz08TonJXX6NEoSJmuGKO59M/Y0Pz8DTK1g32Wk44kn7dyScDiPlvvndl+UOv0A==}
'@tiptap/extension-underline@2.26.2':
resolution: {integrity: sha512-kpKJSfsn1+b8l96YPg2GRn3aaN78pLqSeyzfA5FYVbN0lyptbqRVOrNM8p3n6l0LbAkiEjc/TgOMwNNEL93kyA==}
peerDependencies:
'@tiptap/core': ^2.7.0
@@ -3916,14 +3892,14 @@ packages:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/pm@2.26.1':
resolution: {integrity: sha512-8aF+mY/vSHbGFqyG663ds84b+vca5Lge3tHdTMTKazxCnhXR9dn2oQJMnZ78YZvdRbkPkMJJHti9h3K7u2UQvw==}
'@tiptap/pm@2.26.3':
resolution: {integrity: sha512-8gUmdxWlUevmgq2mNvGxvf2CpDW097tVKECMWKEn8sf846kXv3CoqaGRhI3db4kfR+09uWZeRM7rtrjRBmUThg==}
'@tiptap/pm@3.6.6':
resolution: {integrity: sha512-E/rtpPEqPiQJrchdOUDcMPR69x96a+JeMWLL12fos4KfF7YVzQ5oUIij21RffV+qeHxug7HMUpQKBtCuJfek/Q==}
'@tiptap/react@2.26.1':
resolution: {integrity: sha512-Zxlwzi1iML7aELa+PyysFD2ncVo2mEcjTkhoDok9iTbMGpm1oU8hgR1i6iHrcSNQLfaRiW6M7HNhZZQPKIC9yw==}
'@tiptap/react@2.26.2':
resolution: {integrity: sha512-p7jv0sltCC2L4iHIVNthtjv/CIxajOalb7ytjLx6ijx5q2J564VIny0U7O33Ymbo2cV0dJoB+Bo5aeaJ5SfHGg==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
@@ -3933,8 +3909,8 @@ packages:
'@tiptap/starter-kit@2.26.1':
resolution: {integrity: sha512-oziMGCds8SVQ3s5dRpBxVdEKZAmO/O//BjZ69mhA3q4vJdR0rnfLb5fTxSeQvHiqB878HBNn76kNaJrHrV35GA==}
'@tiptap/suggestion@2.26.1':
resolution: {integrity: sha512-iNWJdQN7h01keNoVwyCsdI7ZX11YkrexZjCnutWK17Dd72s3NYVTmQXu7saftwddT4nDdlczNxAFosrt0zMhcg==}
'@tiptap/suggestion@2.26.2':
resolution: {integrity: sha512-BtigI3xOJQbdNh2OeKN5wQDI/EPGN4GXdpoMHhENZeXKrX6PvWNaqjyLVsV3QiLgv6P352nZWcgo51wPXY2JgQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
@@ -11612,177 +11588,165 @@ snapshots:
dependencies:
'@testing-library/dom': 10.4.0
'@tiptap/core@2.26.3(@tiptap/pm@2.26.1)':
'@tiptap/core@2.26.3(@tiptap/pm@2.26.3)':
dependencies:
'@tiptap/pm': 2.26.1
'@tiptap/pm': 2.26.3
'@tiptap/core@2.26.3(@tiptap/pm@3.6.6)':
dependencies:
'@tiptap/pm': 3.6.6
'@tiptap/extension-blockquote@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-blockquote@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-bold@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-bold@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-bubble-menu@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)':
'@tiptap/extension-bubble-menu@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
tippy.js: 6.3.7
'@tiptap/extension-bullet-list@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-bullet-list@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-character-count@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)':
'@tiptap/extension-character-count@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
'@tiptap/extension-code-block@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)':
'@tiptap/extension-code-block@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
'@tiptap/extension-code@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-code@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-collaboration@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))':
'@tiptap/extension-collaboration@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
y-prosemirror: 1.3.7(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
'@tiptap/extension-document@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-document@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-document@3.2.0(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-dropcursor@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
'@tiptap/extension-dropcursor@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)':
'@tiptap/extension-emoji@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)(@tiptap/suggestion@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3))(emojibase@16.0.0)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/extension-emoji@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)(@tiptap/suggestion@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1))(emojibase@16.0.0)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/suggestion': 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
'@tiptap/suggestion': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
emoji-regex: 10.5.0
emojibase-data: 15.3.2(emojibase@16.0.0)
is-emoji-supported: 0.0.5
transitivePeerDependencies:
- emojibase
'@tiptap/extension-floating-menu@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)':
'@tiptap/extension-floating-menu@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
tippy.js: 6.3.7
'@tiptap/extension-gapcursor@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)':
'@tiptap/extension-gapcursor@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
'@tiptap/extension-hard-break@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-hard-break@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-heading@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-heading@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-heading@3.4.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-history@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
'@tiptap/extension-history@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)':
'@tiptap/extension-horizontal-rule@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
'@tiptap/extension-horizontal-rule@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)':
'@tiptap/extension-image@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-image@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-italic@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-italic@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-list-item@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-list-item@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-mention@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)(@tiptap/suggestion@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
'@tiptap/suggestion': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
'@tiptap/extension-mention@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)(@tiptap/suggestion@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1))':
'@tiptap/extension-ordered-list@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/suggestion': 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-ordered-list@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-paragraph@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-paragraph@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-placeholder@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
'@tiptap/extension-placeholder@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)':
'@tiptap/extension-strike@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-strike@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-task-item@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
'@tiptap/extension-task-item@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)':
'@tiptap/extension-task-list@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-task-list@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-text-align@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-text-align@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-text-style@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-text-style@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-text@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-text@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/extension-underline@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-text@3.2.0(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
'@tiptap/html@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/extension-underline@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/html@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
zeed-dom: 0.15.1
'@tiptap/html@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@3.6.6))(@tiptap/pm@3.6.6)':
@@ -11791,7 +11755,7 @@ snapshots:
'@tiptap/pm': 3.6.6
zeed-dom: 0.15.1
'@tiptap/pm@2.26.1':
'@tiptap/pm@2.26.3':
dependencies:
prosemirror-changeset: 2.3.1
prosemirror-collab: 1.3.1
@@ -11833,12 +11797,12 @@ snapshots:
prosemirror-transform: 1.10.4
prosemirror-view: 1.40.0
'@tiptap/react@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
'@tiptap/react@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/extension-bubble-menu': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
'@tiptap/extension-floating-menu': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-bubble-menu': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
'@tiptap/extension-floating-menu': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
'@types/use-sync-external-store': 0.0.6
fast-deep-equal: 3.1.3
react: 18.3.1
@@ -11847,32 +11811,32 @@ snapshots:
'@tiptap/starter-kit@2.26.1':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/extension-blockquote': 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
'@tiptap/extension-bold': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
'@tiptap/extension-bullet-list': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
'@tiptap/extension-code': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
'@tiptap/extension-code-block': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
'@tiptap/extension-document': 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
'@tiptap/extension-dropcursor': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
'@tiptap/extension-gapcursor': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
'@tiptap/extension-hard-break': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
'@tiptap/extension-heading': 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
'@tiptap/extension-history': 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
'@tiptap/extension-horizontal-rule': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
'@tiptap/extension-italic': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
'@tiptap/extension-list-item': 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
'@tiptap/extension-ordered-list': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
'@tiptap/extension-paragraph': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
'@tiptap/extension-strike': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
'@tiptap/extension-text': 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
'@tiptap/extension-text-style': 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-blockquote': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-bold': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-bullet-list': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-code': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-code-block': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
'@tiptap/extension-document': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-dropcursor': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
'@tiptap/extension-gapcursor': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
'@tiptap/extension-hard-break': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-heading': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-history': 2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
'@tiptap/extension-horizontal-rule': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
'@tiptap/extension-italic': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-list-item': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-ordered-list': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-paragraph': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-strike': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-text': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/extension-text-style': 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/pm': 2.26.3
'@tiptap/suggestion@2.26.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)':
'@tiptap/suggestion@2.26.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/pm': 2.26.3
'@tokenizer/inflate@0.2.7':
dependencies:
@@ -15579,7 +15543,7 @@ snapshots:
neo-async@2.6.2: {}
next-themes@0.2.1(next@14.2.32(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
next-themes@0.2.1(next@14.2.32(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
next: 14.2.32(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
@@ -17292,9 +17256,9 @@ snapshots:
dependencies:
'@popperjs/core': 2.11.8
tiptap-markdown@0.8.10(@tiptap/core@2.26.3(@tiptap/pm@2.26.1)):
tiptap-markdown@0.8.10(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)):
dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.1)
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@types/markdown-it': 13.0.9
markdown-it: 14.1.0
markdown-it-task-lists: 2.1.1