8.9 KiB
Web Clipboard Image Compatibility Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Broaden the new Web composer's image-paste formats without changing immediate send or native text paste.
Architecture: Extract clipboard inspection into a small dependency-free module. The React paste callback synchronously snapshots event data and calls preventDefault() only for an image or an image-looking fallback; asynchronous data-URL/Clipboard API decoding then feeds the existing sendImage path.
Tech Stack: React 19, TypeScript, Vite, Node 25 built-in node:test, Playwright CLI.
File map and boundaries
- Create
web/src/features/messages/clipboard-image.ts: clipboard format selection, HTML data-image extraction, async Blob conversion, Clipboard API fallback. It must not import React, Tinode, or UI state. - Create
web/tests/clipboard-image.test.ts: direct behavior tests for the helper; no new test dependency. - Modify
web/src/features/messages/ConversationView.tsx:939-962: replace the narrow paste loop and reuse existingsendImage/error toast. Do not changehandlePickImage,sendImage, drafts, or other views. - Modify
web/package.json: addtest:clipboardinvokingnode --test tests/clipboard-image.test.ts.
Task 1: Clipboard extraction with test-first coverage
Files: Create web/tests/clipboard-image.test.ts; create web/src/features/messages/clipboard-image.ts; modify web/package.json.
- Step 1: Add a failing assertion and API stubs. In the test, import
firstImageFile,htmlDataImageUrl,shouldReadClipboard,fileFromDataImageUrl, andreadClipboardImagefrom../src/features/messages/clipboard-image.ts. Usenode:test/node:assert/strict. Start withnew File(['png'], 'shot.png', {type:'image/png'})initems[0].getAsFile()and assertfirstImageFile(data) === file. Export typed stub functions returningnull/falsein the new helper solely so the test runs to an assertion failure.
import assert from 'node:assert/strict';
import test from 'node:test';
import { firstImageFile } from '../src/features/messages/clipboard-image.ts';
test('reads an image file from clipboard items', () => {
const file = new File(['png'], 'shot.png', { type: 'image/png' });
const data = { items: [{ kind: 'file', type: 'image/png', getAsFile: () => file }], files: [] } as unknown as DataTransfer;
assert.equal(firstImageFile(data), file);
});
- Step 2: Run
cd web && node --test tests/clipboard-image.test.ts; confirm the first assertion fails (actual: null, expected File), not a loader/type error. - Step 3: Implement the smallest
firstImageFile(data: DataTransfer | null): File | nullthat checks eachkind=fileitem viagetAsFile(), acceptsfile.typeor an imageitem.type, then checksdata.fileseven whenitemsexists. If onlyitem.typesupplies MIME, wrap the File with that MIME. Return the first image only. - Step 4: Rerun the focused test; confirm pass. Add one failing test at a time for JPEG/BMP, empty
item.typewith valid File MIME, nullgetAsFile()followed byfiles, a non-image file, and two images selecting the first. Run red then green for each. - Step 5: Add failing HTML tests: an
<img src="data:image/png;base64,...">URL is returned; a remotehttps:image, plain HTML text, and adata-srcattribute are not. ImplementhtmlDataImageUrl(data)by readinggetData('text/html')synchronously and matching only an actualsrcattribute on an<img>, withdata:image/scheme. Add a failing test thatfileFromDataImageUrl()converts a valid data URL to an image File and rejects malformed data. Implement it withfetch(dataUrl),Blob, andnew File(...); never fetch a non-data URL. - Step 6: Add failing fallback-decision tests:
shouldReadClipboard(data)is false for plain text and ordinary HTML, true for a file/image hint without usable file or a completely empty event. Implement withtypes,items, andgetData('text/plain'), without asynchronous event reads. - Step 7: Add failing Clipboard API tests: injected
read()returning aClipboardItemwithimage/pngproduces a File; a text-only item returns null; permission rejection propagates. ImplementreadClipboardImage(clipboard?: Pick<Clipboard,'read'>): Promise<File | null>by trying image MIME types,getType(), and wrapping the Blob. No navigator access inside this helper, so tests can inject a stub.
const clipboard = { read: async () => [{ types: ['image/png'], getType: async () => new Blob(['png'], { type: 'image/png' }) }] } as unknown as Pick<Clipboard, 'read'>;
const image = await readClipboardImage(clipboard);
assert.equal(image?.type, 'image/png');
- Step 8: Add
"test:clipboard": "node --test tests/clipboard-image.test.ts"toweb/package.json. Runnpm run test:clipboard; expected all tests pass. Commit only these files withgit add web/src/features/messages/clipboard-image.ts web/tests/clipboard-image.test.ts web/package.json && git commit -m 'test: cover clipboard image formats'.
Task 2: Integrate with the composer
Files: Modify web/src/features/messages/ConversationView.tsx:939-962; test web/tests/clipboard-image.test.ts.
- Step 1: Add a failing orchestration test: introduce
handleImagePaste(data, preventDefault, onImage, onError, clipboard)as a named export and test that PNG callspreventDefaultsynchronously and thenonImage, plain text does neither, HTML data-image claims before awaiting conversion, and Clipboard API failure callsonError. Run this test red (first as a typed stub returningPromise.resolve(false)), then implement the orchestrator inclipboard-image.ts. It must callpreventDefaultbefore its firstawait, and must not catch an exception thrown byonImageas a clipboard-read failure.
let prevented = false;
let sent: File | null = null;
const pending = handleImagePaste(data, () => { prevented = true; }, file => { sent = file; }, () => { throw new Error('unexpected'); });
assert.equal(prevented, true);
await pending;
assert.equal(sent, file);
- Step 2: Replace the existing
handleComposerPasteloop with a thin call tohandleImagePaste. Passe.clipboardData,() => e.preventDefault(), a callback that callsconsumePendingReply()then the existingsendImage(topic,file,reply).catch(...), a callback that callsshowError(undefined, '无法读取剪贴板图片'), andnavigator.clipboard. This preserves existing send behavior and does not retain the React event across an asynchronous boundary. Do not broaden to previews or multi-image sends.
const handleComposerPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
void handleImagePaste(
e.clipboardData,
() => e.preventDefault(),
(file) => {
const reply = consumePendingReply();
sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败'));
},
() => showError(undefined, '无法读取剪贴板图片'),
navigator.clipboard,
);
};
- Step 3: Run
cd web && npm run test:clipboard && npm run build && npm run lint; all commands must exit 0. Reviewgit diff --checkandgit diff -- web/src/features/messages/ConversationView.tsxfor scope and native-text behavior. Commit only the touched files withgit add web/src/features/messages/ConversationView.tsx web/src/features/messages/clipboard-image.ts web/tests/clipboard-image.test.ts && git commit -m 'fix: support more clipboard image formats'.
Task 3: Browser verification and handoff
Files: No production changes unless a targeted test finds a defect.
- Step 1: Start local Vite (
cd web && npm run dev -- --host 127.0.0.1) and use Playwright CLI on the local origin. Import/src/features/messages/clipboard-image.tsin the browser, paste a real PNG ClipboardItem, and verify it appears askind=file,type=image/png, and is selected. Separately dispatch a text-only ClipboardEvent and confirmshouldReadClipboardis false and native default is not prevented. Test HTML data image selection and remote HTML exclusion. - Step 2: Rerun
npm run test:clipboard,npm run build,npm run lint, andgit diff --checkfresh after any fixes. Checkgit status --shortand report exact commits/files. Do not push or deploy. Explain that macOS Chromium/browser-event simulation is not a real Windows WeChat end-to-end test.
Self-review against the approved spec
Items/files, MIME fallback, HTML data image, Clipboard API, text passthrough, error feedback, first-image-only behavior, no remote fetch, test-first, browser verification, and no deployment are all assigned above. The only unavoidable limit is native Windows clipboard conversion before JavaScript receives the event; the final report must not claim that was verified.