fix(desktop): bypass markdown parser for plain link paste

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 15:50:00 -07:00
parent e0ba031a6f
commit 8158afe210
3 changed files with 58 additions and 15 deletions

View File

@@ -812,6 +812,35 @@ 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
@@ -899,6 +928,7 @@ export function useRichTextEditor({
focusPreserve,
getPlainTextAndCursor,
replacePlainTextRange,
insertPlainHttpLink,
getLinkSelectionInfo,
applyLink,
removeLink,

View File

@@ -100,11 +100,10 @@ function MessageComposerImpl({
syncContentRefFromEditorRef,
} = useComposerContentState();
const [previewContent, setPreviewContent] = React.useState("");
const deferredPreviewContent = React.useDeferredValue(previewContent);
const {
previewList: composerLinkPreviews,
getReadyTags: getReadyLinkPreviewTags,
} = useComposerLinkPreviews(deferredPreviewContent);
} = useComposerLinkPreviews(previewContent);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
@@ -264,7 +263,7 @@ function MessageComposerImpl({
onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false,
onUpdate: ({ cursor, text }) => {
setComposerContentFromText(text);
setPreviewContent(text);
React.startTransition(() => setPreviewContent(text));
mentions.updateMentionQuery(text, cursor);
channelLinks.updateChannelQuery(text, cursor);
emojiAutocomplete.updateEmojiQuery(text, cursor);
@@ -735,6 +734,8 @@ 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({
@@ -792,6 +793,10 @@ function MessageComposerImpl({
return true;
}
const plainText = event.clipboardData?.getData("text/plain") ?? "";
if (insertPlainHttpLinkRef.current(plainText)) {
event.preventDefault();
return true;
}
if (plainText.includes("\n")) {
scrollComposerToBottom();
}
@@ -820,11 +825,9 @@ function MessageComposerImpl({
],
);
const handleCaptureSelection = React.useCallback(() => {}, []);
const handlePaperclipClick = React.useCallback(() => {
void media.handlePaperclip();
}, [media.handlePaperclip]);
const handleRemoveAttachment = React.useCallback(
(url: string) => {
setSpoileredAttachmentUrls((current) => {
@@ -837,14 +840,12 @@ 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);
@@ -949,7 +950,6 @@ 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(
"loading card asynchronously",
"avoids a long task",
)
? 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 paste paints the loading card asynchronously", async ({
test("link preview plain URL paste avoids a long task before the loading card", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/3246?paste=async";
@@ -495,16 +495,29 @@ test("link preview paste paints the loading card asynchronously", async ({
clipboardData,
}),
);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
const pasteEventMs = performance.now() - startedAt;
const frameTimes: number[] = [];
for (let frame = 0; frame < 3; frame += 1) {
await new Promise<void>((resolve) =>
requestAnimationFrame((timestamp) => {
frameTimes.push(timestamp - startedAt);
resolve();
}),
);
}
return {
elapsedMs: performance.now() - startedAt,
pasteEventMs,
frameTimes,
text: element.textContent,
};
}, previewUrl);
expect(firstPaint.text).toContain(previewUrl);
expect(firstPaint.elapsedMs).toBeLessThan(250);
// 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]);
const composerPreview = page
.locator("[data-composer-link-previews]")
.locator('[data-link-preview="github-pull-request"]');