mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat(desktop): hyperlink selected composer text on link paste (#6684)
## Summary Pasting a link while composer text is selected now turns that text into a hyperlink instead of replacing it — the behavior people expect from every other editor. - `resolveExactLinkPaste` recognizes a clipboard payload that is exactly one supported link (bare or `<…>`-wrapped `http(s)`, or a Buzz deep link, canonicalized through the existing composer-link attribute resolver) and returns the href to apply. - The paste handler applies that href as a `link` mark over the current selection, collapses the caret to the end of the newly linked range, clears stored marks so subsequent typing isn't linked, and refocuses the editor. Edge cases handled: - **No link mark available / selection can't carry marks** — falls through to the existing replace-with-node path rather than silently dropping the paste. - **Selection contains a composer message-link chip** — keeps the old replacement behavior instead of nesting a link over a chip. - **Whitespace in the clipboard payload** — `unwrapExactHttpLink` now rejects any text containing whitespace, so multi-line or "text plus URL" pastes are not treated as an exact link. - **Empty selection (caret paste)** — unchanged. - **Idempotent re-paste** of an already-wrapped link is consumed rather than doubling up. ## Testing `desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs` gains coverage for exact-link resolution (per-shape accept/reject cases, Buzz canonicalization) and for the paste handler: linking selected text, canonicalizing Buzz links over a selection, idempotent wrapped-link paste, fall-through when the selection can't carry marks, all-selection collapse, chip-containing selections, and preserved caret-paste behavior. Full pre-push gate ran green on this branch: `file-size-check`, `desktop-check`, `desktop-typecheck`, and `desktop-test` (5425 tests, 0 failures). ## Demo Pasting an `https://` URL over selected composer text turns the selected words into a hyperlink (instead of replacing them); text typed immediately after is not linked: https://github.com/user-attachments/assets/bbc93574-dd9d-4b1b-bad0-53307b6e887c --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -66,6 +66,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"jdenticon": "^3.3.0",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"lucide-react": "^1.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"motion": "^12.38.0",
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Composed-editor regression coverage for selected-text link paste.
|
||||
*
|
||||
* The unit tests in `composerMessageLinkNode.test.mjs` drive
|
||||
* `createComposerLinkPasteHandler` against a hand-built schema and a mock
|
||||
* view — they cannot see what happens *after* the handler declines. That gap
|
||||
* hid a real bug: `false` from `editorProps.handleDOMEvents.paste` does not end
|
||||
* paste handling, it hands the event to ProseMirror's built-in paste, which
|
||||
* runs every plugin's `handlePaste`. TipTap's Link plugin recognised the same
|
||||
* URLs one layer down and partially linked selections the composer had
|
||||
* deliberately refused.
|
||||
*
|
||||
* So these tests build the *production* editor from `useRichTextEditor` and
|
||||
* dispatch a real DOM `paste` event at `view.dom`. If someone re-enables
|
||||
* `linkOnPaste` or slots another URL-aware plugin into the chain, this fails.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { after, afterEach, before, test } from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import { find as findLinks } from "linkifyjs";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
const CHANNEL_HREF = `buzz://channel/${CHANNEL_ID}`;
|
||||
const MESSAGE_LINK_CHANNELS = [{ id: CHANNEL_ID, name: "general" }];
|
||||
|
||||
before(() => {
|
||||
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
|
||||
// `navigator` is a getter-only global from Node 21 on, so Object.assign
|
||||
// throws on it. prosemirror-view reads userAgent for browser quirks.
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: dom.window.navigator,
|
||||
});
|
||||
Object.assign(globalThis, {
|
||||
ClipboardEvent: dom.window.Event,
|
||||
CustomEvent: dom.window.CustomEvent,
|
||||
DOMParser: dom.window.DOMParser,
|
||||
document: dom.window.document,
|
||||
Element: dom.window.Element,
|
||||
Event: dom.window.Event,
|
||||
getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
MutationObserver: dom.window.MutationObserver,
|
||||
Node: dom.window.Node,
|
||||
Range: dom.window.Range,
|
||||
ResizeObserver: class {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
},
|
||||
window: dom.window,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { cleanup } = await import("@testing-library/react");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
after(() => dom.window.close());
|
||||
|
||||
/**
|
||||
* Mounts the production composer editor and returns its Tiptap instance.
|
||||
*/
|
||||
async function mountComposerEditor() {
|
||||
const React = await import("react");
|
||||
const { act, render, waitFor } = await import("@testing-library/react");
|
||||
const { EditorContent } = await import("@tiptap/react");
|
||||
const { useRichTextEditor } = await import("./useRichTextEditor.ts");
|
||||
|
||||
let editor = null;
|
||||
function Harness() {
|
||||
const instance = useRichTextEditor({
|
||||
messageLinkChannels: MESSAGE_LINK_CHANNELS,
|
||||
}).editor;
|
||||
editor = instance;
|
||||
return instance
|
||||
? React.createElement(EditorContent, { editor: instance })
|
||||
: null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
render(React.createElement(Harness));
|
||||
});
|
||||
// Tiptap emits `create` from a `setTimeout(…, 0)`, and Link's `onCreate` is
|
||||
// what teaches linkify the `buzz` protocol. Paste before that lands and a
|
||||
// `buzz://` assertion passes for the wrong reason.
|
||||
await waitFor(() =>
|
||||
assert.ok(editor?.isInitialized, "composer editor never emitted `create`"),
|
||||
);
|
||||
|
||||
// Check that precondition rather than trust the wait. It has to be checked
|
||||
// *after* `create`, never as the poll itself: `find` initialises linkify's
|
||||
// scanner on first call, and `registerCustomProtocol` after that only warns,
|
||||
// so polling on `find` would break the registration it is watching for.
|
||||
assert.equal(
|
||||
findLinks(CHANNEL_HREF)[0]?.href,
|
||||
CHANNEL_HREF,
|
||||
"expected Link's onCreate to register the buzz protocol with linkify",
|
||||
);
|
||||
return editor;
|
||||
}
|
||||
|
||||
const paragraph = (...content) => ({ type: "paragraph", content });
|
||||
const codeBlock = (text) => ({
|
||||
type: "codeBlock",
|
||||
content: [{ type: "text", text }],
|
||||
});
|
||||
const text = (value, marks) => ({
|
||||
type: "text",
|
||||
text: value,
|
||||
...(marks && { marks }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Seeds the document from ProseMirror JSON. Not an HTML string —
|
||||
* `tiptap-markdown` parses `setContent` input as Markdown, so HTML arrives as
|
||||
* literal text and the test silently exercises the wrong document.
|
||||
*/
|
||||
function seedDocument(editor, ...content) {
|
||||
editor.commands.setContent({ type: "doc", content });
|
||||
}
|
||||
|
||||
function selectAll(editor) {
|
||||
editor.commands.setTextSelection({
|
||||
from: 0,
|
||||
to: editor.state.doc.content.size,
|
||||
});
|
||||
}
|
||||
|
||||
function pasteText(editor, value) {
|
||||
const event = new dom.window.Event("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: {
|
||||
types: ["text/plain"],
|
||||
getData: (type) => (type === "text/plain" ? value : ""),
|
||||
},
|
||||
});
|
||||
editor.view.dom.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function linkHrefs(editor) {
|
||||
const hrefs = [];
|
||||
editor.state.doc.descendants((node) => {
|
||||
for (const mark of node.marks) {
|
||||
if (mark.type.name === "link") hrefs.push(mark.attrs.href);
|
||||
}
|
||||
});
|
||||
return hrefs;
|
||||
}
|
||||
|
||||
function nodeTypeNames(editor) {
|
||||
const names = [];
|
||||
editor.state.doc.descendants((node) => {
|
||||
names.push(node.type.name);
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
test("mixed paragraph and code-block selection is replaced, never part-linked", async () => {
|
||||
const editor = await mountComposerEditor();
|
||||
seedDocument(
|
||||
editor,
|
||||
paragraph(text("ordinary")),
|
||||
codeBlock("const value = 1;"),
|
||||
);
|
||||
selectAll(editor);
|
||||
|
||||
pasteText(editor, "https://example.com");
|
||||
|
||||
// The whole selection goes, exactly as it would for any non-link paste.
|
||||
assert.equal(editor.state.doc.textContent, "https://example.com");
|
||||
assert.ok(!nodeTypeNames(editor).includes("codeBlock"));
|
||||
// Crucially, "ordinary" is not left behind wearing a link mark.
|
||||
assert.ok(!editor.state.doc.textContent.includes("ordinary"));
|
||||
});
|
||||
|
||||
test("mixed selection paste of a Buzz link becomes a chip, not a part-link", async () => {
|
||||
const editor = await mountComposerEditor();
|
||||
seedDocument(
|
||||
editor,
|
||||
paragraph(text("ordinary")),
|
||||
codeBlock("const value = 1;"),
|
||||
);
|
||||
selectAll(editor);
|
||||
|
||||
pasteText(editor, CHANNEL_HREF);
|
||||
|
||||
const names = nodeTypeNames(editor);
|
||||
assert.ok(names.includes("composerMessageLink"));
|
||||
assert.ok(!names.includes("codeBlock"));
|
||||
assert.ok(!editor.state.doc.textContent.includes("ordinary"));
|
||||
assert.deepEqual(linkHrefs(editor), []);
|
||||
});
|
||||
|
||||
test("mixed plain and inline-code selection is replaced, never part-linked", async () => {
|
||||
const editor = await mountComposerEditor();
|
||||
seedDocument(
|
||||
editor,
|
||||
paragraph(text("plain "), text("inline", [{ type: "code" }])),
|
||||
);
|
||||
selectAll(editor);
|
||||
|
||||
pasteText(editor, "https://example.com");
|
||||
|
||||
assert.ok(!editor.state.doc.textContent.includes("plain "));
|
||||
assert.equal(editor.state.doc.textContent.trim(), "https://example.com");
|
||||
});
|
||||
|
||||
test("fully markable selection keeps its label and gains the link", async () => {
|
||||
const editor = await mountComposerEditor();
|
||||
seedDocument(editor, paragraph(text("read this")));
|
||||
selectAll(editor);
|
||||
|
||||
pasteText(editor, "https://example.com");
|
||||
|
||||
assert.equal(editor.state.doc.textContent, "read this");
|
||||
assert.deepEqual(linkHrefs(editor), ["https://example.com"]);
|
||||
});
|
||||
|
||||
test("fully markable selection keeps its label for linkify-only URL shapes", async () => {
|
||||
for (const [pasted, expectedHref] of [
|
||||
["www.example.com", "http://www.example.com"],
|
||||
["foo@example.com", "mailto:foo@example.com"],
|
||||
]) {
|
||||
const editor = await mountComposerEditor();
|
||||
seedDocument(editor, paragraph(text("read this")));
|
||||
selectAll(editor);
|
||||
|
||||
pasteText(editor, pasted);
|
||||
|
||||
assert.equal(editor.state.doc.textContent, "read this");
|
||||
assert.deepEqual(linkHrefs(editor), [expectedHref]);
|
||||
}
|
||||
});
|
||||
@@ -2,10 +2,16 @@ import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
|
||||
import { Schema } from "@tiptap/pm/model";
|
||||
import { AllSelection, EditorState, TextSelection } from "@tiptap/pm/state";
|
||||
|
||||
import {
|
||||
ComposerMessageLinkNode,
|
||||
createComposerLinkPasteHandler,
|
||||
registerComposerMessageLinkMarkdownIt,
|
||||
resolveComposerMessageLinkAttributes,
|
||||
resolveExactLinkPaste,
|
||||
resolveSelectionLinkPaste,
|
||||
} from "./composerMessageLinkNode.ts";
|
||||
|
||||
const requireFromTiptap = createRequire(import.meta.resolve("tiptap-markdown"));
|
||||
@@ -71,6 +77,408 @@ test("resolves channel and entity links as composer chips", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const resolveKnownChannel = (channelId) =>
|
||||
channelId === CHANNEL_ID ? "general" : undefined;
|
||||
const exactLinkPaste = (text) =>
|
||||
resolveExactLinkPaste(text, resolveKnownChannel);
|
||||
|
||||
const EXACT_LINK_PASTE_ACCEPTED_CASES = [
|
||||
["exact http", "https://example.com", "https://example.com"],
|
||||
[
|
||||
"wrapped http",
|
||||
"<https://example.com/docs?q=1>",
|
||||
"https://example.com/docs?q=1",
|
||||
],
|
||||
["exact message", HREF, HREF],
|
||||
["wrapped message", `<${HREF}>`, HREF],
|
||||
["channel", CHANNEL_HREF, CHANNEL_HREF],
|
||||
[
|
||||
"channel message",
|
||||
CHANNEL_MESSAGE_HREF,
|
||||
`buzz://message?channel=${CHANNEL_ID}&id=${CHANNEL_MESSAGE_ID}`,
|
||||
],
|
||||
["repo", REPO_HREF, REPO_HREF],
|
||||
["project", PROJECT_HREF, PROJECT_HREF],
|
||||
["pull request", PR_HREF, PR_HREF],
|
||||
["issue", ISSUE_HREF, ISSUE_HREF],
|
||||
];
|
||||
|
||||
for (const [label, input, expectedHref] of EXACT_LINK_PASTE_ACCEPTED_CASES) {
|
||||
test(`exact link paste resolves ${label}`, () => {
|
||||
assert.deepEqual(exactLinkPaste(input), { href: expectedHref });
|
||||
});
|
||||
}
|
||||
|
||||
test("exact link paste canonicalizes Buzz links", () => {
|
||||
assert.deepEqual(
|
||||
exactLinkPaste(
|
||||
`BUZZ://channel/${CHANNEL_ID.toUpperCase()}/${CHANNEL_MESSAGE_ID.toUpperCase()}`,
|
||||
),
|
||||
{
|
||||
href: `buzz://message?channel=${CHANNEL_ID}&id=${CHANNEL_MESSAGE_ID}`,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
for (const input of [
|
||||
"https://example.com and words",
|
||||
" https://example.com",
|
||||
"https://example.com ",
|
||||
"https://example.com\n",
|
||||
"<https://example.com> trailing",
|
||||
"www.example.com",
|
||||
"ftp://example.com",
|
||||
"not a url",
|
||||
`See ${HREF}`,
|
||||
`buzz://channel/${CHANNEL_ID}/not-a-message-id`,
|
||||
]) {
|
||||
test(`exact link paste rejects ${input}`, () => {
|
||||
assert.equal(exactLinkPaste(input), null);
|
||||
});
|
||||
}
|
||||
|
||||
// The selection branch is the composer's alone now that `linkOnPaste` is off,
|
||||
// so it has to accept everything linkify accepted — otherwise turning the
|
||||
// second handler off would silently narrow which URLs preserve their label.
|
||||
for (const [label, input, expectedHref] of [
|
||||
["exact http", "https://example.com", "https://example.com"],
|
||||
["wrapped http", "<https://example.com>", "https://example.com"],
|
||||
[
|
||||
"canonical Buzz link",
|
||||
CHANNEL_MESSAGE_HREF,
|
||||
`buzz://message?channel=${CHANNEL_ID}&id=${CHANNEL_MESSAGE_ID}`,
|
||||
],
|
||||
["scheme-less host", "www.example.com", "http://www.example.com"],
|
||||
["bare host with path", "example.com/docs", "http://example.com/docs"],
|
||||
["email address", "foo@example.com", "mailto:foo@example.com"],
|
||||
["ftp", "ftp://example.com", "ftp://example.com"],
|
||||
]) {
|
||||
test(`selection link paste resolves ${label}`, () => {
|
||||
assert.deepEqual(resolveSelectionLinkPaste(input, resolveKnownChannel), {
|
||||
href: expectedHref,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const input of [
|
||||
"https://example.com and words",
|
||||
" https://example.com",
|
||||
"read this",
|
||||
"",
|
||||
]) {
|
||||
test(`selection link paste rejects ${JSON.stringify(input)}`, () => {
|
||||
assert.equal(resolveSelectionLinkPaste(input, resolveKnownChannel), null);
|
||||
});
|
||||
}
|
||||
|
||||
const editorSchema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: "block+" },
|
||||
paragraph: { content: "inline*", group: "block" },
|
||||
text: { group: "inline" },
|
||||
composerMessageLink: {
|
||||
atom: true,
|
||||
attrs: { channelName: { default: "" }, href: { default: "" } },
|
||||
group: "inline",
|
||||
inline: true,
|
||||
selectable: true,
|
||||
},
|
||||
codeBlock: { content: "text*", group: "block", marks: "" },
|
||||
},
|
||||
marks: {
|
||||
// `excludes: "_"` mirrors StarterKit's `code` mark: it silently drops any
|
||||
// other mark added over it, so a link applied to code-marked text never
|
||||
// lands even though the parent paragraph allows link marks.
|
||||
code: { excludes: "_" },
|
||||
link: { attrs: { href: {} }, inclusive: false },
|
||||
},
|
||||
});
|
||||
|
||||
const paragraph = (...content) =>
|
||||
editorSchema.nodes.paragraph.create(null, content);
|
||||
const codeBlock = (...content) =>
|
||||
editorSchema.nodes.codeBlock.create(null, content);
|
||||
const document = (...content) => editorSchema.nodes.doc.create(null, content);
|
||||
const text = (value, marks = []) => editorSchema.text(value, marks);
|
||||
const composerChip = (href = HREF) =>
|
||||
editorSchema.nodes.composerMessageLink.create({
|
||||
channelName: "general",
|
||||
href,
|
||||
});
|
||||
|
||||
function createPasteEvent(value) {
|
||||
let prevented = false;
|
||||
return {
|
||||
clipboardData: { getData: (type) => (type === "text/plain" ? value : "") },
|
||||
get defaultPrevented() {
|
||||
return prevented;
|
||||
},
|
||||
preventDefault() {
|
||||
prevented = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMockView(state) {
|
||||
const view = {
|
||||
dispatch(transaction) {
|
||||
view.state = view.state.apply(transaction);
|
||||
},
|
||||
focusCalled: false,
|
||||
focus() {
|
||||
view.focusCalled = true;
|
||||
},
|
||||
state,
|
||||
};
|
||||
return view;
|
||||
}
|
||||
|
||||
function stateFromDocument(doc, from, to = from) {
|
||||
return EditorState.create({
|
||||
doc,
|
||||
selection: TextSelection.create(doc, from, to),
|
||||
});
|
||||
}
|
||||
|
||||
function allSelectionStateFromDocument(doc) {
|
||||
return EditorState.create({
|
||||
doc,
|
||||
selection: new AllSelection(doc),
|
||||
});
|
||||
}
|
||||
|
||||
function toPlainJson(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
test("paste handler links selected text instead of replacing it", () => {
|
||||
const doc = document(paragraph(text("read this")));
|
||||
const view = createMockView(stateFromDocument(doc, 1, 10));
|
||||
const event = createPasteEvent("https://example.com");
|
||||
const handled = createComposerLinkPasteHandler(resolveKnownChannel)(
|
||||
view,
|
||||
event,
|
||||
);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.equal(view.focusCalled, true);
|
||||
assert.equal(view.state.doc.textContent, "read this");
|
||||
assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0].marks, [
|
||||
{ attrs: { href: "https://example.com" }, type: "link" },
|
||||
]);
|
||||
assert.equal(view.state.selection.empty, true);
|
||||
assert.equal(view.state.selection.from, 10);
|
||||
assert.deepEqual(view.state.storedMarks, []);
|
||||
});
|
||||
|
||||
test("paste handler canonicalizes Buzz links over selected text", () => {
|
||||
const doc = document(paragraph(text("selected")));
|
||||
const view = createMockView(stateFromDocument(doc, 1, 9));
|
||||
const event = createPasteEvent(CHANNEL_MESSAGE_HREF);
|
||||
const handled = createComposerLinkPasteHandler(resolveKnownChannel)(
|
||||
view,
|
||||
event,
|
||||
);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.equal(view.state.doc.textContent, "selected");
|
||||
assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0].marks, [
|
||||
{
|
||||
attrs: {
|
||||
href: `buzz://message?channel=${CHANNEL_ID}&id=${CHANNEL_MESSAGE_ID}`,
|
||||
},
|
||||
type: "link",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("paste handler consumes idempotent wrapped link over selected text", () => {
|
||||
const href = "https://example.com";
|
||||
const linkMark = editorSchema.marks.link.create({ href });
|
||||
const doc = document(paragraph(text("already linked", [linkMark])));
|
||||
const view = createMockView(stateFromDocument(doc, 1, 15));
|
||||
const event = createPasteEvent(`<${href}>`);
|
||||
const handled = createComposerLinkPasteHandler(resolveKnownChannel)(
|
||||
view,
|
||||
event,
|
||||
);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.equal(view.focusCalled, true);
|
||||
assert.equal(view.state.doc.textContent, "already linked");
|
||||
assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0].marks, [
|
||||
{ attrs: { href }, type: "link" },
|
||||
]);
|
||||
assert.equal(view.state.selection.empty, true);
|
||||
assert.equal(view.state.selection.from, 15);
|
||||
assert.deepEqual(view.state.storedMarks, []);
|
||||
});
|
||||
|
||||
test("paste handler falls through when selected text cannot carry link marks", () => {
|
||||
const doc = document(codeBlock(text("const value = 1;")));
|
||||
const view = createMockView(stateFromDocument(doc, 1, 17));
|
||||
const event = createPasteEvent("https://example.com");
|
||||
const handled = createComposerLinkPasteHandler(resolveKnownChannel)(
|
||||
view,
|
||||
event,
|
||||
);
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.equal(view.focusCalled, false);
|
||||
assert.equal(view.state.doc.textContent, "const value = 1;");
|
||||
assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0], {
|
||||
text: "const value = 1;",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
test("paste handler falls through when any selected text cannot carry link marks", () => {
|
||||
const doc = document(
|
||||
paragraph(text("ordinary")),
|
||||
codeBlock(text("const value = 1;")),
|
||||
);
|
||||
const view = createMockView(stateFromDocument(doc, 1, doc.content.size - 1));
|
||||
const initialDoc = toPlainJson(view.state.doc);
|
||||
const initialSelection = view.state.selection.toJSON();
|
||||
const event = createPasteEvent("https://example.com");
|
||||
const handled = createComposerLinkPasteHandler(resolveKnownChannel)(
|
||||
view,
|
||||
event,
|
||||
);
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.equal(view.focusCalled, false);
|
||||
assert.deepEqual(toPlainJson(view.state.doc), initialDoc);
|
||||
assert.deepEqual(view.state.selection.toJSON(), initialSelection);
|
||||
});
|
||||
|
||||
test("paste handler falls through when selected text mixes plain and inline code", () => {
|
||||
const doc = document(
|
||||
paragraph(
|
||||
text("plain "),
|
||||
text("inline", [editorSchema.marks.code.create()]),
|
||||
),
|
||||
);
|
||||
const view = createMockView(stateFromDocument(doc, 1, doc.content.size - 1));
|
||||
const initialDoc = toPlainJson(view.state.doc);
|
||||
const initialSelection = view.state.selection.toJSON();
|
||||
const event = createPasteEvent("https://example.com");
|
||||
const handled = createComposerLinkPasteHandler(resolveKnownChannel)(
|
||||
view,
|
||||
event,
|
||||
);
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.equal(view.focusCalled, false);
|
||||
assert.deepEqual(toPlainJson(view.state.doc), initialDoc);
|
||||
assert.deepEqual(view.state.selection.toJSON(), initialSelection);
|
||||
});
|
||||
|
||||
test("paste handler links selected text for linkify-only URL shapes", () => {
|
||||
for (const [pasted, expectedHref] of [
|
||||
["www.example.com", "http://www.example.com"],
|
||||
["foo@example.com", "mailto:foo@example.com"],
|
||||
]) {
|
||||
const doc = document(paragraph(text("read this")));
|
||||
const view = createMockView(stateFromDocument(doc, 1, 10));
|
||||
const handled = createComposerLinkPasteHandler(resolveKnownChannel)(
|
||||
view,
|
||||
createPasteEvent(pasted),
|
||||
);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.equal(view.state.doc.textContent, "read this");
|
||||
assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0].marks, [
|
||||
{ attrs: { href: expectedHref }, type: "link" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("caret paste stays plain for linkify-only URL shapes", () => {
|
||||
// Only the selection branch widened. A caret paste of `www.example.com` must
|
||||
// still fall through so it arrives as text for `autolink` to pick up, rather
|
||||
// than being inserted as a pre-linked node.
|
||||
const doc = document(paragraph(text("go ")));
|
||||
const view = createMockView(stateFromDocument(doc, 4));
|
||||
const event = createPasteEvent("www.example.com");
|
||||
const handled = createComposerLinkPasteHandler(resolveKnownChannel)(
|
||||
view,
|
||||
event,
|
||||
);
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.equal(view.state.doc.textContent, "go ");
|
||||
});
|
||||
|
||||
test("paste handler collapses an all-selection to inline content", () => {
|
||||
const doc = document(paragraph(text("select all")));
|
||||
const view = createMockView(allSelectionStateFromDocument(doc));
|
||||
const event = createPasteEvent("https://example.com");
|
||||
const handled = createComposerLinkPasteHandler(resolveKnownChannel)(
|
||||
view,
|
||||
event,
|
||||
);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.equal(view.state.doc.textContent, "select all");
|
||||
assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0].marks, [
|
||||
{ attrs: { href: "https://example.com" }, type: "link" },
|
||||
]);
|
||||
assert.equal(view.state.selection.empty, true);
|
||||
assert.equal(view.state.selection.from, 11);
|
||||
assert.equal(view.state.selection.$from.parent.type.name, "paragraph");
|
||||
});
|
||||
|
||||
test("paste handler replaces selected text when it contains a composer chip", () => {
|
||||
const doc = document(
|
||||
paragraph(text("before "), composerChip(), text(" after")),
|
||||
);
|
||||
const view = createMockView(stateFromDocument(doc, 1, doc.content.size - 1));
|
||||
const event = createPasteEvent("https://example.com");
|
||||
const handled = createComposerLinkPasteHandler(resolveKnownChannel)(
|
||||
view,
|
||||
event,
|
||||
);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.equal(view.state.doc.textContent, "https://example.com ");
|
||||
assert.deepEqual(toPlainJson(view.state.doc).content[0].content, [
|
||||
{
|
||||
marks: [{ attrs: { href: "https://example.com" }, type: "link" }],
|
||||
text: "https://example.com",
|
||||
type: "text",
|
||||
},
|
||||
{ text: " ", type: "text" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("paste handler preserves caret paste behavior", () => {
|
||||
const doc = document(paragraph(text("go ")));
|
||||
const view = createMockView(stateFromDocument(doc, 4));
|
||||
const event = createPasteEvent(CHANNEL_HREF);
|
||||
const handled = createComposerLinkPasteHandler(resolveKnownChannel)(
|
||||
view,
|
||||
event,
|
||||
);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.equal(view.state.doc.textContent, "go ");
|
||||
assert.deepEqual(toPlainJson(view.state.doc).content[0].content, [
|
||||
{ text: "go ", type: "text" },
|
||||
{
|
||||
attrs: { channelName: "general", href: CHANNEL_HREF },
|
||||
type: "composerMessageLink",
|
||||
},
|
||||
{ text: " ", type: "text" },
|
||||
]);
|
||||
});
|
||||
|
||||
function captureMarkdownRule() {
|
||||
let capturedAnchor = null;
|
||||
let capturedRule = null;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { TextSelection } from "@tiptap/pm/state";
|
||||
import type { Mark, Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { Selection, TextSelection } from "@tiptap/pm/state";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import { find as findLinks } from "linkifyjs";
|
||||
|
||||
import {
|
||||
buildIssueLink,
|
||||
@@ -117,10 +118,118 @@ function unwrapExactBuzzLink(text: string): string | null {
|
||||
}
|
||||
|
||||
function unwrapExactHttpLink(text: string): string | null {
|
||||
if (!text || /\s/.test(text)) return null;
|
||||
const match = /^(?:<(https?:\/\/[^\s<>]+)>|(https?:\/\/\S+))$/i.exec(text);
|
||||
return match?.[1] ?? match?.[2] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a clipboard payload that is exactly a supported link into the href
|
||||
* the composer should apply when linkifying selected text on paste.
|
||||
*/
|
||||
export function resolveExactLinkPaste(
|
||||
text: string,
|
||||
resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"],
|
||||
): { href: string } | null {
|
||||
const buzzHref = unwrapExactBuzzLink(text);
|
||||
if (buzzHref) {
|
||||
const attrs = resolveComposerMessageLinkAttributes(
|
||||
buzzHref,
|
||||
resolveChannelName,
|
||||
);
|
||||
return attrs ? { href: attrs.href } : null;
|
||||
}
|
||||
|
||||
const httpHref = unwrapExactHttpLink(text);
|
||||
return httpHref ? { href: httpHref } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a clipboard payload for the *selected text* branch of paste
|
||||
* handling, where this handler is the only one that runs.
|
||||
*
|
||||
* The exact Buzz/http matchers win first, so Buzz links keep their canonical
|
||||
* form. Anything else falls back to linkify with `defaultProtocol: "http"` —
|
||||
* the same matcher TipTap's `linkOnPaste` used before the composer took sole
|
||||
* ownership of this branch, so `www.example.com`, `foo@example.com` and
|
||||
* `ftp://…` still hyperlink the selection instead of replacing it.
|
||||
*
|
||||
* Deliberately scoped to the selection branch: broadening
|
||||
* `resolveExactLinkPaste` would also change caret paste, where these shapes
|
||||
* must keep arriving as plain text for `autolink` to pick up.
|
||||
*/
|
||||
export function resolveSelectionLinkPaste(
|
||||
text: string,
|
||||
resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"],
|
||||
): { href: string } | null {
|
||||
const exactLinkPaste = resolveExactLinkPaste(text, resolveChannelName);
|
||||
if (exactLinkPaste) return exactLinkPaste;
|
||||
|
||||
const link = findLinks(text, { defaultProtocol: "http" }).find(
|
||||
(candidate) => candidate.isLink && candidate.value === text,
|
||||
);
|
||||
return link ? { href: link.href } : null;
|
||||
}
|
||||
|
||||
function selectionContainsComposerMessageLinkNode(view: EditorView): boolean {
|
||||
const { from, to } = view.state.selection;
|
||||
let containsMessageLink = false;
|
||||
view.state.doc.nodesBetween(from, to, (node) => {
|
||||
if (containsMessageLink) return false;
|
||||
if (node.type.name === COMPOSER_MESSAGE_LINK_NODE_NAME) {
|
||||
containsMessageLink = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return containsMessageLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the *outcome* of an `addMark` rather than predicting it: every inline
|
||||
* node in the range must have come out carrying `mark`. Predicting is what a
|
||||
* parent-level `allowsMarkType` probe does, and it misses mark exclusion —
|
||||
* `code`'s `excludes: "_"` makes `Mark.addToSet` silently drop a link, so a
|
||||
* selection spanning plain text and an inline code span passes the prediction
|
||||
* but only gets partially linked.
|
||||
*/
|
||||
function everyInlineNodeCarriesMark(
|
||||
doc: ProseMirrorNode,
|
||||
from: number,
|
||||
to: number,
|
||||
mark: Mark,
|
||||
): boolean {
|
||||
let containsInlineContent = false;
|
||||
let allInlineContentCarriesMark = true;
|
||||
doc.nodesBetween(from, to, (node) => {
|
||||
if (!node.isInline) return true;
|
||||
containsInlineContent = true;
|
||||
if (!mark.isInSet(node.marks)) allInlineContentCarriesMark = false;
|
||||
return true;
|
||||
});
|
||||
return containsInlineContent && allInlineContentCarriesMark;
|
||||
}
|
||||
|
||||
function applyLinkToSelection(view: EditorView, href: string): boolean {
|
||||
const { from, to } = view.state.selection;
|
||||
const linkMark = view.state.schema.marks.link;
|
||||
if (!linkMark) return false;
|
||||
|
||||
const mark = linkMark.create({ href });
|
||||
let transaction = view.state.tr.addMark(from, to, mark);
|
||||
// Bail before dispatching, so the document and selection are untouched and
|
||||
// the paste falls through to normal replacement.
|
||||
if (!everyInlineNodeCarriesMark(transaction.doc, from, to, mark))
|
||||
return false;
|
||||
|
||||
transaction = transaction.setSelection(
|
||||
Selection.near(transaction.doc.resolve(transaction.mapping.map(to)), -1),
|
||||
);
|
||||
view.dispatch(transaction.setStoredMarks([]).scrollIntoView());
|
||||
view.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function replaceSelectionWithNode(view: EditorView, node: ProseMirrorNode) {
|
||||
const { from, to } = view.state.selection;
|
||||
let transaction = view.state.tr.replaceRangeWith(from, to, node);
|
||||
@@ -140,6 +249,21 @@ export function createComposerLinkPasteHandler(
|
||||
) {
|
||||
return (view: EditorView, event: ClipboardEvent): boolean => {
|
||||
const text = event.clipboardData?.getData("text/plain") ?? "";
|
||||
if (
|
||||
!view.state.selection.empty &&
|
||||
!selectionContainsComposerMessageLinkNode(view)
|
||||
) {
|
||||
const selectionLinkPaste = resolveSelectionLinkPaste(
|
||||
text,
|
||||
resolveChannelName,
|
||||
);
|
||||
if (selectionLinkPaste) {
|
||||
if (!applyLinkToSelection(view, selectionLinkPaste.href)) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const buzzHref = unwrapExactBuzzLink(text);
|
||||
const buzzLinkType =
|
||||
view.state.schema.nodes[COMPOSER_MESSAGE_LINK_NODE_NAME];
|
||||
|
||||
64
desktop/src/features/messages/lib/linkPasteTrailingSpace.ts
Normal file
64
desktop/src/features/messages/lib/linkPasteTrailingSpace.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, TextSelection } from "@tiptap/pm/state";
|
||||
|
||||
const PASTED_LINK_AT_END_RE =
|
||||
/(?:^|\s)((?:https?:\/\/|www\.)[^\s]+|(?:github\.com|linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s]+)$/i;
|
||||
|
||||
function shouldAppendSpaceAfterPaste(text: string): boolean {
|
||||
const trimmedEnd = text.trimEnd();
|
||||
if (!trimmedEnd || trimmedEnd.length !== text.length) return false;
|
||||
return PASTED_LINK_AT_END_RE.test(trimmedEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a trailing space after a paste that ends in a bare link, so the
|
||||
* caret lands outside the autolinked mark and the next typed character is
|
||||
* not swallowed into the link.
|
||||
*/
|
||||
export const LinkPasteTrailingSpace = Extension.create({
|
||||
name: "linkPasteTrailingSpace",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handlePaste(view, event) {
|
||||
const pastedText = event.clipboardData?.getData("text/plain") ?? "";
|
||||
if (!shouldAppendSpaceAfterPaste(pastedText)) return false;
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!view.dom.isConnected) return;
|
||||
const { state } = view;
|
||||
if (!state.selection.empty) return;
|
||||
|
||||
const from = state.selection.from;
|
||||
if (from < state.doc.content.size) {
|
||||
const nextText = state.doc.textBetween(
|
||||
from,
|
||||
Math.min(state.doc.content.size, from + 1),
|
||||
"\n",
|
||||
"\n",
|
||||
);
|
||||
if (/^\s$/.test(nextText)) return;
|
||||
}
|
||||
|
||||
let transaction = state.tr.insertText(" ", from, from);
|
||||
const linkMark = state.schema.marks.link;
|
||||
if (linkMark) {
|
||||
transaction = transaction.removeMark(from, from + 1, linkMark);
|
||||
}
|
||||
transaction = transaction.setSelection(
|
||||
TextSelection.create(transaction.doc, from + 1),
|
||||
);
|
||||
transaction.setStoredMarks([]);
|
||||
view.dispatch(transaction.scrollIntoView());
|
||||
view.focus();
|
||||
}, 0);
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import { Extension, type KeyboardShortcutCommand } from "@tiptap/core";
|
||||
import { Plugin, Selection, TextSelection } from "@tiptap/pm/state";
|
||||
import { Selection, TextSelection } from "@tiptap/pm/state";
|
||||
import type { ResolvedPos } from "@tiptap/pm/model";
|
||||
|
||||
import { readTextFromSystemClipboard } from "@/shared/api/tauriMedia";
|
||||
@@ -34,6 +34,7 @@ import { buildPlainTextProjection } from "./plainTextProjection";
|
||||
import { parseSnapshotClipboardHtml } from "./agentSnapshotClipboard";
|
||||
import { buildPreviewUpdate } from "./linkPreviewContent";
|
||||
import { createLinkInteractionExtension } from "./linkInteractionExtension";
|
||||
import { LinkPasteTrailingSpace } from "./linkPasteTrailingSpace";
|
||||
import {
|
||||
CodeBlockAfterHardBreak,
|
||||
handleCodeFenceEnter,
|
||||
@@ -136,63 +137,6 @@ export type RichTextEditorOptions = {
|
||||
onLinkShortcut?: () => boolean;
|
||||
};
|
||||
|
||||
const PASTED_LINK_AT_END_RE =
|
||||
/(?:^|\s)((?:https?:\/\/|www\.)[^\s]+|(?:github\.com|linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s]+)$/i;
|
||||
|
||||
function shouldAppendSpaceAfterPaste(text: string): boolean {
|
||||
const trimmedEnd = text.trimEnd();
|
||||
if (!trimmedEnd || trimmedEnd.length !== text.length) return false;
|
||||
return PASTED_LINK_AT_END_RE.test(trimmedEnd);
|
||||
}
|
||||
|
||||
const LinkPasteTrailingSpace = Extension.create({
|
||||
name: "linkPasteTrailingSpace",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handlePaste(view, event) {
|
||||
const pastedText = event.clipboardData?.getData("text/plain") ?? "";
|
||||
if (!shouldAppendSpaceAfterPaste(pastedText)) return false;
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!view.dom.isConnected) return;
|
||||
const { state } = view;
|
||||
if (!state.selection.empty) return;
|
||||
|
||||
const from = state.selection.from;
|
||||
if (from < state.doc.content.size) {
|
||||
const nextText = state.doc.textBetween(
|
||||
from,
|
||||
Math.min(state.doc.content.size, from + 1),
|
||||
"\n",
|
||||
"\n",
|
||||
);
|
||||
if (/^\s$/.test(nextText)) return;
|
||||
}
|
||||
|
||||
let transaction = state.tr.insertText(" ", from, from);
|
||||
const linkMark = state.schema.marks.link;
|
||||
if (linkMark) {
|
||||
transaction = transaction.removeMark(from, from + 1, linkMark);
|
||||
}
|
||||
transaction = transaction.setSelection(
|
||||
TextSelection.create(transaction.doc, from + 1),
|
||||
);
|
||||
transaction.setStoredMarks([]);
|
||||
view.dispatch(transaction.scrollIntoView());
|
||||
view.focus();
|
||||
}, 0);
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates and manages a Tiptap editor configured for Markdown output.
|
||||
*
|
||||
@@ -478,7 +422,13 @@ export function useRichTextEditor({
|
||||
}).configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
// The composer's own paste handler owns every selected-text link
|
||||
// paste (`createComposerLinkPasteHandler`). TipTap's `linkOnPaste`
|
||||
// recognises the same URLs one layer down, and when our handler
|
||||
// declines a selection it can't link cleanly, `linkOnPaste` still
|
||||
// fires and partially links it. No ordering trick removes a second
|
||||
// handler — the only fix is not to register it.
|
||||
linkOnPaste: false,
|
||||
// Allow Buzz message links through TipTap's URL sanitiser.
|
||||
// http(s) and mailto are accepted by default; non-listed protocols are
|
||||
// stripped on paste/typed input.
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -189,6 +189,9 @@ importers:
|
||||
jdenticon:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
linkifyjs:
|
||||
specifier: ^4.3.2
|
||||
version: 4.3.2
|
||||
lucide-react:
|
||||
specifier: ^1.0.0
|
||||
version: 1.16.0(react@19.2.8)
|
||||
|
||||
Reference in New Issue
Block a user