Rebuilding code block menus (#5569)

This commit is contained in:
Tom Moor
2023-07-17 21:25:22 -04:00
committed by GitHub
parent 60b456f35a
commit 2427f4747a
42 changed files with 474 additions and 469 deletions

View File

@@ -1,7 +1,9 @@
import { NodeSelection } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import * as React from "react";
import styled from "styled-components";
import styled, { css } from "styled-components";
import { isCode } from "@shared/editor/lib/isCode";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import { depths, s } from "@shared/styles";
import { Portal } from "~/components/Portal";
import useComponentSize from "~/hooks/useComponentSize";
@@ -23,6 +25,7 @@ const defaultPosition = {
top: 0,
offset: 0,
maxWidth: 1000,
blockSelection: false,
visible: false,
};
@@ -52,6 +55,7 @@ function usePosition({
top: viewportHeight - menuHeight,
offset: 0,
maxWidth: 1000,
blockSelection: false,
visible: true,
};
}
@@ -85,6 +89,17 @@ function usePosition({
left: 0,
} as DOMRect);
// position at the top right of code blocks
const codeBlock = findParentNode(isCode)(view.state.selection);
if (codeBlock) {
const element = view.nodeDOM(codeBlock.pos);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.right - menuWidth;
selectionBounds.right = bounds.right;
}
// tables are an oddity, and need their own positioning logic
const isColSelection =
selection instanceof CellSelection && selection.isColSelection();
@@ -145,7 +160,7 @@ function usePosition({
visible: true,
};
} else {
// calcluate the horizontal center of the selection
// calculate the horizontal center of the selection
const halfSelection =
Math.abs(selectionBounds.right - selectionBounds.left) / 2;
const centerOfSelection = selectionBounds.left + halfSelection;
@@ -178,6 +193,7 @@ function usePosition({
top: Math.round(top - offsetParent.top),
offset: Math.round(offset),
maxWidth: offsetParent.width,
blockSelection: codeBlock || isColSelection || isRowSelection,
visible: true,
};
}
@@ -211,6 +227,7 @@ const FloatingToolbar = React.forwardRef(
<Portal>
<Wrapper
active={props.active && position.visible}
arrow={!position.blockSelection}
ref={menuRef}
$offset={position.offset}
style={{
@@ -227,41 +244,52 @@ const FloatingToolbar = React.forwardRef(
}
);
const Wrapper = styled.div<{
type WrapperProps = {
active?: boolean;
arrow?: boolean;
$offset: number;
}>`
};
const arrow = (props: WrapperProps) =>
props.arrow
? css`
&::before {
content: "";
display: block;
width: 24px;
height: 24px;
transform: translateX(-50%) rotate(45deg);
background: ${s("menuBackground")};
border-radius: 3px;
z-index: -1;
position: absolute;
bottom: -2px;
left: calc(50% - ${props.$offset || 0}px);
pointer-events: none;
}
`
: "";
const Wrapper = styled.div<WrapperProps>`
will-change: opacity, transform;
padding: 8px 16px;
padding: 6px;
position: absolute;
z-index: ${depths.editorToolbar};
opacity: 0;
background-color: ${s("toolbarBackground")};
background-color: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 4px;
transform: scale(0.95);
transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition-delay: 150ms;
line-height: 0;
height: 40px;
height: 36px;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
&::before {
content: "";
display: block;
width: 24px;
height: 24px;
transform: translateX(-50%) rotate(45deg);
background: ${s("toolbarBackground")};
border-radius: 3px;
z-index: -1;
position: absolute;
bottom: -2px;
left: calc(50% - ${(props) => props.$offset || 0}px);
pointer-events: none;
}
${arrow}
* {
box-sizing: border-box;

View File

@@ -3,8 +3,8 @@ import { s } from "@shared/styles";
const Input = styled.input`
font-size: 15px;
background: ${s("toolbarInput")};
color: ${s("toolbarItem")};
background: ${s("inputBorder")};
color: ${s("text")};
border-radius: 2px;
padding: 3px 8px;
border: 0;

View File

@@ -10,7 +10,7 @@ import { Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import { s, hideScrollbars } from "@shared/styles";
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
@@ -396,23 +396,24 @@ class LinkEditor extends React.Component<Props, State> {
}
const Wrapper = styled(Flex)`
margin-left: -8px;
margin-right: -8px;
pointer-events: all;
gap: 8px;
`;
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
background: ${s("toolbarBackground")};
background: ${s("menuBackground")};
box-shadow: ${(props) => (props.$hasResults ? s("menuShadow") : "none")};
clip-path: inset(0px -100px -100px -100px);
position: absolute;
top: 100%;
width: 100%;
height: auto;
left: 0;
margin: -8px 0 0;
margin-top: -6px;
border-radius: 0 0 4px 4px;
padding: ${(props) => (props.$hasResults ? "8px 0" : "0")};
max-height: 260px;
max-height: 240px;
${hideScrollbars()}
@media (hover: none) and (pointer: coarse) {
position: fixed;

View File

@@ -60,8 +60,7 @@ const IconWrapper = styled.span<{ selected: boolean }>`
margin-right: 4px;
height: 24px;
opacity: 0.8;
color: ${(props) =>
props.selected ? props.theme.accentText : props.theme.toolbarItem};
color: ${(props) => (props.selected ? s("accentText") : s("textSecondary"))};
`;
const ListItem = styled.div<{
@@ -72,11 +71,9 @@ const ListItem = styled.div<{
align-items: center;
padding: 8px;
border-radius: 4px;
margin: 0 8px;
color: ${(props) =>
props.selected ? props.theme.accentText : props.theme.toolbarItem};
background: ${(props) =>
props.selected ? props.theme.accent : "transparent"};
margin: 0 6px;
color: ${(props) => (props.selected ? s("accentText") : s("textSecondary"))};
background: ${(props) => (props.selected ? s("accent") : "transparent")};
font-family: ${s("fontFamily")};
text-decoration: none;
overflow: hidden;

View File

@@ -1,4 +1,3 @@
import { some } from "lodash";
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
@@ -15,6 +14,7 @@ import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useToasts from "~/hooks/useToasts";
import getCodeMenuItems from "../menus/code";
import getDividerMenuItems from "../menus/divider";
import getFormattingMenuItems from "../menus/formatting";
import getImageMenuItems from "../menus/image";
@@ -48,6 +48,13 @@ function useIsActive(state: EditorState) {
if (isMarkActive(state.schema.marks.link)(state)) {
return true;
}
if (
isNodeActive(state.schema.nodes.code_block)(state) ||
isNodeActive(state.schema.nodes.code_fence)(state)
) {
return true;
}
if (!selection || selection.empty) {
return false;
}
@@ -70,10 +77,7 @@ function useIsActive(state: EditorState) {
}
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
return some(nodes, (n) => n.content.size);
return !!slice.content.textBetween(0, slice.content.size);
}
function useIsDragging() {
@@ -188,17 +192,11 @@ export default function SelectionToolbar(props: Props) {
const { onCreateLink, isTemplate, rtl, canComment, ...rest } = props;
const { state } = view;
const { selection }: { selection: any } = state;
const isCodeSelection = isNodeActive(state.schema.nodes.code_block)(state);
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
// toolbar is disabled in code blocks, no bold / italic etc
if (isCodeSelection || isDragging) {
return null;
}
// no toolbar in this circumstance
if (readOnly && !canComment) {
// no toolbar in read-only without commenting or when dragging
if ((readOnly && !canComment) || isDragging) {
return null;
}
@@ -207,10 +205,17 @@ export default function SelectionToolbar(props: Props) {
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
const link = isMarkActive(state.schema.marks.link)(state);
const range = getMarkRange(selection.$from, state.schema.marks.link);
const isImageSelection = selection.node?.type?.name === "image";
const isImageSelection =
selection instanceof NodeSelection && selection.node.type.name === "image";
const isCodeSelection =
isNodeActive(state.schema.nodes.code_block)(state) ||
isNodeActive(state.schema.nodes.code_fence)(state);
let items: MenuItem[] = [];
if (isTableSelection) {
if (isCodeSelection) {
items = getCodeMenuItems(state, dictionary);
} else if (isTableSelection) {
items = getTableMenuItems(dictionary);
} else if (colIndex !== undefined) {
items = getTableColMenuItems(state, colIndex, rtl, dictionary);

View File

@@ -212,11 +212,13 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
handleClearSearch();
const command = item.name ? commands[item.name] : undefined;
const attrs =
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
if (command) {
command(item.attrs);
command(attrs);
} else {
commands[`create${capitalize(item.name)}`](item.attrs);
commands[`create${capitalize(item.name)}`](attrs);
}
if ("appendSpace" in item) {
const { dispatch } = view;

View File

@@ -1,7 +1,12 @@
import styled from "styled-components";
import { transparentize } from "polished";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
type Props = { active?: boolean; disabled?: boolean };
type Props = {
active?: boolean;
disabled?: boolean;
hovering?: boolean;
};
export default styled.button.attrs((props) => ({
type: props.type || "button",
@@ -14,6 +19,7 @@ export default styled.button.attrs((props) => ({
height: 24px;
cursor: var(--pointer);
border: none;
border-radius: 2px;
background: none;
transition: opacity 100ms ease-in-out;
padding: 0;
@@ -21,12 +27,19 @@ export default styled.button.attrs((props) => ({
outline: none;
pointer-events: all;
position: relative;
color: ${s("toolbarItem")};
transition: background 100ms ease-in-out;
color: ${s("text")};
&:hover {
opacity: 1;
}
${(props) =>
props.hovering &&
css`
opacity: 1;
`};
&:disabled {
opacity: 0.3;
cursor: default;
@@ -35,11 +48,16 @@ export default styled.button.attrs((props) => ({
&:before {
position: absolute;
content: "";
top: -4px;
top: -6px;
right: -4px;
left: -4px;
bottom: -4px;
bottom: -6px;
}
${(props) => props.active && "opacity: 1;"};
${(props) =>
props.active &&
css`
opacity: 1;
background: ${(props) => transparentize(0.9, s("accent")(props))};
`};
`;

View File

@@ -1,7 +1,13 @@
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useMenuState } from "reakit";
import { MenuButton } from "reakit/Menu";
import styled from "styled-components";
import { MenuItem } from "@shared/editor/types";
import { s } from "@shared/styles";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { MenuItem as TMenuItem } from "~/types";
import { useEditor } from "./EditorContext";
import ToolbarButton from "./ToolbarButton";
import ToolbarSeparator from "./ToolbarSeparator";
@@ -12,11 +18,59 @@ type Props = {
};
const FlexibleWrapper = styled.div`
color: ${s("toolbarItem")};
color: ${s("textSecondary")};
display: flex;
gap: 8px;
`;
/*
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: { item: MenuItem }) {
const menu = useMenuState();
const { commands, view } = useEditor();
const { item } = props;
const { state } = view;
const items: TMenuItem[] = React.useMemo(() => {
const handleClick = (item: MenuItem) => () => {
if (!item.name) {
return;
}
commands[item.name](
typeof item.attrs === "function" ? item.attrs(state) : item.attrs
);
};
return item.children
? item.children.map((child) => ({
type: "button",
title: child.label,
icon: child.icon,
selected: child.active ? child.active(state) : false,
onClick: handleClick(child),
}))
: [];
}, [item.children, commands, state]);
return (
<>
<MenuButton {...menu}>
{(props) => (
<ToolbarButton {...props} hovering={menu.visible}>
{item.label && <Label>{item.label}</Label>}
<Arrow />
</ToolbarButton>
)}
</MenuButton>
<ContextMenu aria-label={item.label} {...menu}>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
}
function ToolbarMenu(props: Props) {
const { commands, view } = useEditor();
const { items } = props;
@@ -27,10 +81,9 @@ function ToolbarMenu(props: Props) {
return;
}
const attrs =
typeof item.attrs === "function" ? item.attrs(state) : item.attrs;
commands[item.name](attrs);
commands[item.name](
typeof item.attrs === "function" ? item.attrs(state) : item.attrs
);
};
return (
@@ -49,10 +102,14 @@ function ToolbarMenu(props: Props) {
tooltip={item.label === item.tooltip ? undefined : item.tooltip}
key={index}
>
<ToolbarButton onClick={handleClick(item)} active={isActive}>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
{item.children ? (
<ToolbarDropdown item={item} />
) : (
<ToolbarButton onClick={handleClick(item)} active={isActive}>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
)}
</Tooltip>
);
})}
@@ -60,6 +117,11 @@ function ToolbarMenu(props: Props) {
);
}
const Arrow = styled(ExpandedIcon)`
margin-right: -4px;
color: ${s("textSecondary")};
`;
const Label = styled.span`
font-size: 15px;
font-weight: 500;

View File

@@ -2,12 +2,12 @@ import styled from "styled-components";
import { s } from "@shared/styles";
const Separator = styled.div`
height: 24px;
width: 2px;
background: ${s("toolbarItem")};
opacity: 0.3;
height: 36px;
width: 1px;
background: ${s("textTertiary")};
opacity: 0.25;
display: inline-block;
margin-left: 8px;
margin: -6px 2px;
`;
export default Separator;