mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(p2): paste images into the composer
A paste with only images (a screenshot) attaches them through the composer's attach flow; a paste with plain text stays a text paste.
This commit is contained in:
parent
386db6130e
commit
707888ece6
3 changed files with 65 additions and 3 deletions
|
|
@ -807,7 +807,9 @@ translated phone drawer and intercept chat taps.
|
|||
(server local) saves immutable snapshots for producers (send_file); `GET|HEAD
|
||||
/download?session=&id=` serves them to the owner and the session's guests (no `Sec-Fetch-Site: cross-site`;
|
||||
ranges for audio/video). Owns local `Uploads`, `Downloads` and `filePreview` implementations. Web: the composer's
|
||||
attach button and drag/drop upload files at send time, including a new session's first prompt.
|
||||
attach button, drag/drop and pasted images upload files at send time, including a new session's first prompt. A
|
||||
paste with plain text stays a text paste (Office apps add a picture of the copied text, Chrome on macOS a Finder
|
||||
file's icon); a paste with only images attaches them.
|
||||
- **File index** (`plugins/file-index`): `FileIndex({ session, conversation })` (participants, as `Workspace`) answers
|
||||
`@` completion (`complete`, ranked like pi's `@`) and Tab completion (`list`, one directory) for the TUI and the web
|
||||
composer. A worker-local cache per conversation (`FileIndexCache`) builds from `WorkspaceBackend`'s `files` query
|
||||
|
|
|
|||
|
|
@ -127,13 +127,31 @@ function refused(error: unknown): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
/** Picked files: shown above the textarea, removable; the attach button adds more. */
|
||||
/** Picked files: shown above the textarea, removable; the attach button and pasted images add more. */
|
||||
type Attaching = {
|
||||
readonly files: readonly PickedFile[];
|
||||
readonly add: (files: File[]) => void;
|
||||
readonly remove: (file: PickedFile) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The images a paste attaches, or none to leave the paste to the textarea. Plain text wins: Office apps put a rendered
|
||||
* picture of the copied cells beside the text, and Chrome on macOS pastes a Finder file as its icon beside its name.
|
||||
*/
|
||||
function pastedImages(data: DataTransfer): File[] {
|
||||
if (data.getData("text/plain") !== "") return [];
|
||||
return [...data.items]
|
||||
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file) => file !== null)
|
||||
.map((file) =>
|
||||
// The server infers an upload's image type from its extension; a nameless paste gets one from its type.
|
||||
/\.[a-z0-9]+$/i.test(file.name)
|
||||
? file
|
||||
: new File([file], `pasted-image.${file.type.slice(6).replace("jpeg", "jpg")}`, { type: file.type }),
|
||||
);
|
||||
}
|
||||
|
||||
/** The composer's attachments from its state. */
|
||||
const attachingOf = ({ files, setFiles, addFiles }: ReturnType<typeof useComposerState>): Attaching => ({
|
||||
files,
|
||||
|
|
@ -263,6 +281,13 @@ function Composer({
|
|||
completion?.update();
|
||||
}}
|
||||
onClick={() => completion?.update()}
|
||||
onPaste={(event) => {
|
||||
if (!attaching) return;
|
||||
const images = pastedImages(event.clipboardData);
|
||||
if (!images.length) return;
|
||||
event.preventDefault();
|
||||
attaching.add(images);
|
||||
}}
|
||||
onFocus={() => completion?.onFocus()}
|
||||
onBlur={() => completion?.onBlur()}
|
||||
onCompositionStart={() => completion?.onCompositionStart()}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Main-view controls exercise real browser events against fake providers and disposable data.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { mkdirSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { after, before, describe, it } from "node:test";
|
||||
import { BACKGROUND_CONTEXT } from "@p2/app/v1";
|
||||
|
|
@ -127,4 +127,39 @@ describe("chat control parity", { skip: !browserTests }, () => {
|
|||
);
|
||||
await browser.click("button[aria-label=Stop]");
|
||||
});
|
||||
it("attaches pasted images like picked files and leaves text pastes to the textarea", async () => {
|
||||
const pasted = (
|
||||
await (
|
||||
await holdIn(fixture.browser(), Sessions, {}, "owner")
|
||||
).create({ cwd: fixture.workspace, title: "Paste" }, BACKGROUND_CONTEXT)
|
||||
).id;
|
||||
await browser.metrics(1280, 900);
|
||||
await browser.open(`http://127.0.0.1:${fixture.server.port}/s/${pasted}`);
|
||||
await browser.wait(`document.querySelector('.chat .composer textarea')`);
|
||||
// A 1x1 PNG; `paste(text, names)` dispatches a paste of that text and PNGs and returns whether it was cancelled.
|
||||
await browser.evaluate(
|
||||
`window.paste=(text,names)=>{const png=Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='),c=>c.charCodeAt(0));const d=new DataTransfer();if(text)d.setData('text/plain',text);for(const name of names)d.items.add(new File([png],name,{type:'image/png'}));return !document.querySelector('.composer textarea').dispatchEvent(new ClipboardEvent('paste',{bubbles:true,cancelable:true,clipboardData:d}))}`,
|
||||
);
|
||||
assert.equal(await browser.evaluate<boolean>(`paste('', ['image.png', ''])`), true);
|
||||
await browser.wait(`document.querySelectorAll('.attachment img').length===2`);
|
||||
assert.deepEqual(
|
||||
await browser.evaluate<string[]>(`[...document.querySelectorAll('.attachment-name')].map(e=>e.textContent)`),
|
||||
["image.png", "pasted-image.png"],
|
||||
);
|
||||
// Text with a picture of it (an Office copy) and plain text keep the default paste and attach nothing.
|
||||
assert.equal(await browser.evaluate<boolean>(`paste('a1\\tb1', ['cells.png'])`), false);
|
||||
assert.equal(await browser.evaluate<boolean>(`paste('just text', [])`), false);
|
||||
assert.equal(await browser.evaluate<number>(`document.querySelectorAll('.attachment').length`), 2);
|
||||
await browser.evaluate(input(".composer textarea", "with screenshots"));
|
||||
await browser.click("button[aria-label=Send]");
|
||||
await browser.wait(
|
||||
`[...document.querySelectorAll('.bubble-files span')].map(e=>e.textContent).join()==='image.png,pasted-image.png' && !document.querySelector('.attachment')`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
readdirSync(join(fixture.dataDir, "uploads", pasted))
|
||||
.map((file) => file.slice(37))
|
||||
.sort(),
|
||||
["image.png", "pasted-image.png"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue