Files
shanxia2.0/docs/superpowers/demos/2026-09-23-attachment-image-preview-rotate-demo.html
T
wuzilongandCursor 3583e6456b docs: add attachment image preview rotate/zoom design and demo.
Capture the approved AttachmentUploadField preview enhancements (rotate, fit-full, wheel/button zoom, drag pan) before implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 14:16:00 +08:00

654 lines
20 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>附件图片预览 · 左转 / 右转 Demo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--primary: #10b981;
--primary-dark: #059669;
--primary-bg: #ecfdf5;
--text: #1e293b;
--text-muted: #64748b;
--border: #d9e8e2;
--bg-page: #f8fafc;
--surface: #ffffff;
--radius: 10px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: var(--bg-page);
padding: 28px 20px 48px;
}
.page {
max-width: 920px;
margin: 0 auto;
}
.intro h1 {
font-size: 18px;
font-weight: 700;
margin-bottom: 6px;
}
.intro p {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 4px;
}
.intro code {
font-size: 12px;
background: #fff;
border: 1px solid #cbd5e1;
border-radius: 4px;
padding: 1px 6px;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 14px 0 20px;
}
.legend span {
font-size: 12px;
background: #fff;
border: 1px solid #cbd5e1;
border-radius: 999px;
padding: 4px 10px;
color: #475569;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
}
.panel-top {
margin-bottom: 14px;
}
.panel-top h2 {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
padding-left: 10px;
}
.panel-top h2::before {
content: '';
position: absolute;
left: 0;
top: 2px;
bottom: 2px;
width: 3px;
border-radius: 2px;
background: var(--primary);
}
.panel-tip {
margin-top: 6px;
font-size: 12px;
color: var(--text-muted);
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
}
.upload-card {
width: 140px;
height: 96px;
border: 1px dashed #a7f3d0;
background: #f0fdf4;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--primary);
font-size: 12px;
font-weight: 500;
cursor: default;
user-select: none;
}
.upload-card .plus {
font-size: 28px;
line-height: 1;
font-weight: 400;
}
.attachment-item {
width: 140px;
}
.attachment-preview {
width: 140px;
height: 96px;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: #f8fafc;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.attachment-preview:hover {
border-color: var(--primary);
}
.attachment-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.attachment-preview.is-doc {
flex-direction: column;
gap: 4px;
color: var(--text-muted);
font-size: 12px;
cursor: default;
}
.attachment-name {
display: block;
margin-top: 6px;
font-size: 12px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 预览弹窗 */
.mask {
display: none;
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: none;
z-index: 1000;
align-items: center;
justify-content: center;
padding: 24px;
}
.mask.show { display: flex; }
.dialog {
width: min(860px, 100%);
background: #fff;
border-radius: 10px;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.18);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: calc(100vh - 48px);
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
}
.dialog-title {
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dialog-close {
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: 6px;
color: var(--text-muted);
font-size: 18px;
cursor: pointer;
line-height: 1;
}
.dialog-close:hover {
background: #f1f5f9;
color: var(--text);
}
.dialog-body {
padding: 16px 18px 18px;
overflow: auto;
}
.preview-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 12px;
}
.toolbar-sep {
width: 1px;
height: 20px;
background: var(--border);
margin: 0 4px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: #fff;
color: var(--text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
}
.btn:hover {
border-color: var(--primary);
color: var(--primary-dark);
background: var(--primary-bg);
}
.btn:active { transform: translateY(1px); }
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
}
.btn svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.status-chip {
min-width: 108px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.preview-stage {
height: min(62vh, 520px);
min-height: 320px;
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 16px;
cursor: grab;
touch-action: none;
}
.preview-stage.is-dragging {
cursor: grabbing;
}
.preview-stage img {
width: auto;
height: auto;
max-width: none;
max-height: none;
border-radius: 4px;
transition: transform 0.15s ease;
transform-origin: center center;
user-select: none;
-webkit-user-drag: none;
will-change: transform;
pointer-events: none;
}
.preview-stage.is-zooming img,
.preview-stage.is-dragging img {
transition: none;
}
.note {
margin-top: 16px;
padding: 12px 14px;
background: var(--primary-bg);
border: 1px solid #a7f3d0;
border-radius: 8px;
font-size: 12px;
color: #065f46;
}
.note strong { font-weight: 600; }
</style>
</head>
<body>
<div class="page">
<div class="intro">
<h1>附件图片预览 · 旋转 + 缩放</h1>
<p>对齐 <code>AttachmentUploadField</code>:缩略图点击打开预览弹窗;仅图片显示工具条。</p>
<p>交互:左转 / 右转 · 放大 / 缩小 / 重置 · 滚轮缩放 · 拖拽平移 · 关闭后重置。</p>
<p><strong>完整可见:</strong>缩放倍率 = 1 时,旋转后按外接矩形完整装入预览区;放大后可拖拽查看细节。</p>
<div class="legend">
<span>Fresh Mint · #10b981</span>
<span>弹窗 860px</span>
<span>滚轮缩放</span>
<span>仅预览态(不写回文件)</span>
</div>
</div>
<div class="panel">
<div class="panel-top">
<h2>协议附件(示意)</h2>
<p class="panel-tip">点击下方图片缩略图体验预览旋转;PDF 卡片不可预览旋转。</p>
</div>
<div class="attachment-list">
<div class="upload-card" title="示意:正式页为上传入口">
<span class="plus">+</span>
<span>上传附件</span>
</div>
<div class="attachment-item">
<div class="attachment-preview" id="thumb-a" title="点击预览">
<img id="thumb-a-img" alt="现场照片-01.jpg" />
</div>
<span class="attachment-name">现场照片-01.jpg</span>
</div>
<div class="attachment-item">
<div class="attachment-preview" id="thumb-b" title="点击预览">
<img id="thumb-b-img" alt="证件扫描.jpg" />
</div>
<span class="attachment-name">证件扫描.jpg</span>
</div>
<div class="attachment-item">
<div class="attachment-preview is-doc" title="文档:本 Demo 不演示旋转">
<span style="font-size:22px">📄</span>
<span>PDF</span>
</div>
<span class="attachment-name">协议正文.pdf</span>
</div>
</div>
<div class="note">
<strong>推荐落点:</strong>工具条 = 左转 / 右转 · 缩小 / 放大 / 重置;
预览区滚轮缩放 + 按住拖拽平移。
倍率 1 = 完整装入。全部仅预览态,不改 MinIO 原文件。
</div>
</div>
</div>
<div class="mask" id="mask" role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<div class="dialog">
<div class="dialog-header">
<div class="dialog-title" id="dialog-title">附件预览</div>
<button type="button" class="dialog-close" id="btn-close" aria-label="关闭">×</button>
</div>
<div class="dialog-body">
<div class="preview-toolbar">
<button type="button" class="btn" id="btn-left" title="逆时针 90°">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
左转
</button>
<button type="button" class="btn" id="btn-right" title="顺时针 90°">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
</svg>
右转
</button>
<span class="toolbar-sep" aria-hidden="true"></span>
<button type="button" class="btn" id="btn-zoom-out" title="缩小">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3M8 11h6" />
</svg>
缩小
</button>
<button type="button" class="btn" id="btn-zoom-in" title="放大">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3M11 8v6M8 11h6" />
</svg>
放大
</button>
<button type="button" class="btn" id="btn-zoom-reset" title="重置为完整可见">
重置
</button>
<span class="status-chip" id="status-chip">0° · 100%</span>
</div>
<div class="preview-stage" id="preview-stage" title="滚轮缩放 · 按住拖拽平移">
<img id="preview-img" alt="预览" />
</div>
</div>
</div>
</div>
<script>
/** 生成带方向标记的示意图(无需外网) */
function makeDemoImage(label, hue, w, h) {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">` +
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
`<stop stop-color="hsl(${hue},45%,88%)"/><stop offset="1" stop-color="hsl(${hue},40%,72%)"/>` +
`</linearGradient></defs>` +
`<rect width="100%" height="100%" fill="url(#g)"/>` +
`<rect x="24" y="24" width="120" height="36" rx="6" fill="#059669"/>` +
`<text x="36" y="48" fill="#fff" font-size="16" font-family="Inter,sans-serif" font-weight="600">TOP</text>` +
`<text x="${w / 2}" y="${h / 2}" text-anchor="middle" fill="#1e293b" font-size="28" font-family="Inter,sans-serif" font-weight="600">${label}</text>` +
`<text x="${w / 2}" y="${h / 2 + 36}" text-anchor="middle" fill="#64748b" font-size="14" font-family="Inter,sans-serif">${w}×${h} · 旋转 · 滚轮 · 拖拽</text>` +
`<polygon points="${w / 2 - 16},${h - 56} ${w / 2 + 16},${h - 56} ${w / 2},${h - 28}" fill="#059669"/>` +
`<text x="${w / 2}" y="${h - 12}" text-anchor="middle" fill="#059669" font-size="12" font-family="Inter,sans-serif">BOTTOM</text>` +
`</svg>`
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
}
const files = {
a: { name: '现场照片-01.jpg', src: makeDemoImage('宽图现场照 A', 150, 960, 360) },
b: { name: '证件扫描.jpg', src: makeDemoImage('竖图证件 B', 175, 420, 640) }
}
document.getElementById('thumb-a-img').src = files.a.src
document.getElementById('thumb-b-img').src = files.b.src
const ZOOM_MIN = 0.5
const ZOOM_MAX = 5
const ZOOM_STEP = 0.15
const mask = document.getElementById('mask')
const stage = document.getElementById('preview-stage')
const previewImg = document.getElementById('preview-img')
const dialogTitle = document.getElementById('dialog-title')
const statusChip = document.getElementById('status-chip')
const btnZoomOut = document.getElementById('btn-zoom-out')
const btnZoomIn = document.getElementById('btn-zoom-in')
let angle = 0
/** 相对「完整装入」的倍率:1 = 旋转后整图可见 */
let zoom = 1
let offsetX = 0
let offsetY = 0
let dragging = false
let dragStartX = 0
let dragStartY = 0
let dragOriginX = 0
let dragOriginY = 0
function clampZoom(v) {
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(v * 100) / 100))
}
function fitBaseScale() {
const padX = 32
const padY = 32
const stageW = Math.max(stage.clientWidth - padX, 1)
const stageH = Math.max(stage.clientHeight - padY, 1)
const natW = previewImg.naturalWidth || 1
const natH = previewImg.naturalHeight || 1
const rad = (angle * Math.PI) / 180
const cos = Math.abs(Math.cos(rad))
const sin = Math.abs(Math.sin(rad))
const boundW = natW * cos + natH * sin
const boundH = natW * sin + natH * cos
return Math.min(stageW / boundW, stageH / boundH)
}
function applyTransform() {
const natW = previewImg.naturalWidth || 1
const natH = previewImg.naturalHeight || 1
const base = fitBaseScale()
const scale = base * zoom
previewImg.style.width = natW + 'px'
previewImg.style.height = natH + 'px'
previewImg.style.transform =
'translate(' + offsetX + 'px,' + offsetY + 'px) rotate(' + angle + 'deg) scale(' + scale + ')'
const deg = ((angle % 360) + 360) % 360
statusChip.textContent = deg + '° · ' + Math.round(zoom * 100) + '%'
btnZoomOut.disabled = zoom <= ZOOM_MIN
btnZoomIn.disabled = zoom >= ZOOM_MAX
}
function resetView(keepAngle) {
if (!keepAngle) angle = 0
zoom = 1
offsetX = 0
offsetY = 0
applyTransform()
}
function setZoom(next, fromWheel) {
zoom = clampZoom(next)
if (zoom <= 1) {
offsetX = 0
offsetY = 0
}
if (fromWheel) stage.classList.add('is-zooming')
applyTransform()
if (fromWheel) {
requestAnimationFrame(function () {
stage.classList.remove('is-zooming')
})
}
}
function openPreview(key) {
const file = files[key]
dialogTitle.textContent = file.name
angle = 0
zoom = 1
offsetX = 0
offsetY = 0
mask.classList.add('show')
previewImg.onload = function () {
applyTransform()
}
previewImg.src = file.src
if (previewImg.complete) applyTransform()
}
function closePreview() {
mask.classList.remove('show')
angle = 0
zoom = 1
offsetX = 0
offsetY = 0
previewImg.style.transform = ''
statusChip.textContent = '0° · 100%'
}
document.getElementById('thumb-a').addEventListener('click', function () { openPreview('a') })
document.getElementById('thumb-b').addEventListener('click', function () { openPreview('b') })
document.getElementById('btn-close').addEventListener('click', closePreview)
document.getElementById('btn-left').addEventListener('click', function () {
angle -= 90
applyTransform()
})
document.getElementById('btn-right').addEventListener('click', function () {
angle += 90
applyTransform()
})
btnZoomOut.addEventListener('click', function () {
setZoom(zoom - ZOOM_STEP)
})
btnZoomIn.addEventListener('click', function () {
setZoom(zoom + ZOOM_STEP)
})
document.getElementById('btn-zoom-reset').addEventListener('click', function () {
resetView(true)
})
stage.addEventListener(
'wheel',
function (e) {
if (!mask.classList.contains('show')) return
e.preventDefault()
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP
setZoom(zoom + delta, true)
},
{ passive: false }
)
stage.addEventListener('pointerdown', function (e) {
if (e.button !== 0) return
dragging = true
dragStartX = e.clientX
dragStartY = e.clientY
dragOriginX = offsetX
dragOriginY = offsetY
stage.classList.add('is-dragging')
stage.setPointerCapture(e.pointerId)
})
stage.addEventListener('pointermove', function (e) {
if (!dragging) return
offsetX = dragOriginX + (e.clientX - dragStartX)
offsetY = dragOriginY + (e.clientY - dragStartY)
applyTransform()
})
function endDrag(e) {
if (!dragging) return
dragging = false
stage.classList.remove('is-dragging')
try {
stage.releasePointerCapture(e.pointerId)
} catch (_) {}
}
stage.addEventListener('pointerup', endDrag)
stage.addEventListener('pointercancel', endDrag)
mask.addEventListener('click', function (e) {
if (e.target === mask) closePreview()
})
document.addEventListener('keydown', function (e) {
if (!mask.classList.contains('show')) return
if (e.key === 'Escape') closePreview()
if (e.key === 'ArrowLeft') { angle -= 90; applyTransform() }
if (e.key === 'ArrowRight') { angle += 90; applyTransform() }
if (e.key === '+' || e.key === '=') setZoom(zoom + ZOOM_STEP)
if (e.key === '-' || e.key === '_') setZoom(zoom - ZOOM_STEP)
if (e.key === '0') resetView(true)
})
window.addEventListener('resize', function () {
if (mask.classList.contains('show')) applyTransform()
})
</script>
</body>
</html>