Compare commits

...

45 Commits

Author SHA1 Message Date
Tom Moor 54a5deb6a5 fix: Mentions do not render in first frame 2025-01-16 23:50:29 -05:00
Tom Moor f2ebac066a fix: Various issues with copy/pasted mentions 2025-01-16 21:35:16 -05:00
Tom Moor c33fad966a fix: Ensure data- attributes on React mention nodes 2025-01-16 21:15:06 -05:00
Tom Moor 5e5a6ec189 Order by section priority rather than translated label 2025-01-16 21:00:16 -05:00
Tom Moor acae1aecd7 Merge branch 'main' into tom/document-mentions 2025-01-16 20:42:03 -05:00
Tom Moor d40a3a77e0 fix: Urls displayed in TOC if mention in heading
fix: Ordering of suggestion menu
2025-01-13 21:39:33 -05:00
Tom Moor dbcf23d3cd Fix shared pages prefetch loading 2025-01-13 20:06:06 -05:00
Tom Moor 2ee8167cdc fix: Filtering in suggestion menu 2025-01-13 19:21:37 -05:00
Tom Moor 8eb73b4079 Reprioritize items 2025-01-13 18:38:23 -05:00
Tom Moor 8dfe635427 fix: Prevent duplicate preloads 2025-01-13 09:58:58 -05:00
Tom Moor ec8521461d Enter on selected mention should navigate 2025-01-12 23:19:41 -05:00
Tom Moor d76cb3bd17 fix: Copying mention should paste full url 2025-01-12 22:15:04 -05:00
Tom Moor 05b3a39f4d preload 2025-01-12 21:58:19 -05:00
Tom Moor 8f3139da7a tsc 2025-01-11 17:56:27 -05:00
Tom Moor 741f9aa796 Add ability to create new docs from @ 2025-01-11 15:06:34 -05:00
Tom Moor 338b10658b test 2025-01-11 13:24:22 -05:00
Tom Moor 97284780f9 Add support for mentions in BacklinkProcessor 2025-01-11 12:51:52 -05:00
Tom Moor d3683413af fix: Links should work correctly 2025-01-11 11:51:49 -05:00
Tom Moor 47a067cd19 Remove onSearchLink prop 2025-01-10 10:05:40 -05:00
Tom Moor 11f744e7d6 Remove doc search from link editor 2025-01-10 09:54:23 -05:00
Tom Moor 1d2292d2a7 tsc 2025-01-09 23:58:27 -05:00
Tom Moor 5eaab0e14a Remove LinkToolbar 2025-01-09 23:11:08 -05:00
Tom Moor 22ff9d394b Add groups to mention menu 2025-01-09 22:34:36 -05:00
Tom Moor a2a84102a6 Merge main 2025-01-09 20:41:22 -05:00
Tom Moor 908c147e3d lint 2025-01-09 09:30:45 -05:00
Tom Moor 6d0270ae37 Add paste handling 2025-01-09 09:18:10 -05:00
Tom Moor abf49bc04d wip 2025-01-08 23:45:25 -05:00
Tom Moor 58732ddd9b wip 2025-01-08 08:40:27 -05:00
Tom Moor 684d622754 Merge branch 'main' into tom/document-mentions 2025-01-07 19:58:27 -05:00
Tom Moor d11e15b360 suggestions 2025-01-04 21:03:11 -05:00
Tom Moor 8429c68b7a fix observability 2025-01-03 18:24:36 -05:00
Tom Moor c4e3786291 Merge main 2025-01-03 17:23:07 -05:00
Tom Moor e6626e1b1a Restore translations 2024-11-16 19:00:05 -05:00
Tom Moor d3964875ff wip 2024-11-16 18:56:28 -05:00
Tom Moor 5156b92d6a Refactor components for use in editor 2024-11-16 16:25:38 -05:00
Tom Moor 4648a405bb Merge public/main 2024-11-16 14:06:32 -05:00
Tom Moor 5f8a754cd9 Merge main 2024-10-21 19:04:38 -04:00
Tom Moor ad7d808704 stash 2024-10-04 23:29:29 -04:00
Tom Moor 59a8d801a4 Merge main 2024-10-03 21:56:29 -04:00
Tom Moor a815f0a12c Merge main 2024-10-03 20:58:18 -04:00
Tom Moor 5ed5c24498 wip 2024-06-10 20:33:06 -04:00
Tom Moor a77b24cf01 stash 2024-06-09 23:59:27 -04:00
Tom Moor 08efb34c29 poc 2024-06-09 22:39:36 -04:00
Tom Moor df3dde951a Merge branch 'main' into tom/document-mentions 2024-06-09 21:38:10 -04:00
Tom Moor d10149ccb3 Add type filter to parseMentions 2024-06-09 15:26:03 -04:00
75 changed files with 781 additions and 988 deletions
+1 -1
View File
@@ -32,6 +32,7 @@ import {
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import {
ExportContentType,
TeamPreference,
@@ -46,7 +47,6 @@ import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import Icon from "~/components/Icon";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
+4
View File
@@ -13,6 +13,8 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
const activeDocument = stores.documents.active;
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
@@ -34,6 +36,8 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
export const UserSection = ({ t }: ActionContext) => t("People");
UserSection.priority = 0.5;
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
+1 -1
View File
@@ -3,6 +3,7 @@ import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import { CollectionPermission } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
@@ -11,7 +12,6 @@ import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import Switch from "~/components/Switch";
@@ -1,6 +1,6 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import useStores from "~/hooks/useStores";
@@ -1,6 +1,6 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import {
ActiveCollectionSection,
+1 -1
View File
@@ -3,10 +3,10 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
+1 -1
View File
@@ -7,6 +7,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import Squircle from "@shared/components/Squircle";
import { s, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
@@ -14,7 +15,6 @@ import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
+1 -1
View File
@@ -14,12 +14,12 @@ import { FixedSizeList as List } from "react-window";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
+1 -1
View File
@@ -9,13 +9,13 @@ import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import Icon from "@shared/components/Icon";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import Badge from "~/components/Badge";
import DocumentMeta from "~/components/DocumentMeta";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
+1 -74
View File
@@ -1,6 +1,4 @@
import deburr from "lodash/deburr";
import difference from "lodash/difference";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
@@ -9,10 +7,7 @@ import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import { AttachmentPreset } from "@shared/types";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
import { AttachmentValidation } from "@shared/validations";
import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
@@ -22,12 +17,8 @@ import useDictionary from "~/hooks/useDictionary";
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
import Icon from "./Icon";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -50,76 +41,13 @@ export type Props = Optional<
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
props;
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
const { comments, documents } = useStores();
const { comments } = useStores();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const localRef = React.useRef<SharedEditor>();
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
const previousCommentIds = React.useRef<string[]>();
const handleSearchLink = React.useCallback(
async (term: string) => {
if (isInternalUrl(term)) {
// search for exact internal document
const slug = parseDocumentSlug(term);
if (!slug) {
return [];
}
try {
const document = await documents.fetch(slug);
const time = dateToRelative(Date.parse(document.updatedAt), {
addSuffix: true,
shorten: true,
locale,
});
return [
{
title: document.title,
subtitle: `Updated ${time}`,
url: document.url,
icon: document.icon ? (
<Icon
value={document.icon}
color={document.color ?? undefined}
/>
) : undefined,
},
];
} catch (error) {
// NotFoundError could not find document for slug
if (!(error instanceof NotFoundError)) {
throw error;
}
}
}
// default search for anything that doesn't look like a URL
const results = await documents.searchTitles({ query: term });
return sortBy(
results.map(({ document }) => ({
title: document.title,
subtitle: <DocumentBreadcrumb document={document} onlyText />,
url: document.url,
icon: document.icon ? (
<Icon value={document.icon} color={document.color ?? undefined} />
) : undefined,
})),
(document) =>
deburr(document.title)
.toLowerCase()
.startsWith(deburr(term).toLowerCase())
? -1
: 1
);
},
[locale, documents]
);
const handleUploadFile = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, {
@@ -263,7 +191,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onSearchLink={handleSearchLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
+1 -1
View File
@@ -10,12 +10,12 @@ import {
useTabState,
} from "reakit";
import styled, { css } from "styled-components";
import Icon from "@shared/components/Icon";
import { s } from "@shared/styles";
import theme from "@shared/styles/theme";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Popover from "~/components/Popover";
import useMobile from "~/hooks/useMobile";
+1 -1
View File
@@ -2,9 +2,9 @@ import { observer } from "mobx-react";
import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import * as React from "react";
import Icon from "@shared/components/Icon";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores";
type Props = {
@@ -5,13 +5,13 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Fade from "~/components/Fade";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
@@ -2,10 +2,10 @@ import includes from "lodash/includes";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { descendants } from "~/utils/tree";
@@ -6,6 +6,7 @@ import { getEmptyImage } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
@@ -13,7 +14,6 @@ import GroupMembership from "~/models/GroupMembership";
import Star from "~/models/Star";
import UserMembership from "~/models/UserMembership";
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
import Icon from "~/components/Icon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { DragObject } from "../components/SidebarLink";
@@ -1,6 +1,6 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import Icon from "@shared/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import useStores from "~/hooks/useStores";
+9 -242
View File
@@ -1,43 +1,24 @@
import {
ArrowIcon,
DocumentIcon,
CloseIcon,
PlusIcon,
OpenIcon,
} from "outline-icons";
import { ArrowIcon, CloseIcon, OpenIcon } from "outline-icons";
import { Mark } from "prosemirror-model";
import { Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { toast } from "sonner";
import styled from "styled-components";
import { s, hideScrollbars } 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 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?: React.ReactNode;
icon?: React.ReactNode;
url: string;
};
type Props = {
mark?: Mark;
from: number;
to: number;
dictionary: Dictionary;
onRemoveLink?: () => void;
onCreateLink?: (title: string, nested?: boolean) => Promise<void>;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onSelectLink: (options: {
href: string;
title?: string;
@@ -52,46 +33,25 @@ type Props = {
};
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>();
inputRef = React.createRef<HTMLInputElement>();
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;
}
componentDidMount(): void {
window.addEventListener("keydown", this.handleGlobalKeyDown);
}
@@ -139,25 +99,12 @@ class LinkEditor extends React.Component<Props, State> {
};
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;
const { value } = this.state;
if (selectedIndex >= 0) {
const result = results[selectedIndex];
if (result) {
this.save(result.url, result.title);
} else if (onCreateLink && selectedIndex === results.length) {
void this.handleCreateLink(this.suggestedLinkTitle);
}
} else {
// saves the raw input as href
this.save(value, value);
}
this.save(value, value);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
@@ -176,45 +123,9 @@ class LinkEditor extends React.Component<Props, State> {
}
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 + 1;
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> => {
@@ -222,21 +133,15 @@ class LinkEditor extends React.Component<Props, State> {
this.setState({
value,
selectedIndex: -1,
});
const trimmedValue = value.trim() || this.selectedText;
const trimmedValue = value.trim();
if (trimmedValue && this.props.onSearchLink) {
if (trimmedValue) {
try {
const results = await this.props.onSearchLink(trimmedValue);
this.setState((state) => ({
results: {
...state.results,
[trimmedValue]: results,
},
this.setState({
previousValue: trimmedValue,
}));
});
} catch (err) {
Logger.error("Error searching for link", err);
}
@@ -257,20 +162,6 @@ class LinkEditor extends React.Component<Props, State> {
}
};
handleCreateLink = async (title: string, nested?: boolean) => {
this.discardInputValue = true;
const { onCreateLink } = this.props;
title = title.trim();
if (title.length === 0) {
return;
}
if (onCreateLink) {
return onCreateLink(title, nested);
}
};
handleRemoveLink = (): void => {
this.discardInputValue = true;
@@ -285,16 +176,6 @@ class LinkEditor extends React.Component<Props, State> {
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;
@@ -305,42 +186,17 @@ class LinkEditor extends React.Component<Props, State> {
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 { value } = this.state;
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
ref={this.inputRef}
value={value}
placeholder={
showCreateLink
? dictionary.findOrCreateDoc
: dictionary.searchOrPasteLink
}
placeholder={dictionary.enterLink}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
onChange={this.handleSearch}
@@ -360,70 +216,6 @@ class LinkEditor extends React.Component<Props, State> {
<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={result.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={async () => {
await this.handleCreateLink(suggestedLinkTitle);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
}}
selected={results.length === selectedIndex}
/>
<LinkSearchResult
key="create-nested"
containerRef={this.resultsRef}
title={suggestedLinkTitle}
subtitle={dictionary.createNewChildDoc}
icon={<PlusIcon />}
onPointerMove={() =>
this.handleFocusLink(results.length + 1)
}
onClick={async () => {
await this.handleCreateLink(suggestedLinkTitle, true);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
}}
selected={results.length + 1 === selectedIndex}
/>
</>
)}
</>
)}
</ResizingHeightContainer>
</SearchResults>
</Wrapper>
);
}
@@ -434,29 +226,4 @@ const Wrapper = styled(Flex)`
gap: 8px;
`;
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
background: ${s("menuBackground")};
box-shadow: ${(props) => (props.$hasResults ? s("menuShadow") : "none")};
clip-path: inset(0px -100px -100px -100px);
position: absolute;
top: 100%;
width: 100%;
height: auto;
left: 0;
margin-top: -6px;
border-radius: 0 0 4px 4px;
padding: ${(props) => (props.$hasResults ? "8px 0" : "0")};
max-height: 240px;
${hideScrollbars()}
@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;
-109
View File
@@ -1,109 +0,0 @@
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import { s, ellipsis } from "@shared/styles";
type Props = React.HTMLAttributes<HTMLDivElement> & {
icon: React.ReactNode;
selected: boolean;
title: React.ReactNode;
subtitle?: React.ReactNode;
containerRef: React.RefObject<HTMLDivElement>;
};
function LinkSearchResult({
title,
subtitle,
containerRef,
selected,
icon,
...rest
}: Props) {
const ref = React.useCallback(
(node: HTMLElement | null) => {
if (selected && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
block: "center",
boundary: (parent) =>
// Prevents body and other parent elements from being scrolled
parent !== containerRef.current,
});
}
},
[containerRef, selected]
);
return (
<ListItem
ref={ref}
compact={!subtitle}
selected={selected}
role="menuitem"
{...rest}
>
<IconWrapper selected={selected}>{icon}</IconWrapper>
<Content>
<Title title={title}>{title}</Title>
{subtitle ? <Subtitle selected={selected}>{subtitle}</Subtitle> : null}
</Content>
</ListItem>
);
}
const Content = styled.div`
overflow: hidden;
`;
const IconWrapper = styled.span<{ selected: boolean }>`
flex-shrink: 0;
margin-right: 4px;
height: 24px;
opacity: 0.8;
${(props) =>
props.selected &&
css`
svg {
fill: ${s("accentText")};
color: ${s("accentText")};
}
`};
`;
const ListItem = styled.div<{
selected: boolean;
compact: boolean;
}>`
display: flex;
align-items: center;
padding: 8px;
border-radius: 4px;
margin: 0 6px;
color: ${(props) => (props.selected ? s("accentText") : s("textSecondary"))};
background: ${(props) => (props.selected ? s("accent") : "transparent")};
font-family: ${s("fontFamily")};
text-decoration: none;
overflow: hidden;
white-space: nowrap;
cursor: var(--pointer);
user-select: none;
line-height: ${(props) => (props.compact ? "inherit" : "1.2")};
height: ${(props) => (props.compact ? "28px" : "auto")};
`;
const Title = styled.div`
${ellipsis()}
font-size: 14px;
font-weight: 500;
`;
const Subtitle = styled.div<{
selected: boolean;
}>`
${ellipsis()}
font-size: 13px;
opacity: ${(props) => (props.selected ? 0.75 : 0.5)};
`;
export default LinkSearchResult;
-146
View File
@@ -1,146 +0,0 @@
import { EditorView } from "prosemirror-view";
import * as React from "react";
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
import { creatingUrlPrefix } from "@shared/utils/urls";
import useDictionary from "~/hooks/useDictionary";
import useEventListener from "~/hooks/useEventListener";
import { useEditor } from "./EditorContext";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor, { SearchResult } from "./LinkEditor";
type Props = {
isActive: boolean;
onCreateLink?: (title: string) => Promise<string>;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onClickLink: (
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onClose: () => void;
};
function isActive(view: EditorView, active: boolean): boolean {
try {
const { selection } = view.state;
const paragraph = view.domAtPos(selection.from);
return active && !!paragraph.node;
} catch (err) {
return false;
}
}
export default function LinkToolbar({
onCreateLink,
onSearchLink,
onClickLink,
onClose,
...rest
}: Props) {
const dictionary = useDictionary();
const { view } = useEditor();
const menuRef = React.useRef<HTMLDivElement>(null);
useEventListener("mousedown", (event: Event) => {
if (
event.target instanceof HTMLElement &&
menuRef.current &&
menuRef.current.contains(event.target)
) {
return;
}
onClose();
});
const handleOnCreateLink = React.useCallback(
async (title: string, nested?: boolean) => {
onClose();
view.focus();
if (!onCreateLink) {
return;
}
const { dispatch, state } = view;
const { from, to } = state.selection;
if (from !== to) {
// selection must be collapsed
return;
}
const href = `${creatingUrlPrefix}#${title}`;
// Insert a placeholder link
dispatch(
view.state.tr
.insertText(title, from, to)
.addMark(
from,
to + title.length,
state.schema.marks.link.create({ href })
)
);
return createAndInsertLink(view, title, href, {
nested,
onCreateLink,
dictionary,
});
},
[onCreateLink, onClose, view, dictionary]
);
const handleOnSelectLink = React.useCallback(
({
href,
title,
}: {
href: string;
title: string;
from: number;
to: number;
}) => {
onClose();
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 })
)
);
},
[onClose, view]
);
const { selection } = view.state;
const active = isActive(view, rest.isActive);
return (
<FloatingToolbar ref={menuRef} active={active} width={336}>
{active && (
<LinkEditor
key={`${selection.from}-${selection.to}`}
from={selection.from}
to={selection.to}
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
onSelectLink={handleOnSelectLink}
onRemoveLink={onClose}
onClickLink={onClickLink}
onSearchLink={onSearchLink}
dictionary={dictionary}
view={view}
/>
)}
</FloatingToolbar>
);
}
+100 -41
View File
@@ -1,15 +1,20 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { DocumentIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { toast } from "sonner";
import { v4 } from "uuid";
import Icon from "@shared/components/Icon";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { DocumentsSection, UserSection } from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
@@ -19,9 +24,6 @@ import SuggestionsMenu, {
import SuggestionsMenuItem from "./SuggestionsMenuItem";
interface MentionItem extends MenuItem {
name: string;
user: User;
appendSpace: boolean;
attrs: {
id: string;
type: MentionType;
@@ -40,17 +42,22 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = React.useState(false);
const [items, setItems] = React.useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, users } = useStores();
const { auth, documents, users } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
const { data, loading, request } = useRequest(
React.useCallback(
() =>
documentId
? users.fetchPage({ id: documentId, query: search })
: Promise.resolve([]),
[users, documentId, search]
)
const { data, loading, request } = useRequest<{
documents: Document[];
users: User[];
}>(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query: search });
return {
documents: res.data.documents.map(documents.add),
users: res.data.users.map(users.add),
};
}, [search, documents, users])
);
React.useEffect(() => {
@@ -60,28 +67,92 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
}, [request, isActive]);
React.useEffect(() => {
if (data && !loading) {
const items = data.map((user) => ({
name: "mention",
user,
title: user.name,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.User,
modelId: user.id,
actorId: auth.currentUserId ?? undefined,
label: user.name,
},
}));
if (data && actorId && !loading) {
const items = data.users
.map(
(user) =>
({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24 }}
>
<Avatar
model={user}
showBorder={false}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
</Flex>
),
title: user.name,
section: UserSection,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.User,
modelId: user.id,
actorId,
label: user.name,
},
} as MentionItem)
)
.concat(
data.documents.map(
(doc) =>
({
name: "mention",
icon: doc.icon ? (
<Icon value={doc.icon} color={doc.color ?? undefined} />
) : (
<DocumentIcon />
),
title: doc.title,
subtitle: doc.collection?.name,
section: DocumentsSection,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.Document,
modelId: doc.id,
actorId,
label: doc.title,
},
} as MentionItem)
)
)
.concat([
{
name: "link",
icon: <PlusIcon />,
title: search?.trim(),
section: DocumentsSection,
subtitle: t("Create a new doc"),
visible: !!search && !isEmail(search),
priority: -1,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.Document,
modelId: v4(),
actorId,
label: search,
},
} as MentionItem,
]);
setItems(items);
setLoaded(true);
}
}, [auth.currentUserId, loading, data]);
}, [t, actorId, loading, search, data]);
const handleSelect = React.useCallback(
async (item: MentionItem) => {
if (item.attrs.type === MentionType.Document) {
return;
}
// Check if the mentioned user has access to the document
const res = await client.post("/documents.users", {
id: documentId,
@@ -125,21 +196,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
subtitle={item.subtitle}
title={item.title}
icon={
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24 }}
>
<Avatar
model={item.user}
showBorder={false}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
</Flex>
}
icon={item.icon}
/>
)}
items={items}
+2 -43
View File
@@ -1,7 +1,6 @@
import some from "lodash/some";
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
@@ -9,7 +8,6 @@ import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { creatingUrlPrefix } from "@shared/utils/urls";
import useBoolean from "~/hooks/useBoolean";
import useDictionary from "~/hooks/useDictionary";
import useEventListener from "~/hooks/useEventListener";
@@ -26,7 +24,7 @@ import getTableColMenuItems from "../menus/tableCol";
import getTableRowMenuItems from "../menus/tableRow";
import { useEditor } from "./EditorContext";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor, { SearchResult } from "./LinkEditor";
import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
type Props = {
@@ -37,12 +35,10 @@ type Props = {
canUpdate?: boolean;
onOpen: () => void;
onClose: () => void;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onClickLink: (
href: string,
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
onCreateLink?: (title: string) => Promise<string>;
};
function useIsActive(state: EditorState) {
@@ -149,40 +145,6 @@ export default function SelectionToolbar(props: Props) {
};
}, [isActive, previousIsActive, readOnly, view]);
const handleOnCreateLink = async (
title: string,
nested?: boolean
): Promise<void> => {
const { onCreateLink } = props;
if (!onCreateLink) {
return;
}
const { dispatch, state } = view;
const { from, to } = state.selection;
if (from === to) {
// Do not display a selection toolbar for collapsed selections
return;
}
const href = `${creatingUrlPrefix}${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 }))
);
return createAndInsertLink(view, title, href, {
nested,
onCreateLink,
dictionary,
});
};
const handleOnSelectLink = ({
href,
from,
@@ -203,8 +165,7 @@ export default function SelectionToolbar(props: Props) {
);
};
const { onCreateLink, isTemplate, rtl, canComment, canUpdate, ...rest } =
props;
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
@@ -283,8 +244,6 @@ export default function SelectionToolbar(props: Props) {
from={link.from}
to={link.to}
onClickLink={props.onClickLink}
onSearchLink={props.onSearchLink}
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
onSelectLink={handleOnSelectLink}
/>
) : (
+54 -19
View File
@@ -1,7 +1,8 @@
import commandScore from "command-score";
import capitalize from "lodash/capitalize";
import orderBy from "lodash/orderBy";
import * as React from "react";
import { Trans } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import { toast } from "sonner";
import styled from "styled-components";
@@ -13,6 +14,7 @@ import { MenuItem } from "@shared/editor/types";
import { depths, s } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import Header from "~/components/ContextMenu/Header";
import { Portal } from "~/components/Portal";
import Scrollable from "~/components/Scrollable";
import useDictionary from "~/hooks/useDictionary";
@@ -78,8 +80,9 @@ export type Props<T extends MenuItem = MenuItem> = {
};
function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { view, commands } = useEditor();
const { view, commands, props: editorProps } = useEditor();
const dictionary = useDictionary();
const { t } = useTranslation();
const hasActivated = React.useRef(false);
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
clientX: 0,
@@ -250,6 +253,16 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
props.onSelect?.(item);
switch (item.name) {
case "link":
insertNode({
...item,
name: "mention",
});
void editorProps.onCreateLink?.({
title: item.attrs.label,
id: item.attrs.modelId,
});
return;
case "image":
return triggerFilePick(
AttachmentValidation.imageContentTypes.join(", ")
@@ -264,7 +277,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
insertNode(item);
}
},
[insertNode]
[editorProps, props, insertNode]
);
const close = React.useCallback(() => {
@@ -414,6 +427,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return true;
}
if (item.visible === false) {
return false;
}
// Some extensions may be disabled, remove corresponding menu items
if (
item.name &&
@@ -445,16 +462,22 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
});
return filterExcessSeparators(
filtered
.map((item) => ({
orderBy(
filtered.map((item) => ({
item,
section:
"section" in item && item.section && "priority" in item.section
? (item.section.priority as number) ?? 0
: 0,
priority: "priority" in item ? item.priority : 0,
score:
searchInput && item.title
? commandScore(item.title, searchInput)
: 0,
}))
.sort((a, b) => b.score - a.score)
.map(({ item }) => item)
})),
["section", "priority", "score"],
["desc", "desc", "desc"]
).map(({ item }) => item)
);
}, [commands, props]);
@@ -555,6 +578,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { isActive, uploadFile } = props;
const items = filtered;
let previousHeading: string | undefined;
return (
<Portal>
@@ -614,18 +638,29 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
};
return (
<ListItem
key={index}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onClick: () => handleClickItem(item),
})}
</ListItem>
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<>
{currentHeading !== previousHeading && (
<Header key={currentHeading}>{currentHeading}</Header>
)}
<ListItem
key={index}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onClick: () => handleClickItem(item),
})}
</ListItem>
</>
);
previousHeading = currentHeading;
return response;
})}
{items.length === 0 && (
<ListItem>
@@ -56,7 +56,7 @@ function SuggestionsMenuItem({
icon={icon}
>
{title}
{subtitle && <Subtitle $active={selected}>{subtitle}</Subtitle>}
{subtitle && <Subtitle $active={selected}>&middot; {subtitle}</Subtitle>}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuItem>
);
+25 -8
View File
@@ -1,13 +1,14 @@
import { toggleMark } from "prosemirror-commands";
import { Slice } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import { v4 } from "uuid";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import Extension from "@shared/editor/lib/Extension";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInList } from "@shared/editor/queries/isInList";
import { IconType } from "@shared/types";
import { IconType, MentionType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
@@ -185,15 +186,31 @@ export default class PasteHandler extends Extension {
return;
}
if (document) {
const { hash } = new URL(text);
if (state.schema.nodes.mention) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
state.selection.to,
state.schema.nodes.mention.create({
type: MentionType.Document,
modelId: document.id,
label: document.titleWithDefault,
id: v4(),
})
)
);
} else {
const { hash } = new URL(text);
const hasEmoji =
determineIconType(document.icon) ===
IconType.Emoji;
const hasEmoji =
determineIconType(document.icon) === IconType.Emoji;
const title = `${
hasEmoji ? document.icon + " " : ""
}${document.titleWithDefault}`;
const title = `${
hasEmoji ? document.icon + " " : ""
}${document.titleWithDefault}`;
insertLink(`${document.path}${hash}`, title);
insertLink(`${document.path}${hash}`, title);
}
}
})
.catch(() => {
+8 -48
View File
@@ -1,5 +1,6 @@
/* global File Promise */
import { PluginSimple } from "markdown-it";
import { observable } from "mobx";
import { Observer } from "mobx-react";
import { darken, transparentize } from "polished";
import { baseKeymap } from "prosemirror-commands";
@@ -39,18 +40,18 @@ import Mark from "@shared/editor/marks/Mark";
import { basicExtensions as extensions } from "@shared/editor/nodes";
import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode";
import { ComponentProps, EventType } from "@shared/editor/types";
import { ComponentProps } from "@shared/editor/types";
import { ProsemirrorData, UserPreferences } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import EventEmitter from "@shared/utils/events";
import Document from "~/models/Document";
import Flex from "~/components/Flex";
import { PortalContext } from "~/components/Portal";
import { Dictionary } from "~/hooks/useDictionary";
import { Properties } from "~/types";
import Logger from "~/utils/Logger";
import ComponentView from "./components/ComponentView";
import EditorContext from "./components/EditorContext";
import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar";
import { NodeViewRenderer } from "./components/NodeViewRenderer";
import SelectionToolbar from "./components/SelectionToolbar";
import WithTheme from "./components/WithTheme";
@@ -117,13 +118,11 @@ export type Props = {
/** Callback when a file upload ends */
onFileUploadStop?: () => void;
/** Callback when a link is created, should return url to created document */
onCreateLink?: (title: string) => Promise<string>;
/** Callback when user searches for documents from link insert interface */
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onCreateLink?: (params: Properties<Document>) => Promise<string>;
/** Callback when user clicks on any link in the document */
onClickLink: (
href: string,
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
event?: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
/** Callback when user presses any key with document focused */
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
@@ -147,8 +146,6 @@ type State = {
isEditorFocused: boolean;
/** If the toolbar for a text selection is visible */
selectionToolbarOpen: boolean;
/** If the insert link toolbar is visible */
linkToolbarOpen: boolean;
};
/**
@@ -178,7 +175,6 @@ export class Editor extends React.PureComponent<
isRTL: false,
isEditorFocused: false,
selectionToolbarOpen: false,
linkToolbarOpen: false,
};
isInitialized = false;
@@ -199,7 +195,7 @@ export class Editor extends React.PureComponent<
};
widgets: { [name: string]: (props: WidgetProps) => React.ReactElement };
renderers: Set<NodeViewRenderer<ComponentProps>> = new Set();
renderers: Set<NodeViewRenderer<ComponentProps>> = observable.set();
nodes: { [name: string]: NodeSpec };
marks: { [name: string]: MarkSpec };
commands: Record<string, CommandFactory>;
@@ -207,11 +203,6 @@ export class Editor extends React.PureComponent<
events = new EventEmitter();
mutationObserver?: MutationObserver;
public constructor(props: Props & ThemeProps<DefaultTheme>) {
super(props);
this.events.on(EventType.LinkToolbarOpen, this.handleOpenLinkToolbar);
}
/**
* We use componentDidMount instead of constructor as the init method requires
* that the dom is already mounted.
@@ -273,7 +264,6 @@ export class Editor extends React.PureComponent<
if (
!this.isBlurred &&
!this.state.isEditorFocused &&
!this.state.linkToolbarOpen &&
!this.state.selectionToolbarOpen
) {
this.isBlurred = true;
@@ -282,9 +272,7 @@ export class Editor extends React.PureComponent<
if (
this.isBlurred &&
(this.state.isEditorFocused ||
this.state.linkToolbarOpen ||
this.state.selectionToolbarOpen)
(this.state.isEditorFocused || this.state.selectionToolbarOpen)
) {
this.isBlurred = false;
this.props.onFocus?.();
@@ -783,23 +771,6 @@ export class Editor extends React.PureComponent<
}));
};
private handleOpenLinkToolbar = () => {
if (this.state.selectionToolbarOpen) {
return;
}
this.setState((state) => ({
...state,
linkToolbarOpen: true,
}));
};
private handleCloseLinkToolbar = () => {
this.setState((state) => ({
...state,
linkToolbarOpen: false,
}));
};
public render() {
const { readOnly, canUpdate, grow, style, className, onKeyDown } =
this.props;
@@ -837,18 +808,7 @@ export class Editor extends React.PureComponent<
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionToolbar}
onClose={this.handleCloseSelectionToolbar}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onCreateLink={this.props.onCreateLink}
/>
)}
{!readOnly && this.view && this.marks.link && (
<LinkToolbar
isActive={this.state.linkToolbarOpen}
onCreateLink={this.props.onCreateLink}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onClose={this.handleCloseLinkToolbar}
/>
)}
{this.widgets &&
+1 -1
View File
@@ -39,7 +39,7 @@ export default function useDictionary() {
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
file: t("File attachment"),
findOrCreateDoc: `${t("Paste a link, search, or create")}`,
enterLink: `${t("Enter a link")}`,
h1: t("Big heading"),
h2: t("Medium heading"),
h3: t("Small heading"),
+12 -3
View File
@@ -1,9 +1,10 @@
import * as React from "react";
import { useHistory } from "react-router-dom";
import { isModKey } from "@shared/utils/keyboard";
import { isInternalUrl } from "@shared/utils/urls";
import { isDocumentUrl, isInternalUrl } from "@shared/utils/urls";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { isHash } from "~/utils/urls";
import useStores from "./useStores";
type Params = {
/** The share ID of the document being viewed, if any */
@@ -12,8 +13,9 @@ type Params = {
export default function useEditorClickHandlers({ shareId }: Params) {
const history = useHistory();
const { documents } = useStores();
const handleClickLink = React.useCallback(
(href: string, event: MouseEvent) => {
(href: string, event?: MouseEvent) => {
// on page hash
if (isHash(href)) {
window.location.href = href;
@@ -49,13 +51,20 @@ export default function useEditorClickHandlers({ shareId }: Params) {
navigateTo = sharedDocumentPath(shareId, navigateTo);
}
if (isDocumentUrl(navigateTo)) {
const document = documents.getByUrl(navigateTo);
if (document) {
navigateTo = document.path;
}
}
// If we're navigating to a share link from a non-share link then open it in a new tab
if (!shareId && navigateTo.startsWith("/s/")) {
window.open(href, "_blank");
return;
}
if (!isModKey(event) && !event.shiftKey) {
if (!event || (!isModKey(event) && !event.shiftKey)) {
history.push(navigateTo, { sidebarContext: "collections" }); // optimistic preference of "collections"
} else {
window.open(navigateTo, "_blank");
+1 -1
View File
@@ -3,12 +3,12 @@ import { DocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import Icon from "@shared/components/Icon";
import { TextHelper } from "@shared/utils/TextHelper";
import Document from "~/models/Document";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import Icon from "~/components/Icon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
+1 -1
View File
@@ -12,6 +12,7 @@ import {
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon, { IconTitleWrapper } from "@shared/components/Icon";
import { s } from "@shared/styles";
import { StatusFilter } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
@@ -22,7 +23,6 @@ import CenteredContent from "~/components/CenteredContent";
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
import CollectionDescription from "~/components/CollectionDescription";
import Heading from "~/components/Heading";
import Icon, { IconTitleWrapper } from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSearchPage from "~/components/InputSearchPage";
import PlaceholderList from "~/components/List/Placeholder";
+11
View File
@@ -25,6 +25,7 @@ import useBuildTheme from "~/hooks/useBuildTheme";
import useCurrentUser from "~/hooks/useCurrentUser";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import { AuthorizationError, OfflineError } from "~/utils/errors";
import isCloudHosted from "~/utils/isCloudHosted";
import { changeLanguage, detectLanguage } from "~/utils/language";
@@ -109,6 +110,16 @@ function SharedDocumentScene(props: Props) {
: undefined;
const theme = useBuildTheme(response?.team?.customTheme, themeOverride);
React.useEffect(() => {
if (shareId) {
client.setShareId(shareId);
}
return () => {
client.setShareId(undefined);
};
}, [shareId]);
React.useEffect(() => {
if (!user) {
void changeLanguage(detectLanguage(), i18n);
@@ -14,6 +14,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { Properties } from "~/types";
import Logger from "~/utils/Logger";
import {
NotFoundError,
@@ -46,7 +47,10 @@ type Children = (options: {
revision: Revision | undefined;
abilities: Record<string, boolean>;
readOnly: boolean;
onCreateLink: (title: string, nested?: boolean) => Promise<string>;
onCreateLink: (
params: Properties<Document>,
nested?: boolean
) => Promise<string>;
sharedTree: NavigationNode | undefined;
}) => React.ReactNode;
@@ -143,7 +147,7 @@ function DataLoader({ match, children }: Props) {
}, [document?.id, document?.isDeleted, revisionId, views]);
const onCreateLink = React.useCallback(
async (title: string, nested?: boolean) => {
async (params: Properties<Document>, nested?: boolean) => {
if (!document) {
throw new Error("Document not loaded yet");
}
@@ -152,8 +156,8 @@ function DataLoader({ match, children }: Props) {
{
collectionId: nested ? undefined : document.collectionId,
parentDocumentId: nested ? document.id : document.parentDocumentId,
title,
data: ProsemirrorHelper.getEmptyDocument(),
...params,
},
{
publish: document.isDraft ? undefined : true,
+5 -4
View File
@@ -45,7 +45,7 @@ import RegisterKeyDown from "~/components/RegisterKeyDown";
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
import withStores from "~/components/withStores";
import type { Editor as TEditor } from "~/editor";
import { SearchResult } from "~/editor/components/LinkEditor";
import { Properties } from "~/types";
import { client } from "~/utils/ApiClient";
import { emojiToUrl } from "~/utils/emoji";
@@ -90,8 +90,10 @@ type Props = WithTranslation &
readOnly: boolean;
shareId?: string;
tocPosition?: TOCPosition;
onCreateLink?: (title: string, nested?: boolean) => Promise<string>;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onCreateLink?: (
params: Properties<Document>,
nested?: boolean
) => Promise<string>;
};
@observer
@@ -571,7 +573,6 @@ class DocumentScene extends React.Component<Props> {
onSynced={this.onSynced}
onFileUploadStart={this.onFileUploadStart}
onFileUploadStop={this.onFileUploadStop}
onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.handleChangeTitle}
onChangeIcon={this.handleChangeIcon}
@@ -6,6 +6,7 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon, { IconTitleWrapper } from "@shared/components/Icon";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { extraArea, s } from "@shared/styles";
@@ -19,7 +20,6 @@ import { isModKey } from "@shared/utils/keyboard";
import { DocumentValidation } from "@shared/validations";
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
import { useDocumentContext } from "~/components/DocumentContext";
import Icon, { IconTitleWrapper } from "~/components/Icon";
import { PopoverButton } from "~/components/IconPicker/components/PopoverButton";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
+1 -1
View File
@@ -11,6 +11,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
import { Theme } from "~/stores/UiStore";
@@ -24,7 +25,6 @@ import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import { useDocumentContext } from "~/components/DocumentContext";
import Flex from "~/components/Flex";
import Header from "~/components/Header";
import Icon from "~/components/Icon";
import Star from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import { publishDocument } from "~/actions/definitions/documents";
@@ -1,5 +1,5 @@
import * as React from "react";
import useComponentSize from "@shared/editor/components/hooks/useComponentSize";
import useComponentSize from "@shared/hooks/useComponentSize";
export const MeasuredContainer = <T extends React.ElementType>({
as: As,
@@ -1,7 +1,7 @@
import * as React from "react";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import Breadcrumb from "~/components/Breadcrumb";
import Icon from "~/components/Icon";
import { MenuInternalLink } from "~/types";
import { sharedDocumentPath } from "~/utils/routeHelpers";
@@ -3,12 +3,12 @@ import { DocumentIcon } from "outline-icons";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { s, ellipsis } from "@shared/styles";
import { IconType, NavigationNode } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
import { hover } from "~/styles";
import { sharedDocumentPath } from "~/utils/routeHelpers";
+4 -1
View File
@@ -825,7 +825,10 @@ export default class DocumentsStore extends Store<Document> {
};
getByUrl = (url = ""): Document | undefined =>
find(this.orderedData, (doc) => url.endsWith(doc.urlId));
find(
this.orderedData,
(doc) => url.endsWith(doc.urlId) || url.endsWith(doc.id)
);
getCollectionForDocument(document: Document) {
return document.collectionId
+35 -17
View File
@@ -54,6 +54,8 @@ export default abstract class Store<T extends Model> {
@observable
isLoaded = false;
requests: Map<string, Promise<any>> = new Map();
model: typeof Model;
modelName: string;
@@ -302,27 +304,43 @@ export default abstract class Store<T extends Model> {
if (item && !options.force) {
return item;
}
if (this.requests.has(id)) {
return this.requests.get(id);
}
this.isFetching = true;
try {
const res = await client.post(`/${this.apiEndpoint}.info`, {
id,
});
const promise = new Promise<T>((resolve, reject) => {
client
.post(`/${this.apiEndpoint}.info`, {
id,
})
.then((res) =>
runInAction(`info#${this.modelName}`, () => {
invariant(res?.data, "Data should be available");
this.addPolicies(res.policies);
resolve(this.add(accessor(res)));
})
)
.catch((err) => {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
this.remove(id);
}
return runInAction(`info#${this.modelName}`, () => {
invariant(res?.data, "Data should be available");
this.addPolicies(res.policies);
return this.add(accessor(res));
});
} catch (err) {
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
this.remove(id);
}
reject(err);
})
.finally(() => {
this.requests.delete(id);
this.isFetching = false;
});
});
throw err;
} finally {
this.isFetching = false;
}
this.requests.set(id, promise);
return promise;
}
@action
+14
View File
@@ -36,10 +36,16 @@ const fetchWithRetry = retry(fetch);
class ApiClient {
baseUrl: string;
shareId?: string;
constructor(options: Options = {}) {
this.baseUrl = options.baseUrl || "/api";
}
setShareId = (shareId: string | undefined) => {
this.shareId = shareId;
};
fetch = async <T = any>(
path: string,
method: string,
@@ -51,6 +57,14 @@ class ApiClient {
let urlToFetch;
let isJson;
if (this.shareId) {
// add to data
data = {
...(data || {}),
shareId: this.shareId,
};
}
if (method === "GET") {
if (data) {
modifiedPath = `${path}?${data && queryString.stringify(data)}`;
@@ -1,5 +1,6 @@
import differenceBy from "lodash/differenceBy";
import * as React from "react";
import { MentionType } from "@shared/types";
import { Document, Revision } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
@@ -63,12 +64,12 @@ export default class DocumentMentionedEmail extends BaseEmail<
}
const currMentions = DocumentHelper.parseMentions(currDoc, {
type: "user",
type: MentionType.User,
modelId: userId,
});
const prevMentions = prevDoc
? DocumentHelper.parseMentions(prevDoc, {
type: "user",
type: MentionType.User,
modelId: userId,
})
: [];
+11
View File
@@ -234,6 +234,17 @@ export class DocumentHelper {
return ProsemirrorHelper.parseMentions(node, options);
}
/**
* Parse a list of document IDs contained in a document or revision
*
* @param document Document or Revision
* @returns An array of identifiers in passed document or revision
*/
static parseDocumentIds(document: Document | Revision) {
const node = DocumentHelper.toProsemirror(document);
return ProsemirrorHelper.parseDocumentIds(node);
}
/**
* Generates a HTML diff between documents or revisions.
*
+3 -2
View File
@@ -1,6 +1,6 @@
import uniq from "lodash/uniq";
import { Op } from "sequelize";
import { NotificationEventType } from "@shared/types";
import { NotificationEventType, MentionType } from "@shared/types";
import Logger from "@server/logging/Logger";
import {
User,
@@ -82,7 +82,8 @@ export default class NotificationHelper {
const mentionedUserIdsInThread = contextComments
.flatMap((c) =>
ProsemirrorHelper.parseMentions(
ProsemirrorHelper.toProsemirror(c.data)
ProsemirrorHelper.toProsemirror(c.data),
{ type: MentionType.User }
)
)
.map((mention) => mention.modelId);
@@ -1,5 +1,5 @@
import { DeepPartial } from "utility-types";
import { ProsemirrorData } from "@shared/types";
import { MentionType, ProsemirrorData } from "@shared/types";
import { buildProseMirrorDoc } from "@server/test/factories";
import { MentionAttrs, ProsemirrorHelper } from "./ProsemirrorHelper";
@@ -8,7 +8,7 @@ describe("ProseMirrorHelper", () => {
it("should return the paragraph node", () => {
const mentionAttrs: MentionAttrs = {
id: "31d5899f-e544-4ff6-b6d3-c49dd6b81901",
type: "user",
type: MentionType.User,
label: "test.user",
actorId: "ccec260a-e060-4925-ade8-17cfabaf2cac",
modelId: "9a17c1c8-d178-4350-9001-203a73070fcb",
@@ -58,7 +58,7 @@ describe("ProseMirrorHelper", () => {
it("should return the heading node", () => {
const mentionAttrs: MentionAttrs = {
id: "31d5899f-e544-4ff6-b6d3-c49dd6b81901",
type: "user",
type: MentionType.User,
label: "test.user",
actorId: "ccec260a-e060-4925-ade8-17cfabaf2cac",
modelId: "9a17c1c8-d178-4350-9001-203a73070fcb",
@@ -111,7 +111,7 @@ describe("ProseMirrorHelper", () => {
it("should return the table node with the mentioned row only", () => {
const mentionAttrs: MentionAttrs = {
id: "31d5899f-e544-4ff6-b6d3-c49dd6b81901",
type: "user",
type: MentionType.User,
label: "test.user",
actorId: "ccec260a-e060-4925-ade8-17cfabaf2cac",
modelId: "9a17c1c8-d178-4350-9001-203a73070fcb",
@@ -195,7 +195,7 @@ describe("ProseMirrorHelper", () => {
it("should return the checkbox list with the mentioned item only", () => {
const mentionAttrs: MentionAttrs = {
id: "31d5899f-e544-4ff6-b6d3-c49dd6b81901",
type: "user",
type: MentionType.User,
label: "test.user",
actorId: "ccec260a-e060-4925-ade8-17cfabaf2cac",
modelId: "9a17c1c8-d178-4350-9001-203a73070fcb",
@@ -275,7 +275,7 @@ describe("ProseMirrorHelper", () => {
it("should not return anything when the mention attrs could not be found", () => {
const mentionAttrs: MentionAttrs = {
id: "31d5899f-e544-4ff6-b6d3-c49dd6b81901",
type: "user",
type: MentionType.User,
label: "test.user",
actorId: "ccec260a-e060-4925-ade8-17cfabaf2cac",
modelId: "9a17c1c8-d178-4350-9001-203a73070fcb",
+47 -2
View File
@@ -13,8 +13,9 @@ import EditorContainer from "@shared/editor/components/Styles";
import embeds from "@shared/editor/embeds";
import GlobalStyles from "@shared/styles/globals";
import light from "@shared/styles/theme";
import { ProsemirrorData } from "@shared/types";
import { MentionType, ProsemirrorData } from "@shared/types";
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isRTL } from "@shared/utils/rtl";
import { isInternalUrl } from "@shared/utils/urls";
import { schema, parser } from "@server/editor";
@@ -37,7 +38,7 @@ export type HTMLOptions = {
};
export type MentionAttrs = {
type: string;
type: MentionType;
label: string;
modelId: string;
actorId: string | undefined;
@@ -165,6 +166,50 @@ export class ProsemirrorHelper {
return mentions;
}
/**
* Returns an array of document IDs referenced through links or mentions in the node.
*
* @param node The node to parse document IDs from
* @returns An array of document IDs
*/
static parseDocumentIds(doc: Node) {
const identifiers: string[] = [];
doc.descendants((node: Node) => {
if (
node.type.name === "mention" &&
node.attrs.type === MentionType.Document &&
!identifiers.includes(node.attrs.modelId)
) {
identifiers.push(node.attrs.modelId);
return true;
}
if (node.type.name === "text") {
// get marks for text nodes
node.marks.forEach((mark) => {
// any of the marks identifiers?
if (mark.type.name === "link") {
const slug = parseDocumentSlug(mark.attrs.href);
// don't return the same link more than once
if (slug && !identifiers.includes(slug)) {
identifiers.push(slug);
}
}
});
}
if (!node.content.size) {
return false;
}
return true;
});
return identifiers;
}
/**
* Find the nearest ancestor block node which contains the mention.
*
@@ -1,3 +1,4 @@
import { parser } from "@server/editor";
import { Backlink } from "@server/models";
import { buildDocument } from "@server/test/factories";
import BacklinksProcessor from "./BacklinksProcessor";
@@ -5,7 +6,7 @@ import BacklinksProcessor from "./BacklinksProcessor";
const ip = "127.0.0.1";
describe("documents.publish", () => {
test("should create new backlink records", async () => {
it("should create new backlink records", async () => {
const otherDocument = await buildDocument();
const document = await buildDocument({
text: `[this is a link](${otherDocument.url})`,
@@ -29,14 +30,16 @@ describe("documents.publish", () => {
expect(backlinks.length).toBe(1);
});
test("should not fail when linked document is destroyed", async () => {
it("should not fail when linked document is destroyed", async () => {
const otherDocument = await buildDocument();
await otherDocument.destroy();
const document = await buildDocument({
version: 0,
text: `[ ] checklist item`,
});
document.text = `[this is a link](${otherDocument.url})`;
document.content = parser
.parse(`[this is a link](${otherDocument.url})`)
?.toJSON();
await document.save();
const processor = new BacklinksProcessor();
@@ -59,7 +62,7 @@ describe("documents.publish", () => {
});
describe("documents.update", () => {
test("should not fail on a document with no previous revisions", async () => {
it("should not fail on a document with no previous revisions", async () => {
const otherDocument = await buildDocument();
const document = await buildDocument({
text: `[this is a link](${otherDocument.url})`,
@@ -84,13 +87,15 @@ describe("documents.update", () => {
expect(backlinks.length).toBe(1);
});
test("should not fail when previous revision is different document version", async () => {
it("should not fail when previous revision is different document version", async () => {
const otherDocument = await buildDocument();
const document = await buildDocument({
version: undefined,
text: `[ ] checklist item`,
});
document.text = `[this is a link](${otherDocument.url})`;
document.content = parser
.parse(`[this is a link](${otherDocument.url})`)
?.toJSON();
await document.save();
const processor = new BacklinksProcessor();
@@ -112,10 +117,12 @@ describe("documents.update", () => {
expect(backlinks.length).toBe(1);
});
test("should create new backlink records", async () => {
it("should create new backlink records", async () => {
const otherDocument = await buildDocument();
const document = await buildDocument();
document.text = `[this is a link](${otherDocument.url})`;
document.content = parser
.parse(`[this is a link](${otherDocument.url})`)
?.toJSON();
await document.save();
const processor = new BacklinksProcessor();
@@ -137,7 +144,7 @@ describe("documents.update", () => {
expect(backlinks.length).toBe(1);
});
test("should destroy removed backlink records", async () => {
it("should destroy removed backlink records", async () => {
const otherDocument = await buildDocument();
const yetAnotherDocument = await buildDocument();
const document = await buildDocument({
@@ -156,9 +163,13 @@ describe("documents.update", () => {
data: { title: document.title },
ip,
});
document.text = `First link is gone
document.content = parser
.parse(
`First link is gone
[this is a another link](${yetAnotherDocument.url})`;
[this is a another link](${yetAnotherDocument.url})`
)
?.toJSON();
await document.save();
await processor.perform({
@@ -182,10 +193,12 @@ describe("documents.update", () => {
});
describe("documents.delete", () => {
test("should destroy related backlinks", async () => {
it("should destroy related backlinks", async () => {
const otherDocument = await buildDocument();
const document = await buildDocument();
document.text = `[this is a link](${otherDocument.url})`;
document.content = parser
.parse(`[this is a link](${otherDocument.url})`)
?.toJSON();
await document.save();
const processor = new BacklinksProcessor();
@@ -1,7 +1,7 @@
import { Op } from "sequelize";
import { Document, Backlink } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { Event, DocumentEvent, RevisionEvent } from "@server/types";
import parseDocumentIds from "@server/utils/parseDocumentIds";
import BaseProcessor from "./BaseProcessor";
export default class BacklinksProcessor extends BaseProcessor {
@@ -18,7 +18,7 @@ export default class BacklinksProcessor extends BaseProcessor {
if (!document) {
return;
}
const linkIds = parseDocumentIds(document.text);
const linkIds = DocumentHelper.parseDocumentIds(document);
await Promise.all(
linkIds.map(async (linkId) => {
const linkedDocument = await Document.findByPk(linkId);
@@ -52,7 +52,7 @@ export default class BacklinksProcessor extends BaseProcessor {
return;
}
const linkIds = parseDocumentIds(document.text);
const linkIds = DocumentHelper.parseDocumentIds(document);
const linkedDocumentIds: string[] = [];
// create or find existing backlink records for referenced docs
@@ -1,4 +1,4 @@
import { NotificationEventType } from "@shared/types";
import { MentionType, NotificationEventType } from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { createContext } from "@server/context";
import { Comment, Document, Notification, User } from "@server/models";
@@ -40,7 +40,8 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
});
const mentions = ProsemirrorHelper.parseMentions(
ProsemirrorHelper.toProsemirror(comment.data)
ProsemirrorHelper.toProsemirror(comment.data),
{ type: MentionType.User }
);
const userIdsMentioned: string[] = [];
@@ -1,6 +1,6 @@
import invariant from "invariant";
import { Op } from "sequelize";
import { NotificationEventType } from "@shared/types";
import { MentionType, NotificationEventType } from "@shared/types";
import { Comment, Document, Notification, User } from "@server/models";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { CommentEvent, CommentUpdateEvent } from "@server/types";
@@ -37,7 +37,8 @@ export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEve
}
const mentions = ProsemirrorHelper.parseMentions(
ProsemirrorHelper.toProsemirror(comment.data)
ProsemirrorHelper.toProsemirror(comment.data),
{ type: MentionType.User }
).filter((mention) => newMentionIds.includes(mention.id));
const userIdsMentioned: string[] = [];
@@ -99,7 +100,9 @@ export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEve
for (const item of commentsAndReplies) {
// Mentions:
const proseCommentData = ProsemirrorHelper.toProsemirror(item.data);
const mentions = ProsemirrorHelper.parseMentions(proseCommentData);
const mentions = ProsemirrorHelper.parseMentions(proseCommentData, {
type: MentionType.User,
});
const userIds = mentions.map((mention) => mention.modelId);
// Comment author:
@@ -1,4 +1,4 @@
import { NotificationEventType } from "@shared/types";
import { MentionType, NotificationEventType } from "@shared/types";
import { createSubscriptionsForDocument } from "@server/commands/subscriptionCreator";
import { Document, Notification, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
@@ -19,7 +19,9 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
await createSubscriptionsForDocument(document, event);
// Send notifications to mentioned users first
const mentions = DocumentHelper.parseMentions(document);
const mentions = DocumentHelper.parseMentions(document, {
type: MentionType.User,
});
const userIdsMentioned: string[] = [];
for (const mention of mentions) {
@@ -1,7 +1,7 @@
import { subHours } from "date-fns";
import differenceBy from "lodash/differenceBy";
import { Op } from "sequelize";
import { NotificationEventType } from "@shared/types";
import { MentionType, NotificationEventType } from "@shared/types";
import { createSubscriptionsForDocument } from "@server/commands/subscriptionCreator";
import env from "@server/env";
import Logger from "@server/logging/Logger";
@@ -37,8 +37,12 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
}
// Send notifications to mentioned users first
const oldMentions = before ? DocumentHelper.parseMentions(before) : [];
const newMentions = DocumentHelper.parseMentions(document);
const oldMentions = before
? DocumentHelper.parseMentions(before, { type: MentionType.User })
: [];
const newMentions = DocumentHelper.parseMentions(document, {
type: MentionType.User,
});
const mentions = differenceBy(newMentions, oldMentions, "id");
const userIdsMentioned: string[] = [];
+9 -3
View File
@@ -1,7 +1,11 @@
import Router from "koa-router";
import difference from "lodash/difference";
import { FindOptions, Op, WhereOptions } from "sequelize";
import { CommentStatusFilter, TeamPreference } from "@shared/types";
import {
CommentStatusFilter,
TeamPreference,
MentionType,
} from "@shared/types";
import auth from "@server/middlewares/authentication";
import { feature } from "@server/middlewares/feature";
import { rateLimiter } from "@server/middlewares/rateLimiter";
@@ -225,10 +229,12 @@ router.post(
if (data !== undefined) {
const existingMentionIds = ProsemirrorHelper.parseMentions(
ProsemirrorHelper.toProsemirror(comment.data)
ProsemirrorHelper.toProsemirror(comment.data),
{ type: MentionType.User }
).map((mention) => mention.id);
const updatedMentionIds = ProsemirrorHelper.parseMentions(
ProsemirrorHelper.toProsemirror(data)
ProsemirrorHelper.toProsemirror(data),
{ type: MentionType.User }
).map((mention) => mention.id);
newMentionIds = difference(updatedMentionIds, existingMentionIds);
+2
View File
@@ -1574,6 +1574,7 @@ router.post(
transaction(),
async (ctx: APIContext<T.DocumentsCreateReq>) => {
const {
id,
title,
text,
icon,
@@ -1641,6 +1642,7 @@ router.post(
}
const document = await documentCreator({
id,
title,
text: await TextHelper.replaceImagesWithAttachments(ctx, text, user),
icon,
+3
View File
@@ -327,6 +327,9 @@ export type DocumentsImportReq = z.infer<typeof DocumentsImportSchema>;
export const DocumentsCreateSchema = BaseSchema.extend({
body: z.object({
/** Id of the document to be created */
id: z.string().uuid().optional(),
/** Document title */
title: z.string().default(""),
+2
View File
@@ -33,6 +33,7 @@ import searches from "./searches";
import shares from "./shares";
import stars from "./stars";
import subscriptions from "./subscriptions";
import suggestions from "./suggestions";
import teams from "./teams";
import urls from "./urls";
import userMemberships from "./userMemberships";
@@ -82,6 +83,7 @@ router.use("/", searches.routes());
router.use("/", shares.routes());
router.use("/", stars.routes());
router.use("/", subscriptions.routes());
router.use("/", suggestions.routes());
router.use("/", teams.routes());
router.use("/", integrations.routes());
router.use("/", notifications.routes());
+1
View File
@@ -0,0 +1 @@
export { default } from "./suggestions";
+10
View File
@@ -0,0 +1,10 @@
import { z } from "zod";
import { BaseSchema } from "../schema";
export const SuggestionsListSchema = BaseSchema.extend({
body: z.object({
query: z.string().optional(),
}),
});
export type SuggestionsListReq = z.infer<typeof SuggestionsListSchema>;
@@ -0,0 +1,75 @@
import Router from "koa-router";
import { Op } from "sequelize";
import { Sequelize } from "sequelize-typescript";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { User } from "@server/models";
import SearchHelper from "@server/models/helpers/SearchHelper";
import { can } from "@server/policies";
import { presentDocument, presentUser } from "@server/presenters";
import { APIContext } from "@server/types";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
router.post(
"suggestions.mention",
auth(),
pagination(),
validate(T.SuggestionsListSchema),
async (ctx: APIContext<T.SuggestionsListReq>) => {
const { query } = ctx.input.body;
const { offset, limit } = ctx.state.pagination;
const actor = ctx.state.auth.user;
const [documents, users] = await Promise.all([
SearchHelper.searchTitlesForUser(actor, {
query,
offset,
limit,
}),
User.findAll({
where: {
teamId: actor.teamId,
suspendedAt: {
[Op.eq]: null,
},
[Op.and]: query
? {
[Op.or]: [
Sequelize.literal(
`unaccent(LOWER(email)) like unaccent(LOWER(:query))`
),
Sequelize.literal(
`unaccent(LOWER(name)) like unaccent(LOWER(:query))`
),
],
}
: {},
},
order: [["name", "ASC"]],
replacements: { query: `%${query}%` },
offset,
limit,
}),
]);
ctx.body = {
pagination: ctx.state.pagination,
data: {
documents: await Promise.all(
documents.map((document) => presentDocument(ctx, document))
),
users: users.map((user) =>
presentUser(user, {
includeEmail: !!can(actor, "readEmail", user),
includeDetails: !!can(actor, "readDetails", user),
})
),
},
};
}
);
export default router;
+27 -24
View File
@@ -1,6 +1,6 @@
import dns from "dns";
import Router from "koa-router";
import { UnfurlResourceType } from "@shared/types";
import { MentionType, UnfurlResourceType } from "@shared/types";
import { getBaseDomain, parseDomain } from "@shared/utils/domains";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import parseMentionUrl from "@shared/utils/parseMentionUrl";
@@ -36,31 +36,34 @@ router.post(
if (!documentId) {
throw ValidationError("Document ID is required to unfurl a mention");
}
const { modelId: userId } = parseMentionUrl(url);
const { modelId, mentionType } = parseMentionUrl(url);
const [user, document] = await Promise.all([
User.findByPk(userId),
Document.findByPk(documentId, {
userId: actor.id,
}),
]);
if (!user) {
throw NotFoundError("Mentioned user does not exist");
}
if (!document) {
throw NotFoundError("Document does not exist");
}
authorize(actor, "read", user);
authorize(actor, "read", document);
// TODO: Add support for other mention types
if (mentionType === MentionType.User) {
const [user, document] = await Promise.all([
User.findByPk(modelId),
Document.findByPk(documentId, {
userId: actor.id,
}),
]);
if (!user) {
throw NotFoundError("Mentioned user does not exist");
}
if (!document) {
throw NotFoundError("Document does not exist");
}
authorize(actor, "read", user);
authorize(actor, "read", document);
ctx.body = await presentUnfurl(
{
type: UnfurlResourceType.Mention,
user,
document,
},
{ includeEmail: !!can(actor, "readEmail", user) }
);
ctx.body = await presentUnfurl(
{
type: UnfurlResourceType.Mention,
user,
document,
},
{ includeEmail: !!can(actor, "readEmail", user) }
);
}
return;
}
-55
View File
@@ -1,55 +0,0 @@
import parseDocumentIds from "./parseDocumentIds";
it("should not return non links", () => {
expect(parseDocumentIds(`# Header`).length).toBe(0);
});
it("should return an array of document ids", () => {
const result = parseDocumentIds(`# Header
[internal](http://app.getoutline.com/doc/test-456733)
More text
[internal](/doc/test-123456#heading-anchor)
`);
expect(result.length).toBe(2);
expect(result[0]).toBe("test-456733");
expect(result[1]).toBe("test-123456");
});
it("should return deeply nested link document ids", () => {
const result = parseDocumentIds(`# Header
[internal](http://app.getoutline.com/doc/test-456733)
More text
- one
- two
- three [internal](/doc/test-123456#heading-anchor)
`);
expect(result.length).toBe(2);
expect(result[0]).toBe("test-456733");
expect(result[1]).toBe("test-123456");
});
it("should not return duplicate document ids", () => {
expect(parseDocumentIds(`# Header`).length).toBe(0);
const result = parseDocumentIds(`# Header
[internal](/doc/test-456733)
[another link to the same doc](/doc/test-456733)
`);
expect(result.length).toBe(1);
expect(result[0]).toBe("test-456733");
});
it("should not return non document links", () => {
expect(parseDocumentIds(`[google](http://www.google.com)`).length).toBe(0);
});
it("should not return non document relative links", () => {
expect(parseDocumentIds(`[relative](/developers)`).length).toBe(0);
});
-42
View File
@@ -1,42 +0,0 @@
import { Node } from "prosemirror-model";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { parser } from "@server/editor";
/**
* Parse a list of unique document identifiers contained in links in markdown
* text.
*
* @param text The text to parse in Markdown format
* @returns An array of document identifiers
*/
export default function parseDocumentIds(text: string): string[] {
const doc = parser.parse(text);
const identifiers: string[] = [];
if (!doc) {
return identifiers;
}
doc.descendants((node: Node) => {
// get text nodes
if (node.type.name === "text") {
// get marks for text nodes
node.marks.forEach((mark) => {
// any of the marks identifiers?
if (mark.type.name === "link") {
const slug = parseDocumentSlug(mark.attrs.href);
// don't return the same link more than once
if (slug && !identifiers.includes(slug)) {
identifiers.push(slug);
}
}
});
return false;
}
return true;
});
return identifiers;
}
+7 -2
View File
@@ -4,7 +4,7 @@ import { Primitive } from "utility-types";
import validator from "validator";
import isIn from "validator/lib/isIn";
import isUUID from "validator/lib/isUUID";
import { CollectionPermission } from "@shared/types";
import { CollectionPermission, MentionType } from "@shared/types";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { validateColorHex } from "@shared/utils/color";
import { validateIndexCharacters } from "@shared/utils/indexCharacters";
@@ -247,7 +247,12 @@ export class ValidateURL {
}
const { id, mentionType, modelId } = parseMentionUrl(url);
return id && isUUID(id) && mentionType === "user" && isUUID(modelId);
return (
id &&
isUUID(id) &&
Object.values(MentionType).includes(mentionType as MentionType) &&
isUUID(modelId)
);
} catch (err) {
return false;
}
@@ -1,6 +1,6 @@
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import { s } from "../styles";
type Props = {
/** The emoji to render */
@@ -2,13 +2,13 @@ import { observer } from "mobx-react";
import { getLuminance } from "polished";
import * as React from "react";
import styled from "styled-components";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { determineIconType } from "@shared/utils/icon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
import useStores from "../hooks/useStores";
import { IconType } from "../types";
import { IconLibrary } from "../utils/IconLibrary";
import { colorPalette } from "../utils/collections";
import { determineIconType } from "../utils/icon";
import EmojiIcon from "./EmojiIcon";
// import Logger from "~/utils/Logger";
import Flex from "./Flex";
export type Props = {
@@ -40,9 +40,9 @@ const Icon = ({
const iconType = determineIconType(icon);
if (!iconType) {
Logger.warn("Failed to determine icon type", {
icon,
});
// Logger.warn("Failed to determine icon type", {
// icon,
// });
return null;
}
@@ -62,9 +62,9 @@ const Icon = ({
return <EmojiIcon emoji={icon} size={size} className={className} />;
} catch (err) {
Logger.warn("Failed to render icon", {
icon,
});
// Logger.warn("Failed to render icon", {
// icon,
// });
}
return null;
@@ -80,7 +80,6 @@ const SVGIcon = observer(
forceColor,
}: Props) => {
const { ui } = useStores();
let color = inputColor ?? colorPalette[0];
// If the chosen icon color is very dark then we invert it in dark mode
+69
View File
@@ -0,0 +1,69 @@
import { observer } from "mobx-react";
import { DocumentIcon, EmailIcon } from "outline-icons";
import { Node } from "prosemirror-model";
import * as React from "react";
import { Link } from "react-router-dom";
import Icon from "../../components/Icon";
import useStores from "../../hooks/useStores";
import { cn } from "../styles/utils";
import { ComponentProps } from "../types";
const getAttributesFromNode = (node: Node) => {
const spec = node.type.spec.toDOM?.(node) as any as Record<string, string>[];
const { class: className, ...attrs } = spec[1];
return { className, ...attrs };
};
export const MentionUser = observer(function MentionUser_(
props: ComponentProps
) {
const { isSelected, node } = props;
const { users } = useStores();
const user = users.get(node.attrs.modelId);
const { className, ...attrs } = getAttributesFromNode(node);
return (
<span
{...attrs}
className={cn(className, {
"ProseMirror-selectednode": isSelected,
})}
>
<EmailIcon size={18} />
{user?.name || node.attrs.label}
</span>
);
});
export const MentionDocument = observer(function MentionDocument_(
props: ComponentProps
) {
const { isSelected, node } = props;
const { documents } = useStores();
const doc = documents.get(node.attrs.modelId);
const modelId = node.attrs.modelId;
const { className, ...attrs } = getAttributesFromNode(node);
React.useEffect(() => {
if (modelId) {
void documents.prefetchDocument(modelId);
}
}, [modelId, documents]);
return (
<Link
{...attrs}
className={cn(className, {
"ProseMirror-selectednode": isSelected,
})}
to={doc?.path ?? `/doc/${node.attrs.modelId}`}
>
{doc?.icon ? (
<Icon value={doc?.icon} color={doc?.color} size={18} />
) : (
<DocumentIcon size={18} />
)}
{doc?.title || node.attrs.label}
</Link>
);
});
+17 -3
View File
@@ -289,17 +289,31 @@ width: 100%;
.mention {
background: ${props.theme.mentionBackground};
border-radius: 8px;
padding-bottom: 2px;
padding-top: 1px;
padding-bottom: 1px;
padding-left: 4px;
padding-right: 4px;
padding-right: 6px;
font-weight: 500;
font-size: 0.9em;
cursor: default;
text-decoration: none !important;
&::before {
display: inline-flex;
align-items: center;
gap: 4px;
vertical-align: bottom;
&:hover {
cursor: default;
}
&.mention-user::before {
content: "@";
}
&.mention-document::before {
content: "+";
}
}
> div {
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import useComponentSize from "../../hooks/useComponentSize";
import Frame from "../components/Frame";
import useComponentSize from "../components/hooks/useComponentSize";
import { EmbedProps as Props } from ".";
export default function Berrycast({ matches, ...props }: Props) {
+1 -3
View File
@@ -14,7 +14,6 @@ import { toast } from "sonner";
import { sanitizeUrl } from "../../utils/urls";
import { getMarkRange } from "../queries/getMarkRange";
import { isMarkActive } from "../queries/isMarkActive";
import { EventType } from "../types";
import Mark from "./Mark";
const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/;
@@ -109,8 +108,7 @@ export default class Link extends Mark {
return {
"Mod-k": (state, dispatch) => {
if (state.selection.empty) {
this.editor.events.emit(EventType.LinkToolbarOpen);
return true;
return false;
}
return toggleMark(type, { href: "" })(state, dispatch);
@@ -5,17 +5,24 @@ import {
NodeType,
Schema,
} from "prosemirror-model";
import { Command, TextSelection } from "prosemirror-state";
import {
Command,
NodeSelection,
Plugin,
TextSelection,
} from "prosemirror-state";
import * as React from "react";
import { Primitive } from "utility-types";
import Extension from "../lib/Extension";
import { v4 as uuidv4 } from "uuid";
import env from "../../env";
import { MentionType } from "../../types";
import { MentionDocument, MentionUser } from "../components/Mentions";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import mentionRule from "../rules/mention";
import { ComponentProps } from "../types";
import Node from "./Node";
export default class Mention extends Extension {
get type() {
return "node";
}
export default class Mention extends Node {
get name() {
return "mention";
}
@@ -39,8 +46,9 @@ export default class Mention extends Extension {
atom: true,
parseDOM: [
{
tag: `span.${this.name}`,
tag: `.${this.name}`,
preserveWhitespace: "full",
priority: 100,
getAttrs: (dom: HTMLElement) => {
const type = dom.dataset.type;
const modelId = dom.dataset.id;
@@ -51,7 +59,7 @@ export default class Mention extends Extension {
return {
type,
modelId,
actorId: dom.dataset.actorId,
actorId: dom.dataset.actorid,
label: dom.innerText,
id: dom.id,
};
@@ -59,25 +67,97 @@ export default class Mention extends Extension {
},
],
toDOM: (node) => [
"span",
node.attrs.type === MentionType.User ? "span" : "a",
{
class: `${node.type.name} use-hover-preview`,
id: node.attrs.id,
href:
node.attrs.type === MentionType.User
? undefined
: `${env.URL}/doc/${node.attrs.modelId}`,
"data-type": node.attrs.type,
"data-id": node.attrs.modelId,
"data-actorId": node.attrs.actorId,
"data-actorid": node.attrs.actorId,
"data-url": `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
},
String(node.attrs.label),
],
toPlainText: (node) => `@${node.attrs.label}`,
toPlainText: (node) =>
node.attrs.type === MentionType.User
? `@${node.attrs.label}`
: node.attrs.label,
};
}
component = (props: ComponentProps) => {
switch (props.node.attrs.type) {
case MentionType.User:
return <MentionUser {...props} />;
case MentionType.Document:
return <MentionDocument {...props} />;
default:
return null;
}
};
get rulePlugins() {
return [mentionRule];
}
get plugins() {
return [
// Ensure mentions have unique IDs
new Plugin({
appendTransaction: (_transactions, _oldState, newState) => {
const tr = newState.tr;
const existingIds = new Set();
let modified = false;
tr.doc.descendants((node, pos) => {
let nodeId = node.attrs.id;
if (
node.type.name === this.name &&
(!nodeId || existingIds.has(nodeId))
) {
nodeId = uuidv4();
modified = true;
tr.setNodeAttribute(pos, "id", nodeId);
}
existingIds.add(nodeId);
});
if (modified) {
return tr;
}
return null;
},
}),
];
}
keys(): Record<string, Command> {
return {
Enter: (state) => {
const { selection } = state;
if (selection instanceof NodeSelection) {
const { from } = selection;
const node = state.doc.nodeAt(from);
if (
node &&
node.type.name === "mention" &&
node.attrs.type === MentionType.Document
) {
const { modelId } = node.attrs;
this.editor.props.onClickLink?.(`/doc/${modelId}`);
return true;
}
}
return false;
},
};
}
commands({ type }: { type: NodeType; schema: Schema }) {
return (attrs: Record<string, Primitive>): Command =>
(state, dispatch) => {
+6 -4
View File
@@ -1,3 +1,4 @@
import { TFunction } from "i18next";
import { Node as ProsemirrorNode } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
@@ -7,24 +8,25 @@ import { Primitive } from "utility-types";
export type PlainTextSerializer = (node: ProsemirrorNode) => string;
export enum EventType {
LinkToolbarOpen = "linkMenuOpen",
}
export enum TableLayout {
fullWidth = "full-width",
}
type Section = ({ t }: { t: TFunction }) => string;
export type MenuItem = {
icon?: React.ReactElement;
name?: string;
title?: string;
section?: Section;
subtitle?: string;
shortcut?: string;
keywords?: string;
tooltip?: string;
label?: string;
dangerous?: boolean;
/** Higher number is higher in results, default is 0. */
priority?: number;
children?: MenuItem[];
defaultHidden?: boolean;
attrs?:
+6
View File
@@ -0,0 +1,6 @@
import { MobXProviderContext } from "mobx-react";
import * as React from "react";
export default function useStores() {
return React.useContext(MobXProviderContext);
}
+4 -4
View File
@@ -138,6 +138,7 @@
"Collection": "Collection",
"Debug": "Debug",
"Document": "Document",
"Documents": "Documents",
"Recently viewed": "Recently viewed",
"Revision": "Revision",
"Navigation": "Navigation",
@@ -302,7 +303,6 @@
"Unknown": "Unknown",
"Mark all as read": "Mark all as read",
"You're all caught up": "You're all caught up",
"Documents": "Documents",
"{{ username }} reacted with {{ emoji }}": "{{ username }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
@@ -416,8 +416,9 @@
"Replacement": "Replacement",
"Replace": "Replace",
"Replace all": "Replace all",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
"Profile picture": "Profile picture",
"Create a new doc": "Create a new doc",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
"Add column after": "Add column after",
"Add column before": "Add column before",
"Add row after": "Add row after",
@@ -435,7 +436,6 @@
"Comment": "Comment",
"Create link": "Create link",
"Sorry, an error occurred creating the link": "Sorry, an error occurred creating the link",
"Create a new doc": "Create a new doc",
"Create a new child doc": "Create a new child doc",
"Delete table": "Delete table",
"Delete file": "Delete file",
@@ -447,7 +447,7 @@
"Italic": "Italic",
"Sorry, that link wont work for this embed type": "Sorry, that link wont work for this embed type",
"File attachment": "File attachment",
"Paste a link, search, or create": "Paste a link, search, or create",
"Enter a link": "Enter a link",
"Big heading": "Big heading",
"Medium heading": "Medium heading",
"Small heading": "Small heading",
+1
View File
@@ -56,6 +56,7 @@ export enum FileOperationState {
export enum MentionType {
User = "user",
Document = "document",
}
export type PublicEnv = {
+1 -1
View File
@@ -66,7 +66,7 @@ export function isInternalUrl(href: string) {
*/
export function isDocumentUrl(url: string) {
try {
const parsed = new URL(url);
const parsed = new URL(url, env.URL);
return (
isInternalUrl(url) &&
(parsed.pathname.startsWith("/doc/") || parsed.pathname.startsWith("/d/"))