[WIKI-480] feat: unique id extension (#8098)

This commit is contained in:
Vipin Chaudhary 2025-11-17 16:14:20 +05:30 committed by GitHub
parent ebab5e209f
commit bd0361de92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 433 additions and 3 deletions

View file

@ -99,6 +99,7 @@ ATTRIBUTES = {
"data-background-color",
"data-text-color",
"data-name",
"data-id",
# callout attributes
"data-icon-name",
"data-icon-color",

View file

@ -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<Props> = observer((props) => {
setIsSubmitting,
swrDescription,
workspaceSlug,
issueSequenceId,
} = props;
// states
const [localDescription, setLocalDescription] = useState<TFormData>({
@ -195,6 +200,7 @@ export const DescriptionInput: React.FC<Props> = observer((props) => {
editable={!disabled}
ref={editorRef}
id={entityId}
issueSequenceId={issueSequenceId}
disabledExtensions={disabledExtensions}
initialValue={localDescription.description_html ?? "<p></p>"}
value={swrDescription ?? null}

View file

@ -19,6 +19,7 @@ type RichTextEditorWrapperProps = MakeOptional<
workspaceSlug: string;
workspaceId: string;
projectId?: string;
issueSequenceId?: number;
} & (
| {
editable: false;

View file

@ -195,6 +195,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
<DescriptionInputLoader />
) : (
<DescriptionInput
issueSequenceId={issue.sequence_id}
containerClassName="-ml-3 border-none"
disabled={!isEditable}
editorRef={editorRef}

View file

@ -129,6 +129,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
/>
<DescriptionInput
issueSequenceId={issue.sequence_id}
containerClassName="-ml-3 border-none"
disabled={isArchived || !isEditable}
editorRef={editorRef}

View file

@ -128,6 +128,7 @@ export const PeekOverviewIssueDetails: FC<Props> = observer((props) => {
/>
<DescriptionInput
issueSequenceId={issue.sequence_id}
containerClassName="-ml-3 border-none"
disabled={disabled || isArchived}
editorRef={editorRef}

View file

@ -1 +1,3 @@
export enum ADDITIONAL_EXTENSIONS {}
export const ADDITIONAL_BLOCK_NODE_TYPES = [];

View file

@ -18,6 +18,7 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
fileHandler,
flaggedExtensions,
extendedEditorProps,
workItemIdentifier,
} = props;
const getExtensions = useCallback(() => {
@ -43,7 +44,12 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
{(editor) => (
<>
{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
<BlockMenu editor={editor} flaggedExtensions={flaggedExtensions} disabledExtensions={disabledExtensions} />
<BlockMenu
editor={editor}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
workItemIdentifier={workItemIdentifier}
/>
</>
)}
</EditorWrapper>

View file

@ -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<HTMLDivElement | null>(null);

View file

@ -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,
];

View file

@ -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")) {

View file

@ -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<UniqueIDOptions>({
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)];
},
});

View file

@ -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);
},
},
});
};

View file

@ -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);
};

View file

@ -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,
],

View file

@ -165,6 +165,7 @@ export type IEditorProps = {
tabIndex?: number;
value?: string | null;
extendedEditorProps: IEditorPropsExtended;
workItemIdentifier?: string | null;
};
export type ILiteTextEditorProps = IEditorProps;