# AGENTS.md — react-zmage > 给 AI Agent / LLM 编排用的浓缩版项目信息。 > 人类完整文档见 [README.md](./README.md)。 ## Works 如果你发现工作区中有额外变更, 始终假定是另一个 AI Agent 在当前分支中同步工作, 你需要做的是优先处理重突 (如果有) 并遵循工作互不干涉原则 如果非用户主动要求, 不允许在 worktree 上执行改动。 ## What this package does (1 sentence) A React component that turns any `` into a fullscreen-zoomable, multi-image, keyboard-navigable image viewer. Drop-in replacement for ``. ## Public API surface (treat as contract) ```ts // Default + statics import Zmage from 'react-zmage' // browser / bundler entry import Zmage from 'react-zmage/ssr' // SSR / RSC entry import 'react-zmage/style.css' // required for visuals // Types (also exported from the same module) import type { BaseType, // union of all props (use this when typing config objects) ReactZmageComponent, // typeof Zmage Set, // shape of items in `set` prop Preset, // 'desktop' | 'mobile' | 'auto' (auto = matchMedia-driven) ControllerSet, ControllerPlacement, ControllerOverlayLayout, ControllerLayoutTargets, ControllerLayoutTarget, ControllerLayoutInset, ControllerLayoutInsetValue, ControllerRender, ControllerRenderState, ControllerRenderActions, ControllerRenderSlots, HotKey, Animate, AnimateFlip, AnimateCoverOptions, GestureSet, GestureSwipeOptions, GestureDragExitOptions, GestureWheelZoomOptions, GesturePinchZoomOptions, GestureDoubleTapZoomOptions, GestureTouchAction, } from 'react-zmage' // Three usage modes // 1. Component Zmage.browsing(props): () => void // 2. Imperative; returns destructor {children} // 3. Auto-attach to in children // Ref forwarding const ref = useRef(null) // ref points to the cover ``` ## Required peer deps - `react: >=16.8.0 <20` - `react-dom: >=16.8.0 <20` The library auto-detects React 18+ at runtime and uses `react-dom/client.createRoot` when available, falling back to legacy `ReactDOM.render` otherwise. No consumer configuration needed. ## Minimum reproducible example ```tsx import { useState } from 'react' import Zmage from 'react-zmage' import 'react-zmage/style.css' export default function Demo() { const [open, setOpen] = useState(false) return ( <> ) } ``` ## Prop quick reference Single `BaseType` covers all. Grouped here by purpose: | Group | Props | Notes | |---|---|---| | Data | `src`, `alt`, `caption`, `set`, `defaultPage` | `set` enables multi-image mode. `caption` is `string \| { text, style?, className? }` — renders below the image; per-set override via `set[i].caption`. In Wrapper mode, child `` nodes provide `src` / `alt`; top-level `set` may define a shared gallery, and clicked `img.src` opens the matching `set[i]` before falling back to `defaultPage`. Without `set`, `data-zmage-caption` or nearest `figcaption` may provide caption. | | Preset | `preset: 'desktop' \| 'mobile' \| 'auto'` | defaults to `'auto'` when omitted; drives default `controller` / `hotKey` / `animate` / `gesture` plus preset-aware viewer spacing. `'auto'` resolves via `matchMedia('(pointer: coarse) and (hover: none)')` on the client; SSR falls back to desktop | | Controlled | `browsing` | omit for self-managed; pair with `onBrowsing` if set. Does not control `` | | Functional | `controller`, `hotKey`, `animate`, `gesture` | pass `boolean` to disable, or partial object to override. `controller.flip` / `hotKey.flip` and `controller.rotate` / `hotKey.rotate` are umbrellas over their per-side counterparts; enabling the umbrella forces both sides on. `controller.placement` moves only the toolbar capsule (`top-right` default); side flips and pagination keep their existing positions. `controller.layout` adjusts toolbar / flip / pagination / caption overlay safe insets without changing image animation geometry; number = px, string = CSS length, scalar `inset` follows each target's natural edge (toolbar by placement, flip left/right, pagination/caption bottom), and `layout.mobile` overrides base layout for mobile preset. Desktop defaults include `pagination.inset=24` and `caption.inset=60`; mobile leaves `layout` unset unless configured. `controller.render({ state, actions, slots })` replaces the whole controller UI, and `controller=false` disables both built-in slots and render. **`hotKey` entries accept `boolean \| string \| string[]`** — string is an `e.code` descriptor (`'Escape'` / `'BracketLeft'` / `'S'`) with cross-platform `Mod` prefix (= ⌘ on macOS, Ctrl elsewhere; e.g. `'Mod+S'`). New defaults: `[`/`]` rotate, `Mod+S` download (download is opt-in: defaults `false` because it hijacks the browser's "Save Page As"). Per-side string descriptor wins over umbrella. `controller.backdrop` / `controller.color` decouple the toolbar bg/icon-color from the modal `backdrop` (set both when `backdrop` is solid dark). `animate.cover` is preset-driven and defaults to `{ objectFit: true, clip: true, radius: true }`; it reads the cover `` itself and does not infer parent-wrapper clipping. `clip-path` / `border-radius` animation may repaint; use `cover.clip=false` or `cover.radius=false` for performance-sensitive mobile pages. Set `animate={{ cover: false }}` for legacy cover geometry. `animate.slowMotion` defaults `false`; when enabled, holding `Shift` while opening or closing slows the full browsing transition to 10x for inspection and demos. `gesture` is preset-driven: desktop enables `wheelZoom` while already zoomed and disables `swipe` / `dragExit` / `pinchZoom` / `doubleTapZoom`; mobile enables horizontal drag paging, vertical drag-to-exit, pinch zoom, and double-tap zoom, while disabling `wheelZoom`. `gesture.touchAction` defaults to `'managed'`: pinch uses CSS `touch-action: none`, double-tap-only uses `manipulation`, otherwise `auto`; set it explicitly if the host page owns touch behavior. `pinchZoom.resetBelowFit` defaults `true`, so shrinking back to fit exits zoom and recenters. `doubleTapZoom.interval` / `distance` define the second-tap window. `wheelZoom.reverse` flips wheel direction; `wheelZoom.exitGuardDuration` defaults to `1000`, so wheel zooming out to `minScale` exits zoom immediately and blocks residual wheel events for that duration. `gesture=false` disables all gestures, and per-child overrides such as `gesture={{ swipe: false }}` / `gesture={{ wheelZoom: false }}` / `gesture={{ pinchZoom: false }}` keep the other preset defaults intact | | Interface | `hideOnScroll`, `hideOnDblClick`, `coverVisible`, `backdrop`, `zIndex`, `portalTarget`, `radius`, `edge`, `loop`, `loadingDelay` | desktop-only flags noted in README. `portalTarget` defaults to `document.body` and is for app overlay roots / modal roots / micro-frontend containers; it changes only the Portal mount parent, while the viewer remains fullscreen fixed. `radius` defaults to desktop `8` / mobile `0`; `edge` defaults to desktop `16` / mobile `0`. `hideOnScroll` and `hideOnDblClick` are the auto-dismiss trigger family (user action → close viewer); `hideOnDblClick` defaults `false`. `loadingDelay` defaults `200ms` — anti-flicker delay before showing the loading indicator (set 0 for legacy instant-show) | | Lifecycle | `onBrowsing`, `onZooming`, `onSwitching`, `onRotating`, `onError` | first 4 callback args: `boolean`/`boolean`/`number`/`number`. `onError(e: SyntheticEvent)` fires for cover **or** viewer img-load failure — the only hook for the viewer-side failure (cover also flows via native `` `onError` passthrough) | | Native | All `HTMLAttributes` | className, style, onClick, etc. transparently forwarded to inner `` | Defaults & sub-shapes: see [`packages/core/src/types/default.ts`](./packages/core/src/types/default.ts) and [`packages/core/src/types/global.ts`](./packages/core/src/types/global.ts) (single source of truth). ## Common pitfalls (LLM-written code often hits these) 1. **Forgetting `import 'react-zmage/style.css'`** — component renders but viewer is unstyled. 2. **Hard-coding `preset='desktop'` on a touch-targeted page** — omitted `preset` already defaults to `'auto'`. The desktop bundle ships hotkeys + arrow buttons, enables wheel zoom while zoomed, and disables mobile `gesture.swipe` / `gesture.dragExit` / `gesture.pinchZoom` / `gesture.doubleTapZoom`. Use `'desktop'` only when the page deliberately wants desktop behavior on touch devices. 3. **Treating `Zmage` as a class** — it is a `forwardRef` exotic component. ❌ `new Zmage()`. ✅ JSX or `Zmage.browsing()`. 4. **Mixing controlled and uncontrolled** — if `browsing` is in props, it must be a fully controlled `boolean` (provide `onBrowsing` to receive changes). Mixing both modes silently breaks state sync. 5. **Calling `Zmage.browsing` server-side** — it manipulates the DOM (`document.body` by default, or `portalTarget` when provided). Guard with `typeof window !== 'undefined'` or call from event handlers / effects. 6. **Putting `src` / `alt` on `` as if it rendered an image** — Wrapper binds real descendant `` nodes. Put image data in the HTML, and pass only viewer config / optional shared `set` to Wrapper. 7. **Wrapping with `Zmage.Wrapper` without re-rendering** — wrapper attaches click handlers in `componentDidMount` / `componentDidUpdate` by querying `wrapperRef.current.querySelectorAll('img')`. Dynamically-injected `` (after wrapper update) won't get attached unless wrapper re-renders. ## File layout (when generating PRs) ``` packages/ core/ # the published package "react-zmage" src/ Zmage.tsx # component entry (forwardRef) Zmage.callee.tsx # imperative entry (Zmage.browsing) Zmage.wrapper.tsx # wrapper entry (Zmage.wrapper) components/ Browser/ # main viewer container (state owner) Image/ # image rendering + touch/zoom logic Control/ # toolbar buttons Background/ # backdrop layer Portal/ # ReactDOM.createPortal wrapper types/ global.ts # ★ canonical prop types (BaseType, etc.) default.ts # ★ canonical defaults (defProp, defPreset) externals.d.ts # *.less ambient module decl utils/ # debounce, click monitor, math helpers __tests__/ # vitest + @testing-library/react tsup.config.ts # build config (esm/cjs/iife + ssr subentry) tsconfig.declarations.json # tsc -- emitDeclarationOnly for .d.ts package.json # exports map: . | ./ssr | ./style.css home/ # CSR demo (Vite SPA, switchable React via env) sandbox-r17/ # ┐ sandbox-r18/ # ├ R17/18/19 real-npm-consumer integration tests sandbox-r19/ # │ (installed via `pnpm pack` tgz, NOT workspace:*) # │ each: tsc --noEmit + node ssr-smoke.cjs sandbox-nextjs/ # ┘ Next.js 15 + R19 + RSC, runs `next build` apps/ demo-ssr/ # Express + Vite SSR demo (R19 only) demo-nextjs/ # Next.js 15 App Router demo (R19) ``` ## Build & test ```bash pnpm install pnpm build # turbo: tsup + tsc -- core; vite -- home pnpm test # vitest in jsdom (component-level) pnpm -w run check # FULL: build → pack → install → tsc + ssr-smoke for each sandbox ``` `pnpm -w run check` is the single command that verifies dist correctness across React versions. The `scripts/refresh-sandbox-tgz.mjs` helper makes it idempotent on Windows by only invalidating the react-zmage cache entry rather than rerunning a full `--force` install (which races on Windows + Next.js's many small files). ## Interactive demos (human verification, not for agents) These exist for the human maintainer to visually verify GUI/animation/ interaction behavior. Agents shouldn't claim "demo verified" — only the human who actually opened the page in a browser can. ```bash pnpm dev:csr-r17 # CSR R17 :8080 pnpm dev:csr-r18 # CSR R18 :8080 pnpm dev:csr-r19 # CSR R19 :8080 pnpm dev:ssr-r19 # SSR R19 :8090 pnpm dev:nextjs # RSC R19 :8095 ``` Each page shows a fixed top-bar `ContextBanner` with the React.version actually loaded and the render mode label. ## Architectural invariants (don't break these) - **All cleanup must be cancelable**. Class components in this codebase track every `requestAnimationFrame` / `setTimeout` handle as instance properties and cancel them in `componentWillUnmount`. New async work must follow the same pattern; otherwise StrictMode dev mode leaks listeners or fires setState on unmounted components. See `Browser.tsx` `initRaf` / `unInitTimer`, `Image.tsx` `pendingRafHandles`, `Zmage.callee.tsx` `inBrowsingRaf`. - **`unInit({force: true})` must run cleanup synchronously**. Don't bury cleanup in `setState → setTimeout → setState` chains; on unmount path the inner setState is dropped and side effects (scroll lock release, cover restore) never run. - **`global.ts` must NOT be `global.d.ts`**. tsc does not emit source `.d.ts` to outDir; renaming to `.ts` keeps `BaseType` reachable in the published `dist/index.d.ts`. Do not rename it back. - **Public component type stays callable, NOT `ForwardRefExoticComponent`**. The exported `ReactZmageComponent` deliberately uses `(props) => JSX.Element | null` + statics, NOT an intersection with `ForwardRefExoticComponent`. The latter triggers two cross-version TS bugs (R18+ ReactPortal regression; defaultProps-driven prop inference loss). Cast via `as unknown as ReactZmageComponent`. - **`react-dom/client` stays a static import and remains externalized by tsup**. Do not reintroduce browser runtime `require`; the browser ESM build cannot rely on it. React 16/17 compatibility is guarded by the sandbox checks and SSR smoke tests, while React 18+ resolves `createRoot` through that external import. See `resolveMountAdapter` in `Zmage.callee.tsx`. ## Where the docs are right If README and source disagree, **source wins**: - props & defaults: `packages/core/src/types/global.ts` + `default.ts` - exported runtime API: `packages/core/src/index.ts` - package contract: `packages/core/package.json` (`exports` field) ## How to verify your change Before claiming a fix: 1. `pnpm test` — unit tests must stay green (currently 11 tests). 2. `pnpm -w run check` — all 4 sandboxes must pass: tsc + ssr-smoke for r17/r18/r19, `next build` for sandbox-nextjs. 3. If you touched callee or Browser, also confirm no React "setState on unmounted component" warnings appear in test output. 4. **Do not claim GUI/animation/interaction behavior verified.** That requires a human to open `pnpm dev:csr-r19` / `pnpm dev:ssr-r19` / `pnpm dev:nextjs` and check the actual rendering. Agents cannot do this. ## Browser screenshots — playwright MCP When invoking any playwright MCP screenshot tool (`mcp__plugin_playwright_*__browser_take_screenshot`, or any descendant), **the output path must live under `tmp/screenshot/`**. Never write to: - the repo root (pollutes `git status` and risks accidental commits) - `docs/` (those are committed assets — logo, demo images) - any tracked directory Concrete rules: 1. Always pass an explicit `filename` / `path` argument like `tmp/screenshot/.png`. 2. If `tmp/screenshot/` doesn't exist yet, create it first (`mkdir -p tmp/screenshot/`). 3. `tmp/` is gitignored, so screenshots never make it into commits. Background: a previous session dropped five MCP screenshots into the repo root (`before-click.png`, `narrow-zmage.png`, `scrolled-x.png`, `scrolled-zmage-first.png`, `zmage-during.png`), polluting `git status`. The gitignore now defensively blocks `/*.png`, but the primary mechanism is agents passing the right path on each call. > Note: Playwright's `testConfig.snapshotPathTemplate` only governs > `@playwright/test` assertions (`toHaveScreenshot` / `toMatchSnapshot`). It > does **not** affect the MCP plugin — MCP screenshot paths are caller-controlled. > Hence the rule is enforced here rather than in a config file.