regression: downgrade to tiptap v2 (#7982)

* chore: downgrade to tiptap v2

* fix: revert back to hocuspocus

* fix: collaboration events added

* fix: lock unlock issues

* fix: build errors

* fix: type errors

* fix: graceful shutdown

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
This commit is contained in:
Aaryan Khandelwal 2025-10-21 18:28:16 +05:30 committed by GitHub
parent 59022b6beb
commit 64781be7d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 2123 additions and 824 deletions

View file

@ -38,29 +38,31 @@
"@floating-ui/dom": "^1.7.1",
"@floating-ui/react": "^0.26.4",
"@headlessui/react": "^1.7.3",
"@hocuspocus/provider": "3.2.5",
"@hocuspocus/provider": "2.15.2",
"@plane/constants": "workspace:*",
"@plane/hooks": "workspace:*",
"@plane/types": "workspace:*",
"@plane/ui": "workspace:*",
"@plane/utils": "workspace:*",
"@tiptap/core": "catalog:",
"@tiptap/extension-blockquote": "^3.5.3",
"@tiptap/extension-collaboration": "^3.5.3",
"@tiptap/extension-emoji": "^3.5.3",
"@tiptap/extension-image": "^3.5.3",
"@tiptap/extension-list-item": "^3.5.3",
"@tiptap/extension-mention": "^3.5.3",
"@tiptap/extension-task-item": "^3.5.3",
"@tiptap/extension-task-list": "^3.5.3",
"@tiptap/extension-text-align": "^3.5.3",
"@tiptap/extension-text-style": "^3.5.3",
"@tiptap/extensions": "^3.5.3",
"@tiptap/extension-blockquote": "^2.22.3",
"@tiptap/extension-character-count": "^2.22.3",
"@tiptap/extension-collaboration": "^2.22.3",
"@tiptap/extension-emoji": "^2.22.3",
"@tiptap/extension-image": "^2.22.3",
"@tiptap/extension-list-item": "^2.22.3",
"@tiptap/extension-mention": "^2.22.3",
"@tiptap/extension-placeholder": "^2.22.3",
"@tiptap/extension-task-item": "^2.22.3",
"@tiptap/extension-task-list": "^2.22.3",
"@tiptap/extension-text-align": "^2.22.3",
"@tiptap/extension-text-style": "^2.22.3",
"@tiptap/extension-underline": "^2.22.3",
"@tiptap/html": "catalog:",
"@tiptap/pm": "^3.5.3",
"@tiptap/react": "^3.5.3",
"@tiptap/starter-kit": "^3.5.3",
"@tiptap/suggestion": "^3.5.3",
"@tiptap/pm": "^2.22.3",
"@tiptap/react": "^2.22.3",
"@tiptap/starter-kit": "^2.22.3",
"@tiptap/suggestion": "^2.22.3",
"emoji-regex": "^10.3.0",
"highlight.js": "^11.8.0",
"is-emoji-supported": "^0.0.5",
@ -70,7 +72,7 @@
"lucide-react": "catalog:",
"prosemirror-codemark": "^0.4.2",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.9.0",
"tiptap-markdown": "^0.8.10",
"uuid": "catalog:",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.15",

View file

@ -1,6 +1,5 @@
import { type Editor, isNodeSelection } from "@tiptap/core";
import { useEditorState } from "@tiptap/react";
import { BubbleMenu, type BubbleMenuProps } from "@tiptap/react/menus";
import { BubbleMenu, type BubbleMenuProps, useEditorState } from "@tiptap/react";
import { FC, useEffect, useState, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
@ -119,7 +118,10 @@ export const EditorBubbleMenu: FC<Props> = (props) => {
}
return true;
},
options: {
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
duration: [300, 0],
zIndex: 9,
onShow: () => {
if (editor.storage.link) {
editor.storage.link.isBubbleMenuOpen = true;
@ -134,13 +136,15 @@ export const EditorBubbleMenu: FC<Props> = (props) => {
editor.commands.removeActiveDropbarExtension("bubble-menu");
}, 0);
},
onHidden: () => {
if (editor.storage.link) {
editor.storage.link.isBubbleMenuOpen = false;
}
setTimeout(() => {
editor.commands.removeActiveDropbarExtension("bubble-menu");
}, 0);
},
},
// TODO: Migrate these to floating UI options
// tippyOptions: {
// moveTransition: "transform 0.15s ease-out",
// duration: [300, 0],
// zIndex: 9,
// },
};
useEffect(() => {

View file

@ -1,8 +1,91 @@
import { EPageAccess } from "@plane/constants";
import { TPage } from "@plane/types";
import { CreatePayload, BaseActionPayload } from "@/types";
// Define all payload types for each event.
export type ArchivedPayload = CreatePayload<{ archived_at: string | null }>;
export type UnarchivedPayload = BaseActionPayload;
export type LockedPayload = CreatePayload<{ is_locked: boolean }>;
export type UnlockedPayload = BaseActionPayload;
export type MadePublicPayload = CreatePayload<{ access: EPageAccess }>;
export type MadePrivatePayload = CreatePayload<{ access: EPageAccess }>;
export type DeletedPayload = CreatePayload<{ deleted_at: Date | null }>;
export type DuplicatedPayload = CreatePayload<{ new_page_id: string }>;
export type PropertyUpdatedPayload = CreatePayload<Partial<TPage>>;
export type MovedPayload = CreatePayload<{
new_project_id: string;
new_page_id: string;
}>;
export type RestoredPayload = CreatePayload<{ deleted_page_ids?: string[] }>;
export type ErrorPayload = CreatePayload<{
error_message: string;
error_type: "fetch" | "store";
error_code?: "content_too_large" | "page_locked" | "page_archived";
should_disconnect?: boolean;
}>;
// Enhanced DocumentCollaborativeEvents with payload types.
// Both the client name and server name are defined, and we add a "payloadType" property
// so that we can later derive a mapping from client event to payload type.
export const DocumentCollaborativeEvents = {
lock: { client: "locked", server: "lock" },
unlock: { client: "unlocked", server: "unlock" },
archive: { client: "archived", server: "archive" },
unarchive: { client: "unarchived", server: "unarchive" },
"make-public": { client: "made-public", server: "make-public" },
"make-private": { client: "made-private", server: "make-private" },
lock: {
client: "locked",
server: "lock",
payloadType: {} as LockedPayload,
},
unlock: {
client: "unlocked",
server: "unlock",
payloadType: {} as UnlockedPayload,
},
archive: {
client: "archived",
server: "archive",
payloadType: {} as ArchivedPayload,
},
unarchive: {
client: "unarchived",
server: "unarchive",
payloadType: {} as UnarchivedPayload,
},
"make-public": {
client: "made-public",
server: "make-public",
payloadType: {} as MadePublicPayload,
},
"make-private": {
client: "made-private",
server: "make-private",
payloadType: {} as MadePrivatePayload,
},
delete: {
client: "deleted",
server: "delete",
payloadType: {} as DeletedPayload,
},
move: {
client: "moved",
server: "move",
payloadType: {} as MovedPayload,
},
duplicate: {
client: "duplicated",
server: "duplicate",
payloadType: {} as DuplicatedPayload,
},
property_update: {
client: "property_updated",
server: "property_update",
payloadType: {} as PropertyUpdatedPayload,
},
restore: {
client: "restored",
server: "restore",
payloadType: {} as RestoredPayload,
},
error: {
client: "error",
server: "error",
payloadType: {} as ErrorPayload,
},
} as const;

View file

@ -56,7 +56,7 @@ export const CodeBlockComponent: React.FC<Props> = ({ node }) => {
</Tooltip>
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-4 my-2">
<NodeViewContent<"code"> as="code" className="whitespace-pre-wrap" />
<NodeViewContent as="code" className="whitespace-pre-wrap" />
</pre>
</NodeViewWrapper>
);

View file

@ -1,6 +1,7 @@
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { TextStyle } from "@tiptap/extension-text-style";
import { Underline } from "@tiptap/extension-underline";
// plane editor imports
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
// extensions
@ -30,6 +31,7 @@ export const CoreEditorExtensionsWithoutProps = [
CustomLinkExtension,
ImageExtensionConfig,
CustomImageExtensionConfig,
Underline,
TextStyle,
TaskList.configure({
HTMLAttributes: {

View file

@ -50,6 +50,32 @@ type LinkOptions = {
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_LINK]: {
/**
* Set a link mark
*/
setLink: (attributes: {
href: string;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
/**
* Toggle a link mark
*/
toggleLink: (attributes: {
href: string;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
/**
* Unset a link mark
*/
unsetLink: () => ReturnType;
};
}
interface Storage {
[CORE_EXTENSIONS.CUSTOM_LINK]: CustomLinkStorage;
}

View file

@ -1,4 +1,4 @@
import type { EmojiOptions } from "@tiptap/extension-emoji";
import type { EmojiOptions, EmojiStorage } from "@tiptap/extension-emoji";
import { ReactRenderer, type Editor } from "@tiptap/react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
@ -12,7 +12,7 @@ const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"];
export const emojiSuggestion: EmojiOptions["suggestion"] = {
items: ({ editor, query }: { editor: Editor; query: string }): EmojiItem[] => {
const { emojis, isSupported } = editor.storage.emoji;
const { emojis, isSupported } = editor.storage.emoji as EmojiStorage;
const filteredEmojis = emojis.filter((emoji) => {
const hasEmoji = !!emoji?.emoji;
const hasFallbackImage = !!emoji?.fallbackImage;
@ -79,7 +79,7 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = {
component.updateProps(props);
if (!props.clientRect) return;
cleanup();
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element).cleanup;
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element as HTMLElement).cleanup;
},
onKeyDown: ({ event }) => {
if ([...DROPDOWN_NAVIGATION_KEYS, "Escape"].includes(event.key)) {

View file

@ -1,8 +1,9 @@
import { Extensions } from "@tiptap/core";
import { CharacterCount } from "@tiptap/extension-character-count";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { TextStyle } from "@tiptap/extension-text-style";
import { CharacterCount } from "@tiptap/extensions";
import { Underline } from "@tiptap/extension-underline";
import { Markdown } from "tiptap-markdown";
// extensions
import {
@ -75,6 +76,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
ListKeymap({ tabIndex }),
CustomLinkExtension,
CustomTypographyExtension,
Underline,
TextStyle,
TaskList.configure({
HTMLAttributes: {

View file

@ -52,7 +52,6 @@ export const HeadingListExtension = Extension.create<unknown, HeadingExtensionSt
this.editor.emit("update", {
editor: this.editor,
transaction: newState.tr,
appendedTransactions: [],
});
return null;
@ -61,4 +60,8 @@ export const HeadingListExtension = Extension.create<unknown, HeadingExtensionSt
return [plugin];
},
getHeadings() {
return this.storage.headings;
},
});

View file

@ -48,7 +48,7 @@ export const renderMentionsDropdown =
component.updateProps(props);
if (!props.clientRect) return;
cleanup();
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element).cleanup;
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element as HTMLElement).cleanup;
},
onKeyDown: ({ event }) => {
if ([...DROPDOWN_NAVIGATION_KEYS, "Escape"].includes(event.key)) {

View file

@ -1,4 +1,4 @@
import { Placeholder } from "@tiptap/extensions";
import { Placeholder } from "@tiptap/extension-placeholder";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// types

View file

@ -27,8 +27,6 @@ export const CustomStarterKitExtension = (args: TArgs) => {
codeBlock: false,
horizontalRule: false,
blockquote: false,
link: false,
listKeymap: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
@ -43,6 +41,6 @@ export const CustomStarterKitExtension = (args: TArgs) => {
class:
"text-custom-text-300 transition-all motion-reduce:transition-none motion-reduce:hover:transform-none duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]",
},
...(enableHistory ? {} : { undoRedo: false }),
...(enableHistory ? {} : { history: false }),
});
};

View file

@ -94,11 +94,8 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
?.chain()
.setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true)
.setMeta(CORE_EDITOR_META.INTENTIONAL_DELETION, true)
.setContent(content, {
emitUpdate,
parseOptions: {
preserveWhitespace: true,
},
.setContent(content, emitUpdate, {
preserveWhitespace: true,
})
.run();
},

View file

@ -93,11 +93,8 @@ export const useEditor = (props: TEditorHookProps) => {
const { uploadInProgress: isUploadInProgress } = editor.storage.utility;
if (!editor.isDestroyed && !isUploadInProgress) {
try {
editor.commands.setContent(value, {
emitUpdate: false,
parseOptions: {
preserveWhitespace: true,
},
editor.commands.setContent(value, false, {
preserveWhitespace: true,
});
if (editor.state.selection) {
const docLength = editor.state.doc.content.size;

View file

@ -57,7 +57,6 @@ export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHand
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
if (!nodeFileSetDetails || !src) return;
try {
// @ts-expect-error add proper types for storage
editor.storage[nodeType]?.[nodeFileSetDetails.fileSetName]?.set(src, true);
// update assets list storage value
editor.commands.updateAssetsList?.({

View file

@ -63,7 +63,6 @@ export const TrackFileRestorationPlugin = (editor: Editor, restoreHandler: TFile
const src = node.attrs.src;
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
if (!nodeFileSetDetails) return;
// @ts-expect-error add proper types for storage
const extensionFileSetStorage = editor.storage[nodeType]?.[nodeFileSetDetails.fileSetName];
const wasDeleted = extensionFileSetStorage?.get(src);
if (!nodeFileSetDetails || !src) return;

View file

@ -9,7 +9,6 @@ export const MarkdownClipboardPlugin = (editor: Editor): Plugin =>
key: new PluginKey("markdownClipboard"),
props: {
clipboardTextSerializer: (slice) => {
// @ts-expect-error tiptap-markdown types are not updated
const markdownSerializer = editor.storage.markdown.serializer;
const isTableRow = slice.content.firstChild?.type?.name === CORE_EXTENSIONS.TABLE_ROW;
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;

View file

@ -1,10 +1,87 @@
import { DocumentCollaborativeEvents } from "@/constants/document-collaborative-events";
export type TDocumentEventKey = keyof typeof DocumentCollaborativeEvents;
export type TDocumentEventsClient = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["client"];
export type TDocumentEventsServer = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["server"];
// Base type for all action payloads
export type BaseActionPayload = {
user_id?: string;
};
// Generic type for creating specific payloads
export type CreatePayload<T = Record<string, never>> = BaseActionPayload & T;
export type TDocumentEventEmitter = {
on: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
off: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
};
export type TDocumentEventKey = keyof typeof DocumentCollaborativeEvents;
export type TDocumentEventsClient = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["client"];
export type TDocumentEventsServer = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["server"];
// In this version, our union of all events (the client names) is:
export type TAllEventTypes = TDocumentEventsClient;
// Create a mapping from each client event to its payload type using key remapping.
export type EventToPayloadMap = {
[K in keyof typeof DocumentCollaborativeEvents as (typeof DocumentCollaborativeEvents)[K]["client"]]: (typeof DocumentCollaborativeEvents)[K]["payloadType"];
};
// Common fields for every realtime event
export type CommonRealtimeFields = {
affectedPages: {
currentPage: string;
parentPage: string | null;
descendantPages: string[];
};
workspace_slug: string;
project_id?: string;
teamspace_id?: string;
user_id: string;
timestamp: string;
};
// Helper function to create a realtime event in a typesafe way.
export function createRealtimeEvent<T extends keyof EventToPayloadMap>(
opts: ApiServerPayload<T>
): CommonRealtimeFields & BroadcastedEvent<T> {
return {
affectedPages: {
currentPage: opts.page_id || "",
parentPage: opts.parent_id || null,
descendantPages: opts.descendants_ids || [],
},
workspace_slug: opts.workspace_slug,
project_id: opts.project_id || "",
teamspace_id: opts.teamspace_id || "",
user_id: opts.user_id,
timestamp: new Date().toISOString(),
action: opts.action,
data: opts.data,
};
}
export type ApiServerPayload<T extends keyof EventToPayloadMap> = {
action: T;
descendants_ids: string[];
page_id?: string;
parent_id?: string;
data: EventToPayloadMap[T];
project_id?: string;
teamspace_id?: string;
workspace_slug: string;
user_id: string;
};
// Create a discriminated union for broadcast payloads.
// For every key in EventToPayloadMap, we make a union member with the common fields.
export type BroadcastPayloadUnion = {
[K in keyof EventToPayloadMap]: ApiServerPayload<K>;
}[keyof EventToPayloadMap];
export type BroadcastedEventUnion = {
[K in keyof EventToPayloadMap]: BroadcastedEvent<K>;
}[keyof EventToPayloadMap];
export type BroadcastedEvent<T extends keyof EventToPayloadMap = keyof EventToPayloadMap> = CommonRealtimeFields & {
action: T;
data: EventToPayloadMap[T];
};