fix: block menu ref

This commit is contained in:
VipinDevelops
2025-09-22 18:24:29 +05:30
parent 4f39fb3ae8
commit 3ec184e883
2 changed files with 204 additions and 106 deletions

View File

@@ -111,36 +111,64 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
onFocus={() => isFullVariant && !showToolbarInitially && setIsFocused(true)}
onBlur={() => isFullVariant && !showToolbarInitially && setIsFocused(false)}
>
<LiteTextEditorWithRef
ref={ref}
disabledExtensions={[...liteTextEditorExtensions.disabled, ...additionalDisabledExtensions]}
editable={editable}
flaggedExtensions={liteTextEditorExtensions.flagged}
fileHandler={getEditorFileHandlers({
projectId,
uploadFile: editable ? props.uploadFile : async () => "",
workspaceId,
workspaceSlug,
})}
mentionHandler={{
searchCallback: async (query) => {
const res = await fetchMentions(query);
if (!res) throw new Error("Failed in fetching mentions");
return res;
},
renderComponent: EditorMentionsRoot,
getMentionedEntityDetails: (id) => ({
display_name: getUserDetails(id)?.display_name ?? "",
}),
}}
placeholder={placeholder}
containerClassName={cn(containerClassName, "relative", {
"p-2": !editable,
})}
extendedEditorProps={{}}
{...rest}
/>
{showToolbar && editable && (
{/* Wrapper for lite toolbar layout */}
<div className={cn(isLiteVariant && editable ? "flex items-end gap-1" : "")}>
{/* Main Editor - always rendered once */}
<div className={cn(isLiteVariant && editable ? "flex-1 min-w-0" : "")}>
<LiteTextEditorWithRef
ref={ref}
disabledExtensions={[...liteTextEditorExtensions.disabled, ...additionalDisabledExtensions]}
editable={editable}
flaggedExtensions={liteTextEditorExtensions.flagged}
fileHandler={getEditorFileHandlers({
projectId,
uploadFile: editable ? props.uploadFile : async () => "",
workspaceId,
workspaceSlug,
})}
mentionHandler={{
searchCallback: async (query) => {
const res = await fetchMentions(query);
if (!res) throw new Error("Failed in fetching mentions");
return res;
},
renderComponent: EditorMentionsRoot,
getMentionedEntityDetails: (id) => ({
display_name: getUserDetails(id)?.display_name ?? "",
}),
}}
placeholder={placeholder}
containerClassName={cn(containerClassName, "relative", {
"p-2": !editable,
})}
extendedEditorProps={{
isSmoothCursorEnabled: is_smooth_cursor_enabled,
}}
editorClassName={editorClassName}
{...rest}
/>
</div>
{/* Lite Toolbar - conditionally rendered */}
{isLiteVariant && editable && (
<LiteToolbar
executeCommand={(item) => {
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
editorRef?.executeMenuItemCommand({
itemKey: item.itemKey,
...item.extraProps,
});
}}
onSubmit={(e) => rest.onEnterKeyPress?.(e)}
isSubmitting={isSubmitting}
isEmpty={isEmpty}
/>
)}
</div>
{/* Full Toolbar - conditionally rendered */}
{isFullVariant && editable && (
<div
className={cn(
"transition-all duration-300 ease-out origin-top overflow-hidden",

View File

@@ -16,7 +16,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
// import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
// constants
import { cn } from "@plane/utils";
import { CORE_EXTENSIONS } from "@/constants/extension";
import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions";
// types
@@ -53,58 +52,118 @@ const stripCommentMarksFromJSON = (node: JSONContent | null | undefined): JSONCo
export const BlockMenu = (props: Props) => {
const { editor } = props;
const menuRef = useRef<HTMLDivElement>(null);
const popup = useRef<Instance | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [isAnimatedIn, setIsAnimatedIn] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const virtualReferenceRef = useRef<{ getBoundingClientRect: () => DOMRect }>({
getBoundingClientRect: () => new DOMRect(),
});
// const { t } = useTranslation();
const isEmbedFlagged =
props.flaggedExtensions?.includes("external-embed") || props.disabledExtensions?.includes("external-embed");
const handleClickDragHandle = useCallback((event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target.matches("#drag-handle")) {
event.preventDefault();
// Set up Floating UI with virtual reference element
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
middleware: [offset({ crossAxis: -10 }), flip(), shift()],
whileElementsMounted: autoUpdate,
placement: "left-start",
});
popup.current?.setProps({
getReferenceClientRect: () => target.getBoundingClientRect(),
});
const dismiss = useDismiss(context);
const { getFloatingProps } = useInteractions([dismiss]);
popup.current?.show();
return;
}
// Handle click on drag handle
const handleClickDragHandle = useCallback(
(event: MouseEvent) => {
const target = event.target as HTMLElement;
const dragHandle = target.closest("#drag-handle");
popup.current?.hide();
return;
}, []);
if (dragHandle) {
event.preventDefault();
// Update virtual reference with current drag handle position
virtualReferenceRef.current = {
getBoundingClientRect: () => dragHandle.getBoundingClientRect(),
};
// Set the virtual reference as the reference element
refs.setReference(virtualReferenceRef.current);
// Ensure the targeted block is selected
const rect = dragHandle.getBoundingClientRect();
const coords = { left: rect.left + rect.width / 2, top: rect.top + rect.height / 2 };
const posAtCoords = editor.view.posAtCoords(coords);
if (posAtCoords) {
const $pos = editor.state.doc.resolve(posAtCoords.pos);
const nodePos = $pos.before($pos.depth);
editor.chain().setNodeSelection(nodePos).run();
}
// Show the menu
setIsOpen(true);
return;
}
// If clicking outside and not on a menu item, hide the menu
if (menuRef.current && !menuRef.current.contains(target)) {
setIsOpen(false);
}
},
[editor, refs]
);
const editorState = useEditorState({
editor,
selector: ({ editor }) => {
const selection = editor.state.selection;
const content = selection.content().content;
const firstChild = content.firstChild;
let linkUrl: string | null = null;
const foundLinkMarks: string[] = [];
const isEmbedActive = editor.isActive(ADDITIONAL_EXTENSIONS.EXTERNAL_EMBED);
const isRichCard = firstChild?.attrs[EExternalEmbedAttributeNames.IS_RICH_CARD];
const isNotEmbeddable = firstChild?.attrs[EExternalEmbedAttributeNames.HAS_EMBED_FAILED];
if (firstChild) {
for (let i = 0; i < firstChild.childCount; i++) {
const node = firstChild.child(i);
const linkMarks = node.marks?.filter(
(mark) => mark.type.name === CORE_EXTENSIONS.CUSTOM_LINK && mark.attrs?.href
);
if (linkMarks && linkMarks.length > 0) {
linkMarks.forEach((mark) => {
foundLinkMarks.push(mark.attrs.href);
});
}
}
if (firstChild.attrs.src) {
foundLinkMarks.push(firstChild.attrs.src);
}
}
if (foundLinkMarks.length === 1) {
linkUrl = foundLinkMarks[0];
}
return {
isEmbedActive,
isLinkEmbeddable: isEmbedActive || !!linkUrl,
linkUrl,
isRichCard,
isNotEmbeddable,
};
},
});
// Set up event listeners
useEffect(() => {
if (menuRef.current) {
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
// @ts-expect-error - Tippy types are incorrect
popup.current = tippy(document.body, {
getReferenceClientRect: null,
content: menuRef.current,
appendTo: () => document.querySelector(".frame-renderer"),
trigger: "manual",
interactive: true,
arrow: false,
placement: "left-start",
animation: "shift-away",
maxWidth: 500,
hideOnClick: true,
onShown: () => {
menuRef.current?.focus();
},
});
}
return () => {
popup.current?.destroy();
popup.current = null;
};
}, []);
useEffect(() => {
const handleKeyDown = () => {
popup.current?.hide();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
}
};
const handleScroll = () => {
@@ -243,7 +302,7 @@ export const BlockMenu = (props: Props) => {
label: "Delete",
onClick: (e) => {
editor.chain().deleteSelection().focus().run();
popup.current?.hide();
setIsOpen(false);
e.preventDefault();
e.stopPropagation();
@@ -315,37 +374,48 @@ export const BlockMenu = (props: Props) => {
},
];
if (!isOpen) {
return null;
}
if (!isOpen) {
return null;
}
return (
<div
ref={menuRef}
className="z-10 max-h-60 min-w-[7rem] overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
>
{MENU_ITEMS.map((item) => {
// Skip rendering the button if it should be disabled
if (item.isDisabled && item.key === "duplicate") {
return null;
}
return (
<button
key={item.key}
type="button"
className="flex w-full items-center gap-2 truncate rounded px-1 py-1.5 text-xs text-custom-text-200 hover:bg-custom-background-80"
onClick={item.onClick}
disabled={item.isDisabled}
>
<item.icon className="h-3 w-3" />
{item.label}
</button>
);
})}
</div>
<FloatingPortal>
<div
ref={(node) => {
refs.setFloating(node);
menuRef.current = node;
}}
style={{
...floatingStyles,
zIndex: 99,
animationFillMode: "forwards",
transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)", // Expo ease out
}}
className={cn(
"z-20 max-h-60 min-w-[7rem] overflow-y-scroll rounded-lg border border-custom-border-200 bg-custom-background-100 p-1.5 shadow-custom-shadow-rg",
"transition-all duration-300 transform origin-top-right",
isAnimatedIn ? "opacity-100 scale-100" : "opacity-0 scale-75"
)}
{...getFloatingProps()}
>
{MENU_ITEMS.map((item) => {
if (item.isDisabled) {
return null;
}
return (
<button
key={item.key}
type="button"
className="flex w-full items-center gap-1.5 truncate rounded px-1 py-1.5 text-xs text-custom-text-200 hover:bg-custom-background-90"
onClick={item.onClick}
disabled={item.isDisabled}
>
<item.icon className="h-3 w-3" />
{item.label}
{/* {t(item.label)} */}
</button>
);
})}
</div>
</FloatingPortal>
);
};