chore: Move editor into codebase (#2930)

This commit is contained in:
Tom Moor
2022-01-19 18:43:15 -08:00
committed by GitHub
parent 266f8c96c4
commit 062016b164
216 changed files with 12417 additions and 382 deletions

View File

@@ -0,0 +1,50 @@
import { findParentNode } from "prosemirror-utils";
import React from "react";
import getMenuItems from "../menus/block";
import BlockMenuItem from "./BlockMenuItem";
import CommandMenu, { Props } from "./CommandMenu";
type BlockMenuProps = Omit<
Props,
"renderMenuItem" | "items" | "onClearSearch"
> &
Required<Pick<Props, "onLinkToolbarOpen" | "embeds">>;
class BlockMenu extends React.Component<BlockMenuProps> {
get items() {
return getMenuItems(this.props.dictionary);
}
clearSearch = () => {
const { state, dispatch } = this.props.view;
const parent = findParentNode((node) => !!node)(state.selection);
if (parent) {
dispatch(state.tr.insertText("", parent.pos, state.selection.to));
}
};
render() {
return (
<CommandMenu
{...this.props}
filterable={true}
onClearSearch={this.clearSearch}
renderMenuItem={(item, _index, options) => {
return (
<BlockMenuItem
onClick={options.onClick}
selected={options.selected}
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
/>
);
}}
items={this.items}
/>
);
}
}
export default BlockMenu;

View File

@@ -0,0 +1,110 @@
import * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
export type Props = {
selected: boolean;
disabled?: boolean;
onClick: () => void;
icon?: typeof React.Component | React.FC<any>;
title: React.ReactNode;
shortcut?: string;
containerId?: string;
};
function BlockMenuItem({
selected,
disabled,
onClick,
title,
shortcut,
icon,
containerId = "block-menu-container",
}: Props) {
const Icon = icon;
const theme = useTheme();
const ref = React.useCallback(
(node) => {
if (selected && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
block: "center",
boundary: (parent) => {
// All the parent elements of your target are checked until they
// reach the #block-menu-container. Prevents body and other parent
// elements from being scrolled
return parent.id !== containerId;
},
});
}
},
[selected, containerId]
);
return (
<MenuItem
selected={selected}
onClick={disabled ? undefined : onClick}
ref={ref}
>
{Icon && (
<>
<Icon
color={
selected ? theme.blockToolbarIconSelected : theme.blockToolbarIcon
}
/>
&nbsp;&nbsp;
</>
)}
{title}
{shortcut && <Shortcut>{shortcut}</Shortcut>}
</MenuItem>
);
}
const MenuItem = styled.button<{
selected: boolean;
}>`
display: flex;
align-items: center;
justify-content: flex-start;
font-weight: 500;
font-size: 14px;
line-height: 1;
width: 100%;
height: 36px;
cursor: pointer;
border: none;
opacity: ${(props) => (props.disabled ? ".5" : "1")};
color: ${(props) =>
props.selected
? props.theme.blockToolbarTextSelected
: props.theme.blockToolbarText};
background: ${(props) =>
props.selected
? props.theme.blockToolbarSelectedBackground ||
props.theme.blockToolbarTrigger
: "none"};
padding: 0 16px;
outline: none;
&:hover,
&:active {
color: ${(props) => props.theme.blockToolbarTextSelected};
background: ${(props) =>
props.selected
? props.theme.blockToolbarSelectedBackground ||
props.theme.blockToolbarTrigger
: props.theme.blockToolbarHoverBackground};
}
`;
const Shortcut = styled.span`
color: ${(props) => props.theme.textSecondary};
flex-grow: 1;
text-align: right;
`;
export default BlockMenuItem;

View File

@@ -0,0 +1,614 @@
import { capitalize } from "lodash";
import { findDomRefAtPos, findParentNode } from "prosemirror-utils";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { Portal } from "react-portal";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import insertFiles from "@shared/editor/commands/insertFiles";
import { CommandFactory } from "@shared/editor/lib/Extension";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { EmbedDescriptor, MenuItem, ToastType } from "@shared/editor/types";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import { Dictionary } from "~/hooks/useDictionary";
import Input from "./Input";
const defaultPosition = {
left: -1000,
top: 0,
bottom: undefined,
isAbove: false,
};
export type Props<T extends MenuItem = MenuItem> = {
rtl: boolean;
isActive: boolean;
commands: Record<string, CommandFactory>;
dictionary: Dictionary;
view: EditorView;
search: string;
uploadImage?: (file: File) => Promise<string>;
onImageUploadStart?: () => void;
onImageUploadStop?: () => void;
onShowToast?: (message: string, id: string) => void;
onLinkToolbarOpen?: () => void;
onClose: () => void;
onClearSearch: () => void;
embeds?: EmbedDescriptor[];
renderMenuItem: (
item: T,
index: number,
options: {
selected: boolean;
onClick: () => void;
}
) => React.ReactNode;
filterable?: boolean;
items: T[];
id?: string;
};
type State = {
insertItem?: EmbedDescriptor;
left?: number;
top?: number;
bottom?: number;
isAbove: boolean;
selectedIndex: number;
};
class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
menuRef = React.createRef<HTMLDivElement>();
inputRef = React.createRef<HTMLInputElement>();
state: State = {
left: -1000,
top: 0,
bottom: undefined,
isAbove: false,
selectedIndex: 0,
insertItem: undefined,
};
componentDidMount() {
window.addEventListener("keydown", this.handleKeyDown);
}
shouldComponentUpdate(nextProps: Props, nextState: State) {
return (
nextProps.search !== this.props.search ||
nextProps.isActive !== this.props.isActive ||
nextState !== this.state
);
}
componentDidUpdate(prevProps: Props) {
if (!prevProps.isActive && this.props.isActive) {
const position = this.calculatePosition(this.props);
this.setState({
insertItem: undefined,
selectedIndex: 0,
...position,
});
} else if (prevProps.search !== this.props.search) {
this.setState({ selectedIndex: 0 });
}
}
componentWillUnmount() {
window.removeEventListener("keydown", this.handleKeyDown);
}
handleKeyDown = (event: KeyboardEvent) => {
if (!this.props.isActive) return;
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
const item = this.filtered[this.state.selectedIndex];
if (item) {
this.insertItem(item);
} else {
this.props.onClose();
}
}
if (
event.key === "ArrowUp" ||
(event.key === "Tab" && event.shiftKey) ||
(event.ctrlKey && event.key === "p")
) {
event.preventDefault();
event.stopPropagation();
if (this.filtered.length) {
const prevIndex = this.state.selectedIndex - 1;
const prev = this.filtered[prevIndex];
this.setState({
selectedIndex: Math.max(
0,
prev && prev.name === "separator" ? prevIndex - 1 : prevIndex
),
});
} else {
this.close();
}
}
if (
event.key === "ArrowDown" ||
(event.key === "Tab" && !event.shiftKey) ||
(event.ctrlKey && event.key === "n")
) {
event.preventDefault();
event.stopPropagation();
if (this.filtered.length) {
const total = this.filtered.length - 1;
const nextIndex = this.state.selectedIndex + 1;
const next = this.filtered[nextIndex];
this.setState({
selectedIndex: Math.min(
next && next.name === "separator" ? nextIndex + 1 : nextIndex,
total
),
});
} else {
this.close();
}
}
if (event.key === "Escape") {
this.close();
}
};
insertItem = (item: any) => {
switch (item.name) {
case "image":
return this.triggerImagePick();
case "embed":
return this.triggerLinkInput(item);
case "link": {
this.clearSearch();
this.props.onClose();
this.props.onLinkToolbarOpen?.();
return;
}
default:
this.insertBlock(item);
}
};
close = () => {
this.props.onClose();
this.props.view.focus();
};
handleLinkInputKeydown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!this.props.isActive) return;
if (!this.state.insertItem) return;
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
const href = event.currentTarget.value;
const matches = this.state.insertItem.matcher(href);
if (!matches && this.props.onShowToast) {
this.props.onShowToast(
this.props.dictionary.embedInvalidLink,
ToastType.Error
);
return;
}
this.insertBlock({
name: "embed",
attrs: {
href,
},
});
}
if (event.key === "Escape") {
this.props.onClose();
this.props.view.focus();
}
};
handleLinkInputPaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
if (!this.props.isActive) return;
if (!this.state.insertItem) return;
const href = event.clipboardData.getData("text/plain");
const matches = this.state.insertItem.matcher(href);
if (matches) {
event.preventDefault();
event.stopPropagation();
this.insertBlock({
name: "embed",
attrs: {
href,
},
});
}
};
triggerImagePick = () => {
if (this.inputRef.current) {
this.inputRef.current.click();
}
};
triggerLinkInput = (item: EmbedDescriptor) => {
this.setState({ insertItem: item });
};
handleImagePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = getDataTransferFiles(event);
const {
view,
uploadImage,
onImageUploadStart,
onImageUploadStop,
onShowToast,
} = this.props;
const { state } = view;
const parent = findParentNode((node) => !!node)(state.selection);
this.clearSearch();
if (!uploadImage) {
throw new Error("uploadImage prop is required to replace images");
}
if (parent) {
insertFiles(view, event, parent.pos, files, {
uploadImage,
onImageUploadStart,
onImageUploadStop,
onShowToast,
dictionary: this.props.dictionary,
});
}
if (this.inputRef.current) {
this.inputRef.current.value = "";
}
this.props.onClose();
};
clearSearch = () => {
this.props.onClearSearch();
};
insertBlock(item: MenuItem) {
this.clearSearch();
const command = item.name ? this.props.commands[item.name] : undefined;
if (command) {
command(item.attrs);
} else {
this.props.commands[`create${capitalize(item.name)}`](item.attrs);
}
this.props.onClose();
}
get caretPosition(): { top: number; left: number } {
const selection = window.document.getSelection();
if (!selection || !selection.anchorNode || !selection.focusNode) {
return {
top: 0,
left: 0,
};
}
const range = window.document.createRange();
range.setStart(selection.anchorNode, selection.anchorOffset);
range.setEnd(selection.focusNode, selection.focusOffset);
// This is a workaround for an edgecase where getBoundingClientRect will
// return zero values if the selection is collapsed at the start of a newline
// see reference here: https://stackoverflow.com/a/59780954
const rects = range.getClientRects();
if (rects.length === 0) {
// probably buggy newline behavior, explicitly select the node contents
if (range.startContainer && range.collapsed) {
range.selectNodeContents(range.startContainer);
}
}
const rect = range.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
};
}
calculatePosition(props: Props) {
const { view } = props;
const { selection } = view.state;
let startPos;
try {
startPos = view.coordsAtPos(selection.from);
} catch (err) {
console.warn(err);
return defaultPosition;
}
const domAtPos = view.domAtPos.bind(view);
const ref = this.menuRef.current;
const offsetHeight = ref ? ref.offsetHeight : 0;
const node = findDomRefAtPos(selection.from, domAtPos);
const paragraph: any = { node };
if (
!props.isActive ||
!paragraph.node ||
!paragraph.node.getBoundingClientRect
) {
return defaultPosition;
}
const { left } = this.caretPosition;
const { top, bottom, right } = paragraph.node.getBoundingClientRect();
const margin = 24;
let leftPos = left + window.scrollX;
if (props.rtl && ref) {
leftPos = right - ref.scrollWidth;
}
if (startPos.top - offsetHeight > margin) {
return {
left: leftPos,
top: undefined,
bottom: window.innerHeight - top - window.scrollY,
isAbove: false,
};
} else {
return {
left: leftPos,
top: bottom + window.scrollY,
bottom: undefined,
isAbove: true,
};
}
}
get filtered() {
const {
embeds = [],
search = "",
uploadImage,
commands,
filterable = true,
} = this.props;
let items: (EmbedDescriptor | MenuItem)[] = this.props.items;
const embedItems: EmbedDescriptor[] = [];
for (const embed of embeds) {
if (embed.title && embed.icon) {
embedItems.push({
...embed,
name: "embed",
});
}
}
if (embedItems.length) {
items.push({
name: "separator",
});
items = items.concat(embedItems);
}
const filtered = items.filter((item) => {
if (item.name === "separator") return true;
// Some extensions may be disabled, remove corresponding menu items
if (
item.name &&
!commands[item.name] &&
!commands[`create${capitalize(item.name)}`]
) {
return false;
}
// If no image upload callback has been passed, filter the image block out
if (!uploadImage && item.name === "image") return false;
// some items (defaultHidden) are not visible until a search query exists
if (!search) return !item.defaultHidden;
const n = search.toLowerCase();
if (!filterable) {
return item;
}
return (
(item.title || "").toLowerCase().includes(n) ||
(item.keywords || "").toLowerCase().includes(n)
);
});
return filterExcessSeparators(filtered);
}
render() {
const { dictionary, isActive, uploadImage } = this.props;
const items = this.filtered;
const { insertItem, ...positioning } = this.state;
return (
<Portal>
<Wrapper
id={this.props.id || "block-menu-container"}
active={isActive}
ref={this.menuRef}
{...positioning}
>
{insertItem ? (
<LinkInputWrapper>
<LinkInput
type="text"
placeholder={
insertItem.title
? dictionary.pasteLinkWithTitle(insertItem.title)
: dictionary.pasteLink
}
onKeyDown={this.handleLinkInputKeydown}
onPaste={this.handleLinkInputPaste}
autoFocus
/>
</LinkInputWrapper>
) : (
<List>
{items.map((item, index) => {
if (item.name === "separator") {
return (
<ListItem key={index}>
<hr />
</ListItem>
);
}
const selected = index === this.state.selectedIndex && isActive;
if (!item.title) {
return null;
}
return (
<ListItem key={index}>
{this.props.renderMenuItem(item as any, index, {
selected,
onClick: () => this.insertItem(item),
})}
</ListItem>
);
})}
{items.length === 0 && (
<ListItem>
<Empty>{dictionary.noResults}</Empty>
</ListItem>
)}
</List>
)}
{uploadImage && (
<VisuallyHidden>
<input
type="file"
ref={this.inputRef}
onChange={this.handleImagePicked}
accept="image/*"
/>
</VisuallyHidden>
)}
</Wrapper>
</Portal>
);
}
}
const LinkInputWrapper = styled.div`
margin: 8px;
`;
const LinkInput = styled(Input)`
height: 36px;
width: 100%;
color: ${(props) => props.theme.blockToolbarText};
`;
const List = styled.ol`
list-style: none;
text-align: left;
height: 100%;
padding: 8px 0;
margin: 0;
`;
const ListItem = styled.li`
padding: 0;
margin: 0;
`;
const Empty = styled.div`
display: flex;
align-items: center;
color: ${(props) => props.theme.textSecondary};
font-weight: 500;
font-size: 14px;
height: 36px;
padding: 0 16px;
`;
export const Wrapper = styled.div<{
active: boolean;
top?: number;
bottom?: number;
left?: number;
isAbove: boolean;
}>`
color: ${(props) => props.theme.text};
font-family: ${(props) => props.theme.fontFamily};
position: absolute;
z-index: ${(props) => props.theme.zIndex + 100};
${(props) => props.top !== undefined && `top: ${props.top}px`};
${(props) => props.bottom !== undefined && `bottom: ${props.bottom}px`};
left: ${(props) => props.left}px;
background-color: ${(props) => props.theme.blockToolbarBackground};
border-radius: 4px;
box-shadow: rgba(0, 0, 0, 0.05) 0px 0px 0px 1px,
rgba(0, 0, 0, 0.08) 0px 4px 8px, rgba(0, 0, 0, 0.08) 0px 2px 4px;
opacity: 0;
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;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
width: 300px;
max-height: 224px;
overflow: hidden;
overflow-y: auto;
* {
box-sizing: border-box;
}
hr {
border: 0;
height: 0;
border-top: 1px solid ${(props) => props.theme.blockToolbarDivider};
}
${({ active, isAbove }) =>
active &&
`
transform: translateY(${isAbove ? "6px" : "-6px"}) scale(1);
pointer-events: all;
opacity: 1;
`};
@media print {
display: none;
}
`;
export default CommandMenu;

View File

@@ -0,0 +1,116 @@
import { Node as ProsemirrorNode } from "prosemirror-model";
import { EditorView, Decoration } from "prosemirror-view";
import * as React from "react";
import ReactDOM from "react-dom";
import { ThemeProvider } from "styled-components";
import Extension from "@shared/editor/lib/Extension";
import { ComponentProps } from "@shared/editor/types";
import { Editor } from "~/editor";
type Component = (props: ComponentProps) => React.ReactElement;
export default class ComponentView {
component: Component;
editor: Editor;
extension: Extension;
node: ProsemirrorNode;
view: EditorView;
getPos: () => number;
decorations: Decoration<{
[key: string]: any;
}>[];
isSelected = false;
dom: HTMLElement | null;
// See https://prosemirror.net/docs/ref/#view.NodeView
constructor(
component: Component,
{
editor,
extension,
node,
view,
getPos,
decorations,
}: {
editor: Editor;
extension: Extension;
node: ProsemirrorNode;
view: EditorView;
getPos: () => number;
decorations: Decoration<{
[key: string]: any;
}>[];
}
) {
this.component = component;
this.editor = editor;
this.extension = extension;
this.getPos = getPos;
this.decorations = decorations;
this.node = node;
this.view = view;
this.dom = node.type.spec.inline
? document.createElement("span")
: document.createElement("div");
this.renderElement();
}
renderElement() {
const { theme } = this.editor.props;
const children = this.component({
theme,
node: this.node,
isSelected: this.isSelected,
isEditable: this.view.editable,
getPos: this.getPos,
});
ReactDOM.render(
<ThemeProvider theme={theme}>{children}</ThemeProvider>,
this.dom
);
}
update(node: ProsemirrorNode) {
if (node.type !== this.node.type) {
return false;
}
this.node = node;
this.renderElement();
return true;
}
selectNode() {
if (this.view.editable) {
this.isSelected = true;
this.renderElement();
}
}
deselectNode() {
if (this.view.editable) {
this.isSelected = false;
this.renderElement();
}
}
stopEvent() {
return true;
}
destroy() {
if (this.dom) {
ReactDOM.unmountComponentAtNode(this.dom);
}
this.dom = null;
}
ignoreMutation() {
return true;
}
}

View File

@@ -0,0 +1,90 @@
import FuzzySearch from "fuzzy-search";
import gemojies from "gemoji";
import React from "react";
import CommandMenu, { Props } from "./CommandMenu";
import EmojiMenuItem from "./EmojiMenuItem";
type Emoji = {
name: string;
title: string;
emoji: string;
description: string;
attrs: { markup: string; "data-name": string };
};
const searcher = new FuzzySearch<{
names: string[];
description: string;
emoji: string;
}>(gemojies, ["names"], {
caseSensitive: true,
sort: true,
});
class EmojiMenu extends React.Component<
Omit<
Props<Emoji>,
| "renderMenuItem"
| "items"
| "onLinkToolbarOpen"
| "embeds"
| "onClearSearch"
>
> {
get items(): Emoji[] {
const { search = "" } = this.props;
const n = search.toLowerCase();
const result = searcher.search(n).map((item) => {
const description = item.description;
const name = item.names[0];
return {
...item,
name: "emoji",
title: name,
description,
attrs: { markup: name, "data-name": name },
};
});
return result.slice(0, 10);
}
clearSearch = () => {
const { state, dispatch } = this.props.view;
// clear search input
dispatch(
state.tr.insertText(
"",
state.selection.$from.pos - (this.props.search ?? "").length - 1,
state.selection.to
)
);
};
render() {
return (
<CommandMenu
{...this.props}
id="emoji-menu-container"
filterable={false}
onClearSearch={this.clearSearch}
renderMenuItem={(item, _index, options) => {
return (
<EmojiMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.description}
emoji={item.emoji}
containerId="emoji-menu-container"
/>
);
}}
items={this.items}
/>
);
}
}
export default EmojiMenu;

View File

@@ -0,0 +1,35 @@
import * as React from "react";
import styled from "styled-components";
import BlockMenuItem, { Props as BlockMenuItemProps } from "./BlockMenuItem";
const Emoji = styled.span`
font-size: 16px;
`;
type Props = {
emoji: React.ReactNode;
title: React.ReactNode;
};
const EmojiTitle = ({ emoji, title }: Props) => {
return (
<p>
<Emoji className="emoji">{emoji}</Emoji>
&nbsp;&nbsp;
{title}
</p>
);
};
type EmojiMenuItemProps = Omit<BlockMenuItemProps, "shortcut" | "theme"> & {
emoji: string;
};
export default function EmojiMenuItem(props: EmojiMenuItemProps) {
return (
<BlockMenuItem
{...props}
title={<EmojiTitle emoji={props.emoji} title={props.title} />}
/>
);
}

View File

@@ -0,0 +1,267 @@
import { NodeSelection } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import useComponentSize from "~/hooks/useComponentSize";
import useMediaQuery from "~/hooks/useMediaQuery";
import useViewportHeight from "~/hooks/useViewportHeight";
type Props = {
active?: boolean;
view: EditorView;
children: React.ReactNode;
forwardedRef?: React.RefObject<HTMLDivElement> | null;
};
const defaultPosition = {
left: -1000,
top: 0,
offset: 0,
visible: false,
};
function usePosition({
menuRef,
isSelectingText,
props,
}: {
menuRef: React.RefObject<HTMLDivElement>;
isSelectingText: boolean;
props: Props;
}) {
const { view, active } = props;
const { selection } = view.state;
const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef);
const viewportHeight = useViewportHeight();
const isTouchDevice = useMediaQuery("(hover: none) and (pointer: coarse)");
if (!active || !menuWidth || !menuHeight || isSelectingText) {
return defaultPosition;
}
// If we're on a mobile device then stick the floating toolbar to the bottom
// of the screen above the virtual keyboard.
if (isTouchDevice && viewportHeight) {
return {
left: 0,
right: 0,
top: viewportHeight - menuHeight,
offset: 0,
visible: true,
};
}
// based on the start and end of the selection calculate the position at
// the center top
let fromPos;
let toPos;
try {
fromPos = view.coordsAtPos(selection.from);
toPos = view.coordsAtPos(selection.to, -1);
} catch (err) {
console.warn(err);
return defaultPosition;
}
// ensure that start < end for the menu to be positioned correctly
const selectionBounds = {
top: Math.min(fromPos.top, toPos.top),
bottom: Math.max(fromPos.bottom, toPos.bottom),
left: Math.min(fromPos.left, toPos.left),
right: Math.max(fromPos.right, toPos.right),
};
// tables are an oddity, and need their own positioning logic
const isColSelection =
selection instanceof CellSelection &&
selection.isColSelection &&
selection.isColSelection();
const isRowSelection =
selection instanceof CellSelection &&
selection.isRowSelection &&
selection.isRowSelection();
if (isColSelection) {
const { node: element } = view.domAtPos(selection.from);
const { width } = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top -= 20;
selectionBounds.right = selectionBounds.left + width;
}
if (isRowSelection) {
selectionBounds.right = selectionBounds.left = selectionBounds.left - 18;
}
const isImageSelection =
selection instanceof NodeSelection && selection.node?.type.name === "image";
// Images need their own positioning to get the toolbar in the center
if (isImageSelection) {
const element = view.nodeDOM(selection.from);
// Images are wrapped which impacts positioning - need to traverse through
// p > span > div.image
const imageElement = (element as HTMLElement).getElementsByTagName(
"img"
)[0];
const { left, top, width } = imageElement.getBoundingClientRect();
return {
left: Math.round(left + width / 2 + window.scrollX - menuWidth / 2),
top: Math.round(top + window.scrollY - menuHeight),
offset: 0,
visible: true,
};
} else {
// calcluate the horizontal center of the selection
const halfSelection =
Math.abs(selectionBounds.right - selectionBounds.left) / 2;
const centerOfSelection = selectionBounds.left + halfSelection;
// position the menu so that it is centered over the selection except in
// the cases where it would extend off the edge of the screen. In these
// instances leave a margin
const margin = 12;
const left = Math.min(
window.innerWidth - menuWidth - margin,
Math.max(margin, centerOfSelection - menuWidth / 2)
);
const top = Math.min(
window.innerHeight - menuHeight - margin,
Math.max(margin, selectionBounds.top - menuHeight)
);
// if the menu has been offset to not extend offscreen then we should adjust
// the position of the triangle underneath to correctly point to the center
// of the selection still
const offset = left - (centerOfSelection - menuWidth / 2);
return {
left: Math.round(left + window.scrollX),
top: Math.round(top + window.scrollY),
offset: Math.round(offset),
visible: true,
};
}
}
function FloatingToolbar(props: Props) {
const menuRef = props.forwardedRef || React.createRef<HTMLDivElement>();
const [isSelectingText, setSelectingText] = React.useState(false);
const position = usePosition({
menuRef,
isSelectingText,
props,
});
React.useEffect(() => {
const handleMouseDown = () => {
if (!props.active) {
setSelectingText(true);
}
};
const handleMouseUp = () => {
setSelectingText(false);
};
window.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [props.active]);
// only render children when state is updated to visible
// to prevent gaining input focus before calculatePosition runs
return (
<Portal>
<Wrapper
active={props.active && position.visible}
ref={menuRef}
offset={position.offset}
style={{
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
{position.visible && props.children}
</Wrapper>
</Portal>
);
}
const Wrapper = styled.div<{
active?: boolean;
offset: number;
}>`
will-change: opacity, transform;
padding: 8px 16px;
position: absolute;
z-index: ${(props) => props.theme.zIndex + 100};
opacity: 0;
background-color: ${(props) => props.theme.toolbarBackground};
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;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
&::before {
content: "";
display: block;
width: 24px;
height: 24px;
transform: translateX(-50%) rotate(45deg);
background: ${(props) => props.theme.toolbarBackground};
border-radius: 3px;
z-index: -1;
position: absolute;
bottom: -2px;
left: calc(50% - ${(props) => props.offset || 0}px);
pointer-events: none;
}
* {
box-sizing: border-box;
}
${({ active }) =>
active &&
`
transform: translateY(-6px) scale(1);
opacity: 1;
`};
@media print {
display: none;
}
@media (hover: none) and (pointer: coarse) {
&:before {
display: none;
}
transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transform: scale(1);
border-radius: 0;
width: 100vw;
position: fixed;
}
`;
export default React.forwardRef(function FloatingToolbarWithForwardedRef(
props: Props,
ref: React.RefObject<HTMLDivElement>
) {
return <FloatingToolbar {...props} forwardedRef={ref} />;
});

View File

@@ -0,0 +1,19 @@
import styled from "styled-components";
const Input = styled.input`
font-size: 15px;
background: ${(props) => props.theme.toolbarInput};
color: ${(props) => props.theme.toolbarItem};
border-radius: 2px;
padding: 3px 8px;
border: 0;
margin: 0;
outline: none;
flex-grow: 1;
@media (hover: none) and (pointer: coarse) {
font-size: 16px;
}
`;
export default Input;

View File

@@ -0,0 +1,403 @@
import {
DocumentIcon,
CloseIcon,
PlusIcon,
TrashIcon,
OpenIcon,
} from "outline-icons";
import { Mark } from "prosemirror-model";
import { setTextSelection } from "prosemirror-utils";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import styled from "styled-components";
import isUrl from "@shared/editor/lib/isUrl";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
import Input from "./Input";
import LinkSearchResult from "./LinkSearchResult";
import ToolbarButton from "./ToolbarButton";
import Tooltip from "./Tooltip";
export type SearchResult = {
title: string;
subtitle?: string;
url: string;
};
type Props = {
mark?: Mark;
from: number;
to: number;
dictionary: Dictionary;
onRemoveLink?: () => void;
onCreateLink?: (title: string) => Promise<void>;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onSelectLink: (options: {
href: string;
title?: string;
from: number;
to: number;
}) => void;
onClickLink: (
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onShowToast?: (message: string, code: string) => void;
view: EditorView;
};
type State = {
results: {
[keyword: string]: SearchResult[];
};
value: string;
previousValue: string;
selectedIndex: number;
};
class LinkEditor extends React.Component<Props, State> {
discardInputValue = false;
initialValue = this.href;
initialSelectionLength = this.props.to - this.props.from;
state: State = {
selectedIndex: -1,
value: this.href,
previousValue: "",
results: {},
};
get href(): string {
return this.props.mark ? this.props.mark.attrs.href : "";
}
get suggestedLinkTitle(): string {
const { state } = this.props.view;
const { value } = this.state;
const selectionText = state.doc.cut(
state.selection.from,
state.selection.to
).textContent;
return value.trim() || selectionText.trim();
}
componentWillUnmount = () => {
// If we discarded the changes then nothing to do
if (this.discardInputValue) {
return;
}
// If the link is the same as it was when the editor opened, nothing to do
if (this.state.value === this.initialValue) {
return;
}
// If the link is totally empty or only spaces then remove the mark
const href = (this.state.value || "").trim();
if (!href) {
return this.handleRemoveLink();
}
this.save(href, href);
};
save = (href: string, title?: string): void => {
href = href.trim();
if (href.length === 0) return;
this.discardInputValue = true;
const { from, to } = this.props;
// Make sure a protocol is added to the beginning of the input if it's
// likely an absolute URL that was entered without one.
if (
!isUrl(href) &&
!href.startsWith("/") &&
!href.startsWith("#") &&
!href.startsWith("mailto:")
) {
href = `https://${href}`;
}
this.props.onSelectLink({ href, title, from, to });
};
handleKeyDown = (event: React.KeyboardEvent): void => {
switch (event.key) {
case "Enter": {
event.preventDefault();
const { selectedIndex, value } = this.state;
const results = this.state.results[value] || [];
const { onCreateLink } = this.props;
if (selectedIndex >= 0) {
const result = results[selectedIndex];
if (result) {
this.save(result.url, result.title);
} else if (onCreateLink && selectedIndex === results.length) {
this.handleCreateLink(this.suggestedLinkTitle);
}
} else {
// saves the raw input as href
this.save(value, value);
}
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
return;
}
case "Escape": {
event.preventDefault();
if (this.initialValue) {
this.setState({ value: this.initialValue }, this.moveSelectionToEnd);
} else {
this.handleRemoveLink();
}
return;
}
case "ArrowUp": {
if (event.shiftKey) return;
event.preventDefault();
event.stopPropagation();
const prevIndex = this.state.selectedIndex - 1;
this.setState({
selectedIndex: Math.max(-1, prevIndex),
});
return;
}
case "ArrowDown":
case "Tab": {
if (event.shiftKey) return;
event.preventDefault();
event.stopPropagation();
const { selectedIndex, value } = this.state;
const results = this.state.results[value] || [];
const total = results.length;
const nextIndex = selectedIndex + 1;
this.setState({
selectedIndex: Math.min(nextIndex, total),
});
return;
}
}
};
handleFocusLink = (selectedIndex: number) => {
this.setState({ selectedIndex });
};
handleChange = async (
event: React.ChangeEvent<HTMLInputElement>
): Promise<void> => {
const value = event.target.value;
this.setState({
value,
selectedIndex: -1,
});
const trimmedValue = value.trim();
if (trimmedValue && this.props.onSearchLink) {
try {
const results = await this.props.onSearchLink(trimmedValue);
this.setState((state) => ({
results: {
...state.results,
[trimmedValue]: results,
},
previousValue: trimmedValue,
}));
} catch (error) {
console.error(error);
}
}
};
handlePaste = (): void => {
setTimeout(() => this.save(this.state.value, this.state.value), 0);
};
handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
this.props.onClickLink(this.href, event);
};
handleCreateLink = async (value: string) => {
this.discardInputValue = true;
const { onCreateLink } = this.props;
value = value.trim();
if (value.length === 0) return;
if (onCreateLink) return onCreateLink(value);
};
handleRemoveLink = (): void => {
this.discardInputValue = true;
const { from, to, mark, view, onRemoveLink } = this.props;
const { state, dispatch } = this.props.view;
if (mark) {
dispatch(state.tr.removeMark(from, to, mark));
}
if (onRemoveLink) {
onRemoveLink();
}
view.focus();
};
handleSelectLink = (url: string, title: string) => (
event: React.MouseEvent
) => {
event.preventDefault();
this.save(url, title);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
};
moveSelectionToEnd = () => {
const { to, view } = this.props;
const { state, dispatch } = view;
dispatch(setTextSelection(to)(state.tr));
view.focus();
};
render() {
const { dictionary } = this.props;
const { value, selectedIndex } = this.state;
const results =
this.state.results[value.trim()] ||
this.state.results[this.state.previousValue] ||
[];
const looksLikeUrl = value.match(/^https?:\/\//i);
const suggestedLinkTitle = this.suggestedLinkTitle;
const showCreateLink =
!!this.props.onCreateLink &&
!(suggestedLinkTitle === this.initialValue) &&
suggestedLinkTitle.length > 0 &&
!looksLikeUrl;
const showResults =
!!suggestedLinkTitle && (showCreateLink || results.length > 0);
return (
<Wrapper>
<Input
value={value}
placeholder={
showCreateLink
? dictionary.findOrCreateDoc
: dictionary.searchOrPasteLink
}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
onChange={this.handleChange}
autoFocus={this.href === ""}
/>
<Tooltip tooltip={dictionary.openLink}>
<ToolbarButton onClick={this.handleOpenLink} disabled={!value}>
<OpenIcon color="currentColor" />
</ToolbarButton>
</Tooltip>
<Tooltip tooltip={dictionary.removeLink}>
<ToolbarButton onClick={this.handleRemoveLink}>
{this.initialValue ? (
<TrashIcon color="currentColor" />
) : (
<CloseIcon color="currentColor" />
)}
</ToolbarButton>
</Tooltip>
{showResults && (
<SearchResults id="link-search-results">
{results.map((result, index) => (
<LinkSearchResult
key={result.url}
title={result.title}
subtitle={result.subtitle}
icon={<DocumentIcon color="currentColor" />}
onMouseOver={() => this.handleFocusLink(index)}
onClick={this.handleSelectLink(result.url, result.title)}
selected={index === selectedIndex}
/>
))}
{showCreateLink && (
<LinkSearchResult
key="create"
title={suggestedLinkTitle}
subtitle={dictionary.createNewDoc}
icon={<PlusIcon color="currentColor" />}
onMouseOver={() => this.handleFocusLink(results.length)}
onClick={() => {
this.handleCreateLink(suggestedLinkTitle);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
}}
selected={results.length === selectedIndex}
/>
)}
</SearchResults>
)}
</Wrapper>
);
}
}
const Wrapper = styled(Flex)`
margin-left: -8px;
margin-right: -8px;
min-width: 336px;
pointer-events: all;
gap: 8px;
`;
const SearchResults = styled.ol`
background: ${(props) => props.theme.toolbarBackground};
position: absolute;
top: 100%;
width: 100%;
height: auto;
left: 0;
padding: 4px 8px 8px;
margin: 0;
margin-top: -3px;
margin-bottom: 0;
border-radius: 0 0 4px 4px;
overflow-y: auto;
max-height: 25vh;
@media (hover: none) and (pointer: coarse) {
position: fixed;
top: auto;
bottom: 40px;
border-radius: 0;
max-height: 50vh;
padding: 8px 8px 4px;
}
`;
export default LinkEditor;

View File

@@ -0,0 +1,84 @@
import * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled from "styled-components";
type Props = {
onClick: (event: React.MouseEvent) => void;
onMouseOver: (event: React.MouseEvent) => void;
icon: React.ReactNode;
selected: boolean;
title: string;
subtitle?: string;
};
function LinkSearchResult({ title, subtitle, selected, icon, ...rest }: Props) {
const ref = React.useCallback(
(node: HTMLElement | null) => {
if (selected && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
block: "center",
boundary: (parent) => {
// All the parent elements of your target are checked until they
// reach the #link-search-results. Prevents body and other parent
// elements from being scrolled
return parent.id !== "link-search-results";
},
});
}
},
[selected]
);
return (
<ListItem ref={ref} compact={!subtitle} selected={selected} {...rest}>
<IconWrapper>{icon}</IconWrapper>
<div>
<Title>{title}</Title>
{subtitle ? <Subtitle selected={selected}>{subtitle}</Subtitle> : null}
</div>
</ListItem>
);
}
const IconWrapper = styled.span`
flex-shrink: 0;
margin-right: 4px;
opacity: 0.8;
color: ${(props) => props.theme.toolbarItem};
`;
const ListItem = styled.li<{
selected: boolean;
compact: boolean;
}>`
display: flex;
align-items: center;
padding: 8px;
border-radius: 2px;
color: ${(props) => props.theme.toolbarItem};
background: ${(props) =>
props.selected ? props.theme.toolbarHoverBackground : "transparent"};
font-family: ${(props) => props.theme.fontFamily};
text-decoration: none;
overflow: hidden;
white-space: nowrap;
cursor: pointer;
user-select: none;
line-height: ${(props) => (props.compact ? "inherit" : "1.2")};
height: ${(props) => (props.compact ? "28px" : "auto")};
`;
const Title = styled.div`
font-size: 14px;
font-weight: 500;
`;
const Subtitle = styled.div<{
selected: boolean;
}>`
font-size: 13px;
opacity: ${(props) => (props.selected ? 0.75 : 0.5)};
`;
export default LinkSearchResult;

View File

@@ -0,0 +1,151 @@
import { EditorView } from "prosemirror-view";
import * as React from "react";
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
import { Dictionary } from "~/hooks/useDictionary";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor, { SearchResult } from "./LinkEditor";
type Props = {
isActive: boolean;
view: EditorView;
dictionary: Dictionary;
onCreateLink?: (title: string) => Promise<string>;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onClickLink: (
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onShowToast?: (msg: string, code: string) => void;
onClose: () => void;
};
function isActive(props: Props) {
const { view } = props;
const { selection } = view.state;
try {
const paragraph = view.domAtPos(selection.from);
return props.isActive && !!paragraph.node;
} catch (err) {
return false;
}
}
export default class LinkToolbar extends React.Component<Props> {
menuRef = React.createRef<HTMLDivElement>();
state = {
left: -1000,
top: undefined,
};
componentDidMount() {
window.addEventListener("mousedown", this.handleClickOutside);
}
componentWillUnmount() {
window.removeEventListener("mousedown", this.handleClickOutside);
}
handleClickOutside = (event: Event) => {
if (
event.target instanceof HTMLElement &&
this.menuRef.current &&
this.menuRef.current.contains(event.target)
) {
return;
}
this.props.onClose();
};
handleOnCreateLink = async (title: string) => {
const { dictionary, onCreateLink, view, onClose, onShowToast } = this.props;
onClose();
this.props.view.focus();
if (!onCreateLink) {
return;
}
const { dispatch, state } = view;
const { from, to } = state.selection;
if (from !== to) {
// selection must be collapsed
return;
}
const href = `creating#${title}`;
// Insert a placeholder link
dispatch(
view.state.tr
.insertText(title, from, to)
.addMark(
from,
to + title.length,
state.schema.marks.link.create({ href })
)
);
createAndInsertLink(view, title, href, {
onCreateLink,
onShowToast,
dictionary,
});
};
handleOnSelectLink = ({
href,
title,
}: {
href: string;
title: string;
from: number;
to: number;
}) => {
const { view, onClose } = this.props;
onClose();
this.props.view.focus();
const { dispatch, state } = view;
const { from, to } = state.selection;
if (from !== to) {
// selection must be collapsed
return;
}
dispatch(
view.state.tr
.insertText(title, from, to)
.addMark(
from,
to + title.length,
state.schema.marks.link.create({ href })
)
);
};
render() {
const { onCreateLink, onClose, ...rest } = this.props;
const { selection } = this.props.view.state;
const active = isActive(this.props);
return (
<FloatingToolbar ref={this.menuRef} active={active} {...rest}>
{active && (
<LinkEditor
from={selection.from}
to={selection.to}
onCreateLink={onCreateLink ? this.handleOnCreateLink : undefined}
onSelectLink={this.handleOnSelectLink}
onRemoveLink={onClose}
{...rest}
/>
)}
</FloatingToolbar>
);
}
}

View File

@@ -0,0 +1,255 @@
import { some } from "lodash";
import { NodeSelection, TextSelection } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { Portal } from "react-portal";
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import getColumnIndex from "@shared/editor/queries/getColumnIndex";
import getMarkRange from "@shared/editor/queries/getMarkRange";
import getRowIndex from "@shared/editor/queries/getRowIndex";
import isMarkActive from "@shared/editor/queries/isMarkActive";
import isNodeActive from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
import getDividerMenuItems from "../menus/divider";
import getFormattingMenuItems from "../menus/formatting";
import getImageMenuItems from "../menus/image";
import getTableMenuItems from "../menus/table";
import getTableColMenuItems from "../menus/tableCol";
import getTableRowMenuItems from "../menus/tableRow";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor, { SearchResult } from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
type Props = {
dictionary: Dictionary;
rtl: boolean;
isTemplate: boolean;
commands: Record<string, any>;
onOpen: () => void;
onClose: () => void;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onClickLink: (
href: string,
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
onCreateLink?: (title: string) => Promise<string>;
onShowToast?: (msg: string, code: string) => void;
view: EditorView;
};
function isVisible(props: Props) {
const { view } = props;
const { selection } = view.state;
if (!selection) return false;
if (selection.empty) return false;
if (selection instanceof NodeSelection && selection.node.type.name === "hr") {
return true;
}
if (
selection instanceof NodeSelection &&
selection.node.type.name === "image"
) {
return true;
}
if (selection instanceof NodeSelection) {
return false;
}
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
return some(nodes, (n) => n.content.size);
}
export default class SelectionToolbar extends React.Component<Props> {
isActive = false;
menuRef = React.createRef<HTMLDivElement>();
componentDidUpdate(): void {
const visible = isVisible(this.props);
if (this.isActive && !visible) {
this.isActive = false;
this.props.onClose();
}
if (!this.isActive && visible) {
this.isActive = true;
this.props.onOpen();
}
}
componentDidMount(): void {
window.addEventListener("mouseup", this.handleClickOutside);
}
componentWillUnmount(): void {
window.removeEventListener("mouseup", this.handleClickOutside);
}
handleClickOutside = (ev: MouseEvent): void => {
if (
ev.target instanceof HTMLElement &&
this.menuRef.current &&
this.menuRef.current.contains(ev.target)
) {
return;
}
if (!this.isActive) {
return;
}
const { view } = this.props;
if (view.hasFocus()) {
return;
}
const { dispatch } = view;
dispatch(
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
);
};
handleOnCreateLink = async (title: string): Promise<void> => {
const { dictionary, onCreateLink, view, onShowToast } = this.props;
if (!onCreateLink) {
return;
}
const { dispatch, state } = view;
const { from, to } = state.selection;
if (from === to) {
// selection cannot be collapsed
return;
}
const href = `creating#${title}`;
const markType = state.schema.marks.link;
// Insert a placeholder link
dispatch(
view.state.tr
.removeMark(from, to, markType)
.addMark(from, to, markType.create({ href }))
);
createAndInsertLink(view, title, href, {
onCreateLink,
onShowToast,
dictionary,
});
};
handleOnSelectLink = ({
href,
from,
to,
}: {
href: string;
from: number;
to: number;
}): void => {
const { view } = this.props;
const { state, dispatch } = view;
const markType = state.schema.marks.link;
dispatch(
state.tr
.removeMark(from, to, markType)
.addMark(from, to, markType.create({ href }))
);
};
render() {
const { dictionary, onCreateLink, isTemplate, rtl, ...rest } = this.props;
const { view } = rest;
const { state } = view;
const { selection }: { selection: any } = state;
const isCodeSelection = isNodeActive(state.schema.nodes.code_block)(state);
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
// toolbar is disabled in code blocks, no bold / italic etc
if (isCodeSelection) {
return null;
}
const colIndex = getColumnIndex(
(state.selection as unknown) as CellSelection
);
const rowIndex = getRowIndex((state.selection as unknown) as CellSelection);
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 && selection.node.type.name === "image";
let isTextSelection = false;
let items: MenuItem[] = [];
if (isTableSelection) {
items = getTableMenuItems(dictionary);
} else if (colIndex !== undefined) {
items = getTableColMenuItems(state, colIndex, rtl, dictionary);
} else if (rowIndex !== undefined) {
items = getTableRowMenuItems(state, rowIndex, dictionary);
} else if (isImageSelection) {
items = getImageMenuItems(state, dictionary);
} else if (isDividerSelection) {
items = getDividerMenuItems(state, dictionary);
} else {
items = getFormattingMenuItems(state, isTemplate, dictionary);
isTextSelection = true;
}
// Some extensions may be disabled, remove corresponding items
items = items.filter((item) => {
if (item.name === "separator") return true;
if (item.name && !this.props.commands[item.name]) return false;
return true;
});
items = filterExcessSeparators(items);
if (!items.length) {
return null;
}
const selectionText = state.doc.cut(
state.selection.from,
state.selection.to
).textContent;
if (isTextSelection && !selectionText) {
return null;
}
return (
<Portal>
<FloatingToolbar
view={view}
active={isVisible(this.props)}
ref={this.menuRef}
>
{link && range ? (
<LinkEditor
dictionary={dictionary}
mark={range.mark}
from={range.from}
to={range.to}
onCreateLink={onCreateLink ? this.handleOnCreateLink : undefined}
onSelectLink={this.handleOnSelectLink}
{...rest}
/>
) : (
<ToolbarMenu items={items} {...rest} />
)}
</FloatingToolbar>
</Portal>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
import styled from "styled-components";
type Props = { active?: boolean; disabled?: boolean };
export default styled.button<Props>`
display: inline-block;
flex: 0;
width: 24px;
height: 24px;
cursor: pointer;
border: none;
background: none;
transition: opacity 100ms ease-in-out;
padding: 0;
opacity: 0.7;
outline: none;
pointer-events: all;
position: relative;
color: ${(props) => props.theme.toolbarItem};
&:hover {
opacity: 1;
}
&:disabled {
opacity: 0.3;
cursor: default;
}
&:before {
position: absolute;
content: "";
top: -4px;
right: -4px;
left: -4px;
bottom: -4px;
}
${(props) => props.active && "opacity: 1;"};
`;

View File

@@ -0,0 +1,54 @@
import { EditorView } from "prosemirror-view";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import { CommandFactory } from "@shared/editor/lib/Extension";
import { MenuItem } from "@shared/editor/types";
import ToolbarButton from "./ToolbarButton";
import ToolbarSeparator from "./ToolbarSeparator";
import Tooltip from "./Tooltip";
type Props = {
commands: Record<string, CommandFactory>;
view: EditorView;
items: MenuItem[];
};
const FlexibleWrapper = styled.div`
display: flex;
gap: 8px;
`;
function ToolbarMenu(props: Props) {
const theme = useTheme();
const { view, items } = props;
const { state } = view;
return (
<FlexibleWrapper>
{items.map((item, index) => {
if (item.name === "separator" && item.visible !== false) {
return <ToolbarSeparator key={index} />;
}
if (item.visible === false || !item.icon) {
return null;
}
const Icon = item.icon;
const isActive = item.active ? item.active(state) : false;
return (
<Tooltip tooltip={item.tooltip}>
<ToolbarButton
key={index}
onClick={() => item.name && props.commands[item.name](item.attrs)}
active={isActive}
>
<Icon color={theme.toolbarItem} />
</ToolbarButton>
</Tooltip>
);
})}
</FlexibleWrapper>
);
}
export default ToolbarMenu;

View File

@@ -0,0 +1,12 @@
import styled from "styled-components";
const Separator = styled.div`
height: 24px;
width: 2px;
background: ${(props) => props.theme.toolbarItem};
opacity: 0.3;
display: inline-block;
margin-left: 8px;
`;
export default Separator;

View File

@@ -0,0 +1,20 @@
import * as React from "react";
import styled from "styled-components";
import Tooltip from "~/components/Tooltip";
type Props = {
children: React.ReactNode;
tooltip?: string;
};
const WrappedTooltip = ({ children, tooltip }: Props) => (
<Tooltip offset="0, 8" delay={150} tooltip={tooltip} placement="top">
<TooltipContent>{children}</TooltipContent>
</Tooltip>
);
const TooltipContent = styled.span`
outline: none;
`;
export default WrappedTooltip;

View File

@@ -0,0 +1,11 @@
import * as React from "react";
import { DefaultTheme, useTheme } from "styled-components";
type Props = {
children: (theme: DefaultTheme) => React.ReactElement;
};
export default function WithTheme({ children }: Props) {
const theme = useTheme();
return children(theme);
}