mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
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:
@@ -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"};
|
||||
}
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
`
|
||||
: "";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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} />
|
||||
|
||||
Vendored
-1
@@ -150,7 +150,6 @@ declare module "styled-components" {
|
||||
menuItemSelected: string;
|
||||
menuBackground: string;
|
||||
menuShadow: string;
|
||||
menuBorder?: string;
|
||||
divider: string;
|
||||
titleBarDivider: string;
|
||||
inputBorder: string;
|
||||
|
||||
Reference in New Issue
Block a user