mirror of
https://github.com/makeplane/plane.git
synced 2026-02-24 12:11:39 +01:00
fix: bubble menu floating ui fix
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user