Insert document title when pasting internal doc url (#6352)

* refactor

* DRY
This commit is contained in:
Tom Moor
2024-01-06 13:44:11 -08:00
committed by GitHub
parent 08b1755f8e
commit 92cbceb6c7
14 changed files with 123 additions and 51 deletions

View File

@@ -1,6 +1,6 @@
import { Plugin, PluginKey } from "prosemirror-state"; import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "../lib/Extension"; import Extension from "@shared/editor/lib/Extension";
import textBetween from "../lib/textBetween"; import textBetween from "@shared/editor/lib/textBetween";
/** /**
* A plugin that allows overriding the default behavior of the editor to allow * A plugin that allows overriding the default behavior of the editor to allow

View File

@@ -7,8 +7,8 @@ import {
EditorState, EditorState,
Command, Command,
} from "prosemirror-state"; } from "prosemirror-state";
import Extension from "../lib/Extension"; import Extension from "@shared/editor/lib/Extension";
import isInCode from "../queries/isInCode"; import isInCode from "@shared/editor/queries/isInCode";
export default class Keys extends Extension { export default class Keys extends Extension {
get name() { get name() {

View File

@@ -7,7 +7,7 @@ import {
} from "@getoutline/y-prosemirror"; } from "@getoutline/y-prosemirror";
import { keymap } from "prosemirror-keymap"; import { keymap } from "prosemirror-keymap";
import * as Y from "yjs"; import * as Y from "yjs";
import Extension from "../lib/Extension"; import Extension from "@shared/editor/lib/Extension";
export default class Multiplayer extends Extension { export default class Multiplayer extends Extension {
get name() { get name() {

View File

@@ -1,13 +1,15 @@
import { toggleMark } from "prosemirror-commands"; import { toggleMark } from "prosemirror-commands";
import { Slice } from "prosemirror-model"; import { Slice } from "prosemirror-model";
import { Plugin } from "prosemirror-state"; import { Plugin } from "prosemirror-state";
import { isUrl } from "../../utils/urls"; import { LANGUAGES } from "@shared/editor/extensions/Prism";
import Extension from "../lib/Extension"; import Extension from "@shared/editor/lib/Extension";
import isMarkdown from "../lib/isMarkdown"; import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "../lib/markdown/normalize"; import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import isInCode from "../queries/isInCode"; import isInCode from "@shared/editor/queries/isInCode";
import isInList from "../queries/isInList"; import isInList from "@shared/editor/queries/isInList";
import { LANGUAGES } from "./Prism"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
/** /**
* Checks if the HTML string is likely coming from Dropbox Paper. * Checks if the HTML string is likely coming from Dropbox Paper.
@@ -108,10 +110,35 @@ export default class PasteHandler extends Extension {
const html = event.clipboardData.getData("text/html"); const html = event.clipboardData.getData("text/html");
const vscode = event.clipboardData.getData("vscode-editor-data"); const vscode = event.clipboardData.getData("vscode-editor-data");
// first check if the clipboard contents can be parsed as a single function insertLink(href: string, title?: string) {
// url, this is mainly for allowing pasted urls to become embeds const normalized = href.replace(/^https?:\/\//, "");
// If it's not an embed and there is no text selected just go ahead and insert the
// link directly
const transaction = view.state.tr
.insertText(
title ?? normalized,
state.selection.from,
state.selection.to
)
.addMark(
state.selection.from,
state.selection.to + (title ?? normalized).length,
state.schema.marks.link.create({ href })
);
view.dispatch(transaction);
}
// If the users selection is currently in a code block then paste
// as plain text, ignore all formatting and HTML content.
if (isInCode(state)) {
event.preventDefault();
view.dispatch(state.tr.insertText(text));
return true;
}
// Check if the clipboard contents can be parsed as a single url
if (isUrl(text)) { if (isUrl(text)) {
// just paste the link mark directly onto the selected text // If there is selected text then we want to wrap it in a link to the url
if (!state.selection.empty) { if (!state.selection.empty) {
toggleMark(this.editor.schema.marks.link, { href: text })( toggleMark(this.editor.schema.marks.link, { href: text })(
state, state,
@@ -122,7 +149,6 @@ export default class PasteHandler extends Extension {
// Is this link embeddable? Create an embed! // Is this link embeddable? Create an embed!
const { embeds } = this.editor.props; const { embeds } = this.editor.props;
if ( if (
embeds && embeds &&
this.editor.commands.embed && this.editor.commands.embed &&
@@ -140,25 +166,35 @@ export default class PasteHandler extends Extension {
} }
} }
// well, it's not an embed and there is no text selected so just // Is the link a link to a document? If so, we can grab the title and insert it.
// go ahead and insert the link directly if (isDocumentUrl(text)) {
const transaction = view.state.tr const slug = parseDocumentSlug(text);
.insertText(text, state.selection.from, state.selection.to)
.addMark(
state.selection.from,
state.selection.to + text.length,
state.schema.marks.link.create({ href: text })
);
view.dispatch(transaction);
return true;
}
// If the users selection is currently in a code block then paste if (slug) {
// as plain text, ignore all formatting and HTML content. void stores.documents
if (isInCode(state)) { .fetch(slug)
event.preventDefault(); .then((document) => {
if (view.isDestroyed) {
return;
}
if (document) {
const title = `${
document.emoji ? document.emoji + " " : ""
}${document.titleWithDefault}`;
insertLink(document.path, title);
}
})
.catch(() => {
if (view.isDestroyed) {
return;
}
insertLink(text);
});
}
} else {
insertLink(text);
}
view.dispatch(state.tr.insertText(text));
return true; return true;
} }

View File

@@ -1,5 +1,5 @@
import { Command } from "prosemirror-state"; import { Command } from "prosemirror-state";
import Extension from "../lib/Extension"; import Extension from "@shared/editor/lib/Extension";
export default class PreventTab extends Extension { export default class PreventTab extends Extension {
get name() { get name() {

View File

@@ -1,5 +1,5 @@
import { ellipsis, smartQuotes, InputRule } from "prosemirror-inputrules"; import { ellipsis, smartQuotes, InputRule } from "prosemirror-inputrules";
import Extension from "../lib/Extension"; import Extension from "@shared/editor/lib/Extension";
const rightArrow = new InputRule(/->$/, "→"); const rightArrow = new InputRule(/->$/, "→");
const oneHalf = new InputRule(/1\/2$/, "½"); const oneHalf = new InputRule(/1\/2$/, "½");

View File

@@ -2,13 +2,24 @@ import * as React from "react";
import { basicExtensions, withComments } from "@shared/editor/nodes"; import { basicExtensions, withComments } from "@shared/editor/nodes";
import Editor, { Props as EditorProps } from "~/components/Editor"; import Editor, { Props as EditorProps } from "~/components/Editor";
import type { Editor as SharedEditor } from "~/editor"; import type { Editor as SharedEditor } from "~/editor";
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu"; import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import Keys from "~/editor/extensions/Keys";
import MentionMenuExtension from "~/editor/extensions/MentionMenu"; import MentionMenuExtension from "~/editor/extensions/MentionMenu";
import PasteHandler from "~/editor/extensions/PasteHandler";
import PreventTab from "~/editor/extensions/PreventTab";
import SmartText from "~/editor/extensions/SmartText";
const extensions = [ const extensions = [
...withComments(basicExtensions), ...withComments(basicExtensions),
SmartText,
PasteHandler,
ClipboardTextSerializer,
EmojiMenuExtension, EmojiMenuExtension,
MentionMenuExtension, MentionMenuExtension,
// Order these default key handlers last
PreventTab,
Keys,
]; ];
const CommentEditor = ( const CommentEditor = (

View File

@@ -12,10 +12,15 @@ import { useDocumentContext } from "~/components/DocumentContext";
import Editor, { Props as EditorProps } from "~/components/Editor"; import Editor, { Props as EditorProps } from "~/components/Editor";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import BlockMenuExtension from "~/editor/extensions/BlockMenu"; import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu"; import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace"; import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews"; import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import Keys from "~/editor/extensions/Keys";
import MentionMenuExtension from "~/editor/extensions/MentionMenu"; import MentionMenuExtension from "~/editor/extensions/MentionMenu";
import PasteHandler from "~/editor/extensions/PasteHandler";
import PreventTab from "~/editor/extensions/PreventTab";
import SmartText from "~/editor/extensions/SmartText";
import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser"; import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment"; import useFocusedComment from "~/hooks/useFocusedComment";
@@ -32,11 +37,17 @@ import DocumentTitle from "./DocumentTitle";
const extensions = [ const extensions = [
...withComments(richExtensions), ...withComments(richExtensions),
SmartText,
PasteHandler,
ClipboardTextSerializer,
BlockMenuExtension, BlockMenuExtension,
EmojiMenuExtension, EmojiMenuExtension,
MentionMenuExtension, MentionMenuExtension,
FindAndReplaceExtension, FindAndReplaceExtension,
HoverPreviewsExtension, HoverPreviewsExtension,
// Order these default key handlers last
PreventTab,
Keys,
]; ];
type Props = Omit<EditorProps, "editorStyle"> & { type Props = Omit<EditorProps, "editorStyle"> & {

View File

@@ -6,9 +6,9 @@ import { useHistory } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { IndexeddbPersistence } from "y-indexeddb"; import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs"; import * as Y from "yjs";
import MultiplayerExtension from "@shared/editor/extensions/Multiplayer";
import { supportsPassiveListener } from "@shared/utils/browser"; import { supportsPassiveListener } from "@shared/utils/browser";
import Editor, { Props as EditorProps } from "~/components/Editor"; import Editor, { Props as EditorProps } from "~/components/Editor";
import MultiplayerExtension from "~/editor/extensions/Multiplayer";
import env from "~/env"; import env from "~/env";
import useCurrentUser from "~/hooks/useCurrentUser"; import useCurrentUser from "~/hooks/useCurrentUser";
import useIdle from "~/hooks/useIdle"; import useIdle from "~/hooks/useIdle";

View File

@@ -180,6 +180,13 @@ export default class DocumentsStore extends Store<Document> {
return naturalSort(this.inCollection(collectionId), "title"); return naturalSort(this.inCollection(collectionId), "title");
} }
get(id: string): Document | undefined {
return (
this.data.get(id) ??
this.orderedData.find((doc) => id.endsWith(doc.urlId))
);
}
@computed @computed
get archived(): Document[] { get archived(): Document[] {
return orderBy(this.orderedData, "archivedAt", "desc").filter( return orderBy(this.orderedData, "archivedAt", "desc").filter(

View File

@@ -219,7 +219,7 @@ export default abstract class Store<T extends Model> {
throw new Error(`Cannot fetch ${this.modelName}`); throw new Error(`Cannot fetch ${this.modelName}`);
} }
const item = this.data.get(id); const item = this.get(id);
if (item && !options.force) { if (item && !options.force) {
return item; return item;
} }

View File

@@ -3,7 +3,7 @@ import { InputRule } from "prosemirror-inputrules";
import { NodeType, MarkType, Schema } from "prosemirror-model"; import { NodeType, MarkType, Schema } from "prosemirror-model";
import { Command, Plugin } from "prosemirror-state"; import { Command, Plugin } from "prosemirror-state";
import { Primitive } from "utility-types"; import { Primitive } from "utility-types";
import { Editor } from "../../../app/editor"; import type { Editor } from "../../../app/editor";
export type CommandFactory = (attrs?: Record<string, Primitive>) => Command; export type CommandFactory = (attrs?: Record<string, Primitive>) => Command;

View File

@@ -1,12 +1,7 @@
import ClipboardTextSerializer from "../extensions/ClipboardTextSerializer";
import DateTime from "../extensions/DateTime"; import DateTime from "../extensions/DateTime";
import History from "../extensions/History"; import History from "../extensions/History";
import Keys from "../extensions/Keys";
import MaxLength from "../extensions/MaxLength"; import MaxLength from "../extensions/MaxLength";
import PasteHandler from "../extensions/PasteHandler";
import Placeholder from "../extensions/Placeholder"; import Placeholder from "../extensions/Placeholder";
import PreventTab from "../extensions/PreventTab";
import SmartText from "../extensions/SmartText";
import TrailingNode from "../extensions/TrailingNode"; import TrailingNode from "../extensions/TrailingNode";
import Extension from "../lib/Extension"; import Extension from "../lib/Extension";
import Bold from "../marks/Bold"; import Bold from "../marks/Bold";
@@ -68,14 +63,10 @@ export const basicExtensions: Nodes = [
Link, Link,
Strikethrough, Strikethrough,
History, History,
SmartText,
TrailingNode, TrailingNode,
PasteHandler,
Placeholder, Placeholder,
MaxLength, MaxLength,
DateTime, DateTime,
Keys,
ClipboardTextSerializer,
]; ];
/** /**
@@ -83,7 +74,7 @@ export const basicExtensions: Nodes = [
* editors that need advanced formatting. * editors that need advanced formatting.
*/ */
export const richExtensions: Nodes = [ export const richExtensions: Nodes = [
...basicExtensions.filter((n) => n !== SimpleImage && n !== Keys), ...basicExtensions.filter((n) => n !== SimpleImage),
Image, Image,
HardBreak, HardBreak,
CodeBlock, CodeBlock,
@@ -108,8 +99,6 @@ export const richExtensions: Nodes = [
TemplatePlaceholder, TemplatePlaceholder,
Math, Math,
MathBlock, MathBlock,
PreventTab,
Keys,
]; ];
/** /**

View File

@@ -38,10 +38,28 @@ export function isInternalUrl(href: string) {
return ( return (
outline.host === domain.host || outline.host === domain.host ||
(domain.host.endsWith(getBaseDomain()) && (domain.host.endsWith(getBaseDomain()) &&
!RESERVED_SUBDOMAINS.includes(domain.teamSubdomain)) !RESERVED_SUBDOMAINS.find((reserved) => domain.host.startsWith(reserved)))
); );
} }
/**
* Returns true if the given string is a link to a documement.
*
* @param options Parsing options.
* @returns True if a document, false otherwise.
*/
export function isDocumentUrl(url: string) {
try {
const parsed = new URL(url);
return (
isInternalUrl(url) &&
(parsed.pathname.startsWith("/doc/") || parsed.pathname.startsWith("/d/"))
);
} catch (err) {
return false;
}
}
/** /**
* Returns true if the given string is a url. * Returns true if the given string is a url.
* *