fix: node view renders (#8559)

* fix node renders

* fix handlers

* fix: duplicate id
This commit is contained in:
M. Palanikannan
2026-01-23 13:47:49 +05:30
committed by GitHub
parent 57806f9bd5
commit 20e266c9bb
10 changed files with 106 additions and 56 deletions

View File

@@ -29,13 +29,14 @@ export function CustomCalloutBlock(props: CustomCalloutNodeViewProps) {
return (
<NodeViewWrapper
key={node.attrs[ECalloutAttributeNames.ID]}
className="editor-callout-component group/callout-node relative bg-layer-3 rounded-lg text-primary p-4 my-2 flex items-start gap-4 transition-colors duration-500 break-words"
style={{
backgroundColor: activeBackgroundColor,
}}
>
<CalloutBlockLogoSelector
key={node.attrs["id"]}
key={node.attrs[ECalloutAttributeNames.ID]}
blockAttributes={node.attrs}
disabled={!editor.isEditable}
isOpen={isEmojiPickerOpen}

View File

@@ -1,6 +1,7 @@
import type { Node as ProseMirrorNode } from "@tiptap/core";
export enum ECalloutAttributeNames {
ID = "id",
ICON_COLOR = "data-icon-color",
ICON_NAME = "data-icon-name",
EMOJI_UNICODE = "data-emoji-unicode",
@@ -21,6 +22,7 @@ export type TCalloutBlockEmojiAttributes = {
};
export type TCalloutBlockAttributes = {
[ECalloutAttributeNames.ID]: string | null;
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
[ECalloutAttributeNames.BACKGROUND]: string | undefined;
[ECalloutAttributeNames.BLOCK_TYPE]: "callout-component";

View File

@@ -6,6 +6,7 @@ import type { TCalloutBlockAttributes, TCalloutBlockEmojiAttributes, TCalloutBlo
import { ECalloutAttributeNames } from "./types";
export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = {
[ECalloutAttributeNames.ID]: null,
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji",
[ECalloutAttributeNames.ICON_COLOR]: undefined,
[ECalloutAttributeNames.ICON_NAME]: undefined,
@@ -31,7 +32,7 @@ export const getStoredLogo = (): TStoredLogoValue => {
if (storedData) {
let parsedData: TLogoProps;
try {
parsedData = JSON.parse(storedData);
parsedData = JSON.parse(storedData) as TLogoProps;
} catch (error) {
console.error(`Error parsing stored callout logo, stored value- ${storedData}`, error);
localStorage.removeItem("editor-calloutComponent-logo");

View File

@@ -9,6 +9,9 @@ import { CopyIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
// plane utils
import { cn } from "@plane/utils";
// types
import type { TCodeBlockAttributes } from "./types";
import { ECodeBlockAttributeNames } from "./types";
// we just have ts support for now
const lowlight = createLowlight(common);
@@ -20,6 +23,8 @@ type Props = {
export function CodeBlockComponent({ node }: Props) {
const [copied, setCopied] = useState(false);
// derived values
const attrs = node.attrs as TCodeBlockAttributes;
const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
try {
@@ -34,7 +39,7 @@ export function CodeBlockComponent({ node }: Props) {
};
return (
<NodeViewWrapper className="code-block relative group/code">
<NodeViewWrapper key={attrs[ECodeBlockAttributeNames.ID]} className="code-block relative group/code">
<Tooltip tooltipContent="Copy code">
<button
type="button"
@@ -44,7 +49,7 @@ export function CodeBlockComponent({ node }: Props) {
"bg-success-subtle hover:bg-success-subtle-1 active:bg-success-subtle-1": copied,
}
)}
onClick={copyToClipboard}
onClick={(e) => void copyToClipboard(e)}
>
{copied ? (
<CheckIcon className="h-3 w-3 text-success-primary" strokeWidth={3} />

View File

@@ -0,0 +1,9 @@
export enum ECodeBlockAttributeNames {
ID = "id",
LANGUAGE = "language",
}
export type TCodeBlockAttributes = {
[ECodeBlockAttributeNames.ID]: string | null;
[ECodeBlockAttributeNames.LANGUAGE]: string | null;
};

View File

@@ -3,6 +3,7 @@ import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from
// plane imports
import { cn } from "@plane/utils";
// local imports
import { ECustomImageAttributeNames } from "../types";
import type { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
import { ensurePixelString, getImageBlockId, isImageDuplicating } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
@@ -59,7 +60,7 @@ export function CustomImageBlock(props: CustomImageBlockProps) {
const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false);
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
// extension options
const isTouchDevice = !!editor.storage.utility.isTouchDevice;
const isTouchDevice = !!(editor.storage.utility as { isTouchDevice?: boolean } | undefined)?.isTouchDevice;
const updateAttributesSafely = useCallback(
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
@@ -218,7 +219,11 @@ export function CustomImageBlock(props: CustomImageBlockProps) {
return (
<div
id={getImageBlockId(node.attrs.id ?? "")}
id={
node.attrs[ECustomImageAttributeNames.ID]
? getImageBlockId(node.attrs[ECustomImageAttributeNames.ID])
: undefined
}
className={cn("w-fit max-w-full transition-all", {
"ml-[50%] -translate-x-1/2": nodeAlignment === "center",
"ml-[100%] -translate-x-full": nodeAlignment === "right",
@@ -239,42 +244,45 @@ export function CustomImageBlock(props: CustomImageBlockProps) {
<img
ref={imageRef}
src={displayedImageSrc}
alt=""
onLoad={handleImageLoad}
onError={async (e) => {
// for old image extension this command doesn't exist or if the image failed to load for the first time
if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
setFailedToLoadImage(true);
return;
}
onError={(e) =>
void (async () => {
// for old image extension this command doesn't exist or if the image failed to load for the first time
if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
setFailedToLoadImage(true);
return;
}
try {
setHasErroredOnFirstLoad(true);
// this is a type error from tiptap, don't remove await until it's fixed
if (!imgNodeSrc) {
throw new Error("No source image to restore from");
try {
setHasErroredOnFirstLoad(true);
// this is a type error from tiptap, don't remove await until it's fixed
if (!imgNodeSrc) {
throw new Error("No source image to restore from");
}
await extension.options.restoreImage?.(imgNodeSrc);
if (!imageRef.current) {
throw new Error("Image reference not found");
}
if (!resolvedImageSrc) {
throw new Error("No resolved image source available");
}
if (isTouchDevice) {
const refreshedSrc = await extension.options.getImageSource?.(imgNodeSrc);
imageRef.current.src = refreshedSrc;
} else {
imageRef.current.src = resolvedImageSrc;
}
} catch (error) {
// if the image failed to even restore, then show the error state
setFailedToLoadImage(true);
console.error("Error while loading image", error);
} finally {
setHasErroredOnFirstLoad(false);
setHasTriedRestoringImageOnce(true);
}
await extension.options.restoreImage?.(imgNodeSrc);
if (!imageRef.current) {
throw new Error("Image reference not found");
}
if (!resolvedImageSrc) {
throw new Error("No resolved image source available");
}
if (isTouchDevice) {
const refreshedSrc = await extension.options.getImageSource?.(imgNodeSrc);
imageRef.current.src = refreshedSrc;
} else {
imageRef.current.src = resolvedImageSrc;
}
} catch {
// if the image failed to even restore, then show the error state
setFailedToLoadImage(true);
console.error("Error while loading image", e);
} finally {
setHasErroredOnFirstLoad(false);
setHasTriedRestoringImageOnce(true);
}
}}
})()
}
width={size.width}
className={cn("image-component block rounded-md", {
// hide the image while the background calculations of the image loader are in progress (to avoid flickering) and show the loader until then
@@ -287,7 +295,9 @@ export function CustomImageBlock(props: CustomImageBlockProps) {
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
}}
/>
{showUploadStatus && node.attrs.id && <ImageUploadStatus editor={editor} nodeId={node.attrs.id} />}
{showUploadStatus && node.attrs[ECustomImageAttributeNames.ID] && (
<ImageUploadStatus editor={editor} nodeId={node.attrs[ECustomImageAttributeNames.ID]} />
)}
{showImageToolbar && (
<ImageToolbarRoot
alignment={nodeAlignment ?? "left"}

View File

@@ -3,7 +3,7 @@ import type { NodeViewProps } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
// local imports
import type { CustomImageExtensionType, TCustomImageAttributes } from "../types";
import { ECustomImageStatus } from "../types";
import { ECustomImageAttributeNames, ECustomImageStatus } from "../types";
import { hasImageDuplicationFailed } from "../utils";
import { CustomImageBlock } from "./block";
import { CustomImageUploader } from "./uploader";
@@ -71,8 +71,9 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) {
setFailedToLoadImage(true);
}
};
getImageSource();
}, [imgNodeSrc, extension.options]);
void getImageSource();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [imgNodeSrc, extension.options.getImageSource, extension.options.getImageDownloadSource]);
useEffect(() => {
const handleDuplication = async () => {
@@ -106,7 +107,8 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) {
}
};
handleDuplication();
void handleDuplication();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, imgNodeSrc, extension.options.duplicateImage, updateAttributes]);
useEffect(() => {
@@ -129,7 +131,7 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) {
const shouldShowBlock = hasValidImageSource && !failedToLoadImage && !hasDuplicationFailed;
return (
<NodeViewWrapper>
<NodeViewWrapper key={node.attrs[ECustomImageAttributeNames.ID]}>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{shouldShowBlock && !hasDuplicationFailed ? (
<CustomImageBlock
@@ -146,7 +148,7 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) {
failedToLoadImage={failedToLoadImage}
hasDuplicationFailed={hasDuplicationFailed}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={editor.storage.imageComponent?.maxFileSize}
maxFileSize={(editor.storage.imageComponent as { maxFileSize?: number } | undefined)?.maxFileSize ?? 0}
setIsUploaded={setIsUploaded}
{...props}
/>

View File

@@ -19,7 +19,7 @@ export function MentionNodeView(props: MentionNodeViewProps) {
} = props;
return (
<NodeViewWrapper className="mention-component inline w-fit">
<NodeViewWrapper key={attrs[EMentionComponentAttributeNames.ID]} className="mention-component inline w-fit">
{(extension.options as TMentionExtensionOptions).renderComponent({
entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER] ?? "",
entity_name: attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention",

View File

@@ -2,6 +2,8 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
// local imports
import { WorkItemEmbedExtensionConfig } from "./extension-config";
import type { TWorkItemEmbedAttributes } from "./types";
import { EWorkItemEmbedAttributeNames } from "./types";
type Props = {
widgetCallback: ({
@@ -18,15 +20,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={attrs[EWorkItemEmbedAttributeNames.ID]}>
{props.widgetCallback({
issueId: attrs[EWorkItemEmbedAttributeNames.ENTITY_IDENTIFIER] ?? "",
projectId: attrs[EWorkItemEmbedAttributeNames.PROJECT_IDENTIFIER],
workspaceSlug: attrs[EWorkItemEmbedAttributeNames.WORKSPACE_IDENTIFIER],
})}
</NodeViewWrapper>
);
});
},
});
}

View File

@@ -0,0 +1,15 @@
export enum EWorkItemEmbedAttributeNames {
ID = "id",
ENTITY_IDENTIFIER = "entity_identifier",
PROJECT_IDENTIFIER = "project_identifier",
WORKSPACE_IDENTIFIER = "workspace_identifier",
ENTITY_NAME = "entity_name",
}
export type TWorkItemEmbedAttributes = {
[EWorkItemEmbedAttributeNames.ID]: string | undefined;
[EWorkItemEmbedAttributeNames.ENTITY_IDENTIFIER]: string | undefined;
[EWorkItemEmbedAttributeNames.PROJECT_IDENTIFIER]: string | undefined;
[EWorkItemEmbedAttributeNames.WORKSPACE_IDENTIFIER]: string | undefined;
[EWorkItemEmbedAttributeNames.ENTITY_NAME]: string | undefined;
};