mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
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:
parent
8158afe210
commit
c29901eb10
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user