From bd0361de92a93ad7d770af8f4d7b0f609a3a7d83 Mon Sep 17 00:00:00 2001 From: Vipin Chaudhary Date: Mon, 17 Nov 2025 16:14:20 +0530 Subject: [PATCH] [WIKI-480] feat: unique id extension (#8098) --- apps/api/plane/utils/content_validator.py | 1 + .../rich-text/description-input/root.tsx | 6 + .../components/editor/rich-text/editor.tsx | 1 + .../components/inbox/content/issue-root.tsx | 1 + .../issues/issue-detail/main-content.tsx | 1 + .../issues/peek-overview/issue-detail.tsx | 1 + .../editor/src/ce/constants/extensions.ts | 2 + .../components/editors/rich-text/editor.tsx | 8 +- .../src/core/components/menus/block-menu.tsx | 3 +- .../editor/src/core/constants/extension.ts | 26 +++ .../editor/src/core/extensions/extensions.ts | 7 + .../core/extensions/unique-id/extension.ts | 136 +++++++++++ .../src/core/extensions/unique-id/plugin.ts | 211 ++++++++++++++++++ .../src/core/extensions/unique-id/utils.ts | 28 +++ packages/editor/src/core/hooks/use-editor.ts | 3 +- packages/editor/src/core/types/editor.ts | 1 + 16 files changed, 433 insertions(+), 3 deletions(-) create mode 100644 packages/editor/src/core/extensions/unique-id/extension.ts create mode 100644 packages/editor/src/core/extensions/unique-id/plugin.ts create mode 100644 packages/editor/src/core/extensions/unique-id/utils.ts diff --git a/apps/api/plane/utils/content_validator.py b/apps/api/plane/utils/content_validator.py index caf740e54..9fe01ed42 100644 --- a/apps/api/plane/utils/content_validator.py +++ b/apps/api/plane/utils/content_validator.py @@ -99,6 +99,7 @@ ATTRIBUTES = { "data-background-color", "data-text-color", "data-name", + "data-id", # callout attributes "data-icon-name", "data-icon-color", diff --git a/apps/web/core/components/editor/rich-text/description-input/root.tsx b/apps/web/core/components/editor/rich-text/description-input/root.tsx index 4a7c8884a..13e05dc71 100644 --- a/apps/web/core/components/editor/rich-text/description-input/root.tsx +++ b/apps/web/core/components/editor/rich-text/description-input/root.tsx @@ -79,6 +79,10 @@ type Props = { * @description Workspace slug, this will be used to get the workspace details */ workspaceSlug: string; + /** + * @description Issue sequence id, this will be used to get the issue sequence id + */ + issueSequenceId?: number; }; /** @@ -100,6 +104,7 @@ export const DescriptionInput: React.FC = observer((props) => { setIsSubmitting, swrDescription, workspaceSlug, + issueSequenceId, } = props; // states const [localDescription, setLocalDescription] = useState({ @@ -195,6 +200,7 @@ export const DescriptionInput: React.FC = observer((props) => { editable={!disabled} ref={editorRef} id={entityId} + issueSequenceId={issueSequenceId} disabledExtensions={disabledExtensions} initialValue={localDescription.description_html ?? "

"} value={swrDescription ?? null} diff --git a/apps/web/core/components/editor/rich-text/editor.tsx b/apps/web/core/components/editor/rich-text/editor.tsx index 612522dcc..8595aa58e 100644 --- a/apps/web/core/components/editor/rich-text/editor.tsx +++ b/apps/web/core/components/editor/rich-text/editor.tsx @@ -19,6 +19,7 @@ type RichTextEditorWrapperProps = MakeOptional< workspaceSlug: string; workspaceId: string; projectId?: string; + issueSequenceId?: number; } & ( | { editable: false; diff --git a/apps/web/core/components/inbox/content/issue-root.tsx b/apps/web/core/components/inbox/content/issue-root.tsx index 5b6b6b6d8..51531cb7c 100644 --- a/apps/web/core/components/inbox/content/issue-root.tsx +++ b/apps/web/core/components/inbox/content/issue-root.tsx @@ -195,6 +195,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { ) : ( = observer((props) => { /> = observer((props) => { /> = (props) => { fileHandler, flaggedExtensions, extendedEditorProps, + workItemIdentifier, } = props; const getExtensions = useCallback(() => { @@ -43,7 +44,12 @@ const RichTextEditor: React.FC = (props) => { {(editor) => ( <> {editor && bubbleMenuEnabled && } - + )} diff --git a/packages/editor/src/core/components/menus/block-menu.tsx b/packages/editor/src/core/components/menus/block-menu.tsx index 014bc372c..5ad846339 100644 --- a/packages/editor/src/core/components/menus/block-menu.tsx +++ b/packages/editor/src/core/components/menus/block-menu.tsx @@ -24,6 +24,7 @@ type Props = { disabledExtensions?: IEditorProps["disabledExtensions"]; editor: Editor; flaggedExtensions?: IEditorProps["flaggedExtensions"]; + workItemIdentifier?: IEditorProps["workItemIdentifier"]; }; export type BlockMenuOption = { icon: LucideIcon; @@ -34,7 +35,7 @@ export type BlockMenuOption = { }; export const BlockMenu = (props: Props) => { - const { editor } = props; + const { editor, workItemIdentifier } = props; const [isOpen, setIsOpen] = useState(false); const [isAnimatedIn, setIsAnimatedIn] = useState(false); const menuRef = useRef(null); diff --git a/packages/editor/src/core/constants/extension.ts b/packages/editor/src/core/constants/extension.ts index eaec3e88f..369be0648 100644 --- a/packages/editor/src/core/constants/extension.ts +++ b/packages/editor/src/core/constants/extension.ts @@ -42,4 +42,30 @@ export enum CORE_EXTENSIONS { UTILITY = "utility", WORK_ITEM_EMBED = "issue-embed-component", EMOJI = "emoji", + UNIQUE_ID = "uniqueID", } + +export const BLOCK_NODE_TYPES = [ + // Basic block nodes + CORE_EXTENSIONS.PARAGRAPH, + CORE_EXTENSIONS.HEADING, + CORE_EXTENSIONS.BLOCKQUOTE, + CORE_EXTENSIONS.CODE_BLOCK, + CORE_EXTENSIONS.HORIZONTAL_RULE, + + // List nodes + CORE_EXTENSIONS.BULLET_LIST, + CORE_EXTENSIONS.ORDERED_LIST, + CORE_EXTENSIONS.LIST_ITEM, + CORE_EXTENSIONS.TASK_LIST, + CORE_EXTENSIONS.TASK_ITEM, + + // Table nodes + CORE_EXTENSIONS.TABLE, + + // Media and embed nodes + CORE_EXTENSIONS.IMAGE, + CORE_EXTENSIONS.CUSTOM_IMAGE, + CORE_EXTENSIONS.CALLOUT, + CORE_EXTENSIONS.WORK_ITEM_EMBED, +]; diff --git a/packages/editor/src/core/extensions/extensions.ts b/packages/editor/src/core/extensions/extensions.ts index 8df1c7ddf..855440df8 100644 --- a/packages/editor/src/core/extensions/extensions.ts +++ b/packages/editor/src/core/extensions/extensions.ts @@ -1,3 +1,4 @@ +import type { HocuspocusProvider } from "@hocuspocus/provider"; import type { Extensions } from "@tiptap/core"; import { CharacterCount } from "@tiptap/extension-character-count"; import TaskItem from "@tiptap/extension-task-item"; @@ -35,6 +36,7 @@ import { CustomImageExtension } from "./custom-image/extension"; import { EmojiExtension } from "./emoji/extension"; import { CustomPlaceholderExtension } from "./placeholder"; import { CustomStarterKitExtension } from "./starter-kit"; +import { UniqueID } from "./unique-id/extension"; type TArguments = Pick< IEditorProps, @@ -49,6 +51,7 @@ type TArguments = Pick< > & { enableHistory: boolean; editable: boolean; + provider: HocuspocusProvider | undefined; }; export const CoreEditorExtensions = (args: TArguments): Extensions => { @@ -63,6 +66,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { tabIndex, editable, extendedEditorProps, + provider, } = args; const extensions = [ @@ -119,6 +123,9 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { fileHandler, extendedEditorProps, }), + UniqueID.configure({ + provider, + }), ]; if (!disabledExtensions.includes("image")) { diff --git a/packages/editor/src/core/extensions/unique-id/extension.ts b/packages/editor/src/core/extensions/unique-id/extension.ts new file mode 100644 index 000000000..93950836b --- /dev/null +++ b/packages/editor/src/core/extensions/unique-id/extension.ts @@ -0,0 +1,136 @@ +import type { HocuspocusProvider } from "@hocuspocus/provider"; +import { Extension } from "@tiptap/core"; +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 { 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]; +export type UniqueIDGenerationContext = { + node: ProseMirrorNode; + pos: number; +}; +export const UniqueIDAttribute = "id"; +export const generateUniqueID = () => uuidv4(); + +export interface UniqueIDOptions { + /** + * The name of the attribute to add the unique ID to. + * @default "id" + */ + attributeName: string; + /** + * The types of nodes to add unique IDs to. + * @default [] + */ + types: string[]; + /** + * The function that generates the unique ID. By default, a UUID v4 is + * generated. However, you can provide your own function to generate the + * unique ID based on the node type and the position. + */ + generateUniqueID: (ctx: UniqueIDGenerationContext) => string; + /** + * Ignore some mutations, for example applied from other users through the collaboration plugin. + * + * @default null + */ + filterTransaction: ((transaction: Transaction) => boolean) | null; + /** + * Whether to update the document by adding unique IDs to the nodes. Set this + * property to `false` if the document is in `readonly` mode, is immutable, or + * you don't want it to be modified. + * + * @default true + */ + updateDocument: boolean; + /** + * The provider to use for the unique ID generation. + * @default null + */ + provider: HocuspocusProvider | undefined; +} + +export const UniqueID = Extension.create({ + name: CORE_EXTENSIONS.UNIQUE_ID, + + // we'll set a very high priority to make sure this runs first + // and is compatible with `appendTransaction` hooks of other extensions + priority: 10000, + + addOptions() { + return { + attributeName: "id", + types: COMBINED_BLOCK_NODE_TYPES, + generateUniqueID: () => uuidv4(), + filterTransaction: null, + updateDocument: true, + provider: undefined, + }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + [this.options.attributeName]: { + default: null, + parseHTML: (element) => element.getAttribute(`data-${this.options.attributeName}`), + renderHTML: (attributes) => { + if (!attributes[this.options.attributeName]) { + return {}; + } + + return { + [`data-${this.options.attributeName}`]: attributes[this.options.attributeName], + }; + }, + }, + }, + }, + ]; + }, + + // check initial content for missing ids + onCreate() { + if (!this.editor.isEditable) { + this.options.updateDocument = false; + } + + if (!this.options.updateDocument) { + return; + } + + const provider = this.options.provider; + + /** + * We need to handle collaboration a bit different here + * because we can't automatically add IDs when the provider is not yet synced + * otherwise we end up with empty paragraphs + */ + if (provider) { + // Check if provider is already synced + if (provider.isSynced) { + createIdsForView(this.editor.view, this.options); + } + // If not synced, the listener will be registered in the plugin + // and handled there with proper cleanup + } else { + return createIdsForView(this.editor.view, this.options); + } + }, + + addProseMirrorPlugins() { + if (!this.options.updateDocument) { + return []; + } + + return [createUniqueIDPlugin(this.options)]; + }, +}); diff --git a/packages/editor/src/core/extensions/unique-id/plugin.ts b/packages/editor/src/core/extensions/unique-id/plugin.ts new file mode 100644 index 000000000..5b0773adb --- /dev/null +++ b/packages/editor/src/core/extensions/unique-id/plugin.ts @@ -0,0 +1,211 @@ +import { combineTransactionSteps, findChildrenInRange, findDuplicates, getChangedRanges } from "@tiptap/core"; +import { Fragment, Slice } from "@tiptap/pm/model"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import type { Transaction } from "@tiptap/pm/state"; +// types +import type { UniqueIDOptions } from "./extension"; +// utils +import { createIdsForView } from "./utils"; + +export const createUniqueIDPlugin = (options: UniqueIDOptions) => { + let dragSourceElement: Element | null = null; + let transformPasted = false; + let syncHandler: (() => void) | null = null; + + return new Plugin({ + key: new PluginKey("uniqueID"), + appendTransaction: (transactions, oldState, newState) => { + const hasDocChanges = + transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc); + const filterTransactions = + options.filterTransaction && transactions.some((tr) => !options.filterTransaction?.(tr)); + + const isCollabTransaction = transactions.find((tr) => tr.getMeta("y-sync$")); + + if (isCollabTransaction) { + return; + } + + if (!hasDocChanges || filterTransactions) { + return; + } + + const { tr } = newState; + + const { types, attributeName, generateUniqueID } = options; + const transform = combineTransactionSteps(oldState.doc, transactions as Transaction[]); + + const { mapping } = transform; + + // get changed ranges based on the old state + const changes = getChangedRanges(transform); + + // Get all IDs from the entire document to check for duplicates globally + const allNodesInDoc: Array<{ node: ProseMirrorNode; pos: number }> = []; + newState.doc.descendants((node, pos) => { + if (types.includes(node.type.name)) { + allNodesInDoc.push({ node, pos }); + } + }); + const allIds = allNodesInDoc.map(({ node }) => node.attrs[attributeName]).filter((id) => id !== null); + const duplicatedIds = findDuplicates(allIds); + + changes.forEach(({ newRange }) => { + const newNodes = findChildrenInRange(newState.doc, newRange, (node) => types.includes(node.type.name)); + + newNodes.forEach(({ node, pos }) => { + // instead of checking `node.attrs[attributeName]` directly + // we look at the current state of the node within `tr.doc`. + // this helps to prevent adding new ids to the same node + // if the node changed multiple times within one transaction + const id = tr.doc.nodeAt(pos)?.attrs[attributeName]; + + if (id === null) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + [attributeName]: generateUniqueID({ node, pos }), + }); + + return; + } + + // check if the node doesn't exist in the old state + const { deleted } = mapping.invert().mapResult(pos); + + // If this is a new node (didn't exist in old state) and its ID is duplicated in the entire document + const newNode = deleted && duplicatedIds.includes(id); + + if (newNode) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + [attributeName]: generateUniqueID({ node, pos }), + }); + } + }); + }); + + if (!tr.steps.length) { + return; + } + + // `tr.setNodeMarkup` resets the stored marks + // so we'll restore them if they exist + tr.setStoredMarks(newState.tr.storedMarks); + + // Don't add ID generation to undo history + tr.setMeta("addToHistory", false); + + return tr; + }, + + // we register a global drag handler to track the current drag source element + view(view) { + const handleDragstart = (event: DragEvent) => { + dragSourceElement = view.dom.parentElement?.contains(event.target as Element) ? view.dom.parentElement : null; + }; + + window.addEventListener("dragstart", handleDragstart); + + // Handle provider sync listener for creating IDs when collaboration provider syncs + const provider = options.provider; + if (provider && !provider.isSynced) { + syncHandler = () => { + createIdsForView(view, options); + + // Clean up the listener after it runs + if (provider && syncHandler) { + provider.off("synced", syncHandler); + syncHandler = null; + } + }; + + provider.on("synced", syncHandler); + } + + return { + destroy() { + window.removeEventListener("dragstart", handleDragstart); + + // Clean up provider sync listener if it exists + if (provider && syncHandler) { + provider.off("synced", syncHandler); + syncHandler = null; + } + }, + }; + }, + + props: { + // `handleDOMEvents` is called before `transformPasted` + // so we can do some checks before + handleDOMEvents: { + // only create new ids for dropped content + // or dropped content while holding `alt` + // or content is dragged from another editor + drop: (view, event) => { + if (dragSourceElement !== view.dom.parentElement || event.dataTransfer?.effectAllowed === "copy") { + dragSourceElement = null; + transformPasted = true; + } + + return false; + }, + // always create new ids on pasted content + paste: () => { + transformPasted = true; + + return false; + }, + }, + + // we'll remove ids for every pasted node + // so we can create a new one within `appendTransaction` + transformPasted: (slice) => { + if (!transformPasted) { + return slice; + } + + const { types, attributeName } = options; + const removeId = (fragment: Fragment): Fragment => { + const list: ProseMirrorNode[] = []; + + fragment.forEach((node) => { + // don't touch text nodes + if (node.isText) { + list.push(node); + + return; + } + + // check for any other child nodes + if (!types.includes(node.type.name)) { + list.push(node.copy(removeId(node.content))); + + return; + } + + // remove id + const nodeWithoutId = node.type.create( + { + ...node.attrs, + [attributeName]: null, + }, + removeId(node.content), + node.marks + ); + + list.push(nodeWithoutId); + }); + + return Fragment.from(list); + }; + + // reset check + transformPasted = false; + + return new Slice(removeId(slice.content), slice.openStart, slice.openEnd); + }, + }, + }); +}; diff --git a/packages/editor/src/core/extensions/unique-id/utils.ts b/packages/editor/src/core/extensions/unique-id/utils.ts new file mode 100644 index 000000000..044a9dcea --- /dev/null +++ b/packages/editor/src/core/extensions/unique-id/utils.ts @@ -0,0 +1,28 @@ +import { findChildren } from "@tiptap/core"; +import type { EditorView } from "@tiptap/pm/view"; +// types +import type { UniqueIDOptions } from "./extension"; + +/** + * Utility function to create IDs for nodes that don't have them + */ +export const createIdsForView = (view: EditorView, options: UniqueIDOptions) => { + const { state } = view; + const { tr, doc } = state; + const { types, attributeName, generateUniqueID } = options; + const nodesWithoutId = findChildren( + doc, + (node) => types.includes(node.type.name) && node.attrs[attributeName] === null + ); + + nodesWithoutId.forEach(({ node, pos }) => { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + [attributeName]: generateUniqueID({ node, pos }), + }); + }); + + tr.setMeta("addToHistory", false); + + view.dispatch(tr); +}; diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 8dd174a19..db1966beb 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -39,8 +39,8 @@ export const useEditor = (props: TEditorHookProps) => { onEditorFocus, onTransaction, placeholder, - provider, tabIndex, + provider, value, } = props; @@ -69,6 +69,7 @@ export const useEditor = (props: TEditorHookProps) => { mentionHandler, placeholder, tabIndex, + provider, }), ...extensions, ], diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index a99870c05..27d538819 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -165,6 +165,7 @@ export type IEditorProps = { tabIndex?: number; value?: string | null; extendedEditorProps: IEditorPropsExtended; + workItemIdentifier?: string | null; }; export type ILiteTextEditorProps = IEditorProps;