Explorar o código

feat(web-admin): add image preview transform helpers for fit/zoom.

Pure functions for AttachmentUploadField image preview rotate/zoom math, with a Node assert script.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong hai 1 mes
pai
achega
0af792f535

+ 34 - 0
web-admin/scripts/test-image-preview-rotate.mjs

@@ -0,0 +1,34 @@
+import assert from 'node:assert/strict'
+import {
+  IMAGE_PREVIEW_ZOOM_MIN,
+  IMAGE_PREVIEW_ZOOM_MAX,
+  IMAGE_PREVIEW_ZOOM_STEP,
+  clampPreviewZoom,
+  computeFitBaseScale,
+  buildPreviewTransformCss
+} from '../src/utils/image-preview-transform.js'
+
+assert.equal(IMAGE_PREVIEW_ZOOM_MIN, 0.5)
+assert.equal(IMAGE_PREVIEW_ZOOM_MAX, 5)
+assert.equal(IMAGE_PREVIEW_ZOOM_STEP, 0.15)
+
+assert.equal(clampPreviewZoom(0.1), 0.5)
+assert.equal(clampPreviewZoom(9), 5)
+assert.equal(clampPreviewZoom(1.234), 1.23)
+
+// 未旋转:640x400 装入 320x200 → scale = 0.5
+assert.ok(
+  Math.abs(computeFitBaseScale(640, 400, 0, 320, 200) - 0.5) < 1e-9
+)
+
+// 旋转 90°:外接 400x640 装入 320x200 → scale = min(320/400, 200/640) = 0.3125
+assert.ok(
+  Math.abs(computeFitBaseScale(640, 400, 90, 320, 200) - 0.3125) < 1e-9
+)
+
+assert.equal(
+  buildPreviewTransformCss(10, -20, 90, 0.5),
+  'translate(10px, -20px) rotate(90deg) scale(0.5)'
+)
+
+console.log('image-preview-transform ok')

+ 65 - 0
web-admin/src/utils/image-preview-transform.js

@@ -0,0 +1,65 @@
+/** @typedef {{ x: number, y: number }} PreviewOffset */
+
+export const IMAGE_PREVIEW_ZOOM_MIN = 0.5
+export const IMAGE_PREVIEW_ZOOM_MAX = 5
+export const IMAGE_PREVIEW_ZOOM_STEP = 0.15
+
+/**
+ * @param {number} value
+ * @returns {number}
+ */
+export function clampPreviewZoom(value) {
+  const n = Number(value)
+  if (!Number.isFinite(n)) return 1
+  const clamped = Math.min(IMAGE_PREVIEW_ZOOM_MAX, Math.max(IMAGE_PREVIEW_ZOOM_MIN, n))
+  return Math.round(clamped * 100) / 100
+}
+
+/**
+ * 旋转后外接矩形完整装入 stage 的基础缩放(zoom=1 时使用)。
+ * @param {number} naturalWidth
+ * @param {number} naturalHeight
+ * @param {number} angleDeg
+ * @param {number} stageWidth
+ * @param {number} stageHeight
+ * @returns {number}
+ */
+export function computeFitBaseScale(
+  naturalWidth,
+  naturalHeight,
+  angleDeg,
+  stageWidth,
+  stageHeight
+) {
+  const natW = Math.max(Number(naturalWidth) || 1, 1)
+  const natH = Math.max(Number(naturalHeight) || 1, 1)
+  const stageW = Math.max(Number(stageWidth) || 1, 1)
+  const stageH = Math.max(Number(stageHeight) || 1, 1)
+  const rad = ((Number(angleDeg) || 0) * 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)
+}
+
+/**
+ * @param {number} offsetX
+ * @param {number} offsetY
+ * @param {number} angleDeg
+ * @param {number} scale
+ * @returns {string}
+ */
+export function buildPreviewTransformCss(offsetX, offsetY, angleDeg, scale) {
+  return (
+    'translate(' +
+    offsetX +
+    'px, ' +
+    offsetY +
+    'px) rotate(' +
+    angleDeg +
+    'deg) scale(' +
+    scale +
+    ')'
+  )
+}