`, + ); + }, + ); + + it("inserts a card for a repo root on a GitHub Enterprise host", () => { + queryClient.setQueryData( + ["installed-integrations"], + [{ type: "github", unfurlHosts: ["github.acme.com"] }], + ); + createEditor([paragraph(text("Hello"))], 6); + + const { handled } = paste("https://github.acme.com/org/repo"); + + expect(handled).toBe(true); + expect(hasIntegrationCard()).toBe(true); + }); + + it("splits the paragraph around a card pasted mid-sentence", () => { + createEditor([paragraph(text("Hello world"))], 7); + + const { handled, preventDefault } = paste(PR_URL); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Hello ")), + card(), + paragraph(text("world")), + ]); + }); + + it("rejoins the sentence when a mid-sentence paste becomes a mention", () => { + createEditor([paragraph(text("Fix in today"))], 8); + paste(PR_URL); + + expect(choosePasteOption("mention")).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Fix in "), mention(), text(" today")), + ]); + expect(editor.state.selection.from).toBe(10); + }); + + it("adds no second space when the rest of the sentence starts with one", () => { + createEditor([paragraph(text("Fix in today"))], 7); + paste(PR_URL); + + expect(choosePasteOption("mention")).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Fix in"), mention(), text(" today")), + ]); + }); + + it("rejoins the sentence when a mid-sentence paste becomes a URL", () => { + createEditor([paragraph(text("Fix in today"))], 8); + paste(PR_URL); + + expect(choosePasteOption("url")).toBe(true); + expect(editor.getJSON().content).toHaveLength(1); + expect(editor.getJSON().content?.[0]).toMatchObject( + paragraph( + text("Fix in "), + text(PR_URL, [{ type: "link", attrs: { href: PR_URL } }]), + text("today"), + ), + ); + }); + + it("keeps an end-of-paragraph paste turned into a mention in its paragraph", () => { + createEditor([paragraph(text("Fix in ")), paragraph(text("Next"))], 8); + paste(PR_URL); + + expect(choosePasteOption("mention")).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Fix in "), mention(), text(" ")), + paragraph(text("Next")), + ]); + }); + + it("joins a start-of-paragraph paste turned into a mention with the rest of the line", () => { + createEditor([paragraph(text("today"))], 1); + paste(PR_URL); + + expect(choosePasteOption("mention")).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(mention(), text(" today")), + ]); + }); + + it("keeps a paste on an empty line in its own paragraph when it becomes a mention", () => { + createEditor( + [paragraph(text("Before")), paragraph(), paragraph(text("After"))], + 9, + ); + paste(PR_URL); + + expect(choosePasteOption("mention")).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Before")), + paragraph(mention(), text(" ")), + paragraph(text("After")), + ]); + }); + + it("pastes a mention inside a footnote, which cannot hold a card", () => { + createEditor( + [ + paragraph(text("Body")), + { + type: "footnotes", + content: [ + { + type: "footnote", + content: [paragraph(text("See "))], + }, + ], + }, + ], + 13, + ); + + const { handled, preventDefault } = paste(PR_URL); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + expect(hasIntegrationCard()).toBe(false); + expect(editor.getJSON().content?.[1]).toMatchObject({ + type: "footnotes", + content: [ + { + type: "footnote", + content: [paragraph(text("See "), mention())], + }, + ], + }); + }); + + it("leaves a code block paste to the default handler", () => { + createEditor( + [ + { + type: "codeBlock", + content: [text("const a = 1;")], + }, + ], + 4, + ); + + expectPassThrough(); + }); + + it("leaves a heading paste to the default handler", () => { + createEditor( + [ + { + type: "heading", + attrs: { level: 2 }, + content: [text("Title")], + }, + ], + 3, + ); + + expectPassThrough(); + }); + + it("leaves an inline code paste to the default handler", () => { + createEditor([paragraph(text("run "), text("cmd", [{ type: "code" }]))], 6); + + expect(editor.isActive("code")).toBe(true); + expectPassThrough(); + }); +}); diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index b01e2b81..642d05a9 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -5,6 +5,11 @@ import { uploadPdfAction } from "../pdf/upload-pdf-action"; import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; import { Editor } from "@tiptap/core"; +import { matchIntegrationLink } from "@docmost/editor-ext"; +import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu"; +import { canPlaceIntegrationCard } from "@/features/editor/components/integration-link/integration-display"; +import { queryClient } from "@/main.tsx"; +import { Integration } from "@/features/integration/types/integration.types"; import { getAttachmentInfo, uploadFile, @@ -22,6 +27,24 @@ const ATTACHMENT_NODE_TYPES = [ const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//; +// copied cards and mentions paste back as nodes +const INTEGRATION_NODE_HTML_RE = /data-type="integration(Card|Mention)"/; + +// Reads the cache the page editor prefetches; a cold cache pastes an ordinary link. +function matchInstalledIntegrationLink(url: string) { + const installed = + queryClient.getQueryData
x
<${tag} data-type="${typeName}" data-url="${ENCODED_URL}">${tag}>`, + ); + + expect(findAttrs(typeName)).toMatchObject({ url: ENCODED_URL }); + expect(editor.getHTML()).toContain(`href="${ENCODED_URL}"`); + }); + + it("drops a non-http data-url when parsing HTML", () => { + createEditor( + `x
<${tag} data-type="${typeName}" data-url="file:///x" data-provider="github">${tag}>`, + ); + + expect(findAttrs(typeName)).toMatchObject({ url: "", provider: "github" }); + }); + + it("renders no unsafe href for JSON content that bypassed parsing", () => { + const node = { type: typeName, attrs: { url: "file:///etc/passwd" } }; + createEditor({ + type: "doc", + content: [ + typeName === "integrationCard" + ? node + : { type: "paragraph", content: [node] }, + ], + }); + + const html = editor.getHTML(); + expect(html).not.toContain("file:"); + expect(html).toContain('href=""'); + }); +}); + +describe("integration node plain text", () => { + it("renders each node as its url", () => { + const issueUrl = "https://github.com/o/r/issues/2"; + const text = generateText( + { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "See " }, + { + type: "integrationMention", + attrs: { url: PR_URL, provider: "github" }, + }, + { type: "text", text: " today" }, + ], + }, + { + type: "integrationCard", + attrs: { url: issueUrl, provider: "github" }, + }, + ], + }, + [StarterKit, IntegrationCard, IntegrationMention], + ); + + expect(text).toBe(`See ${PR_URL} today\n\n${issueUrl}`); + }); +}); diff --git a/apps/client/src/features/editor/components/integration-link/integration-mention-view.module.css b/apps/client/src/features/editor/components/integration-link/integration-mention-view.module.css new file mode 100644 index 00000000..9638902f --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-mention-view.module.css @@ -0,0 +1,53 @@ +.mention { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + vertical-align: baseline; + text-decoration: none; + color: inherit; + border-radius: var(--mantine-radius-sm); +} + +:global(.node-integrationMention) .mention { + border-bottom: none !important; + font-weight: 400; +} + +.mention:hover { + background-color: var(--mantine-color-gray-0); +} + +:global([data-mantine-color-scheme="dark"]) .mention:hover { + background-color: var(--mantine-color-dark-5); +} + +/* Text runs share the paragraph baseline; icons and badges center on the line. */ +.mentionMeta, +.mentionText { + align-self: baseline; +} + +.mentionMeta { + color: var(--mantine-color-dimmed); + white-space: nowrap; +} + +.mentionText { + text-decoration: underline; + text-decoration-thickness: 0.07em; + text-decoration-color: light-dark( + var(--mantine-color-dark-0), + var(--mantine-color-dark-2) + ); + text-underline-offset: 0.2em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 340px; + font-weight: 500; +} + +.mentionIcon { + flex-shrink: 0; +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx b/apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx new file mode 100644 index 00000000..473f9317 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx @@ -0,0 +1,259 @@ +import { NodeViewWrapper } from "@tiptap/react"; +import { + Avatar, + Badge, + Button, + Group, + HoverCard, + Stack, + Text, +} from "@mantine/core"; +import { memo, useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { notifications } from "@mantine/notifications"; +import { describeIntegrationLink, isHttpUrl } from "@docmost/editor-ext"; +import { getIntegrationIcon } from "@/features/integration/components/integration-icons"; +import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service"; +import { useUnfurl } from "./use-unfurl"; +import { badgeTextColor, toBadgeColor } from "./badge-color"; +import classes from "./integration-mention-view.module.css"; + +function shortUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.host}${parsed.pathname}`; + } catch { + return url; + } +} + +function IntegrationMentionView(props: any) { + const { editor, getPos, node } = props; + const { url, provider } = node.attrs; + const safeUrl = isHttpUrl(url) ? url : ""; + const { t } = useTranslation(); + + const unfurl = useUnfurl(safeUrl); + const data = unfurl.state === "loaded" ? unfurl.data : null; + const needsConnection = + unfurl.state === "needsConnection" ? unfurl.needsConnection : null; + const [connecting, setConnecting] = useState(false); + + const selectForEditing = useCallback( + (event: React.MouseEvent