fix: bubble menu floating ui fix

This commit is contained in:
Palanikannan M
2025-06-29 22:59:55 +05:30
parent 4a065e14d0
commit eb0281bb31
8 changed files with 951 additions and 343 deletions

View File

@@ -57,7 +57,7 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
];
return (
<div className="flex gap-0.5 px-2">
<div className="flex items-center gap-0.5 px-1.5 py-1">
{textAlignmentOptions.map((item) => (
<button
key={item.renderKey}
@@ -67,13 +67,17 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
item.command();
}}
className={cn(
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-all duration-200 ease-in-out",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon className="size-4" />
<item.icon
className={cn("size-4 transition-transform duration-200", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>

View File

@@ -0,0 +1,535 @@
import {
type Middleware,
arrow,
autoPlacement,
computePosition,
flip,
hide,
inline,
offset,
shift,
size,
} from "@floating-ui/dom";
import { type Editor, isTextSelection, posToDOMRect } from "@tiptap/core";
import { Plugin, PluginKey, type EditorState, type PluginView } from "@tiptap/pm/state";
import { CellSelection } from "@tiptap/pm/tables";
import type { EditorView } from "@tiptap/pm/view";
function combineDOMRects(rect1: DOMRect, rect2: DOMRect): DOMRect {
const top = Math.min(rect1.top, rect2.top);
const bottom = Math.max(rect1.bottom, rect2.bottom);
const left = Math.min(rect1.left, rect2.left);
const right = Math.max(rect1.right, rect2.right);
const width = right - left;
const height = bottom - top;
const x = left;
const y = top;
return new DOMRect(x, y, width, height);
}
export interface BubbleMenuPluginProps {
/**
* The plugin key.
* @type {PluginKey | string}
* @default 'bubbleMenu'
*/
pluginKey: PluginKey | string;
/**
* The editor instance.
*/
editor: Editor;
/**
* The DOM element that contains your menu.
* @type {HTMLElement}
* @default null
*/
element: HTMLElement;
/**
* The delay in milliseconds before the menu should be updated.
* This can be useful to prevent performance issues.
* @type {number}
* @default 250
*/
updateDelay?: number;
/**
* The delay in milliseconds before the menu position should be updated on window resize.
* This can be useful to prevent performance issues.
* @type {number}
* @default 60
*/
resizeDelay?: number;
/**
* A function that determines whether the menu should be shown or not.
* If this function returns `false`, the menu will be hidden, otherwise it will be shown.
*/
shouldShow:
| ((props: {
editor: Editor;
element: HTMLElement;
view: EditorView;
state: EditorState;
oldState?: EditorState;
from: number;
to: number;
}) => boolean)
| null;
/**
* FloatingUI options.
*/
options?: {
strategy?: "absolute" | "fixed";
placement?:
| "top"
| "right"
| "bottom"
| "left"
| "top-start"
| "top-end"
| "right-start"
| "right-end"
| "bottom-start"
| "bottom-end"
| "left-start"
| "left-end";
offset?: Parameters<typeof offset>[0] | boolean;
flip?: Parameters<typeof flip>[0] | boolean;
shift?: Parameters<typeof shift>[0] | boolean;
arrow?: Parameters<typeof arrow>[0] | false;
size?: Parameters<typeof size>[0] | boolean;
autoPlacement?: Parameters<typeof autoPlacement>[0] | boolean;
hide?: Parameters<typeof hide>[0] | boolean;
inline?: Parameters<typeof inline>[0] | boolean;
onShow?: () => void;
onHide?: () => void;
onUpdate?: () => void;
onDestroy?: () => void;
};
}
export type BubbleMenuViewProps = BubbleMenuPluginProps & {
view: EditorView;
};
export class BubbleMenuView implements PluginView {
public editor: Editor;
public element: HTMLElement;
public view: EditorView;
public preventHide = false;
public updateDelay: number;
public resizeDelay: number;
private updateDebounceTimer: number | undefined;
private resizeDebounceTimer: number | undefined;
private isVisible = false;
private isSelecting = false;
private selectionStarted = false;
private floatingUIOptions: NonNullable<BubbleMenuPluginProps["options"]> = {
strategy: "absolute",
placement: "top",
offset: 8,
flip: {},
shift: {},
arrow: false,
size: false,
autoPlacement: false,
hide: false,
inline: false,
onShow: undefined,
onHide: undefined,
onUpdate: undefined,
onDestroy: undefined,
};
public shouldShow: Exclude<BubbleMenuPluginProps["shouldShow"], null> = ({ view, state, from, to }) => {
const { doc, selection } = state;
const { empty } = selection;
// Sometime check for `empty` is not enough.
// Doubleclick an empty paragraph returns a node size of 2.
// So we check also for an empty text size.
const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(state.selection);
// When clicking on a element inside the bubble menu the editor "blur" event
// is called and the bubble menu item is focussed. In this case we should
// consider the menu as part of the editor and keep showing the menu
const isChildOfMenu = this.element.contains(document.activeElement);
const hasEditorFocus = view.hasFocus() || isChildOfMenu;
if (!hasEditorFocus || empty || isEmptyTextBlock || !this.editor.isEditable) {
return false;
}
return true;
};
get middlewares() {
const middlewares: Middleware[] = [];
if (this.floatingUIOptions.flip) {
middlewares.push(
flip(typeof this.floatingUIOptions.flip !== "boolean" ? this.floatingUIOptions.flip : undefined)
);
}
if (this.floatingUIOptions.shift) {
middlewares.push(
shift(typeof this.floatingUIOptions.shift !== "boolean" ? this.floatingUIOptions.shift : undefined)
);
}
if (this.floatingUIOptions.offset) {
middlewares.push(
offset(typeof this.floatingUIOptions.offset !== "boolean" ? this.floatingUIOptions.offset : undefined)
);
}
if (this.floatingUIOptions.arrow) {
middlewares.push(arrow(this.floatingUIOptions.arrow));
}
if (this.floatingUIOptions.size) {
middlewares.push(
size(typeof this.floatingUIOptions.size !== "boolean" ? this.floatingUIOptions.size : undefined)
);
}
if (this.floatingUIOptions.autoPlacement) {
middlewares.push(
autoPlacement(
typeof this.floatingUIOptions.autoPlacement !== "boolean" ? this.floatingUIOptions.autoPlacement : undefined
)
);
}
if (this.floatingUIOptions.hide) {
middlewares.push(
hide(typeof this.floatingUIOptions.hide !== "boolean" ? this.floatingUIOptions.hide : undefined)
);
}
if (this.floatingUIOptions.inline) {
middlewares.push(
inline(typeof this.floatingUIOptions.inline !== "boolean" ? this.floatingUIOptions.inline : undefined)
);
}
return middlewares;
}
constructor({
editor,
element,
view,
updateDelay = 250,
resizeDelay = 60,
shouldShow,
options,
}: BubbleMenuViewProps) {
this.editor = editor;
this.element = element;
this.view = view;
this.updateDelay = updateDelay;
this.resizeDelay = resizeDelay;
this.floatingUIOptions = {
...this.floatingUIOptions,
...options,
};
if (shouldShow) {
this.shouldShow = shouldShow;
}
// Event listeners
this.element.addEventListener("mousedown", this.mousedownHandler, { capture: true });
this.view.dom.addEventListener("dragstart", this.dragstartHandler);
this.editor.on("focus", this.focusHandler);
this.editor.on("blur", this.blurHandler);
window.addEventListener("resize", this.resizeHandler);
// Add mousedown/mouseup listeners for selection tracking
this.view.dom.addEventListener("mousedown", this.editorMousedownHandler);
this.view.dom.addEventListener("mouseup", this.editorMouseupHandler);
this.update(view, view.state);
// Don't show initially even if there's a selection
// Wait for user interaction
}
mousedownHandler = () => {
this.preventHide = true;
};
dragstartHandler = () => {
this.hide();
};
editorMousedownHandler = () => {
this.isSelecting = true;
this.selectionStarted = true;
// Hide menu when starting a new selection
this.hide();
};
editorMouseupHandler = () => {
if (!this.isSelecting) {
return;
}
this.isSelecting = false;
// Use setTimeout to ensure selection is finalized
setTimeout(() => {
const shouldShow = this.getShouldShow();
if (shouldShow && this.selectionStarted) {
this.updatePosition();
this.show();
}
this.selectionStarted = false;
}, 0);
};
/**
* Handles the window resize event to update the position of the bubble menu.
* It uses a debounce mechanism to prevent excessive updates.
* The delay is defined by the `resizeDelay` property.
*/
resizeHandler = () => {
if (this.resizeDebounceTimer) {
clearTimeout(this.resizeDebounceTimer);
}
this.resizeDebounceTimer = window.setTimeout(() => {
if (this.isVisible) {
this.updatePosition();
}
}, this.resizeDelay);
};
focusHandler = () => {
// we use `setTimeout` to make sure `selection` is already updated
setTimeout(() => this.update(this.editor.view));
};
blurHandler = ({ event }: { event: FocusEvent }) => {
if (this.preventHide) {
this.preventHide = false;
return;
}
if (event?.relatedTarget && this.element.parentNode?.contains(event.relatedTarget as Node)) {
return;
}
if (event?.relatedTarget === this.editor.view.dom) {
return;
}
this.hide();
};
updatePosition() {
const { selection } = this.editor.state;
let virtualElement = {
getBoundingClientRect: () => posToDOMRect(this.view, selection.from, selection.to),
};
// this is a special case for cell selections
if (selection instanceof CellSelection) {
const { $anchorCell, $headCell } = selection;
const from = $anchorCell ? $anchorCell.pos : $headCell!.pos;
const to = $headCell ? $headCell.pos : $anchorCell!.pos;
const fromDOM = this.view.nodeDOM(from);
const toDOM = this.view.nodeDOM(to);
if (!fromDOM || !toDOM) {
return;
}
const clientRect =
fromDOM === toDOM
? (fromDOM as HTMLElement).getBoundingClientRect()
: combineDOMRects(
(fromDOM as HTMLElement).getBoundingClientRect(),
(toDOM as HTMLElement).getBoundingClientRect()
);
virtualElement = {
getBoundingClientRect: () => clientRect,
};
}
computePosition(virtualElement, this.element, {
placement: this.floatingUIOptions.placement,
strategy: this.floatingUIOptions.strategy,
middleware: this.middlewares,
}).then(({ x, y, strategy }) => {
this.element.style.width = "max-content";
this.element.style.position = strategy;
this.element.style.left = `${x}px`;
this.element.style.top = `${y}px`;
if (this.isVisible && this.floatingUIOptions.onUpdate) {
this.floatingUIOptions.onUpdate();
}
});
}
update(view: EditorView, oldState?: EditorState) {
const { state } = view;
const hasValidSelection = state.selection.from !== state.selection.to;
// Don't update while user is actively selecting
if (this.isSelecting) {
return;
}
if (this.updateDelay > 0 && hasValidSelection) {
this.handleDebouncedUpdate(view, oldState);
return;
}
const selectionChanged = !oldState?.selection.eq(view.state.selection);
const docChanged = !oldState?.doc.eq(view.state.doc);
this.updateHandler(view, selectionChanged, docChanged, oldState);
}
handleDebouncedUpdate = (view: EditorView, oldState?: EditorState) => {
const selectionChanged = !oldState?.selection.eq(view.state.selection);
const docChanged = !oldState?.doc.eq(view.state.doc);
if (!selectionChanged && !docChanged) {
return;
}
if (this.updateDebounceTimer) {
clearTimeout(this.updateDebounceTimer);
}
this.updateDebounceTimer = window.setTimeout(() => {
this.updateHandler(view, selectionChanged, docChanged, oldState);
}, this.updateDelay);
};
getShouldShow(oldState?: EditorState) {
const { state } = this.view;
const { selection } = state;
// support for CellSelections
const { ranges } = selection;
const from = Math.min(...ranges.map((range) => range.$from.pos));
const to = Math.max(...ranges.map((range) => range.$to.pos));
const shouldShow = this.shouldShow?.({
editor: this.editor,
element: this.element,
view: this.view,
state,
oldState,
from,
to,
});
return shouldShow;
}
updateHandler = (view: EditorView, selectionChanged: boolean, docChanged: boolean, oldState?: EditorState) => {
const { composing } = view;
const isSame = !selectionChanged && !docChanged;
if (composing || isSame || this.isSelecting) {
return;
}
const shouldShow = this.getShouldShow(oldState);
if (!shouldShow) {
this.hide();
return;
}
// Only update position for already visible menu
// New selections are handled by mouseup
if (this.isVisible) {
this.updatePosition();
}
};
show() {
if (this.isVisible) {
return;
}
this.element.style.visibility = "visible";
this.element.style.opacity = "1";
// attach to editor's parent element
this.view.dom.parentElement?.appendChild(this.element);
if (this.floatingUIOptions.onShow) {
this.floatingUIOptions.onShow();
}
this.isVisible = true;
}
hide() {
if (!this.isVisible) {
return;
}
this.element.style.visibility = "hidden";
this.element.style.opacity = "0";
// remove from the parent element
this.element.remove();
if (this.floatingUIOptions.onHide) {
this.floatingUIOptions.onHide();
}
this.isVisible = false;
}
destroy() {
this.hide();
this.element.removeEventListener("mousedown", this.mousedownHandler, { capture: true });
this.view.dom.removeEventListener("dragstart", this.dragstartHandler);
this.view.dom.removeEventListener("mousedown", this.editorMousedownHandler);
this.view.dom.removeEventListener("mouseup", this.editorMouseupHandler);
window.removeEventListener("resize", this.resizeHandler);
this.editor.off("focus", this.focusHandler);
this.editor.off("blur", this.blurHandler);
if (this.floatingUIOptions.onDestroy) {
this.floatingUIOptions.onDestroy();
}
}
}
export const BubbleMenuPlugin = (options: BubbleMenuPluginProps) =>
new Plugin({
key: typeof options.pluginKey === "string" ? new PluginKey(options.pluginKey) : options.pluginKey,
view: (view) => new BubbleMenuView({ view, ...options }),
});

View File

@@ -0,0 +1,67 @@
import React, { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { type BubbleMenuPluginProps, BubbleMenuPlugin } from "./bubble-menu-plugin";
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
export type BubbleMenuProps = Optional<Omit<Optional<BubbleMenuPluginProps, "pluginKey">, "element">, "editor"> &
React.HTMLAttributes<HTMLDivElement>;
export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(
(
{ pluginKey = "bubbleMenu", editor, updateDelay, resizeDelay, shouldShow = null, options, children, ...restProps },
ref
) => {
const menuEl = useRef(document.createElement("div"));
if (typeof ref === "function") {
ref(menuEl.current);
} else if (ref) {
ref.current = menuEl.current;
}
useEffect(() => {
const bubbleMenuElement = menuEl.current;
bubbleMenuElement.style.visibility = "hidden";
bubbleMenuElement.style.position = "absolute";
if (editor?.isDestroyed) {
return;
}
const attachToEditor = editor;
if (!attachToEditor) {
console.warn(
"BubbleMenu component is not rendered inside of an editor component or does not have editor prop."
);
return;
}
const plugin = BubbleMenuPlugin({
updateDelay,
resizeDelay,
editor: attachToEditor,
element: bubbleMenuElement,
pluginKey,
shouldShow,
options,
});
attachToEditor.registerPlugin(plugin);
return () => {
attachToEditor.unregisterPlugin(pluginKey);
window.requestAnimationFrame(() => {
if (bubbleMenuElement.parentNode) {
bubbleMenuElement.parentNode.removeChild(bubbleMenuElement);
}
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor]);
return createPortal(<div {...restProps}>{children}</div>, menuEl.current);
}
);

View File

@@ -0,0 +1,45 @@
import { Extension } from "@tiptap/core";
import { BubbleMenuPlugin, type BubbleMenuPluginProps } from "./bubble-menu-plugin";
export type BubbleMenuOptions = Omit<BubbleMenuPluginProps, "editor" | "element"> & {
/**
* The DOM element that contains your menu.
* @type {HTMLElement}
* @default null
*/
element: HTMLElement | null;
};
/**
* This extension allows you to create a bubble menu.
* @see https://tiptap.dev/api/extensions/bubble-menu
*/
export const BubbleMenu = Extension.create<BubbleMenuOptions>({
name: "bubbleMenu",
addOptions() {
return {
element: null,
pluginKey: "bubbleMenu",
updateDelay: undefined,
shouldShow: null,
};
},
addProseMirrorPlugins() {
if (!this.options.element) {
return [];
}
return [
BubbleMenuPlugin({
pluginKey: this.options.pluginKey,
editor: this.editor,
element: this.options.element,
updateDelay: this.options.updateDelay,
shouldShow: this.options.shouldShow,
}),
];
},
});

View File

@@ -1,3 +1,4 @@
import { Popover } from "@headlessui/react";
import { Editor } from "@tiptap/react";
import { ALargeSmall, Ban } from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
@@ -7,6 +8,7 @@ import { cn } from "@plane/utils";
import { COLORS_LIST } from "@/constants/common";
// helpers
import { BackgroundColorItem, TextColorItem } from "../menu-items";
import { EditorStateType } from "./root";
type Props = {
@@ -17,93 +19,103 @@ type Props = {
};
export const BubbleMenuColorSelector: FC<Props> = (props) => {
const { editor, isOpen, setIsOpen, editorState } = props;
const { editor, editorState, isOpen, setIsOpen } = props;
const activeTextColor = editorState.color;
const activeBackgroundColor = editorState.backgroundColor;
return (
<div className="relative h-full">
<button
<Popover as="div" className="h-7 px-2">
<Popover.Button
as="button"
type="button"
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors"
className={cn("h-full", {
"outline-none": isOpen,
})}
onClick={() => setIsOpen((prev) => !prev)}
>
<span>Color</span>
<span
className={cn(
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
"h-full px-2 text-custom-text-300 text-sm flex items-center gap-1.5 rounded hover:bg-custom-background-80",
{
"bg-custom-background-100": !activeBackgroundColor,
"text-custom-text-100 bg-custom-background-80": isOpen,
}
)}
style={{
backgroundColor: activeBackgroundColor ? activeBackgroundColor.backgroundColor : "transparent",
}}
>
<ALargeSmall
className={cn("size-3.5", {
"text-custom-text-100": !activeTextColor,
})}
Color
<span
className={cn(
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
{
"bg-custom-background-100": !activeBackgroundColor,
}
)}
style={{
color: activeTextColor ? activeTextColor.textColor : "inherit",
backgroundColor: activeBackgroundColor ? activeBackgroundColor.backgroundColor : "transparent",
}}
/>
>
<ALargeSmall
className={cn("size-3.5", {
"text-custom-text-100": !activeTextColor,
})}
style={{
color: activeTextColor ? activeTextColor.textColor : "inherit",
}}
/>
</span>
</span>
</button>
{isOpen && (
<section className="fixed top-full z-[99999] mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 space-y-2 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.textColor,
}}
onClick={() => TextColorItem(editor).command({ color: color.key })}
/>
))}
</Popover.Button>
<Popover.Panel
as="div"
className="fixed z-20 mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg p-2 space-y-2"
>
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => TextColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
</div>
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.textColor,
}}
onClick={() => TextColorItem(editor).command({ color: color.key })}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => TextColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
</div>
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => BackgroundColorItem(editor).command({ color: color.key })}
/>
))}
</div>
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => BackgroundColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
</div>
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => BackgroundColorItem(editor).command({ color: color.key })}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => BackgroundColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
</div>
</section>
)}
</div>
</div>
</Popover.Panel>
</Popover>
);
};

View File

@@ -1,6 +1,7 @@
import { Popover } from "@headlessui/react";
import { Editor } from "@tiptap/core";
import { Check, Link, Trash2 } from "lucide-react";
import { Dispatch, FC, SetStateAction, useCallback, useRef, useState } from "react";
import { FC, useCallback, useRef, useState, Dispatch, SetStateAction } from "react";
// plane imports
import { cn } from "@plane/utils";
// constants
@@ -38,80 +39,77 @@ export const BubbleMenuLinkSelector: FC<Props> = (props) => {
}, [editor, inputRef, setIsOpen]);
return (
<div className="relative h-full">
<button
<Popover as="div" className="h-7 px-2">
<Popover.Button
as="button"
type="button"
className={cn(
"h-full flex items-center gap-1 px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors",
{
"bg-custom-background-80": isOpen,
"text-custom-text-100": editor.isActive(CORE_EXTENSIONS.CUSTOM_LINK),
}
)}
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
className={cn("h-full", {
"outline-none": isOpen,
})}
onClick={() => setIsOpen((prev) => !prev)}
>
Link
<Link className="flex-shrink-0 size-3" />
</button>
{isOpen && (
<div className="fixed top-full z-[99999] mt-1 w-60 animate-in fade-in slide-in-from-top-1 rounded bg-custom-background-100 shadow-custom-shadow-rg">
<div
className={cn("flex rounded border border-custom-border-300 transition-colors", {
"border-red-500": error,
})}
>
<input
ref={inputRef}
type="url"
placeholder="Enter or paste a link"
onClick={(e) => e.stopPropagation()}
className="flex-1 border-r border-custom-border-300 bg-custom-background-100 py-2 px-1.5 text-xs outline-none placeholder:text-custom-text-400 rounded"
defaultValue={editor.getAttributes("link").href || ""}
onKeyDown={(e) => {
setError(false);
if (e.key === "Enter") {
e.preventDefault();
handleLinkSubmit();
}
<span
className={cn(
"h-full px-2 text-custom-text-300 text-sm flex items-center gap-1.5 rounded hover:bg-custom-background-80",
{
"text-custom-text-100 bg-custom-background-80": isOpen || editor.isActive(CORE_EXTENSIONS.CUSTOM_LINK),
}
)}
>
Link
<Link className="flex-shrink-0 size-3" />
</span>
</Popover.Button>
<Popover.Panel as="div" className="fixed z-20 mt-1 w-60 rounded bg-custom-background-100 shadow-custom-shadow-rg">
<div
className={cn("flex rounded border border-custom-border-300 transition-colors", {
"border-red-500": error,
})}
>
<input
ref={inputRef}
type="url"
placeholder="Enter or paste a link"
onClick={(e) => e.stopPropagation()}
className="flex-1 border-r border-custom-border-300 bg-custom-background-100 py-2 px-1.5 text-xs outline-none placeholder:text-custom-text-400 rounded"
defaultValue={editor.getAttributes("link").href || ""}
onKeyDown={(e) => {
setError(false);
if (e.key === "Enter") {
e.preventDefault();
handleLinkSubmit();
}
}}
onFocus={() => setError(false)}
autoFocus
/>
{editor.getAttributes("link").href ? (
<button
type="button"
className="grid place-items-center rounded-sm p-1 text-red-500 hover:bg-red-500/20 transition-all"
onClick={(e) => {
unsetLinkEditor(editor);
setIsOpen(false);
e.stopPropagation();
}}
onFocus={() => setError(false)}
autoFocus
/>
{editor.getAttributes("link").href ? (
<button
type="button"
className="grid place-items-center rounded-sm p-1 text-red-500 hover:bg-red-500/20 transition-all"
onClick={(e) => {
unsetLinkEditor(editor);
setIsOpen(false);
e.stopPropagation();
}}
>
<Trash2 className="size-4" />
</button>
) : (
<button
type="button"
className="h-full aspect-square grid place-items-center p-1 rounded-sm text-custom-text-300 hover:bg-custom-background-80 transition-all"
onClick={(e) => {
e.stopPropagation();
handleLinkSubmit();
}}
>
<Check className="size-4" />
</button>
)}
</div>
{error && (
<p className="text-xs text-red-500 my-1 px-2 pointer-events-none animate-in fade-in slide-in-from-top-0">
Please enter a valid URL
</p>
>
<Trash2 className="size-4" />
</button>
) : (
<button
type="button"
className="h-full aspect-square grid place-items-center p-1 rounded-sm text-custom-text-300 hover:bg-custom-background-80 transition-all"
onClick={(e) => {
e.stopPropagation();
handleLinkSubmit();
}}
>
<Check className="size-4" />
</button>
)}
</div>
)}
</div>
{error && <p className="text-xs text-red-500 my-1 px-2 pointer-events-none">Please enter a valid URL</p>}
</Popover.Panel>
</Popover>
);
};

View File

@@ -1,8 +1,8 @@
import { Editor } from "@tiptap/react";
import { Check, ChevronDown } from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
// plane utils
import { cn } from "@plane/utils";
import { FC, Dispatch, SetStateAction } from "react";
// plane imports
import { CustomMenu } from "@plane/ui";
// components
import {
BulletListItem,
@@ -29,7 +29,7 @@ type Props = {
};
export const BubbleMenuNodeSelector: FC<Props> = (props) => {
const { editor, isOpen, setIsOpen } = props;
const { editor, setIsOpen } = props;
const items: EditorMenuItem<TEditorCommands>[] = [
TextItem(editor),
@@ -51,45 +51,36 @@ export const BubbleMenuNodeSelector: FC<Props> = (props) => {
};
return (
<div className="relative h-full">
<button
type="button"
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors"
<div className="px-1.5 py-1">
<CustomMenu
customButton={
<span className="text-custom-text-300 text-sm border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 h-7 w-24 rounded px-2 flex items-center justify-between gap-2 whitespace-nowrap text-left">
{activeItem?.name || "Text"}
<ChevronDown className="flex-shrink-0 size-3" />
</span>
}
placement="bottom-start"
closeOnSelect
maxHeight="lg"
>
<span>{activeItem?.name}</span>
<ChevronDown className="flex-shrink-0 size-3" />
</button>
{isOpen && (
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
{items.map((item) => (
<button
key={item.name}
type="button"
onClick={(e) => {
item.command();
setIsOpen(false);
e.stopPropagation();
}}
className={cn(
"flex items-center justify-between rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80",
{
"bg-custom-background-80": activeItem.name === item.name,
}
)}
>
<div className="flex items-center space-x-2">
<item.icon className="size-3 flex-shrink-0" />
<span>{item.name}</span>
</div>
{activeItem.name === item.name && <Check className="size-3 text-custom-text-300 flex-shrink-0" />}
</button>
))}
</section>
)}
{items.map((item) => (
<CustomMenu.MenuItem
key={item.name}
className="flex items-center justify-between gap-2"
onClick={(e) => {
item.command();
setIsOpen(false);
e.stopPropagation();
}}
>
<span className="flex items-center gap-2">
<item.icon className="size-3" />
{item.name}
</span>
{activeItem?.name === item.name && <Check className="size-3 text-custom-text-300 flex-shrink-0" />}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
);
};

View File

@@ -1,32 +1,29 @@
import { BubbleMenu, BubbleMenuProps, Editor, isNodeSelection, useEditorState } from "@tiptap/react";
import { FC, useEffect, useState, useRef } from "react";
// plane utils
import { isNodeSelection, type Editor } from "@tiptap/core";
import { useEditorState } from "@tiptap/react";
import { useState } from "react";
import { cn } from "@plane/utils";
// components
import { COLORS_LIST } from "@/constants/common";
import { CORE_EXTENSIONS } from "@/constants/extension";
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
import { TEditorCommands } from "@/types";
import {
TextColorItem,
BackgroundColorItem,
BoldItem,
BubbleMenuColorSelector,
BubbleMenuLinkSelector,
BubbleMenuNodeSelector,
CodeItem,
EditorMenuItem,
ItalicItem,
StrikeThroughItem,
TextAlignItem,
TextColorItem,
UnderLineItem,
} from "@/components/menus";
// constants
import { COLORS_LIST } from "@/constants/common";
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
// local components
} from "../menu-items";
import { TextAlignmentSelector } from "./alignment-selector";
import { TEditorCommands } from "@/types";
import { BubbleMenu } from "./bubble-menu-renderer";
import { BubbleMenuColorSelector } from "./color-selector";
import { BubbleMenuLinkSelector } from "./link-selector";
import { BubbleMenuNodeSelector } from "./node-selector";
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
type EditorBubbleMenuProps = { editor: Editor };
export interface EditorStateType {
code: boolean;
@@ -39,35 +36,53 @@ export interface EditorStateType {
center: boolean;
color: { key: string; label: string; textColor: string; backgroundColor: string } | undefined;
backgroundColor:
| {
key: string;
label: string;
textColor: string;
backgroundColor: string;
}
| undefined;
| {
key: string;
label: string;
textColor: string;
backgroundColor: string;
}
| undefined;
}
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Editor }) => {
const menuRef = useRef<HTMLDivElement>(null);
export const EditorBubbleMenu = (bubbleMenuProps: EditorBubbleMenuProps) => {
const { editor } = bubbleMenuProps;
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
const [isSelecting, setIsSelecting] = useState(false);
const bubbleMenuPropsInternal = {
shouldShow: ({ state, editor }) => {
const { selection } = state;
const { empty } = selection;
if (
empty ||
!editor.isEditable ||
editor.isActive(CORE_EXTENSIONS.IMAGE) ||
editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE) ||
isNodeSelection(selection) ||
isCellSelection(selection)
) {
return false;
}
return true;
},
};
const formattingItems = {
code: CodeItem(props.editor),
bold: BoldItem(props.editor),
italic: ItalicItem(props.editor),
underline: UnderLineItem(props.editor),
strikethrough: StrikeThroughItem(props.editor),
"text-align": TextAlignItem(props.editor),
code: CodeItem(editor),
bold: BoldItem(editor),
italic: ItalicItem(editor),
underline: UnderLineItem(editor),
strikethrough: StrikeThroughItem(editor),
"text-align": TextAlignItem(editor),
} satisfies {
[K in TEditorCommands]?: EditorMenuItem<K>;
};
const editorState: EditorStateType = useEditorState({
editor: props.editor,
editor: editor,
selector: ({ editor }: { editor: Editor }) => ({
code: formattingItems.code.isActive(),
bold: formattingItems.bold.isActive(),
@@ -86,137 +101,78 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
? [formattingItems.code]
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
shouldShow: ({ state, editor }) => {
const { selection } = state;
const { empty } = selection;
if (
empty ||
!editor.isEditable ||
editor.isActive(CORE_EXTENSIONS.IMAGE) ||
editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE) ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
isSelecting
) {
return false;
}
return true;
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
duration: [300, 0],
zIndex: 9,
onShow: () => {
props.editor.storage.link.isBubbleMenuOpen = true;
},
onHidden: () => {
props.editor.storage.link.isBubbleMenuOpen = false;
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
},
},
};
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (menuRef.current?.contains(e.target as Node)) return;
function handleMouseMove() {
if (!props.editor.state.selection.empty) {
setIsSelecting(true);
document.removeEventListener("mousemove", handleMouseMove);
}
}
function handleMouseUp() {
setIsSelecting(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
}
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, [props.editor]);
return (
<BubbleMenu {...bubbleMenuProps}>
{!isSelecting && (
<div
ref={menuRef}
className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
>
<div className="px-2">
<BubbleMenuNodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen((prev) => !prev);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
<BubbleMenu
editor={editor}
options={{
placement: "top-start",
offset: 2,
}}
shouldShow={bubbleMenuPropsInternal.shouldShow}
>
<div
className={cn(
"flex items-center divide-x divide-custom-border-200 rounded-md border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg"
)}
>
<BubbleMenuNodeSelector
editor={editor}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen((prev) => !prev);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
{!editorState.code && (
<BubbleMenuLinkSelector
editor={editor}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen((prev) => !prev);
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
)}
{!editorState.code && (
<BubbleMenuColorSelector
editor={editor}
isOpen={isColorSelectorOpen}
editorState={editorState}
setIsOpen={() => {
setIsColorSelectorOpen((prev) => !prev);
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
}}
/>
)}
<div className="flex gap-0.5 px-2">
{basicFormattingOptions.map((item) => (
<button
key={item.key}
type="button"
onClick={(e) => {
item.command();
e.stopPropagation();
}}
/>
</div>
{!editorState.code && (
<div className="px-2">
<BubbleMenuLinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen((prev) => !prev);
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
className={cn(
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-all duration-200 ease-in-out",
{
"bg-custom-background-80 text-custom-text-100": editorState[item.key],
}
)}
>
<item.icon
className={cn("size-4 transition-transform duration-200", {
"text-custom-text-100": editorState[item.key],
})}
/>
</div>
)}
{!editorState.code && (
<div className="px-2">
<BubbleMenuColorSelector
editor={props.editor}
isOpen={isColorSelectorOpen}
editorState={editorState}
setIsOpen={() => {
setIsColorSelectorOpen((prev) => !prev);
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
}}
/>
</div>
)}
<div className="flex gap-0.5 px-2">
{basicFormattingOptions.map((item) => (
<button
key={item.key}
type="button"
onClick={(e) => {
item.command();
e.stopPropagation();
}}
className={cn(
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
{
"bg-custom-background-80 text-custom-text-100": editorState[item.key],
}
)}
>
<item.icon className="size-4" />
</button>
))}
</div>
<TextAlignmentSelector editor={props.editor} editorState={editorState} />
</button>
))}
</div>
)}
<TextAlignmentSelector editor={editor} editorState={editorState} />
</div>
</BubbleMenu>
);
};