From 92cbceb6c7f9dea5b5a1b60083452624ba70c0f0 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 6 Jan 2024 13:44:11 -0800 Subject: [PATCH] Insert document title when pasting internal doc url (#6352) * refactor * DRY --- .../extensions/ClipboardTextSerializer.ts | 4 +- {shared => app}/editor/extensions/Keys.ts | 4 +- .../editor/extensions/Multiplayer.ts | 2 +- .../editor/extensions/PasteHandler.ts | 92 +++++++++++++------ .../editor/extensions/PreventTab.ts | 2 +- .../editor/extensions/SmartText.ts | 2 +- .../Document/components/CommentEditor.tsx | 11 +++ app/scenes/Document/components/Editor.tsx | 11 +++ .../Document/components/MultiplayerEditor.tsx | 2 +- app/stores/DocumentsStore.ts | 7 ++ app/stores/base/Store.ts | 2 +- shared/editor/lib/Extension.ts | 2 +- shared/editor/nodes/index.ts | 13 +-- shared/utils/urls.ts | 20 +++- 14 files changed, 123 insertions(+), 51 deletions(-) rename {shared => app}/editor/extensions/ClipboardTextSerializer.ts (91%) rename {shared => app}/editor/extensions/Keys.ts (96%) rename {shared => app}/editor/extensions/Multiplayer.ts (96%) rename {shared => app}/editor/extensions/PasteHandler.ts (78%) rename {shared => app}/editor/extensions/PreventTab.ts (85%) rename {shared => app}/editor/extensions/SmartText.ts (92%) diff --git a/shared/editor/extensions/ClipboardTextSerializer.ts b/app/editor/extensions/ClipboardTextSerializer.ts similarity index 91% rename from shared/editor/extensions/ClipboardTextSerializer.ts rename to app/editor/extensions/ClipboardTextSerializer.ts index 7f4dfc9c1..24a590a61 100644 --- a/shared/editor/extensions/ClipboardTextSerializer.ts +++ b/app/editor/extensions/ClipboardTextSerializer.ts @@ -1,6 +1,6 @@ import { Plugin, PluginKey } from "prosemirror-state"; -import Extension from "../lib/Extension"; -import textBetween from "../lib/textBetween"; +import Extension from "@shared/editor/lib/Extension"; +import textBetween from "@shared/editor/lib/textBetween"; /** * A plugin that allows overriding the default behavior of the editor to allow diff --git a/shared/editor/extensions/Keys.ts b/app/editor/extensions/Keys.ts similarity index 96% rename from shared/editor/extensions/Keys.ts rename to app/editor/extensions/Keys.ts index 6c66ce348..51ac58683 100644 --- a/shared/editor/extensions/Keys.ts +++ b/app/editor/extensions/Keys.ts @@ -7,8 +7,8 @@ import { EditorState, Command, } from "prosemirror-state"; -import Extension from "../lib/Extension"; -import isInCode from "../queries/isInCode"; +import Extension from "@shared/editor/lib/Extension"; +import isInCode from "@shared/editor/queries/isInCode"; export default class Keys extends Extension { get name() { diff --git a/shared/editor/extensions/Multiplayer.ts b/app/editor/extensions/Multiplayer.ts similarity index 96% rename from shared/editor/extensions/Multiplayer.ts rename to app/editor/extensions/Multiplayer.ts index d78c42952..25bf26d36 100644 --- a/shared/editor/extensions/Multiplayer.ts +++ b/app/editor/extensions/Multiplayer.ts @@ -7,7 +7,7 @@ import { } from "@getoutline/y-prosemirror"; import { keymap } from "prosemirror-keymap"; import * as Y from "yjs"; -import Extension from "../lib/Extension"; +import Extension from "@shared/editor/lib/Extension"; export default class Multiplayer extends Extension { get name() { diff --git a/shared/editor/extensions/PasteHandler.ts b/app/editor/extensions/PasteHandler.ts similarity index 78% rename from shared/editor/extensions/PasteHandler.ts rename to app/editor/extensions/PasteHandler.ts index aaef0e92f..9205c7b3e 100644 --- a/shared/editor/extensions/PasteHandler.ts +++ b/app/editor/extensions/PasteHandler.ts @@ -1,13 +1,15 @@ import { toggleMark } from "prosemirror-commands"; import { Slice } from "prosemirror-model"; import { Plugin } from "prosemirror-state"; -import { isUrl } from "../../utils/urls"; -import Extension from "../lib/Extension"; -import isMarkdown from "../lib/isMarkdown"; -import normalizePastedMarkdown from "../lib/markdown/normalize"; -import isInCode from "../queries/isInCode"; -import isInList from "../queries/isInList"; -import { LANGUAGES } from "./Prism"; +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 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. @@ -108,10 +110,35 @@ export default class PasteHandler extends Extension { const html = event.clipboardData.getData("text/html"); const vscode = event.clipboardData.getData("vscode-editor-data"); - // first check if the clipboard contents can be parsed as a single - // url, this is mainly for allowing pasted urls to become embeds + function insertLink(href: string, title?: string) { + 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)) { - // 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) { toggleMark(this.editor.schema.marks.link, { href: text })( state, @@ -122,7 +149,6 @@ export default class PasteHandler extends Extension { // Is this link embeddable? Create an embed! const { embeds } = this.editor.props; - if ( embeds && 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 - // go ahead and insert the link directly - const transaction = view.state.tr - .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; - } + // Is the link a link to a document? If so, we can grab the title and insert it. + if (isDocumentUrl(text)) { + const slug = parseDocumentSlug(text); - // 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(); + if (slug) { + void stores.documents + .fetch(slug) + .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; } diff --git a/shared/editor/extensions/PreventTab.ts b/app/editor/extensions/PreventTab.ts similarity index 85% rename from shared/editor/extensions/PreventTab.ts rename to app/editor/extensions/PreventTab.ts index ef7953a66..9392b9373 100644 --- a/shared/editor/extensions/PreventTab.ts +++ b/app/editor/extensions/PreventTab.ts @@ -1,5 +1,5 @@ import { Command } from "prosemirror-state"; -import Extension from "../lib/Extension"; +import Extension from "@shared/editor/lib/Extension"; export default class PreventTab extends Extension { get name() { diff --git a/shared/editor/extensions/SmartText.ts b/app/editor/extensions/SmartText.ts similarity index 92% rename from shared/editor/extensions/SmartText.ts rename to app/editor/extensions/SmartText.ts index 82c0f30ab..3db9a211e 100644 --- a/shared/editor/extensions/SmartText.ts +++ b/app/editor/extensions/SmartText.ts @@ -1,5 +1,5 @@ import { ellipsis, smartQuotes, InputRule } from "prosemirror-inputrules"; -import Extension from "../lib/Extension"; +import Extension from "@shared/editor/lib/Extension"; const rightArrow = new InputRule(/->$/, "→"); const oneHalf = new InputRule(/1\/2$/, "½"); diff --git a/app/scenes/Document/components/CommentEditor.tsx b/app/scenes/Document/components/CommentEditor.tsx index 4b74b7821..f6b058c29 100644 --- a/app/scenes/Document/components/CommentEditor.tsx +++ b/app/scenes/Document/components/CommentEditor.tsx @@ -2,13 +2,24 @@ import * as React from "react"; import { basicExtensions, withComments } from "@shared/editor/nodes"; import Editor, { Props as EditorProps } from "~/components/Editor"; import type { Editor as SharedEditor } from "~/editor"; +import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer"; import EmojiMenuExtension from "~/editor/extensions/EmojiMenu"; +import Keys from "~/editor/extensions/Keys"; 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 = [ ...withComments(basicExtensions), + SmartText, + PasteHandler, + ClipboardTextSerializer, EmojiMenuExtension, MentionMenuExtension, + // Order these default key handlers last + PreventTab, + Keys, ]; const CommentEditor = ( diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 3408b3a61..3a9144559 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -12,10 +12,15 @@ import { useDocumentContext } from "~/components/DocumentContext"; import Editor, { Props as EditorProps } from "~/components/Editor"; import Flex from "~/components/Flex"; import BlockMenuExtension from "~/editor/extensions/BlockMenu"; +import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer"; import EmojiMenuExtension from "~/editor/extensions/EmojiMenu"; import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace"; import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews"; +import Keys from "~/editor/extensions/Keys"; 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 useCurrentUser from "~/hooks/useCurrentUser"; import useFocusedComment from "~/hooks/useFocusedComment"; @@ -32,11 +37,17 @@ import DocumentTitle from "./DocumentTitle"; const extensions = [ ...withComments(richExtensions), + SmartText, + PasteHandler, + ClipboardTextSerializer, BlockMenuExtension, EmojiMenuExtension, MentionMenuExtension, FindAndReplaceExtension, HoverPreviewsExtension, + // Order these default key handlers last + PreventTab, + Keys, ]; type Props = Omit & { diff --git a/app/scenes/Document/components/MultiplayerEditor.tsx b/app/scenes/Document/components/MultiplayerEditor.tsx index d4a7a2ddb..9e5258b74 100644 --- a/app/scenes/Document/components/MultiplayerEditor.tsx +++ b/app/scenes/Document/components/MultiplayerEditor.tsx @@ -6,9 +6,9 @@ import { useHistory } from "react-router-dom"; import { toast } from "sonner"; import { IndexeddbPersistence } from "y-indexeddb"; import * as Y from "yjs"; -import MultiplayerExtension from "@shared/editor/extensions/Multiplayer"; import { supportsPassiveListener } from "@shared/utils/browser"; import Editor, { Props as EditorProps } from "~/components/Editor"; +import MultiplayerExtension from "~/editor/extensions/Multiplayer"; import env from "~/env"; import useCurrentUser from "~/hooks/useCurrentUser"; import useIdle from "~/hooks/useIdle"; diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts index 9c26063fe..9814762b8 100644 --- a/app/stores/DocumentsStore.ts +++ b/app/stores/DocumentsStore.ts @@ -180,6 +180,13 @@ export default class DocumentsStore extends Store { 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 get archived(): Document[] { return orderBy(this.orderedData, "archivedAt", "desc").filter( diff --git a/app/stores/base/Store.ts b/app/stores/base/Store.ts index 9d3e3956b..63053c947 100644 --- a/app/stores/base/Store.ts +++ b/app/stores/base/Store.ts @@ -219,7 +219,7 @@ export default abstract class Store { throw new Error(`Cannot fetch ${this.modelName}`); } - const item = this.data.get(id); + const item = this.get(id); if (item && !options.force) { return item; } diff --git a/shared/editor/lib/Extension.ts b/shared/editor/lib/Extension.ts index ae0cfad0a..d70b02e40 100644 --- a/shared/editor/lib/Extension.ts +++ b/shared/editor/lib/Extension.ts @@ -3,7 +3,7 @@ import { InputRule } from "prosemirror-inputrules"; import { NodeType, MarkType, Schema } from "prosemirror-model"; import { Command, Plugin } from "prosemirror-state"; import { Primitive } from "utility-types"; -import { Editor } from "../../../app/editor"; +import type { Editor } from "../../../app/editor"; export type CommandFactory = (attrs?: Record) => Command; diff --git a/shared/editor/nodes/index.ts b/shared/editor/nodes/index.ts index 68daefe47..916f35cf0 100644 --- a/shared/editor/nodes/index.ts +++ b/shared/editor/nodes/index.ts @@ -1,12 +1,7 @@ -import ClipboardTextSerializer from "../extensions/ClipboardTextSerializer"; import DateTime from "../extensions/DateTime"; import History from "../extensions/History"; -import Keys from "../extensions/Keys"; import MaxLength from "../extensions/MaxLength"; -import PasteHandler from "../extensions/PasteHandler"; import Placeholder from "../extensions/Placeholder"; -import PreventTab from "../extensions/PreventTab"; -import SmartText from "../extensions/SmartText"; import TrailingNode from "../extensions/TrailingNode"; import Extension from "../lib/Extension"; import Bold from "../marks/Bold"; @@ -68,14 +63,10 @@ export const basicExtensions: Nodes = [ Link, Strikethrough, History, - SmartText, TrailingNode, - PasteHandler, Placeholder, MaxLength, DateTime, - Keys, - ClipboardTextSerializer, ]; /** @@ -83,7 +74,7 @@ export const basicExtensions: Nodes = [ * editors that need advanced formatting. */ export const richExtensions: Nodes = [ - ...basicExtensions.filter((n) => n !== SimpleImage && n !== Keys), + ...basicExtensions.filter((n) => n !== SimpleImage), Image, HardBreak, CodeBlock, @@ -108,8 +99,6 @@ export const richExtensions: Nodes = [ TemplatePlaceholder, Math, MathBlock, - PreventTab, - Keys, ]; /** diff --git a/shared/utils/urls.ts b/shared/utils/urls.ts index 350ae2fb7..f35cdd4d0 100644 --- a/shared/utils/urls.ts +++ b/shared/utils/urls.ts @@ -38,10 +38,28 @@ export function isInternalUrl(href: string) { return ( outline.host === domain.host || (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. *