Files
ttstd-infinite-canvas/web/src/lib/canvas/canvas-image-data.ts
cn-derekdu b79f81c237 fix(canvas): 图片裁剪 - 修复云端图片跨域导致的确认裁剪无反应
裁剪属于像素级操作,需要先把图片画到 canvas 再导出。云端图片地址通常不带
CORS 头,canvas 会被标记为跨域,toDataURL 直接抛 SecurityError;而 loadImage
只挂了 onload、没有 onerror,加载失败时 Promise 永久挂起。两者叠加后,用户
侧的表现都是「点击确认裁剪没有任何反应」,控制台之外看不到原因。

- loadImage 补充 onerror,避免加载失败时 Promise 永不落地
- 仅对非 data:/blob: 的地址设置 crossOrigin=anonymous,本地资源设了反而
  可能取不到像素
- 新增 resolveCroppableImageSource():优先用本地媒体缓存 Blob 构造同源
  objectURL,取不到再回退原始地址,并在 finally 中 revoke
- cropImageNode 补充 try/catch 与失败提示,让错误可见而不是静默失败

无外部行为变化,仅修复失败路径与错误反馈。
2026-09-22 16:31:15 +08:00

183 lines
7.4 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.
export type ImageCropRect = {
x: number;
y: number;
width: number;
height: number;
};
export type ImageAngleTransform = {
horizontalAngle: number;
pitchAngle: number;
cameraDistance: number;
wideAngle: boolean;
};
export type ImageUpscaleAlgorithm = "nearest" | "bilinear" | "high";
export const MAX_UPSCALE_LONG_EDGE = 4096;
export type ImageUpscaleParams = {
targetLongEdge: number;
algorithm: ImageUpscaleAlgorithm;
};
export type ImageSplitParams = {
rows: number;
columns: number;
};
export type ImageSplitPiece = {
row: number;
column: number;
dataUrl: string;
};
export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
const image = await loadImage(dataUrl);
if (crop) {
return drawCrop(image, Math.floor(crop.x * image.width), Math.floor(crop.y * image.height), Math.ceil(crop.width * image.width), Math.ceil(crop.height * image.height));
}
const size = Math.min(image.width, image.height);
const sx = Math.max(0, Math.floor((image.width - size) / 2));
const sy = Math.max(0, Math.floor((image.height - size) / 2));
return drawCrop(image, sx, sy, size, size);
}
export async function splitDataUrl(dataUrl: string, params: ImageSplitParams): Promise<ImageSplitPiece[]> {
const image = await loadImage(dataUrl);
const rows = Math.max(1, Math.floor(params.rows));
const columns = Math.max(1, Math.floor(params.columns));
const pieces: ImageSplitPiece[] = [];
for (let row = 0; row < rows; row += 1) {
const sy = Math.floor((row * image.height) / rows);
const sh = Math.floor(((row + 1) * image.height) / rows) - sy;
for (let column = 0; column < columns; column += 1) {
const sx = Math.floor((column * image.width) / columns);
const sw = Math.floor(((column + 1) * image.width) / columns) - sx;
pieces.push({ row, column, dataUrl: drawCrop(image, sx, sy, sw, sh) });
}
}
return pieces;
}
export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleTransform) {
const image = await loadImage(dataUrl);
const canvas = document.createElement("canvas");
const padding = Math.round(Math.max(image.width, image.height) * 0.18);
canvas.width = image.width + padding * 2;
canvas.height = image.height + padding * 2;
const context = canvas.getContext("2d");
if (!context) return dataUrl;
context.clearRect(0, 0, canvas.width, canvas.height);
const horizontal = params.horizontalAngle / 60;
const pitch = params.pitchAngle / 45;
const distanceScale = 1.12 - params.cameraDistance * 0.035;
const wideScale = params.wideAngle ? 0.88 : 1;
const scale = Math.max(0.64, Math.min(1.1, distanceScale * wideScale));
const width = image.width * scale * (1 - Math.abs(horizontal) * 0.28);
const height = image.height * scale * (1 - Math.abs(pitch) * 0.18);
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const skewX = horizontal * image.width * 0.18;
const skewY = pitch * image.height * 0.12;
const x = cx - width / 2 + horizontal * padding * 0.5;
const y = cy - height / 2 + pitch * padding * 0.45;
context.save();
context.setTransform(1, pitch * 0.08, horizontal * -0.1, 1, 0, 0);
context.drawImage(image, x + skewX, y + skewY, width, height);
context.restore();
if (params.wideAngle) {
const gradient = context.createRadialGradient(cx, cy, Math.min(canvas.width, canvas.height) * 0.2, cx, cy, Math.max(canvas.width, canvas.height) * 0.62);
gradient.addColorStop(0, "rgba(255,255,255,0)");
gradient.addColorStop(1, "rgba(0,0,0,0.18)");
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
}
return canvas.toDataURL("image/png");
}
export async function upscaleDataUrl(dataUrl: string, params: ImageUpscaleParams) {
const image = await loadImage(dataUrl);
const { width, height } = resolveUpscaleSize(image.width, image.height, params.targetLongEdge);
return params.algorithm === "high" ? drawStepUpscale(image, width, height) : drawResize(image, image.width, image.height, width, height, params.algorithm);
}
export function resolveUpscaleSize(width: number, height: number, targetLongEdge: number) {
const longEdge = Math.max(1, width, height);
const target = Math.min(MAX_UPSCALE_LONG_EDGE, Math.max(1, Math.round(targetLongEdge)));
const scale = target / longEdge;
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
}
function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, sh: number) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, sw);
canvas.height = Math.max(1, sh);
const context = canvas.getContext("2d");
if (!context) return image.src;
context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/png");
}
function drawStepUpscale(image: HTMLImageElement, width: number, height: number) {
let source: CanvasImageSource = image;
let sourceWidth = image.width;
let sourceHeight = image.height;
while (sourceWidth * 2 < width && sourceHeight * 2 < height) {
const nextWidth = sourceWidth * 2;
const nextHeight = sourceHeight * 2;
const next = drawResizeCanvas(source, sourceWidth, sourceHeight, nextWidth, nextHeight, "high");
source = next;
sourceWidth = nextWidth;
sourceHeight = nextHeight;
}
return drawResize(source, sourceWidth, sourceHeight, width, height, "high");
}
function drawResize(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) {
return drawResizeCanvas(source, sourceWidth, sourceHeight, width, height, algorithm).toDataURL("image/png");
}
function drawResizeCanvas(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) return canvas;
context.imageSmoothingEnabled = algorithm !== "nearest";
context.imageSmoothingQuality = algorithm === "bilinear" ? "medium" : "high";
context.drawImage(source, 0, 0, sourceWidth, sourceHeight, 0, 0, width, height);
return canvas;
}
function loadImage(dataUrl: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
// 只有跨源 HTTP(S) 地址才需要声明匿名跨域;同源的 /api 资源不能
// 强制走 CORS,否则缺少 ACAO 响应头时反而会加载失败。
if (isCrossOriginHttpUrl(dataUrl)) image.crossOrigin = "anonymous";
image.onload = () => resolve(image);
// 缺少 onerror 时加载失败会让 Promise 永久挂起,调用方 await 后表现为「点击无反应」。
image.onerror = () => reject(new Error("图片加载失败,无法处理该图片"));
image.src = dataUrl;
});
}
function isCrossOriginHttpUrl(value: string) {
if (typeof window === "undefined") return false;
try {
const url = new URL(value, window.location.href);
return (url.protocol === "http:" || url.protocol === "https:") && url.origin !== window.location.origin;
} catch {
return false;
}
}