Files
im_webapp/docs/superpowers/plans/2026-09-29-web-clipboard-image-compatibility.md
T

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 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.
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 <img src="data:image/png;base64,..."> 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 <img>, 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<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" 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.
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.
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. 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.