mirror of
https://github.com/makeplane/plane.git
synced 2026-02-24 20:20:49 +01:00
fix: block menu ref
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user