Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,11 @@ import {
collapseExpandedComposerCursor,
detectComposerTrigger,
expandCollapsedComposerCursor,
extractDraggedImageUrls,
isComposerAttachmentDrag,
replaceTextRange,
} from "../../composer-logic";
import { fetchDroppedImageAsFile } from "../../lib/fetchDroppedImage";
import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic";
import {
type ComposerImageAttachment,
Expand Down Expand Up @@ -1572,21 +1575,21 @@ export const ChatComposer = memo(
};

const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes("Files")) return;
if (!isComposerAttachmentDrag(event.dataTransfer.types)) return;
event.preventDefault();
dragDepthRef.current += 1;
setIsDragOverComposer(true);
};

const onComposerDragOver = (event: React.DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes("Files")) return;
if (!isComposerAttachmentDrag(event.dataTransfer.types)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setIsDragOverComposer(true);
};

const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes("Files")) return;
if (!isComposerAttachmentDrag(event.dataTransfer.types)) return;
event.preventDefault();
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
Expand All @@ -1597,13 +1600,43 @@ export const ChatComposer = memo(
};

const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes("Files")) return;
if (!isComposerAttachmentDrag(event.dataTransfer.types)) return;
event.preventDefault();
dragDepthRef.current = 0;
setIsDragOverComposer(false);

const files = Array.from(event.dataTransfer.files);
addComposerImages(files);
focusComposer();
if (files.length > 0) {
addComposerImages(files);
focusComposer();
return;
}

// No real files — likely an image dragged from another browser tab
// (e.g. the ChatGPT web UI). Snapshot the URLs synchronously because
// `event.dataTransfer` is inert outside this handler, then fetch the
// bytes asynchronously.
const urls = extractDraggedImageUrls(event.dataTransfer);
if (urls.length === 0) return;

void (async () => {
const results = await Promise.all(urls.map((url) => fetchDroppedImageAsFile(url)));
const fetched = results.filter((file): file is File => file !== null);
if (fetched.length > 0) {
addComposerImages(fetched);
focusComposer();
return;
}
// We saw image-shaped URLs but couldn't turn any into a file — usually
// a CORS block on the source host. Let the user know so they know to
// fall back to the Finder round-trip that already works.
toastManager.add({
type: "error",
title: "Couldn't attach dragged image.",
description:
"The source blocked a direct download. Save the image to your computer first, then drop the file.",
});
})();
};
const handleInterruptPrimaryAction = useCallback(() => {
void onInterrupt();
Expand Down
148 changes: 148 additions & 0 deletions apps/web/src/composer-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ import { describe, expect, it } from "vitest";
import {
clampCollapsedComposerCursor,
collapseExpandedComposerCursor,
deriveImageFilenameFromUrl,
detectComposerTrigger,
expandCollapsedComposerCursor,
extractDraggedImageUrls,
isCollapsedCursorAdjacentToInlineToken,
isComposerAttachmentDrag,
parseStandaloneComposerSlashCommand,
replaceTextRange,
} from "./composer-logic";
Expand Down Expand Up @@ -288,6 +291,151 @@ describe("isCollapsedCursorAdjacentToInlineToken", () => {
});
});

describe("isComposerAttachmentDrag", () => {
it("accepts real File drags (Finder, other apps)", () => {
expect(isComposerAttachmentDrag(["Files"])).toBe(true);
});

it("accepts browser image/link drags that carry text/uri-list", () => {
expect(isComposerAttachmentDrag(["text/uri-list", "text/html", "text/plain"])).toBe(true);
});

it("ignores plain-text drags so Lexical keeps handling them", () => {
expect(isComposerAttachmentDrag(["text/plain"])).toBe(false);
expect(isComposerAttachmentDrag(["text/plain", "text/html"])).toBe(false);
});

it("ignores an empty type list", () => {
expect(isComposerAttachmentDrag([])).toBe(false);
});
});

const makeDraggedData = (entries: Record<string, string>) => ({
types: Object.keys(entries),
getData: (type: string) => entries[type] ?? "",
});

describe("extractDraggedImageUrls", () => {
it("parses a single URL from text/uri-list", () => {
const data = makeDraggedData({
"text/uri-list": "https://files.oaiusercontent.com/example.png",
});
expect(extractDraggedImageUrls(data)).toEqual(["https://files.oaiusercontent.com/example.png"]);
});

it("ignores comment lines in text/uri-list per RFC 2483", () => {
const data = makeDraggedData({
"text/uri-list": "# dragged from ChatGPT\nhttps://cdn.example.com/a.png",
});
expect(extractDraggedImageUrls(data)).toEqual(["https://cdn.example.com/a.png"]);
});

it("falls back to <img src> in text/html when uri-list is missing", () => {
const data = makeDraggedData({
"text/html": '<meta charset="utf-8"><img src="https://cdn.example.com/b.webp" alt="dragged">',
});
expect(extractDraggedImageUrls(data)).toEqual(["https://cdn.example.com/b.webp"]);
});

it("accepts single-quoted src attributes", () => {
const data = makeDraggedData({
"text/html": "<img src='https://cdn.example.com/c.gif'>",
});
expect(extractDraggedImageUrls(data)).toEqual(["https://cdn.example.com/c.gif"]);
});

it("deduplicates the same URL appearing in both uri-list and html", () => {
const data = makeDraggedData({
"text/uri-list": "https://cdn.example.com/d.png",
"text/html": '<img src="https://cdn.example.com/d.png">',
});
expect(extractDraggedImageUrls(data)).toEqual(["https://cdn.example.com/d.png"]);
});

it("accepts data:image/ and blob: URLs", () => {
const data = makeDraggedData({
"text/uri-list": "data:image/png;base64,AAAA",
"text/html": '<img src="blob:https://example.com/abc-123">',
});
expect(extractDraggedImageUrls(data)).toEqual([
"data:image/png;base64,AAAA",
"blob:https://example.com/abc-123",
]);
});

it("rejects non-http schemes and non-image data URLs", () => {
const data = makeDraggedData({
"text/uri-list": "file:///Users/alice/secret.txt\nabout:blank\ndata:text/plain,hello",
});
expect(extractDraggedImageUrls(data)).toEqual([]);
});

it("uses text/plain as a last-resort fallback", () => {
const data = makeDraggedData({ "text/plain": "https://cdn.example.com/e.png" });
expect(extractDraggedImageUrls(data)).toEqual(["https://cdn.example.com/e.png"]);
});

it("does not fall back to text/plain when uri-list/html already yielded URLs", () => {
// Guards against an optional-fallthrough bug: the fallback branch must
// only fire when uri-list AND html produced nothing.
const data = makeDraggedData({
"text/uri-list": "https://cdn.example.com/f.png",
"text/plain": "totally unrelated text that happens to mention https://example.com",
});
expect(extractDraggedImageUrls(data)).toEqual(["https://cdn.example.com/f.png"]);
});

it("returns an empty array when nothing is extractable", () => {
expect(extractDraggedImageUrls(makeDraggedData({ "text/plain": "just some text" }))).toEqual(
[],
);
expect(extractDraggedImageUrls(makeDraggedData({}))).toEqual([]);
});
});

describe("deriveImageFilenameFromUrl", () => {
it("preserves the original filename when the URL has one", () => {
expect(deriveImageFilenameFromUrl("https://cdn.example.com/path/photo.png", "image/png")).toBe(
"photo.png",
);
});

it("strips the query string before extracting the filename", () => {
expect(
deriveImageFilenameFromUrl(
"https://cdn.example.com/path/photo.jpg?token=abc&v=2",
"image/jpeg",
),
).toBe("photo.jpg");
});

it("adds an extension when the URL path has none", () => {
expect(deriveImageFilenameFromUrl("https://cdn.example.com/resource/12345", "image/webp")).toBe(
"12345.webp",
);
});

it("falls back to a generic name when the URL has no usable path", () => {
expect(deriveImageFilenameFromUrl("https://cdn.example.com/", "image/png")).toBe(
"dropped-image.png",
);
});

it("falls back to png when the MIME subtype is missing", () => {
expect(deriveImageFilenameFromUrl("https://cdn.example.com/", "")).toBe("dropped-image.png");
});

it("decodes percent-encoded names", () => {
expect(
deriveImageFilenameFromUrl("https://cdn.example.com/path/my%20photo.png", "image/png"),
).toBe("my photo.png");
});

it("handles invalid URLs without throwing", () => {
expect(deriveImageFilenameFromUrl("not a url", "image/png")).toBe("dropped-image.png");
});
});

describe("parseStandaloneComposerSlashCommand", () => {
it("parses standalone /plan command", () => {
expect(parseStandaloneComposerSlashCommand(" /plan ")).toBe("plan");
Expand Down
98 changes: 98 additions & 0 deletions apps/web/src/composer-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,3 +296,101 @@ export function replaceTextRange(
const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`;
return { text: nextText, cursor: safeStart + replacement.length };
}

// ---------------------------------------------------------------------------
// Drag-and-drop helpers (images dragged from other browser tabs / apps).
//
// Background: dropping an image straight from another browser tab (e.g. the
// ChatGPT web UI) into the composer does nothing today because the composer's
// drop handler only accepts `DataTransfer.files`. Browser drag sources expose
// the image via `text/uri-list` / `text/html` instead — the destination has
// to fetch the bytes itself.
// ---------------------------------------------------------------------------

/**
* True when the dragged payload looks attachable — either a real file list
* (Finder, other apps) or a URL/image reference (another browser tab).
*
* Gating on `text/uri-list` (rather than any text/*) keeps plain-text drags
* from flipping the drop-zone highlight and from being hijacked away from
* the Lexical editor's native text-drop behavior.
*/
export function isComposerAttachmentDrag(types: ReadonlyArray<string>): boolean {
return types.includes("Files") || types.includes("text/uri-list");
}

/**
* Extract candidate image URLs from a DataTransfer-like payload. Used when a
* drop carried no real `Files` (image dragged from another browser window).
*
* Preference order mirrors what browsers actually populate for image drags:
* 1. `text/uri-list` — the standard; one URL per non-comment line.
* 2. `text/html` — parse `<img src>` attributes for cases where the
* source only populated rich HTML.
* 3. `text/plain` — last-resort fallback; some sources put the URL here.
*
* Only `http(s):`, `data:image/*`, and `blob:` URLs are returned — everything
* else (chrome://, about:, file:, arbitrary text) is filtered out so the
* caller can safely `fetch()` the result.
*/
export function extractDraggedImageUrls(data: {
getData: (type: string) => string;
types: ReadonlyArray<string>;
}): string[] {
const urls: string[] = [];
const push = (value: string | null | undefined) => {
if (!value) return;
const trimmed = value.trim();
if (!trimmed) return;
if (!/^(?:https?:|data:image\/|blob:)/i.test(trimmed)) return;
if (!urls.includes(trimmed)) urls.push(trimmed);
};

if (data.types.includes("text/uri-list")) {
const raw = data.getData("text/uri-list");
if (raw) {
for (const line of raw.split(/\r?\n/)) {
if (line.startsWith("#")) continue;
push(line);
}
}
}
if (data.types.includes("text/html")) {
const html = data.getData("text/html");
if (html) {
// Match <img ... src="..."> or src='...'; tolerate other attrs in between.
const pattern = /<img\b[^>]*\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)')/gi;
for (const match of html.matchAll(pattern)) {
push(match[1] ?? match[2]);
}
Comment thread
roni-estein marked this conversation as resolved.
}
}
if (urls.length === 0 && data.types.includes("text/plain")) {
push(data.getData("text/plain"));
}
return urls;
}

/**
* Derive a reasonable filename for a File fetched from a URL.
*
* - Strips the query string and takes the last non-empty path segment.
* - Falls back to `dropped-image.<ext>` when the URL has no usable name.
* - Infers extension from the MIME type when the derived name has none.
*/
export function deriveImageFilenameFromUrl(url: string, mimeType: string): string {
let pathname = "";
try {
pathname = new URL(url).pathname;
} catch {
pathname = "";
}
const last = pathname.split("/").findLast((segment) => segment.length > 0) ?? "";
const cleaned = decodeURIComponent(last)
.replace(/[\r\n\t]/g, "")
.trim();
if (cleaned && /\.[a-z0-9]{1,6}$/i.test(cleaned)) return cleaned;
const ext = mimeType.split("/")[1]?.split(";")[0]?.trim() || "png";
if (cleaned) return `${cleaned}.${ext}`;
return `dropped-image.${ext}`;
}
Loading
Loading