fix: rendering node views reliably

This commit is contained in:
Palanikannan M
2025-12-01 16:15:33 +05:30
parent 8db95d9ec0
commit 202336dd9c
16 changed files with 61 additions and 28 deletions

View File

@@ -46,7 +46,6 @@ export const useEditorMention = (args: TArgs) => {
name={user.member__display_name} name={user.member__display_name}
/> />
), ),
id: user.member__id,
entity_identifier: user.member__id, entity_identifier: user.member__id,
entity_name: "user_mention", entity_name: "user_mention",
title: user.member__display_name, title: user.member__display_name,

View File

@@ -69,3 +69,5 @@ export const BLOCK_NODE_TYPES = [
CORE_EXTENSIONS.CALLOUT, CORE_EXTENSIONS.CALLOUT,
CORE_EXTENSIONS.WORK_ITEM_EMBED, CORE_EXTENSIONS.WORK_ITEM_EMBED,
]; ];
export const INLINE_NODE_TYPES = [CORE_EXTENSIONS.MENTION];

View File

@@ -1,6 +1,6 @@
import type { NodeViewProps } from "@tiptap/react"; import type { NodeViewProps } from "@tiptap/react";
import { NodeViewContent, NodeViewWrapper } from "@tiptap/react"; import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
import React, { useState } from "react"; import { useState } from "react";
// constants // constants
import { COLORS_LIST } from "@/constants/common"; import { COLORS_LIST } from "@/constants/common";
// local components // local components
@@ -33,6 +33,7 @@ export function CustomCalloutBlock(props: CustomCalloutNodeViewProps) {
style={{ style={{
backgroundColor: activeBackgroundColor, backgroundColor: activeBackgroundColor,
}} }}
key={`callout-block-${node.attrs.id}`}
> >
<CalloutBlockLogoSelector <CalloutBlockLogoSelector
blockAttributes={node.attrs} blockAttributes={node.attrs}

View File

@@ -1,4 +1,5 @@
import type { Node as ProseMirrorNode } from "@tiptap/core"; import type { Node as ProseMirrorNode } from "@tiptap/core";
import type { TBlockNodeBaseAttributes } from "../unique-id/types";
export enum ECalloutAttributeNames { export enum ECalloutAttributeNames {
ICON_COLOR = "data-icon-color", ICON_COLOR = "data-icon-color",
@@ -20,7 +21,7 @@ export type TCalloutBlockEmojiAttributes = {
[ECalloutAttributeNames.EMOJI_URL]: string | undefined; [ECalloutAttributeNames.EMOJI_URL]: string | undefined;
}; };
export type TCalloutBlockAttributes = { export type TCalloutBlockAttributes = TBlockNodeBaseAttributes & {
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji" | "icon"; [ECalloutAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
[ECalloutAttributeNames.BACKGROUND]: string | undefined; [ECalloutAttributeNames.BACKGROUND]: string | undefined;
[ECalloutAttributeNames.BLOCK_TYPE]: "callout-component"; [ECalloutAttributeNames.BLOCK_TYPE]: "callout-component";

View File

@@ -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 { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import ts from "highlight.js/lib/languages/typescript"; import ts from "highlight.js/lib/languages/typescript";
import { common, createLowlight } from "lowlight"; import { common, createLowlight } from "lowlight";
@@ -8,16 +8,20 @@ import { useState } from "react";
import { Tooltip } from "@plane/ui"; import { Tooltip } from "@plane/ui";
// plane utils // plane utils
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// types
import type { TCodeBlockAttributes } from "./types";
// we just have ts support for now // we just have ts support for now
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
lowlight.register("ts", ts); lowlight.register("ts", ts);
type Props = { export type CodeBlockNodeViewProps = NodeViewProps & {
node: ProseMirrorNode; node: NodeViewProps["node"] & {
attrs: TCodeBlockAttributes;
};
}; };
export function CodeBlockComponent({ node }: Props) { export function CodeBlockComponent({ node }: CodeBlockNodeViewProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
@@ -33,7 +37,7 @@ export function CodeBlockComponent({ node }: Props) {
}; };
return ( 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"> <Tooltip tooltipContent="Copy code">
<button <button
type="button" type="button"

View File

@@ -5,13 +5,16 @@ import { common, createLowlight } from "lowlight";
// components // components
import { CodeBlockLowlight } from "./code-block-lowlight"; import { CodeBlockLowlight } from "./code-block-lowlight";
import { CodeBlockComponent } from "./code-block-node-view"; import { CodeBlockComponent } from "./code-block-node-view";
import type { CodeBlockNodeViewProps } from "./code-block-node-view";
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
lowlight.register("ts", ts); lowlight.register("ts", ts);
export const CustomCodeBlockExtension = CodeBlockLowlight.extend({ export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
addNodeView() { addNodeView() {
return ReactNodeViewRenderer(CodeBlockComponent); return ReactNodeViewRenderer((props) => (
<CodeBlockComponent {...props} node={props.node as CodeBlockNodeViewProps["node"]} />
));
}, },
addKeyboardShortcuts() { addKeyboardShortcuts() {

View File

@@ -0,0 +1,5 @@
import type { TBlockNodeBaseAttributes } from "../unique-id/types";
export type TCodeBlockAttributes = TBlockNodeBaseAttributes & {
language: string | null;
};

View File

@@ -122,7 +122,7 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) {
const shouldShowBlock = (isUploaded || imageFromFileSystem) && !failedToLoadImage; const shouldShowBlock = (isUploaded || imageFromFileSystem) && !failedToLoadImage;
return ( return (
<NodeViewWrapper> <NodeViewWrapper key={`image-block-${node.attrs.id}`}>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}> <div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{shouldShowBlock && !hasDuplicationFailed ? ( {shouldShowBlock && !hasDuplicationFailed ? (
<CustomImageBlock <CustomImageBlock

View File

@@ -1,6 +1,7 @@
import type { Node } from "@tiptap/core"; import type { Node } from "@tiptap/core";
// types // types
import type { TFileHandler } from "@/types"; import type { TFileHandler } from "@/types";
import type { TBlockNodeBaseAttributes } from "../unique-id/types";
export enum ECustomImageAttributeNames { export enum ECustomImageAttributeNames {
ID = "id", ID = "id",
@@ -32,8 +33,7 @@ export enum ECustomImageStatus {
DUPLICATION_FAILED = "duplication-failed", DUPLICATION_FAILED = "duplication-failed",
} }
export type TCustomImageAttributes = { export type TCustomImageAttributes = TBlockNodeBaseAttributes & {
[ECustomImageAttributeNames.ID]: string | null;
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null; [ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
[ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null; [ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null;
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null; [ECustomImageAttributeNames.ASPECT_RATIO]: number | null;

View File

@@ -1,5 +1,7 @@
import type { NodeViewProps } from "@tiptap/react"; import type { NodeViewProps } from "@tiptap/react";
import { NodeViewWrapper } from "@tiptap/react"; import { NodeViewWrapper } from "@tiptap/react";
import { useMemo } from "react";
import { v4 as uuidv4 } from "uuid";
// extension config // extension config
import type { TMentionExtensionOptions } from "./extension-config"; import type { TMentionExtensionOptions } from "./extension-config";
// extension types // extension types
@@ -19,7 +21,7 @@ export function MentionNodeView(props: MentionNodeViewProps) {
} = props; } = props;
return ( return (
<NodeViewWrapper className="mention-component inline w-fit"> <NodeViewWrapper className="mention-component inline w-fit" key={`mention-${attrs.id}`}>
{(extension.options as TMentionExtensionOptions).renderComponent({ {(extension.options as TMentionExtensionOptions).renderComponent({
entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER] ?? "", entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER] ?? "",
entity_name: attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention", entity_name: attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention",

View File

@@ -31,11 +31,9 @@ export const MentionsListDropdown = forwardRef(function MentionsListDropdown(pro
(sectionIndex: number, itemIndex: number) => { (sectionIndex: number, itemIndex: number) => {
try { try {
const item = sections?.[sectionIndex]?.items?.[itemIndex]; const item = sections?.[sectionIndex]?.items?.[itemIndex];
const transactionId = uuidv4();
if (item) { if (item) {
command({ command({
...item, ...item,
id: transactionId,
}); });
} }
} catch (error) { } catch (error) {

View File

@@ -4,13 +4,12 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import type { Transaction } from "@tiptap/pm/state"; import type { Transaction } from "@tiptap/pm/state";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
// constants // 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 { ADDITIONAL_BLOCK_NODE_TYPES } from "@/plane-editor/constants/extensions";
import { createUniqueIDPlugin } from "./plugin"; import { createUniqueIDPlugin } from "./plugin";
import { createIdsForView } from "./utils"; 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 = { export type UniqueIDGenerationContext = {
node: ProseMirrorNode; node: ProseMirrorNode;
pos: number; pos: number;

View File

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

View File

@@ -2,6 +2,7 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react"; import type { NodeViewProps } from "@tiptap/react";
// local imports // local imports
import { WorkItemEmbedExtensionConfig } from "./extension-config"; import { WorkItemEmbedExtensionConfig } from "./extension-config";
import type { TWorkItemEmbedAttributes } from "./types";
type Props = { type Props = {
widgetCallback: ({ widgetCallback: ({
@@ -18,15 +19,18 @@ type Props = {
export function WorkItemEmbedExtension(props: Props) { export function WorkItemEmbedExtension(props: Props) {
return WorkItemEmbedExtensionConfig.extend({ return WorkItemEmbedExtensionConfig.extend({
addNodeView() { addNodeView() {
return ReactNodeViewRenderer((issueProps: NodeViewProps) => ( return ReactNodeViewRenderer((issueProps: NodeViewProps) => {
<NodeViewWrapper> const attrs = issueProps.node.attrs as TWorkItemEmbedAttributes;
return (
<NodeViewWrapper key={`work-item-embed-${attrs.id}`}>
{props.widgetCallback({ {props.widgetCallback({
issueId: issueProps.node.attrs.entity_identifier, issueId: attrs.entity_identifier!,
projectId: issueProps.node.attrs.project_identifier, projectId: attrs.project_identifier,
workspaceSlug: issueProps.node.attrs.workspace_identifier, workspaceSlug: attrs.workspace_identifier,
})} })}
</NodeViewWrapper> </NodeViewWrapper>
)); );
});
}, },
}); });
} }

View File

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

View File

@@ -5,7 +5,7 @@ export type TMentionSuggestion = {
entity_identifier: string; entity_identifier: string;
entity_name: TSearchEntities; entity_name: TSearchEntities;
icon: React.ReactNode; icon: React.ReactNode;
id: string; id?: string | null;
subTitle?: string; subTitle?: string;
title: string; title: string;
}; };