fix(desktop): intercept plain links before clipboard parsing

Co-authored-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Signed-off-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
This commit is contained in:
npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370
2026-08-04 16:01:54 -07:00
parent 8158afe210
commit c29901eb10
3 changed files with 32 additions and 43 deletions

View File

@@ -486,6 +486,28 @@ export function useRichTextEditor({
}),
],
editorProps: {
handleDOMEvents: {
paste: (view, event) => {
const text = (event as ClipboardEvent).clipboardData?.getData(
"text/plain",
);
if (!text || !/^https?:\/\/\S+$/i.test(text)) return false;
const { from, to } = view.state.selection;
const link = view.state.schema.marks.link;
if (!link) return false;
let tr = view.state.tr.replaceRangeWith(
from,
to,
view.state.schema.text(text, [link.create({ href: text })]),
);
const end = tr.mapping.map(to);
tr = tr.insertText(" ", end).removeMark(end, end + 1, link);
tr = tr.setSelection(TextSelection.create(tr.doc, end + 1));
view.dispatch(tr.setStoredMarks([]).scrollIntoView());
event.preventDefault();
return true;
},
},
attributes: {
autocapitalize: "none",
autocorrect: "off",
@@ -812,35 +834,6 @@ export function useRichTextEditor({
[editor, customEmojiWiring.resolveUrl],
);
const insertPlainHttpLink = React.useCallback(
(text: string): boolean => {
// A plain URL is the common link-preview path. Insert it directly as a
// ProseMirror link instead of sending it through tiptap-markdown's full
// clipboard parser; that parser blocks the paste event long enough to
// drop visible frames before the preview can render.
if (!editor || !/^https?:\/\/\S+$/i.test(text)) return false;
const { state, view } = editor;
const { from, to } = state.selection;
const linkMark = state.schema.marks.link;
if (!linkMark) return false;
const linkedText = state.schema.text(text, [
linkMark.create({ href: text }),
]);
let transaction = state.tr.replaceRangeWith(from, to, linkedText);
const linkEnd = transaction.mapping.map(to);
transaction = transaction.insertText(" ", linkEnd);
transaction = transaction.removeMark(linkEnd, linkEnd + 1, linkMark);
transaction = transaction.setSelection(
TextSelection.create(transaction.doc, linkEnd + 1),
);
transaction.setStoredMarks([]);
view.dispatch(transaction.scrollIntoView());
return true;
},
[editor],
);
/**
* Link mark info for the current selection — its href and the covered
* text, expanded to the full link range when the caret merely sits inside
@@ -928,7 +921,6 @@ export function useRichTextEditor({
focusPreserve,
getPlainTextAndCursor,
replacePlainTextRange,
insertPlainHttpLink,
getLinkSelectionInfo,
applyLink,
removeLink,

View File

@@ -100,10 +100,11 @@ function MessageComposerImpl({
syncContentRefFromEditorRef,
} = useComposerContentState();
const [previewContent, setPreviewContent] = React.useState("");
const deferredPreviewContent = React.useDeferredValue(previewContent);
const {
previewList: composerLinkPreviews,
getReadyTags: getReadyLinkPreviewTags,
} = useComposerLinkPreviews(previewContent);
} = useComposerLinkPreviews(deferredPreviewContent);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
@@ -263,7 +264,7 @@ function MessageComposerImpl({
onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false,
onUpdate: ({ cursor, text }) => {
setComposerContentFromText(text);
React.startTransition(() => setPreviewContent(text));
setPreviewContent(text);
mentions.updateMentionQuery(text, cursor);
channelLinks.updateChannelQuery(text, cursor);
emojiAutocomplete.updateEmojiQuery(text, cursor);
@@ -734,8 +735,6 @@ function MessageComposerImpl({
// ── Media paste + ⌘K link shortcut via Tiptap editorProps ──────────
const uploadFileRef = React.useRef(media.uploadFile);
uploadFileRef.current = media.uploadFile;
const insertPlainHttpLinkRef = React.useRef(richText.insertPlainHttpLink);
insertPlainHttpLinkRef.current = richText.insertPlainHttpLink;
React.useEffect(() => {
if (!richText.editor) return;
richText.editor.setOptions({
@@ -793,10 +792,6 @@ function MessageComposerImpl({
return true;
}
const plainText = event.clipboardData?.getData("text/plain") ?? "";
if (insertPlainHttpLinkRef.current(plainText)) {
event.preventDefault();
return true;
}
if (plainText.includes("\n")) {
scrollComposerToBottom();
}
@@ -825,9 +820,11 @@ function MessageComposerImpl({
],
);
const handleCaptureSelection = React.useCallback(() => {}, []);
const handlePaperclipClick = React.useCallback(() => {
void media.handlePaperclip();
}, [media.handlePaperclip]);
const handleRemoveAttachment = React.useCallback(
(url: string) => {
setSpoileredAttachmentUrls((current) => {
@@ -840,12 +837,14 @@ function MessageComposerImpl({
},
[media.removeAttachment],
);
const { handleAttachmentEditSave, handleAttachmentRevert } =
useAttachmentEditing({
revertAttachment: media.revertAttachment,
setSpoileredAttachmentUrls,
uploadEditedAttachment: media.uploadEditedAttachment,
});
const handleToggleAttachmentSpoiler = React.useCallback((url: string) => {
setSpoileredAttachmentUrls((current) => {
const next = new Set(current);
@@ -950,6 +949,7 @@ function MessageComposerImpl({
</button>
</div>
) : null}
{composerLinkPreviews}
{(media.pendingImeta.length > 0 ||
media.queuedAttachments.length > 0 ||

View File

@@ -217,7 +217,7 @@ test.beforeEach(async ({ page }, testInfo) => {
imageDomain: null,
},
linkPreviewMetadataDelayMs: testInfo.title.includes(
"avoids a long task",
"raw paste bypasses clipboard parsing",
)
? 10_000
: testInfo.title.includes("style defaults") ||
@@ -475,7 +475,7 @@ test("link preview style defaults to compact and Rich unfurls descriptions", asy
await page.getByTestId("link-preview-style-compact").click();
});
test("link preview plain URL paste avoids a long task before the loading card", async ({
test("link preview raw paste bypasses clipboard parsing before loading", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/3246?paste=async";
@@ -512,9 +512,6 @@ test("link preview plain URL paste avoids a long task before the loading card",
};
}, previewUrl);
expect(firstPaint.text).toContain(previewUrl);
// A 50ms task is the browser's standard long-task boundary. Guard the
// synchronous paste event itself and require three advancing frames so a
// later multi-frame stall cannot hide behind one permissive rAF assertion.
expect(firstPaint.pasteEventMs).toBeLessThan(50);
expect(firstPaint.frameTimes).toHaveLength(3);
expect(firstPaint.frameTimes[2]).toBeGreaterThan(firstPaint.frameTimes[0]);