Files
claude-code-haha/desktop/src/components/ui/ZoomControls.tsx
T
程序员阿江(Relakkes) 6bab6fbe97 feat(desktop): preview documents in the workspace and images in chat
Documents the agent writes open in the workspace panel instead of another
application, and local images the agent mentions show up in the conversation.

Workspace preview
- PDF (pdf.js with its own layout and text layer), Word (docx-preview inside a
  scripts-disabled sandboxed iframe) and Excel (SheetJS; .xlsx, .xlsm, .xls) open
  in the side panel with zoom and fit, per-file scroll/zoom/sheet memory, and a
  refresh when the agent rewrites the file. The engines load lazily.
- Bytes come from a new GET /api/sessions/:id/workspace/raw route, with an
  extension allowlist, size caps, the workspace boundary and canonical-path
  checks. The file endpoint returns metadata and a version for documents. The
  client fetches with the bearer credential, so it works in Electron, LAN H5 and
  remote access alike.
- Chat links, output cards and the change card open pdf/docx/xlsx in the
  workspace; documents outside the workdir still go to the system application.
- Image viewer with fit, zoom and pan, and "open in system app".

Chat images
- Markdown images outside the workdir, at ~/, C:\ and file:// paths render, open
  in the viewer, and offer "open original" (pictures only).
- Images returned by tools such as Read appear as thumbnails under the call.

Hardening found in review
- previewFsUrl escapes each path segment; a double-escaped %2e%2e used to leave
  /preview-fs/<session>/.
- The CORS, API timing and remote-access header decorators set headers in place.
  Rebuilding the response buffered whole files in memory and dropped
  Content-Length.
- The engine owns the pdf.js worker, so closing one document no longer fails the
  next open.
- Office archives are inflated in steps to check their real sizes, not the sizes
  they declare.
- A viewer that fails to load stays in its panel instead of taking the window down.

Adds pdfjs-dist, docx-preview, xlsx (SheetJS 0.20.3 tarball) and fflate as
renderer dev dependencies; Vite bundles them.

Refs #1397
2026-09-30 01:53:47 +08:00

112 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Maximize2, Minus, Plus } from 'lucide-react'
import { cx } from '@/lib/cx'
import { IconButton } from './IconButton'
export type ZoomControlsLabels = {
/** Names the whole cluster ("Zoom controls"). */
group: string
zoomIn: string
zoomOut: string
/** What the fit button does for this viewer: "Fit to window" or "Fit to width". */
fit: string
}
/**
* The look of a control floating over a viewer: a hairline-bordered pill. Shared
* so an extra action placed beside the zoom cluster reads as part of it.
*/
export function floatingPillClass(surface: 'default' | 'media', { flat = false }: { flat?: boolean } = {}): string {
return cx(
'inline-flex items-center gap-0.5 rounded-[var(--radius-lg)] border p-0.5',
// A pill floating over content lifts off it with a shadow; one sitting in a
// toolbar is already on a surface of its own.
!flat && 'shadow-[var(--shadow-card)]',
surface === 'media'
? 'border-[var(--color-media-border)] bg-[var(--color-media-header)] text-[var(--color-media-muted)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)]',
)
}
export type ZoomControlsProps = {
/** The zoom as the whole-number percentage a person reads. */
percent: number
/** The viewer is showing whatever scale fits, rather than a scale the user chose. */
fitActive: boolean
canZoomIn: boolean
canZoomOut: boolean
onZoomIn: () => void
onZoomOut: () => void
onFit: () => void
/** Caller-supplied so this primitive never carries user-visible text of its own. */
labels: ZoomControlsLabels
/**
* `media` for a control that floats over the dark lightbox, whose colors do
* not follow the theme; `default` for one that sits on a panel.
*/
surface?: 'default' | 'media'
/** No drop shadow: for a cluster that sits in a toolbar rather than floating over content. */
flat?: boolean
className?: string
}
/**
* The zoom cluster shared by every viewer: − / percentage / + / fit.
*
* The percentage is a readout, not a button. "Actual size" is reached by the "+"
* ladder passing through 100%, by a double click on the content, and by the
* keyboard, so it does not need a control of its own competing for room in a
* narrow panel.
*/
export function ZoomControls({
percent,
fitActive,
canZoomIn,
canZoomOut,
onZoomIn,
onZoomOut,
onFit,
labels,
surface = 'default',
flat = false,
className,
}: ZoomControlsProps) {
const media = surface === 'media'
return (
<div
role="group"
aria-label={labels.group}
className={cx(floatingPillClass(surface, { flat }), className)}
>
<IconButton
icon={<Minus size={16} strokeWidth={1.9} />}
label={labels.zoomOut}
size="md"
tone="secondary"
surface={media ? 'media' : 'default'}
disabled={!canZoomOut}
onClick={onZoomOut}
/>
<span className="min-w-[3.25rem] px-1 text-center font-mono text-xs tabular-nums">{percent}%</span>
<IconButton
icon={<Plus size={16} strokeWidth={1.9} />}
label={labels.zoomIn}
size="md"
tone="secondary"
surface={media ? 'media' : 'default'}
disabled={!canZoomIn}
onClick={onZoomIn}
/>
<IconButton
icon={<Maximize2 size={15} strokeWidth={1.9} />}
label={labels.fit}
size="md"
tone="secondary"
surface={media ? 'media' : 'default'}
pressed={fitActive}
onClick={onFit}
/>
</div>
)
}