From 2427f4747a9f02fde25289332d64974f3ff03e6f Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 17 Jul 2023 21:25:22 -0400 Subject: [PATCH] Rebuilding code block menus (#5569) --- app/components/ContextMenu/MenuItem.tsx | 57 ++++--- app/components/ContextMenu/Template.tsx | 4 + app/components/ContextMenu/index.tsx | 2 +- app/editor/components/FloatingToolbar.tsx | 70 +++++--- app/editor/components/Input.tsx | 4 +- app/editor/components/LinkEditor.tsx | 13 +- app/editor/components/LinkSearchResult.tsx | 11 +- app/editor/components/SelectionToolbar.tsx | 37 ++-- app/editor/components/SuggestionsMenu.tsx | 6 +- app/editor/components/ToolbarButton.tsx | 30 +++- app/editor/components/ToolbarMenu.tsx | 80 ++++++++- app/editor/components/ToolbarSeparator.tsx | 10 +- app/editor/menus/code.tsx | 37 ++++ app/editor/menus/image.tsx | 3 - app/editor/menus/table.tsx | 1 - app/editor/menus/tableCol.tsx | 3 - app/editor/menus/tableRow.tsx | 3 - app/hooks/useComponentSize.ts | 2 +- app/hooks/useIdle.ts | 5 +- app/typings/styled-components.d.ts | 4 - shared/editor/commands/toggleWrap.ts | 3 +- shared/editor/components/Image.tsx | 4 +- shared/editor/components/Styles.ts | 114 ++----------- shared/editor/embeds/index.tsx | 3 +- shared/editor/extensions/Mermaid.ts | 160 +++++++++++------- shared/editor/extensions/PasteHandler.ts | 4 +- shared/editor/extensions/Prism.ts | 2 +- shared/editor/lib/Extension.ts | 3 +- shared/editor/lib/ExtensionManager.ts | 7 +- shared/editor/lib/isCode.ts | 5 + shared/editor/nodes/Attachment.tsx | 3 +- shared/editor/nodes/CodeFence.ts | 188 ++++++--------------- shared/editor/nodes/Embed.tsx | 3 +- shared/editor/nodes/Emoji.tsx | 3 +- shared/editor/nodes/Heading.ts | 3 +- shared/editor/nodes/HorizontalRule.ts | 3 +- shared/editor/nodes/Mention.ts | 3 +- shared/editor/nodes/Notice.tsx | 30 +--- shared/editor/nodes/SimpleImage.tsx | 3 +- shared/editor/queries/isNodeActive.ts | 3 +- shared/editor/types/index.ts | 6 +- shared/styles/theme.ts | 8 - 42 files changed, 474 insertions(+), 469 deletions(-) create mode 100644 app/editor/menus/code.tsx create mode 100644 shared/editor/lib/isCode.ts diff --git a/app/components/ContextMenu/MenuItem.tsx b/app/components/ContextMenu/MenuItem.tsx index 1136af981..dace2fba3 100644 --- a/app/components/ContextMenu/MenuItem.tsx +++ b/app/components/ContextMenu/MenuItem.tsx @@ -8,6 +8,7 @@ import breakpoint from "styled-components-breakpoint"; import MenuIconWrapper from "../MenuIconWrapper"; type Props = { + id?: string; onClick?: (event: React.SyntheticEvent) => void | Promise; active?: boolean; selected?: boolean; @@ -37,34 +38,26 @@ const MenuItem = ( }: Props, ref: React.Ref ) => { - const handleClick = React.useCallback( - async (ev) => { - hide?.(); + const content = React.useCallback( + (props) => { + const handleClick = async (ev: React.MouseEvent) => { + hide?.(); - if (onClick) { + if (onClick) { + ev.preventDefault(); + await onClick(ev); + } + }; + + // Preventing default mousedown otherwise menu items do not work in Firefox, + // which triggers the hideOnClickOutside handler first via mousedown – hiding + // and un-rendering the menu contents. + const handleMouseDown = (ev: React.MouseEvent) => { ev.preventDefault(); - await onClick(ev); - } - }, - [onClick, hide] - ); + ev.stopPropagation(); + }; - // Preventing default mousedown otherwise menu items do not work in Firefox, - // which triggers the hideOnClickOutside handler first via mousedown – hiding - // and un-rendering the menu contents. - const handleMouseDown = React.useCallback((ev) => { - ev.preventDefault(); - ev.stopPropagation(); - }, []); - - return ( - - {(props) => ( + return ( {icon}} {children} - )} + ); + }, + [active, as, hide, icon, onClick, ref, selected] + ); + + return ( + + {content} ); }; diff --git a/app/components/ContextMenu/Template.tsx b/app/components/ContextMenu/Template.tsx index 1b68bfc84..4ebebaada 100644 --- a/app/components/ContextMenu/Template.tsx +++ b/app/components/ContextMenu/Template.tsx @@ -135,6 +135,7 @@ function Template({ items, actions, context, ...menu }: Props) { return ( } diff --git a/app/components/ContextMenu/index.tsx b/app/components/ContextMenu/index.tsx index c615f6efc..f44f9afd6 100644 --- a/app/components/ContextMenu/index.tsx +++ b/app/components/ContextMenu/index.tsx @@ -149,7 +149,7 @@ const ContextMenu: React.FC = ({ style={ maxHeight && topAnchor ? { - maxHeight, + maxHeight: `min(${maxHeight}px, 75vh)`, } : undefined } diff --git a/app/editor/components/FloatingToolbar.tsx b/app/editor/components/FloatingToolbar.tsx index e287165a0..ac64005b9 100644 --- a/app/editor/components/FloatingToolbar.tsx +++ b/app/editor/components/FloatingToolbar.tsx @@ -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( ` +}; + +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` 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; diff --git a/app/editor/components/Input.tsx b/app/editor/components/Input.tsx index 0b90a8044..574232f61 100644 --- a/app/editor/components/Input.tsx +++ b/app/editor/components/Input.tsx @@ -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; diff --git a/app/editor/components/LinkEditor.tsx b/app/editor/components/LinkEditor.tsx index 09c17657f..df5ebd027 100644 --- a/app/editor/components/LinkEditor.tsx +++ b/app/editor/components/LinkEditor.tsx @@ -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 { } 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; diff --git a/app/editor/components/LinkSearchResult.tsx b/app/editor/components/LinkSearchResult.tsx index 4328476ef..e9eec25a1 100644 --- a/app/editor/components/LinkSearchResult.tsx +++ b/app/editor/components/LinkSearchResult.tsx @@ -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; diff --git a/app/editor/components/SelectionToolbar.tsx b/app/editor/components/SelectionToolbar.tsx index 380d79719..f9e5609b3 100644 --- a/app/editor/components/SelectionToolbar.tsx +++ b/app/editor/components/SelectionToolbar.tsx @@ -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); diff --git a/app/editor/components/SuggestionsMenu.tsx b/app/editor/components/SuggestionsMenu.tsx index 3d6bc0b0a..4e4b4dcff 100644 --- a/app/editor/components/SuggestionsMenu.tsx +++ b/app/editor/components/SuggestionsMenu.tsx @@ -212,11 +212,13 @@ function SuggestionsMenu(props: Props) { 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; diff --git a/app/editor/components/ToolbarButton.tsx b/app/editor/components/ToolbarButton.tsx index 288470a6f..0ffbb79b1 100644 --- a/app/editor/components/ToolbarButton.tsx +++ b/app/editor/components/ToolbarButton.tsx @@ -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))}; + `}; `; diff --git a/app/editor/components/ToolbarMenu.tsx b/app/editor/components/ToolbarMenu.tsx index bb10a77fa..2c9bd9d08 100644 --- a/app/editor/components/ToolbarMenu.tsx +++ b/app/editor/components/ToolbarMenu.tsx @@ -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 ( + <> + + {(props) => ( + + {item.label && } + + + )} + + +