Files
outline/app/editor/components/LinkEditor.tsx
dependabot[bot] fbd16d4b9a chore(deps-dev): bump prettier from 2.1.2 to 2.8.8 (#5372)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2023-05-22 19:14:56 -07:00

425 lines
11 KiB
TypeScript

import {
ArrowIcon,
DocumentIcon,
CloseIcon,
PlusIcon,
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 { s } from "@shared/styles";
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Scrollable from "~/components/Scrollable";
import { Dictionary } from "~/hooks/useDictionary";
import { ToastOptions } from "~/types";
import Logger from "~/utils/Logger";
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, options?: ToastOptions) => 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;
resultsRef = React.createRef<HTMLDivElement>();
state: State = {
selectedIndex: -1,
value: this.href,
previousValue: "",
results: {},
};
get href(): string {
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
}
get selectedText(): string {
const { state } = this.props.view;
const selectionText = state.doc.cut(
state.selection.from,
state.selection.to
).textContent;
return selectionText.trim();
}
get suggestedLinkTitle(): string {
return this.state.value.trim() || this.selectedText;
}
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;
href = sanitizeUrl(href) ?? "";
this.props.onSelectLink({ href, title, from, to });
};
handleKeyDown = (event: React.KeyboardEvent): void => {
const results = this.results;
switch (event.key) {
case "Enter": {
event.preventDefault();
const { selectedIndex, value } = this.state;
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 } = this.state;
const total = results.length;
const nextIndex = selectedIndex + 1;
this.setState({
selectedIndex: Math.min(nextIndex, total),
});
return;
}
}
};
handleFocusLink = (selectedIndex: number) => {
this.setState({ selectedIndex });
};
handleSearch = async (
event: React.ChangeEvent<HTMLInputElement>
): Promise<void> => {
const value = event.target.value;
this.setState({
value,
selectedIndex: -1,
});
const trimmedValue = value.trim() || this.selectedText;
if (trimmedValue && this.props.onSearchLink) {
try {
const results = await this.props.onSearchLink(trimmedValue);
this.setState((state) => ({
results: {
...state.results,
[trimmedValue]: results,
},
previousValue: trimmedValue,
}));
} catch (err) {
Logger.error("Error searching for link", err);
}
}
};
handlePaste = (): void => {
setTimeout(() => this.save(this.state.value, this.state.value), 0);
};
handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
try {
this.props.onClickLink(this.href, event);
} catch (err) {
this.props.onShowToast(this.props.dictionary.openLinkError);
}
};
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));
}
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();
};
get results() {
const { value } = this.state;
return (
this.state.results[value.trim()] ||
this.state.results[this.state.previousValue] ||
[]
);
}
render() {
const { dictionary } = this.props;
const { value, selectedIndex } = this.state;
const results = this.results;
const looksLikeUrl = value.match(/^https?:\/\//i);
const suggestedLinkTitle = this.suggestedLinkTitle;
const isInternal = isInternalUrl(value);
const showCreateLink =
!!this.props.onCreateLink &&
!(suggestedLinkTitle === this.initialValue) &&
suggestedLinkTitle.length > 0 &&
!looksLikeUrl;
const hasResults =
!!suggestedLinkTitle && (showCreateLink || results.length > 0);
return (
<Wrapper>
<Input
value={value}
placeholder={
showCreateLink
? dictionary.findOrCreateDoc
: dictionary.searchOrPasteLink
}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
onChange={this.handleSearch}
onFocus={this.handleSearch}
autoFocus={this.href === ""}
/>
<Tooltip
tooltip={isInternal ? dictionary.goToLink : dictionary.openLink}
>
<ToolbarButton onClick={this.handleOpenLink} disabled={!value}>
{isInternal ? <ArrowIcon /> : <OpenIcon />}
</ToolbarButton>
</Tooltip>
<Tooltip tooltip={dictionary.removeLink}>
<ToolbarButton onClick={this.handleRemoveLink}>
<CloseIcon />
</ToolbarButton>
</Tooltip>
<SearchResults
ref={this.resultsRef}
$hasResults={hasResults}
role="menu"
>
<ResizingHeightContainer>
{hasResults && (
<>
{results.map((result, index) => (
<LinkSearchResult
key={result.url}
title={result.title}
subtitle={result.subtitle}
icon={<DocumentIcon />}
onPointerMove={() => this.handleFocusLink(index)}
onClick={this.handleSelectLink(result.url, result.title)}
selected={index === selectedIndex}
containerRef={this.resultsRef}
/>
))}
{showCreateLink && (
<LinkSearchResult
key="create"
containerRef={this.resultsRef}
title={suggestedLinkTitle}
subtitle={dictionary.createNewDoc}
icon={<PlusIcon />}
onPointerMove={() => this.handleFocusLink(results.length)}
onClick={() => {
this.handleCreateLink(suggestedLinkTitle);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
}}
selected={results.length === selectedIndex}
/>
)}
</>
)}
</ResizingHeightContainer>
</SearchResults>
</Wrapper>
);
}
}
const Wrapper = styled(Flex)`
margin-left: -8px;
margin-right: -8px;
pointer-events: all;
gap: 8px;
`;
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
background: ${s("toolbarBackground")};
position: absolute;
top: 100%;
width: 100%;
height: auto;
left: 0;
margin: -8px 0 0;
border-radius: 0 0 4px 4px;
padding: ${(props) => (props.$hasResults ? "8px 0" : "0")};
max-height: 260px;
@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;