From 0af792f535c37062982829057c7fe32b852ee77e Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Wed, 23 Sep 2026 14:23:34 +0800 Subject: [PATCH] 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 --- .../scripts/test-image-preview-rotate.mjs | 34 ++++++++++ .../src/utils/image-preview-transform.js | 65 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 web-admin/scripts/test-image-preview-rotate.mjs create mode 100644 web-admin/src/utils/image-preview-transform.js diff --git a/web-admin/scripts/test-image-preview-rotate.mjs b/web-admin/scripts/test-image-preview-rotate.mjs new file mode 100644 index 000000000..22c27376d --- /dev/null +++ b/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') diff --git a/web-admin/src/utils/image-preview-transform.js b/web-admin/src/utils/image-preview-transform.js new file mode 100644 index 000000000..f64d8fccc --- /dev/null +++ b/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 + + ')' + ) +}