|
|
@@ -0,0 +1,653 @@
|
|
|
+<!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>
|