diff --git a/docs/superpowers/plans/2026-09-29-web-clipboard-image-compatibility.md b/docs/superpowers/plans/2026-09-29-web-clipboard-image-compatibility.md new file mode 100644 index 00000000..a1292f20 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-web-clipboard-image-compatibility.md @@ -0,0 +1,92 @@ +# 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 existing `sendImage`/error toast. Do not change `handlePickImage`, `sendImage`, drafts, or other views. +- Modify `web/package.json`: add `test:clipboard` invoking `node --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`, and `readClipboardImage` from `../src/features/messages/clipboard-image.ts`. Use `node:test`/`node:assert/strict`. Start with `new File(['png'], 'shot.png', {type:'image/png'})` in `items[0].getAsFile()` and assert `firstImageFile(data) === file`. Export typed stub functions returning `null`/`false` in the new helper solely so the test runs to an assertion failure. + +```ts +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 | null` that checks each `kind=file` item via `getAsFile()`, accepts `file.type` or an image `item.type`, then checks `data.files` even when `items` exists. If only `item.type` supplies 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.type` with valid File MIME, null `getAsFile()` followed by `files`, a non-image file, and two images selecting the first. Run red then green for each.** +- [ ] **Step 5: Add failing HTML tests:** an `` URL is returned; a remote `https:` image, plain HTML text, and a `data-src` attribute are not. Implement `htmlDataImageUrl(data)` by reading `getData('text/html')` synchronously and matching only an actual `src` attribute on an ``, with `data:image/` scheme. Add a failing test that `fileFromDataImageUrl()` converts a valid data URL to an image File and rejects malformed data. Implement it with `fetch(dataUrl)`, `Blob`, and `new 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 with `types`, `items`, and `getData('text/plain')`, without asynchronous event reads. +- [ ] **Step 7: Add failing Clipboard API tests:** injected `read()` returning a `ClipboardItem` with `image/png` produces a File; a text-only item returns null; permission rejection propagates. Implement `readClipboardImage(clipboard?: Pick): Promise` by trying image MIME types, `getType()`, and wrapping the Blob. No navigator access inside this helper, so tests can inject a stub. + +```ts +const clipboard = { read: async () => [{ types: ['image/png'], getType: async () => new Blob(['png'], { type: 'image/png' }) }] } as unknown as Pick; +const image = await readClipboardImage(clipboard); +assert.equal(image?.type, 'image/png'); +``` +- [ ] **Step 8: Add `"test:clipboard": "node --test tests/clipboard-image.test.ts"` to `web/package.json`. Run `npm run test:clipboard`; expected all tests pass. Commit only these files with `git 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 calls `preventDefault` synchronously and then `onImage`, plain text does neither, HTML data-image claims before awaiting conversion, and Clipboard API failure calls `onError`. Run this test red (first as a typed stub returning `Promise.resolve(false)`), then implement the orchestrator in `clipboard-image.ts`. It must call `preventDefault` before its first `await`, and must not catch an exception thrown by `onImage` as a clipboard-read failure. + +```ts +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 `handleComposerPaste` loop with a thin call to `handleImagePaste`.** Pass `e.clipboardData`, `() => e.preventDefault()`, a callback that calls `consumePendingReply()` then the existing `sendImage(topic,file,reply).catch(...)`, a callback that calls `showError(undefined, '无法读取剪贴板图片')`, and `navigator.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. + +```tsx +const handleComposerPaste = (e: React.ClipboardEvent) => { + 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. Review `git diff --check` and `git diff -- web/src/features/messages/ConversationView.tsx` for scope and native-text behavior. Commit only the touched files with `git 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.ts` in the browser, paste a real PNG ClipboardItem, and verify it appears as `kind=file`, `type=image/png`, and is selected. Separately dispatch a text-only ClipboardEvent and confirm `shouldReadClipboard` is 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`, and `git diff --check` fresh after any fixes. Check `git status --short` and 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.