chore: Move editor into codebase (#2930)
This commit is contained in:
50
app/editor/components/BlockMenu.tsx
Normal file
50
app/editor/components/BlockMenu.tsx
Normal 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;
|
||||
110
app/editor/components/BlockMenuItem.tsx
Normal file
110
app/editor/components/BlockMenuItem.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
{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;
|
||||
614
app/editor/components/CommandMenu.tsx
Normal file
614
app/editor/components/CommandMenu.tsx
Normal 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;
|
||||
116
app/editor/components/ComponentView.tsx
Normal file
116
app/editor/components/ComponentView.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
90
app/editor/components/EmojiMenu.tsx
Normal file
90
app/editor/components/EmojiMenu.tsx
Normal 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;
|
||||
35
app/editor/components/EmojiMenuItem.tsx
Normal file
35
app/editor/components/EmojiMenuItem.tsx
Normal 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>
|
||||
|
||||
{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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
267
app/editor/components/FloatingToolbar.tsx
Normal file
267
app/editor/components/FloatingToolbar.tsx
Normal 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} />;
|
||||
});
|
||||
19
app/editor/components/Input.tsx
Normal file
19
app/editor/components/Input.tsx
Normal 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;
|
||||
403
app/editor/components/LinkEditor.tsx
Normal file
403
app/editor/components/LinkEditor.tsx
Normal 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;
|
||||
84
app/editor/components/LinkSearchResult.tsx
Normal file
84
app/editor/components/LinkSearchResult.tsx
Normal 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;
|
||||
151
app/editor/components/LinkToolbar.tsx
Normal file
151
app/editor/components/LinkToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
255
app/editor/components/SelectionToolbar.tsx
Normal file
255
app/editor/components/SelectionToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
1144
app/editor/components/Styles.ts
Normal file
1144
app/editor/components/Styles.ts
Normal file
File diff suppressed because it is too large
Load Diff
40
app/editor/components/ToolbarButton.tsx
Normal file
40
app/editor/components/ToolbarButton.tsx
Normal 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;"};
|
||||
`;
|
||||
54
app/editor/components/ToolbarMenu.tsx
Normal file
54
app/editor/components/ToolbarMenu.tsx
Normal 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;
|
||||
12
app/editor/components/ToolbarSeparator.tsx
Normal file
12
app/editor/components/ToolbarSeparator.tsx
Normal 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;
|
||||
20
app/editor/components/Tooltip.tsx
Normal file
20
app/editor/components/Tooltip.tsx
Normal 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;
|
||||
11
app/editor/components/WithTheme.tsx
Normal file
11
app/editor/components/WithTheme.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user