mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
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:
parent
e0ba031a6f
commit
8158afe210
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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"]');
|
||||
|
||||
Reference in New Issue
Block a user