chore: Convert SelectionToolbar to editor widget pattern (#10414)

* refactor

* fix: Restore toolbar arrow
fix: Delayed width calculation
fix: Unused menuBorder theme prop
This commit is contained in:
Tom Moor
2025-10-19 02:34:19 +02:00
committed by GitHub
parent 2c27ef9c2c
commit 285b770b3d
7 changed files with 161 additions and 121 deletions
+3 -3
View File
@@ -295,10 +295,10 @@ const Pointer = styled.div<{ top: number; left: number; direction: Direction }>`
&:before {
border: 8px solid transparent;
${({ direction, theme }) =>
${({ direction }) =>
direction === Direction.UP
? `border-bottom-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`
: `border-top-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`};
? `border-bottom-color: rgba(0, 0, 0, 0.1)`
: `border-top-color: rgba(0, 0, 0, 0.1)`};
${({ direction }) =>
direction === Direction.UP ? "right: -1px" : "left: -1px"};
}
+17 -4
View File
@@ -47,9 +47,19 @@ function usePosition({
}) {
const { view } = useEditor();
const { selection } = view.state;
const menuWidth = menuRef.current?.offsetWidth ?? 0;
const [menuWidth, setMenuWidth] = React.useState(0);
const menuHeight = 36;
// Measure the menu width after DOM updates to ensure accurate positioning
React.useLayoutEffect(() => {
if (menuRef.current) {
const width = menuRef.current.offsetWidth;
if (width !== menuWidth) {
setMenuWidth(width);
}
}
});
// based on the start and end of the selection calculate the position at
// the center top
let fromPos;
@@ -309,7 +319,7 @@ type WrapperProps = {
const arrow = (props: WrapperProps) =>
props.arrow
? css`
&::before {
&::after {
content: "";
display: block;
width: 24px;
@@ -317,11 +327,14 @@ const arrow = (props: WrapperProps) =>
transform: translateX(-50%) rotate(45deg);
background: ${s("menuBackground")};
border-radius: 3px;
z-index: -1;
z-index: 0;
position: absolute;
bottom: -3px;
bottom: -2px;
left: calc(50% - ${props.$offset || 0}px);
pointer-events: none;
// clip to show only the bottom right corner
clip-path: polygon(100% 50%, 100% 100%, 50% 100%);
}
`
: "";
+19 -71
View File
@@ -1,11 +1,9 @@
import some from "lodash/some";
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
import { Selection, NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
getColumnIndex,
@@ -17,7 +15,6 @@ import useBoolean from "~/hooks/useBoolean";
import useDictionary from "~/hooks/useDictionary";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import getAttachmentMenuItems from "../menus/attachment";
import getCodeMenuItems from "../menus/code";
import getDividerMenuItems from "../menus/divider";
@@ -35,65 +32,27 @@ import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
type Props = {
/** Whether the text direction is right-to-left */
rtl: boolean;
/** Whether the current document is a template */
isTemplate: boolean;
/** Whether the toolbar is currently active/visible */
isActive: boolean;
/** The current selection */
selection?: Selection;
/** Whether the editor is in read-only mode */
readOnly?: boolean;
/** Whether the user has permission to add comments */
canComment?: boolean;
/** Whether the user has permission to update the document */
canUpdate?: boolean;
onOpen: () => void;
onClose: () => void;
/** Callback function when a link is clicked */
onClickLink: (
href: string,
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
};
function useIsActive(state: EditorState) {
const { selection, doc } = state;
if (isMarkActive(state.schema.marks.link)(state)) {
return true;
}
if (
(isNodeActive(state.schema.nodes.code_block)(state) ||
isNodeActive(state.schema.nodes.code_fence)(state)) &&
selection.from > 0
) {
return true;
}
if (isInNotice(state) && selection.from > 0) {
return true;
}
if (!selection || selection.empty) {
return false;
}
if (selection instanceof NodeSelection && selection.node.type.name === "hr") {
return true;
}
if (
selection instanceof NodeSelection &&
["image", "attachment", "embed"].includes(selection.node.type.name)
) {
return true;
}
if (selection instanceof NodeSelection) {
return false;
}
const selectionText = doc.cut(selection.from, selection.to).textContent;
if (selection instanceof TextSelection && !selectionText) {
return false;
}
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
return some(nodes, (n) => n.content.size);
}
function useIsDragging() {
const [isDragging, setDragging, setNotDragging] = useBoolean();
useEventListener("dragstart", setDragging);
@@ -102,25 +61,14 @@ function useIsDragging() {
return isDragging;
}
export default function SelectionToolbar(props: Props) {
const { onClose, readOnly = false, onOpen } = props;
export function SelectionToolbar(props: Props) {
const { readOnly = false } = props;
const { view, commands } = useEditor();
const dictionary = useDictionary();
const menuRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useMobile();
const isActive = useIsActive(view.state) || isMobile;
const isActive = props.isActive || isMobile;
const isDragging = useIsDragging();
const previousIsActive = usePrevious(isActive);
React.useEffect(() => {
// Trigger callbacks when the toolbar is opened or closed
if (previousIsActive && !isActive) {
onClose();
}
if (!previousIsActive && isActive) {
onOpen();
}
}, [isActive, onClose, onOpen, previousIsActive]);
React.useEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
@@ -154,7 +102,7 @@ export default function SelectionToolbar(props: Props) {
return () => {
window.removeEventListener("mouseup", handleClickOutside);
};
}, [isActive, previousIsActive, readOnly, view]);
}, [isActive, readOnly, view]);
const handleOnSelectLink = ({
href,
@@ -176,14 +124,14 @@ export default function SelectionToolbar(props: Props) {
);
};
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
if (isDragging) {
return null;
}
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
+116
View File
@@ -0,0 +1,116 @@
import some from "lodash/some";
import { action, observable } from "mobx";
import {
EditorState,
NodeSelection,
Selection,
Plugin,
TextSelection,
} from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { SelectionToolbar } from "../components/SelectionToolbar";
export default class SelectionToolbarExtension extends Extension {
get name() {
return "selection-toolbar";
}
get allowInReadOnly() {
return true;
}
get plugins(): Plugin[] {
return [
new Plugin({
view: () => ({
update: this.handleUpdate,
}),
}),
];
}
@observable
state: Selection | boolean = false;
private handleUpdate = action((view: EditorView) => {
const { state } = view;
this.state = this.calculateState(state);
});
private calculateState(state: EditorState): Selection | boolean {
const { selection, doc, schema } = state;
if (isMarkActive(schema.marks.link)(state)) {
return selection;
}
if (
(isNodeActive(schema.nodes.code_block)(state) ||
isNodeActive(schema.nodes.code_fence)(state)) &&
selection.from > 0
) {
return selection;
}
if (isInNotice(state) && selection.from > 0) {
return selection;
}
if (!selection || selection.empty) {
return false;
}
if (
selection instanceof NodeSelection &&
selection.node.type.name === "hr"
) {
return selection;
}
if (
selection instanceof NodeSelection &&
["image", "attachment", "embed"].includes(selection.node.type.name)
) {
return selection;
}
if (selection instanceof NodeSelection) {
return false;
}
const selectionText = doc.cut(selection.from, selection.to).textContent;
if (selection instanceof TextSelection && !selectionText) {
return false;
}
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
if (some(nodes, (n) => n.content.size)) {
return selection;
}
return false;
}
widget = (props: WidgetProps) => {
const editorProps = this.editor.props;
return (
<SelectionToolbar
{...props}
isActive={!!this.state}
selection={this.state ? (this.state as Selection) : undefined}
canUpdate={editorProps.canUpdate}
canComment={editorProps.canComment}
isTemplate={editorProps.template === true}
onClickLink={editorProps.onClickLink}
/>
);
};
}
+2
View File
@@ -10,6 +10,7 @@ import Keys from "~/editor/extensions/Keys";
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
import PasteHandler from "~/editor/extensions/PasteHandler";
import PreventTab from "~/editor/extensions/PreventTab";
import SelectionToolbarExtension from "~/editor/extensions/SelectionToolbar";
import SmartText from "~/editor/extensions/SmartText";
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
@@ -24,6 +25,7 @@ export const withUIExtensions = (nodes: Nodes) => [
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
SelectionToolbarExtension,
// Order these default key handlers last
PreventTab,
Keys,
+4 -42
View File
@@ -52,7 +52,7 @@ import Logger from "~/utils/Logger";
import ComponentView from "./components/ComponentView";
import EditorContext from "./components/EditorContext";
import { NodeViewRenderer } from "./components/NodeViewRenderer";
import SelectionToolbar from "./components/SelectionToolbar";
import WithTheme from "./components/WithTheme";
import isNull from "lodash/isNull";
import { map } from "lodash";
@@ -150,8 +150,6 @@ type State = {
isRTL: boolean;
/** If the editor is currently focused */
isEditorFocused: boolean;
/** If the toolbar for a text selection is visible */
selectionToolbarOpen: boolean;
/** Image that's being currently viewed in Lightbox */
activeLightboxImage: LightboxImage | null;
};
@@ -182,7 +180,6 @@ export class Editor extends React.PureComponent<
state: State = {
isRTL: false,
isEditorFocused: false,
selectionToolbarOpen: false,
activeLightboxImage: null,
};
@@ -270,19 +267,12 @@ export class Editor extends React.PureComponent<
this.calculateDir();
}
if (
!this.isBlurred &&
!this.state.isEditorFocused &&
!this.state.selectionToolbarOpen
) {
if (!this.isBlurred && !this.state.isEditorFocused) {
this.isBlurred = true;
this.props.onBlur?.();
}
if (
this.isBlurred &&
(this.state.isEditorFocused || this.state.selectionToolbarOpen)
) {
if (this.isBlurred && this.state.isEditorFocused) {
this.isBlurred = false;
this.props.onFocus?.();
}
@@ -791,23 +781,6 @@ export class Editor extends React.PureComponent<
return false;
};
private handleOpenSelectionToolbar = () => {
this.setState((state) => ({
...state,
selectionToolbarOpen: true,
}));
};
private handleCloseSelectionToolbar = () => {
if (!this.state.selectionToolbarOpen) {
return;
}
this.setState((state) => ({
...state,
selectionToolbarOpen: false,
}));
};
public render() {
const { readOnly, canUpdate, grow, style, className, onKeyDown } =
this.props;
@@ -837,18 +810,7 @@ export class Editor extends React.PureComponent<
ref={this.elementRef}
lang=""
/>
{this.view && (
<SelectionToolbar
rtl={isRTL}
readOnly={readOnly}
canUpdate={this.props.canUpdate}
canComment={this.props.canComment}
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionToolbar}
onClose={this.handleCloseSelectionToolbar}
onClickLink={this.props.onClickLink}
/>
)}
{this.widgets &&
Object.values(this.widgets).map((Widget, index) => (
<Widget key={String(index)} rtl={isRTL} readOnly={readOnly} />
-1
View File
@@ -150,7 +150,6 @@ declare module "styled-components" {
menuItemSelected: string;
menuBackground: string;
menuShadow: string;
menuBorder?: string;
divider: string;
titleBarDivider: string;
inputBorder: string;