Skip to content

Commit 34360a2

Browse files
committed
fix(web): read all clipboard flavors so iOS paste keeps the URL
On iOS the copied product link lives in a separate clipboard flavor (text/uri-list / URL) from the visible product name (text/plain); reading only 'text' dropped the URL (link field stayed empty). Add web/lib/clipboard.ts to gather every flavor on paste, and use the rich Clipboard read() for the 'Paste from clipboard' button. Applied to the /add screen and dashboard.
1 parent 4fcb82a commit 34360a2

3 files changed

Lines changed: 60 additions & 9 deletions

File tree

web/app/add/add-form.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Link from "next/link";
55
import { toast } from "sonner";
66
import { addSharedItem, createCart, uploadImage } from "@/lib/api-client";
77
import { parseShare, type ParsedShare } from "@/lib/parse-share";
8+
import { richClipboardData, readRichClipboard } from "@/lib/clipboard";
89

910
type CartOpt = { id: string; title: string; slug: string };
1011
const LAST_CART_KEY = "shoplit:lastCart";
@@ -41,12 +42,12 @@ export function AddForm({ carts, initial }: { carts: CartOpt[]; initial: ParsedS
4142
if (p.priceText) setPriceText(p.priceText);
4243
};
4344

44-
// Explicit clipboard read — the reliable path on iOS Safari, where a
45-
// textarea's onChange often doesn't fire for a long-press "Paste".
45+
// Explicit clipboard read — the reliable path on iOS Safari (reads the URL
46+
// flavor, not just the product name).
4647
const pasteFromClipboard = async () => {
4748
try {
48-
const t = await navigator.clipboard.readText();
49-
if (t.trim()) applyPasted(t);
49+
const raw = await readRichClipboard();
50+
if (raw) applyPasted(raw);
5051
else toast.error("Clipboard is empty — copy the product link first.");
5152
} catch {
5253
toast.error("Couldn't read the clipboard — paste into the box instead.");
@@ -148,10 +149,11 @@ export function AddForm({ carts, initial }: { carts: CartOpt[]; initial: ParsedS
148149
onChange={(e) => applyPasted(e.target.value)}
149150
onPaste={(e) => {
150151
// Capture the paste directly — iOS Safari doesn't reliably fire
151-
// onChange for a long-press paste. preventDefault so we set the
152-
// value ourselves (no double insert).
152+
// onChange for a long-press paste, and stores the link in a
153+
// separate clipboard flavor from the product name. Read all
154+
// flavors so the URL isn't dropped.
153155
e.preventDefault();
154-
applyPasted(e.clipboardData.getData("text"));
156+
applyPasted(richClipboardData(e.clipboardData));
155157
}}
156158
placeholder={'Paste the link or the whole “Check out this product…” text'}
157159
className={inputCls}

web/components/paste-url-preview.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Loader2, Link2, Plus } from "lucide-react";
55
import { toast } from "sonner";
66
import { fetchOG, uploadImage } from "@/lib/api-client";
77
import { parseShare } from "@/lib/parse-share";
8+
import { richClipboardData } from "@/lib/clipboard";
89
import type { Product, Retailer } from "@/lib/types";
910
import { RetailerIcon, retailerLabel } from "./retailer-icon";
1011

@@ -125,9 +126,10 @@ export function PasteUrlPreview({ onResolved }: PasteUrlPreviewProps) {
125126
onChange={(e) => handlePaste(e.target.value)}
126127
onPaste={(e) => {
127128
// iOS Safari doesn't reliably fire onChange for a long-press
128-
// paste — capture the clipboard data directly.
129+
// paste, and stores the link in a separate flavor from the
130+
// product name — read all flavors so the URL isn't dropped.
129131
e.preventDefault();
130-
handlePaste(e.clipboardData.getData("text"));
132+
handlePaste(richClipboardData(e.clipboardData));
131133
}}
132134
placeholder="Paste here — e.g. “Check out this product… https://www.myntra.com/…”"
133135
className="w-full resize-y rounded-lg border border-rule bg-cream py-3 pl-10 pr-4 text-base leading-relaxed focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"

web/lib/clipboard.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Clipboard helpers that survive iOS Safari's behaviour: a copied product link
2+
// is stored in a separate clipboard flavor (text/uri-list or URL) from the
3+
// visible text (text/plain — often just the product name). Reading only "text"
4+
// drops the URL, so these gather every flavor and join them for the parser.
5+
6+
const URL_FLAVORS = ["text/uri-list", "URL", "text/plain", "text"];
7+
8+
// From a paste event's DataTransfer.
9+
export function richClipboardData(dt: DataTransfer): string {
10+
const parts: string[] = [];
11+
for (const t of URL_FLAVORS) {
12+
let v = "";
13+
try {
14+
v = dt.getData(t);
15+
} catch {
16+
/* flavor not available */
17+
}
18+
if (v && !parts.includes(v)) parts.push(v);
19+
}
20+
return parts.join("\n");
21+
}
22+
23+
// From the async Clipboard API (for an explicit "Paste" button). Uses the rich
24+
// read() to get the URL flavor; falls back to readText() (text/plain only).
25+
export async function readRichClipboard(): Promise<string> {
26+
const nav = navigator.clipboard as Clipboard & {
27+
read?: () => Promise<ClipboardItem[]>;
28+
};
29+
if (nav.read) {
30+
try {
31+
const items = await nav.read();
32+
const parts: string[] = [];
33+
for (const item of items) {
34+
for (const type of ["text/uri-list", "text/plain"]) {
35+
if (item.types.includes(type)) {
36+
parts.push(await (await item.getType(type)).text());
37+
}
38+
}
39+
}
40+
const raw = parts.join("\n").trim();
41+
if (raw) return raw;
42+
} catch {
43+
/* fall through to readText */
44+
}
45+
}
46+
return (await navigator.clipboard.readText()).trim();
47+
}

0 commit comments

Comments
 (0)