Files
comfyui_o1key/web/js/o1keyReferenceImageEditor.js
T
Jony ba920f2b66 Publish current ComfyUI O1Key code baseline
Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
2026-09-24 19:56:48 +08:00

945 lines
45 KiB
JavaScript
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.
const EDITOR_ID = "o1key-reference-image-editor";
const EDITOR_STYLE_ID = "o1key-reference-image-editor-styles";
const MIN_CROP_SIZE = 8;
export const REFERENCE_EDITOR_RATIOS = Object.freeze([
{ key: "free", label: "自由", ratio: null },
{ key: "original", label: "原图", ratio: "original" },
{ key: "1:1", label: "1:1", ratio: 1 },
{ key: "4:3", label: "4:3", ratio: 4 / 3 },
{ key: "3:4", label: "3:4", ratio: 3 / 4 },
{ key: "3:2", label: "3:2", ratio: 3 / 2 },
{ key: "2:3", label: "2:3", ratio: 2 / 3 },
{ key: "16:9", label: "16:9", ratio: 16 / 9 },
{ key: "9:16", label: "9:16", ratio: 9 / 16 },
]);
const EDITOR_CSS = `
#${EDITOR_ID}{position:fixed;inset:0;z-index:10020;display:grid;place-items:center;padding:20px;box-sizing:border-box;
overflow:hidden;background:rgba(7,8,10,.88);color:#eceef2;font:12px/1.45 Inter,"Microsoft YaHei UI","Microsoft YaHei",system-ui,sans-serif;}
#${EDITOR_ID} *,#${EDITOR_ID} *::before,#${EDITOR_ID} *::after{box-sizing:border-box;}
#${EDITOR_ID} [hidden]{display:none!important;}
.o1key-reference-editor-dialog{width:min(1180px,96vw);height:min(850px,94vh);min-width:640px;min-height:500px;display:grid;
grid-template-rows:52px minmax(0,1fr) 58px;overflow:hidden;border:1px solid rgba(255,255,255,.16);border-radius:14px;
background:#202124;box-shadow:0 28px 90px rgba(0,0,0,.65);}
.o1key-reference-editor-header{display:flex;align-items:center;gap:12px;padding:0 16px;border-bottom:1px solid rgba(255,255,255,.1);}
.o1key-reference-editor-header strong{font-size:15px;}.o1key-reference-editor-filename{min-width:0;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;color:#96999f;}.o1key-reference-editor-close{margin-left:auto;width:32px;height:32px;border:0;border-radius:8px;
background:#303238;color:#d8d9dc;cursor:pointer;font:22px/1 system-ui,sans-serif;}.o1key-reference-editor-close:hover{background:#3b3d43;color:#fff;}
.o1key-reference-editor-body{min-width:0;min-height:0;display:grid;grid-template-columns:184px minmax(0,1fr);overflow:hidden;}
.o1key-reference-editor-sidebar{min-width:0;min-height:0;padding:14px 12px;overflow-x:hidden;overflow-y:auto;scrollbar-gutter:stable;
border-right:1px solid rgba(255,255,255,.1);background:#1b1c1f;}
.o1key-reference-editor-tools{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-bottom:16px;}
.o1key-reference-editor-tool,.o1key-reference-editor-ratio{height:34px;padding:0 8px;border:1px solid rgba(255,255,255,.12);
min-width:0;white-space:nowrap;border-radius:7px;background:#292b30;color:#c6c8cd;cursor:pointer;font:inherit;}.o1key-reference-editor-tool:hover,.o1key-reference-editor-ratio:hover{
border-color:rgba(255,255,255,.28);color:#fff;}.o1key-reference-editor-tool.active,.o1key-reference-editor-ratio.active{
border-color:#94b78a;background:#344332;color:#f4fff0;box-shadow:inset 0 0 0 1px rgba(180,220,166,.15);}
.o1key-reference-editor-section{margin:0 0 16px;}.o1key-reference-editor-section-title{margin:0 0 7px;color:#9da0a6;font-size:11px;font-weight:700;}
.o1key-reference-editor-ratios{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;}.o1key-reference-editor-ratio{height:30px;padding:0 4px;}
.o1key-reference-editor-control{min-width:0;display:grid;gap:7px;margin-top:10px;color:#a8abb0;}.o1key-reference-editor-control-row{min-width:0;display:flex;align-items:center;gap:8px;}
.o1key-reference-editor-control input[type=range]{min-width:0;flex:1;accent-color:#8daa83;}.o1key-reference-editor-control input[type=color]{
width:34px;height:28px;padding:2px;border:1px solid rgba(255,255,255,.14);border-radius:6px;background:#292b30;}
.o1key-reference-editor-width-value{width:44px;text-align:right;color:#d6d8dc;font-variant-numeric:tabular-nums;}
.o1key-reference-editor-sticker-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#8f9298;font-size:10px;}
.o1key-reference-editor-sticker-controls>.o1key-reference-editor-action{width:100%;min-width:0;white-space:nowrap;}
.o1key-reference-editor-help{min-height:50px;margin-top:12px;padding:9px;overflow-wrap:anywhere;border-radius:7px;background:rgba(255,255,255,.045);color:#898c92;font-size:10px;}
.o1key-reference-editor-stage{position:relative;min-width:0;min-height:0;display:grid;place-items:center;padding:18px;overflow:hidden;
background-color:#111214;background-image:linear-gradient(45deg,#18191c 25%,transparent 25%),linear-gradient(-45deg,#18191c 25%,transparent 25%),
linear-gradient(45deg,transparent 75%,#18191c 75%),linear-gradient(-45deg,transparent 75%,#18191c 75%);background-size:20px 20px;
background-position:0 0,0 10px,10px -10px,-10px 0;}
.o1key-reference-editor-canvas{display:block;max-width:100%;max-height:100%;background:#0a0a0a;box-shadow:0 8px 30px rgba(0,0,0,.5);touch-action:none;outline:none;}
.o1key-reference-editor-footer{min-width:0;display:flex;align-items:center;gap:8px;padding:0 16px;overflow:hidden;border-top:1px solid rgba(255,255,255,.1);}
.o1key-reference-editor-size{flex:0 0 auto;color:#8f9298;font-variant-numeric:tabular-nums;}.o1key-reference-editor-message{
min-width:0;flex:1;margin-left:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e5a4a4;}
.o1key-reference-editor-action{height:34px;min-width:74px;padding:0 14px;border:0;border-radius:7px;background:#34363b;color:#dedfe2;cursor:pointer;font:inherit;}
.o1key-reference-editor-action:hover{background:#414349;color:#fff;}.o1key-reference-editor-action:disabled{
cursor:not-allowed;opacity:.55;}.o1key-reference-editor-action:disabled:hover{background:#34363b;color:#dedfe2;}
.o1key-reference-editor-action.primary{background:#82a777;color:#10200d;font-weight:750;}.o1key-reference-editor-action.primary:hover{background:#91b685;}
.o1key-reference-editor-action.ghost{min-width:34px;padding:0 9px;}.o1key-reference-editor-history{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;}
.o1key-reference-editor-sidebar-actions>.o1key-reference-editor-action,.o1key-reference-editor-history>.o1key-reference-editor-action{
width:100%;min-width:0;white-space:nowrap;}
@media (max-width:760px){#${EDITOR_ID}{padding:0}.o1key-reference-editor-dialog{width:100vw;height:100vh;min-width:0;min-height:0;border:0;border-radius:0;}
.o1key-reference-editor-body{grid-template-columns:150px minmax(0,1fr)}.o1key-reference-editor-sidebar{padding:10px 8px}.o1key-reference-editor-stage{padding:8px}}
`;
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
export function constrainCropRect(rect, imageWidth, imageHeight, minimumSize = MIN_CROP_SIZE) {
const widthLimit = Math.max(1, Number(imageWidth) || 1);
const heightLimit = Math.max(1, Number(imageHeight) || 1);
const minimum = Math.min(Math.max(1, Number(minimumSize) || 1), widthLimit, heightLimit);
const width = clamp(Number(rect?.width) || minimum, minimum, widthLimit);
const height = clamp(Number(rect?.height) || minimum, minimum, heightLimit);
return {
x: clamp(Number(rect?.x) || 0, 0, widthLimit - width),
y: clamp(Number(rect?.y) || 0, 0, heightLimit - height),
width,
height,
};
}
export function fitCropToRatio(imageWidth, imageHeight, ratio) {
const width = Math.max(1, Number(imageWidth) || 1);
const height = Math.max(1, Number(imageHeight) || 1);
const numericRatio = ratio === "original" ? width / height : Number(ratio);
if (!(numericRatio > 0)) return { x: 0, y: 0, width, height };
let cropWidth = width;
let cropHeight = cropWidth / numericRatio;
if (cropHeight > height) {
cropHeight = height;
cropWidth = cropHeight * numericRatio;
}
return {
x: (width - cropWidth) / 2,
y: (height - cropHeight) / 2,
width: cropWidth,
height: cropHeight,
};
}
export function editedReferenceFilename(filename) {
const basename = String(filename || "reference.png").split(/[\\/]/).pop() || "reference.png";
const stem = basename.replace(/\.[^.]+$/, "") || "reference";
const safeStem = stem.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_").slice(0, 180) || "reference";
return `${safeStem}_edited.png`;
}
export function fitStickerTransform(imageWidth, imageHeight, canvasWidth, canvasHeight) {
const width = Math.max(1, Number(imageWidth) || 1);
const height = Math.max(1, Number(imageHeight) || 1);
const targetWidth = Math.max(1, Number(canvasWidth) || 1);
const targetHeight = Math.max(1, Number(canvasHeight) || 1);
const scale = Math.min(targetWidth * .5 / width, targetHeight * .5 / height, 1);
return {
x: targetWidth / 2,
y: targetHeight / 2,
width: width * scale,
height: height * scale,
rotation: 0,
opacity: 1,
};
}
function stickerPoint(sticker, localX, localY) {
const cosine = Math.cos(sticker.rotation || 0);
const sine = Math.sin(sticker.rotation || 0);
return {
x: sticker.x + localX * cosine - localY * sine,
y: sticker.y + localX * sine + localY * cosine,
};
}
export function stickerControlGeometry(sticker, rotationHandleDistance = 0) {
const halfWidth = Math.max(1, Number(sticker?.width) || 1) / 2;
const halfHeight = Math.max(1, Number(sticker?.height) || 1) / 2;
return {
corners: [
stickerPoint(sticker, -halfWidth, -halfHeight),
stickerPoint(sticker, halfWidth, -halfHeight),
stickerPoint(sticker, halfWidth, halfHeight),
stickerPoint(sticker, -halfWidth, halfHeight),
],
top: stickerPoint(sticker, 0, -halfHeight),
rotate: stickerPoint(sticker, 0, -halfHeight - rotationHandleDistance),
};
}
export function hitTestSticker(sticker, point, tolerance = 10, rotationHandleDistance = 28) {
if (!sticker || !point) return null;
const geometry = stickerControlGeometry(sticker, rotationHandleDistance);
if (Math.hypot(point.x - geometry.rotate.x, point.y - geometry.rotate.y) <= tolerance) return "rotate";
if (geometry.corners.some((corner) => Math.hypot(point.x - corner.x, point.y - corner.y) <= tolerance)) return "scale";
const cosine = Math.cos(-(sticker.rotation || 0));
const sine = Math.sin(-(sticker.rotation || 0));
const dx = point.x - sticker.x;
const dy = point.y - sticker.y;
const localX = dx * cosine - dy * sine;
const localY = dx * sine + dy * cosine;
return Math.abs(localX) <= sticker.width / 2 && Math.abs(localY) <= sticker.height / 2 ? "move" : null;
}
export function arrowHeadGeometry(start, end, lineWidth = 4) {
const angle = Math.atan2(end.y - start.y, end.x - start.x);
const length = Math.max(12, Math.max(1, Number(lineWidth) || 1) * 4.5);
const spread = Math.PI / 7;
return {
tip: { x: end.x, y: end.y },
left: {
x: end.x - length * Math.cos(angle - spread),
y: end.y - length * Math.sin(angle - spread),
},
right: {
x: end.x - length * Math.cos(angle + spread),
y: end.y - length * Math.sin(angle + spread),
},
};
}
function injectEditorStyles() {
if (document.getElementById(EDITOR_STYLE_ID)) return;
const style = document.createElement("style");
style.id = EDITOR_STYLE_ID;
style.textContent = EDITOR_CSS;
document.head.append(style);
}
function loadEditorImage(sourceUrl) {
return new Promise((resolve, reject) => {
const image = new Image();
image.decoding = "async";
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("图片加载失败,请重新打开编辑器"));
image.src = sourceUrl;
});
}
function canvasBlob(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error("无法导出编辑后的图片"));
}, "image/png");
});
}
function cloneSnapshot(state) {
return {
crop: { ...state.crop },
ratioKey: state.ratioKey,
strokes: state.strokes.map((stroke) => ({
...stroke,
points: stroke.points.map((point) => ({ ...point })),
})),
sticker: state.sticker ? { ...state.sticker } : null,
};
}
function drawStroke(context, stroke, scaleX = 1, scaleY = scaleX, offsetX = 0, offsetY = 0) {
if (!stroke?.points?.length) return;
const points = stroke.points;
if (stroke.tool === "arrow") {
if (points.length < 2) return;
const start = {
x: (points[0].x - offsetX) * scaleX,
y: (points[0].y - offsetY) * scaleY,
};
const last = points[points.length - 1];
const end = {
x: (last.x - offsetX) * scaleX,
y: (last.y - offsetY) * scaleY,
};
if (Math.hypot(end.x - start.x, end.y - start.y) < 1) return;
const lineWidth = Math.max(1, stroke.width * ((scaleX + scaleY) / 2));
const head = arrowHeadGeometry(start, end, lineWidth);
context.save();
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = stroke.color;
context.fillStyle = stroke.color;
context.lineWidth = lineWidth;
context.beginPath();
context.moveTo(start.x, start.y);
context.lineTo(end.x, end.y);
context.stroke();
context.beginPath();
context.moveTo(head.tip.x, head.tip.y);
context.lineTo(head.left.x, head.left.y);
context.lineTo(head.right.x, head.right.y);
context.closePath();
context.fill();
context.restore();
return;
}
context.save();
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = stroke.tool === "mask" ? "rgba(255,55,79,.52)" : stroke.color;
context.fillStyle = context.strokeStyle;
context.lineWidth = Math.max(1, stroke.width * ((scaleX + scaleY) / 2));
context.beginPath();
if (points.length === 1) {
const point = points[0];
context.arc((point.x - offsetX) * scaleX, (point.y - offsetY) * scaleY, context.lineWidth / 2, 0, Math.PI * 2);
context.fill();
} else {
context.moveTo((points[0].x - offsetX) * scaleX, (points[0].y - offsetY) * scaleY);
for (const point of points.slice(1)) {
context.lineTo((point.x - offsetX) * scaleX, (point.y - offsetY) * scaleY);
}
context.stroke();
}
context.restore();
}
function drawSticker(context, sticker, scaleX = 1, scaleY = scaleX, offsetX = 0, offsetY = 0) {
if (!sticker?.image) return;
context.save();
context.scale(scaleX, scaleY);
context.translate(sticker.x - offsetX, sticker.y - offsetY);
context.rotate(sticker.rotation || 0);
context.globalAlpha = clamp(Number(sticker.opacity ?? 1), 0, 1);
context.drawImage(sticker.image, -sticker.width / 2, -sticker.height / 2, sticker.width, sticker.height);
context.restore();
}
function button(label, className = "", title = "") {
const element = document.createElement("button");
element.type = "button";
element.className = className;
element.textContent = label;
if (title) element.title = title;
return element;
}
export async function openReferenceImageEditor({ sourceUrl, filename = "参考图", onConfirm } = {}) {
if (!sourceUrl) throw new Error("缺少可编辑的参考图地址");
if (typeof onConfirm !== "function") throw new Error("缺少图片编辑保存回调");
document.getElementById(EDITOR_ID)?._o1keyClose?.();
injectEditorStyles();
const sourceImage = await loadEditorImage(sourceUrl);
const sourceWidth = sourceImage.naturalWidth || sourceImage.width;
const sourceHeight = sourceImage.naturalHeight || sourceImage.height;
if (!sourceWidth || !sourceHeight) throw new Error("无法读取图片尺寸");
const previousFocus = document.activeElement;
const overlay = document.createElement("div");
overlay.id = EDITOR_ID;
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-label", "编辑参考图");
const dialog = document.createElement("div");
dialog.className = "o1key-reference-editor-dialog";
const header = document.createElement("div");
header.className = "o1key-reference-editor-header";
const title = document.createElement("strong");
title.textContent = "编辑参考图";
const filenameLabel = document.createElement("span");
filenameLabel.className = "o1key-reference-editor-filename";
filenameLabel.textContent = filename;
const closeButton = button("×", "o1key-reference-editor-close", "关闭");
closeButton.setAttribute("aria-label", "关闭图片编辑器");
header.append(title, filenameLabel, closeButton);
const body = document.createElement("div");
body.className = "o1key-reference-editor-body";
const sidebar = document.createElement("aside");
sidebar.className = "o1key-reference-editor-sidebar";
const tools = document.createElement("div");
tools.className = "o1key-reference-editor-tools";
const toolButtons = new Map();
for (const [tool, label, tooltip] of [
["crop", "裁剪", "拖动裁剪框定位;自由比例下可拖动边角调整"],
["mask", "遮罩", "涂画半透明区域标记,不输出 MASK 数据"],
["brush", "画笔", "自由圈选或批注图片中的物品"],
["sticker", "贴图", "上传上层图片,并移动、旋转或等比缩放"],
["arrow", "箭头", "从箭尾拖向目标位置,箭头尖端指向松开处"],
]) {
const toolButton = button(label, "o1key-reference-editor-tool", tooltip);
toolButton.dataset.tool = tool;
toolButtons.set(tool, toolButton);
tools.append(toolButton);
}
const ratioSection = document.createElement("section");
ratioSection.className = "o1key-reference-editor-section";
const ratioTitle = document.createElement("div");
ratioTitle.className = "o1key-reference-editor-section-title";
ratioTitle.textContent = "快速裁剪";
const ratios = document.createElement("div");
ratios.className = "o1key-reference-editor-ratios";
const ratioButtons = new Map();
for (const preset of REFERENCE_EDITOR_RATIOS) {
const ratioButton = button(preset.label, "o1key-reference-editor-ratio");
ratioButton.dataset.ratio = preset.key;
ratioButtons.set(preset.key, ratioButton);
ratios.append(ratioButton);
}
ratioSection.append(ratioTitle, ratios);
const controlSection = document.createElement("section");
controlSection.className = "o1key-reference-editor-section o1key-reference-editor-control";
const widthRow = document.createElement("label");
widthRow.className = "o1key-reference-editor-control-row";
const widthText = document.createElement("span");
widthText.textContent = "粗细";
const widthInput = document.createElement("input");
widthInput.type = "range";
widthInput.min = "2";
widthInput.max = String(Math.max(24, Math.round(Math.min(sourceWidth, sourceHeight) * 0.12)));
widthInput.value = String(Math.max(6, Math.round(Math.min(sourceWidth, sourceHeight) * 0.012)));
const widthValue = document.createElement("span");
widthValue.className = "o1key-reference-editor-width-value";
widthRow.append(widthText, widthInput, widthValue);
const colorRow = document.createElement("label");
colorRow.className = "o1key-reference-editor-control-row";
const colorText = document.createElement("span");
colorText.textContent = "颜色";
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.value = "#ff334f";
colorRow.append(colorText, colorInput);
controlSection.append(widthRow, colorRow);
const stickerSection = document.createElement("section");
stickerSection.className = "o1key-reference-editor-section o1key-reference-editor-control o1key-reference-editor-sticker-controls";
const stickerInput = document.createElement("input");
stickerInput.type = "file";
stickerInput.accept = "image/png,image/jpeg,image/webp,image/gif,image/bmp";
stickerInput.hidden = true;
const stickerUpload = button("上传贴图", "o1key-reference-editor-action", "上传一张上层贴图");
const stickerName = document.createElement("div");
stickerName.className = "o1key-reference-editor-sticker-name";
const opacityRow = document.createElement("label");
opacityRow.className = "o1key-reference-editor-control-row";
const opacityText = document.createElement("span");
opacityText.textContent = "透明度";
const opacityInput = document.createElement("input");
opacityInput.type = "range";
opacityInput.min = "0";
opacityInput.max = "100";
opacityInput.value = "100";
const opacityValue = document.createElement("span");
opacityValue.className = "o1key-reference-editor-width-value";
opacityRow.append(opacityText, opacityInput, opacityValue);
const removeSticker = button("移除贴图", "o1key-reference-editor-action", "移除当前上层贴图");
stickerSection.append(stickerUpload, stickerInput, stickerName, opacityRow, removeSticker);
const help = document.createElement("div");
help.className = "o1key-reference-editor-help";
const sidebarActions = document.createElement("div");
sidebarActions.className = "o1key-reference-editor-control o1key-reference-editor-sidebar-actions";
const historyRow = document.createElement("div");
historyRow.className = "o1key-reference-editor-history";
const undo = button("↶", "o1key-reference-editor-action ghost", "撤销(Ctrl+Z");
const redo = button("↷", "o1key-reference-editor-action ghost", "重做(Ctrl+Y");
const clearMarks = button("清除标记", "o1key-reference-editor-action", "清除遮罩和画笔内容");
historyRow.append(undo, redo);
const reset = button("全部重置", "o1key-reference-editor-action");
sidebarActions.append(historyRow, clearMarks, reset);
sidebar.append(tools, ratioSection, controlSection, stickerSection, help, sidebarActions);
const stage = document.createElement("div");
stage.className = "o1key-reference-editor-stage";
const canvas = document.createElement("canvas");
canvas.className = "o1key-reference-editor-canvas";
canvas.tabIndex = 0;
stage.append(canvas);
body.append(sidebar, stage);
const footer = document.createElement("footer");
footer.className = "o1key-reference-editor-footer";
const sizeLabel = document.createElement("span");
sizeLabel.className = "o1key-reference-editor-size";
const message = document.createElement("span");
message.className = "o1key-reference-editor-message";
const cancel = button("取消", "o1key-reference-editor-action");
const confirm = button("应用编辑", "o1key-reference-editor-action primary");
footer.append(sizeLabel, message, cancel, confirm);
dialog.append(header, body, footer);
overlay.append(dialog);
document.body.append(overlay);
const state = {
mode: "crop",
ratioKey: "original",
crop: fitCropToRatio(sourceWidth, sourceHeight, "original"),
strokes: [],
activeStroke: null,
sticker: null,
pointerAction: null,
canvasWidth: 1,
canvasHeight: 1,
history: [],
historyIndex: -1,
saving: false,
};
const stickerObjectUrls = new Set();
const context = canvas.getContext("2d");
const sourcePoint = (event) => {
const bounds = canvas.getBoundingClientRect();
return {
x: clamp((event.clientX - bounds.left) / Math.max(1, bounds.width) * sourceWidth, 0, sourceWidth),
y: clamp((event.clientY - bounds.top) / Math.max(1, bounds.height) * sourceHeight, 0, sourceHeight),
};
};
const cropDisplay = () => ({
x: state.crop.x / sourceWidth * state.canvasWidth,
y: state.crop.y / sourceHeight * state.canvasHeight,
width: state.crop.width / sourceWidth * state.canvasWidth,
height: state.crop.height / sourceHeight * state.canvasHeight,
});
const updateControls = () => {
for (const [tool, toolButton] of toolButtons) toolButton.classList.toggle("active", tool === state.mode);
for (const [key, ratioButton] of ratioButtons) ratioButton.classList.toggle("active", key === state.ratioKey);
ratioSection.hidden = state.mode !== "crop";
controlSection.hidden = state.mode === "crop" || state.mode === "sticker";
stickerSection.hidden = state.mode !== "sticker";
colorRow.hidden = state.mode !== "brush" && state.mode !== "arrow";
clearMarks.hidden = state.mode !== "mask" && state.mode !== "brush" && state.mode !== "arrow";
reset.textContent = state.mode === "crop" ? "重置裁剪" : state.mode === "sticker" ? "重置贴图" : "全部重置";
widthValue.textContent = `${widthInput.value}px`;
stickerUpload.textContent = state.sticker ? "更换贴图" : "上传贴图";
stickerName.textContent = state.sticker?.name || "尚未上传贴图";
opacityInput.value = String(Math.round((state.sticker?.opacity ?? 1) * 100));
opacityValue.textContent = `${opacityInput.value}%`;
opacityInput.disabled = !state.sticker;
removeSticker.disabled = !state.sticker;
reset.disabled = state.mode === "sticker" && !state.sticker;
help.textContent = state.mode === "crop"
? "选择比例即可得到居中最大裁剪框;拖动框可调整取景。自由比例支持拖动边角。"
: state.mode === "mask"
? "半透明红色只作为图片上的视觉标记,会合成进参考图,不会创建 MASK 数据。"
: state.mode === "brush"
? "用画笔自由圈选或标注物品。可调整颜色和线条粗细。"
: state.mode === "arrow"
? "从箭尾按下并拖向目标,松开位置就是箭头尖端。可调整颜色和粗细。"
: state.sticker
? "拖动贴图移动;拖动四角等比缩放;拖动顶部圆点旋转。按住 Shift 可按 15° 吸附。"
: "上传图片后,它会作为上层贴图置于画面中央。";
undo.disabled = state.historyIndex <= 0;
redo.disabled = state.historyIndex >= state.history.length - 1;
clearMarks.disabled = state.strokes.length === 0;
sizeLabel.textContent = `${Math.max(1, Math.round(state.crop.width))} × ${Math.max(1, Math.round(state.crop.height))} px`;
};
const drawCropOverlay = () => {
const crop = cropDisplay();
context.save();
context.fillStyle = "rgba(0,0,0,.56)";
context.beginPath();
context.rect(0, 0, state.canvasWidth, state.canvasHeight);
context.rect(crop.x, crop.y, crop.width, crop.height);
context.fill("evenodd");
context.strokeStyle = "rgba(255,255,255,.96)";
context.lineWidth = 1.5;
context.strokeRect(crop.x + .75, crop.y + .75, Math.max(0, crop.width - 1.5), Math.max(0, crop.height - 1.5));
context.strokeStyle = "rgba(255,255,255,.4)";
context.lineWidth = 1;
for (const part of [1 / 3, 2 / 3]) {
context.beginPath();
context.moveTo(crop.x + crop.width * part, crop.y);
context.lineTo(crop.x + crop.width * part, crop.y + crop.height);
context.moveTo(crop.x, crop.y + crop.height * part);
context.lineTo(crop.x + crop.width, crop.y + crop.height * part);
context.stroke();
}
if (state.ratioKey === "free") {
context.fillStyle = "#fff";
for (const [x, y] of [
[crop.x, crop.y], [crop.x + crop.width, crop.y],
[crop.x, crop.y + crop.height], [crop.x + crop.width, crop.y + crop.height],
]) context.fillRect(x - 5, y - 5, 10, 10);
}
context.restore();
};
const drawStickerControls = () => {
if (!state.sticker) return;
const scaleX = state.canvasWidth / sourceWidth;
const scaleY = state.canvasHeight / sourceHeight;
const sourceScale = Math.max(scaleX, scaleY);
const geometry = stickerControlGeometry(state.sticker, 28 / sourceScale);
const displayPoint = (point) => ({ x: point.x * scaleX, y: point.y * scaleY });
const corners = geometry.corners.map(displayPoint);
const top = displayPoint(geometry.top);
const rotateHandle = displayPoint(geometry.rotate);
context.save();
context.strokeStyle = "rgba(255,255,255,.96)";
context.fillStyle = "#8daa83";
context.lineWidth = 1.5;
context.beginPath();
context.moveTo(corners[0].x, corners[0].y);
for (const corner of corners.slice(1)) context.lineTo(corner.x, corner.y);
context.closePath();
context.stroke();
context.beginPath();
context.moveTo(top.x, top.y);
context.lineTo(rotateHandle.x, rotateHandle.y);
context.stroke();
for (const corner of corners) {
context.fillRect(corner.x - 5, corner.y - 5, 10, 10);
context.strokeRect(corner.x - 5, corner.y - 5, 10, 10);
}
context.beginPath();
context.arc(rotateHandle.x, rotateHandle.y, 6, 0, Math.PI * 2);
context.fill();
context.stroke();
context.restore();
};
const render = () => {
const pixelRatio = Math.max(1, window.devicePixelRatio || 1);
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, state.canvasWidth, state.canvasHeight);
context.drawImage(sourceImage, 0, 0, state.canvasWidth, state.canvasHeight);
drawSticker(context, state.sticker, state.canvasWidth / sourceWidth, state.canvasHeight / sourceHeight);
const allStrokes = state.activeStroke ? [...state.strokes, state.activeStroke] : state.strokes;
for (const stroke of allStrokes) {
drawStroke(context, stroke, state.canvasWidth / sourceWidth, state.canvasHeight / sourceHeight);
}
if (state.mode === "crop") drawCropOverlay();
if (state.mode === "sticker") drawStickerControls();
updateControls();
};
const resizeCanvas = () => {
const availableWidth = Math.max(240, (stage.clientWidth || window.innerWidth * .7) - 36);
const availableHeight = Math.max(220, (stage.clientHeight || window.innerHeight * .72) - 36);
const scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight, 1);
state.canvasWidth = Math.max(1, Math.round(sourceWidth * scale));
state.canvasHeight = Math.max(1, Math.round(sourceHeight * scale));
const pixelRatio = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(state.canvasWidth * pixelRatio));
canvas.height = Math.max(1, Math.round(state.canvasHeight * pixelRatio));
canvas.style.width = `${state.canvasWidth}px`;
canvas.style.height = `${state.canvasHeight}px`;
render();
};
const pushHistory = () => {
state.history.splice(state.historyIndex + 1);
state.history.push(cloneSnapshot(state));
if (state.history.length > 40) state.history.shift();
state.historyIndex = state.history.length - 1;
updateControls();
};
const restoreHistory = (index) => {
const snapshot = state.history[index];
if (!snapshot) return;
state.historyIndex = index;
state.crop = { ...snapshot.crop };
state.ratioKey = snapshot.ratioKey;
state.strokes = snapshot.strokes.map((stroke) => ({
...stroke,
points: stroke.points.map((point) => ({ ...point })),
}));
state.sticker = snapshot.sticker ? { ...snapshot.sticker } : null;
state.activeStroke = null;
render();
};
const hitCrop = (point) => {
const tolerance = 12 / Math.max(state.canvasWidth / sourceWidth, state.canvasHeight / sourceHeight);
const crop = state.crop;
if (state.ratioKey === "free") {
const corners = {
nw: [crop.x, crop.y], ne: [crop.x + crop.width, crop.y],
sw: [crop.x, crop.y + crop.height], se: [crop.x + crop.width, crop.y + crop.height],
};
for (const [handle, [x, y]] of Object.entries(corners)) {
if (Math.hypot(point.x - x, point.y - y) <= tolerance) return handle;
}
}
if (
point.x >= crop.x && point.x <= crop.x + crop.width
&& point.y >= crop.y && point.y <= crop.y + crop.height
) return "move";
return "create";
};
const updateFreeCrop = (action, point) => {
const origin = action.origin;
if (action.kind === "move") {
state.crop = constrainCropRect({
...action.crop,
x: action.crop.x + point.x - origin.x,
y: action.crop.y + point.y - origin.y,
}, sourceWidth, sourceHeight);
return;
}
const anchor = action.kind === "create" ? origin : {
x: action.kind.includes("w") ? action.crop.x + action.crop.width : action.crop.x,
y: action.kind.includes("n") ? action.crop.y + action.crop.height : action.crop.y,
};
state.crop = constrainCropRect({
x: Math.min(anchor.x, point.x),
y: Math.min(anchor.y, point.y),
width: Math.max(MIN_CROP_SIZE, Math.abs(point.x - anchor.x)),
height: Math.max(MIN_CROP_SIZE, Math.abs(point.y - anchor.y)),
}, sourceWidth, sourceHeight);
};
const moveFixedCrop = (action, point) => {
state.crop = constrainCropRect({
...action.crop,
x: action.crop.x + point.x - action.origin.x,
y: action.crop.y + point.y - action.origin.y,
}, sourceWidth, sourceHeight);
};
const resetStickerTransform = () => {
if (!state.sticker?.image) return;
const fitted = fitStickerTransform(
state.sticker.image.naturalWidth || state.sticker.image.width,
state.sticker.image.naturalHeight || state.sticker.image.height,
sourceWidth,
sourceHeight,
);
state.sticker = {
...state.sticker,
...fitted,
};
};
const stickerHit = (point) => {
const sourceScale = Math.max(state.canvasWidth / sourceWidth, state.canvasHeight / sourceHeight);
return hitTestSticker(state.sticker, point, 11 / sourceScale, 28 / sourceScale);
};
const updateStickerTransform = (action, point, event) => {
if (!state.sticker || !action?.sticker) return;
if (action.kind === "move") {
state.sticker.x = clamp(action.sticker.x + point.x - action.origin.x, 0, sourceWidth);
state.sticker.y = clamp(action.sticker.y + point.y - action.origin.y, 0, sourceHeight);
} else if (action.kind === "scale") {
const initialDistance = Math.max(1, Math.hypot(
action.origin.x - action.sticker.x,
action.origin.y - action.sticker.y,
));
const currentDistance = Math.max(1, Math.hypot(
point.x - action.sticker.x,
point.y - action.sticker.y,
));
const minimumScale = 16 / Math.max(1, Math.min(action.sticker.width, action.sticker.height));
const maximumScale = Math.max(sourceWidth, sourceHeight) * 4
/ Math.max(1, Math.max(action.sticker.width, action.sticker.height));
const scale = clamp(currentDistance / initialDistance, minimumScale, maximumScale);
state.sticker.width = action.sticker.width * scale;
state.sticker.height = action.sticker.height * scale;
} else if (action.kind === "rotate") {
let rotation = Math.atan2(point.y - action.sticker.y, point.x - action.sticker.x) - action.angleOffset;
if (event?.shiftKey) rotation = Math.round(rotation / (Math.PI / 12)) * (Math.PI / 12);
state.sticker.rotation = rotation;
}
};
stickerUpload.addEventListener("click", () => stickerInput.click());
stickerInput.addEventListener("change", async () => {
const file = stickerInput.files?.[0];
stickerInput.value = "";
if (!file) return;
if (!file.type?.startsWith("image/")) {
message.textContent = "请选择图片文件";
return;
}
const objectUrl = URL.createObjectURL(file);
try {
const image = await loadEditorImage(objectUrl);
stickerObjectUrls.add(objectUrl);
state.sticker = {
image,
name: file.name,
objectUrl,
...fitStickerTransform(
image.naturalWidth || image.width,
image.naturalHeight || image.height,
sourceWidth,
sourceHeight,
),
};
state.mode = "sticker";
message.textContent = "";
pushHistory();
render();
} catch (error) {
URL.revokeObjectURL(objectUrl);
message.textContent = error?.message || String(error);
}
});
opacityInput.addEventListener("input", () => {
if (!state.sticker) return;
state.sticker.opacity = Number(opacityInput.value) / 100;
render();
});
opacityInput.addEventListener("change", () => {
if (state.sticker) pushHistory();
});
removeSticker.addEventListener("click", () => {
if (!state.sticker) return;
state.sticker = null;
pushHistory();
render();
});
for (const [tool, toolButton] of toolButtons) {
toolButton.addEventListener("click", () => {
state.mode = tool;
canvas.style.cursor = tool === "crop" ? "move" : tool === "sticker" ? (state.sticker ? "grab" : "default") : "crosshair";
render();
});
}
for (const preset of REFERENCE_EDITOR_RATIOS) {
ratioButtons.get(preset.key).addEventListener("click", () => {
state.mode = "crop";
state.ratioKey = preset.key;
if (preset.key !== "free") state.crop = fitCropToRatio(sourceWidth, sourceHeight, preset.ratio);
pushHistory();
render();
});
}
widthInput.addEventListener("input", render);
colorInput.addEventListener("input", render);
undo.addEventListener("click", () => restoreHistory(state.historyIndex - 1));
redo.addEventListener("click", () => restoreHistory(state.historyIndex + 1));
clearMarks.addEventListener("click", () => {
if (!state.strokes.length) return;
state.strokes = [];
pushHistory();
render();
});
reset.addEventListener("click", () => {
if (state.mode === "crop") {
state.ratioKey = "original";
state.crop = fitCropToRatio(sourceWidth, sourceHeight, "original");
} else if (state.mode === "sticker") {
resetStickerTransform();
} else {
state.strokes = [];
state.activeStroke = null;
}
pushHistory();
render();
});
canvas.addEventListener("pointerdown", (event) => {
if (state.saving) return;
const point = sourcePoint(event);
canvas.setPointerCapture?.(event.pointerId);
if (state.mode === "crop") {
const kind = hitCrop(point);
state.pointerAction = { target: "crop", kind, origin: point, crop: { ...state.crop } };
if (kind === "create") {
state.ratioKey = "free";
updateFreeCrop(state.pointerAction, point);
}
} else if (state.mode === "sticker") {
const kind = stickerHit(point);
if (!kind) return;
state.pointerAction = {
target: "sticker",
kind,
origin: point,
sticker: { ...state.sticker },
angleOffset: Math.atan2(point.y - state.sticker.y, point.x - state.sticker.x)
- state.sticker.rotation,
};
canvas.style.cursor = kind === "move" ? "grabbing" : kind === "scale" ? "nwse-resize" : "crosshair";
} else {
state.activeStroke = {
tool: state.mode,
color: colorInput.value,
width: Number(widthInput.value),
points: [point],
};
}
event.preventDefault();
render();
});
canvas.addEventListener("pointermove", (event) => {
const point = sourcePoint(event);
if (state.activeStroke) {
const previous = state.activeStroke.points[state.activeStroke.points.length - 1];
if (state.activeStroke.tool === "arrow") {
if (state.activeStroke.points.length === 1) state.activeStroke.points.push(point);
else state.activeStroke.points[1] = point;
} else if (Math.hypot(point.x - previous.x, point.y - previous.y) >= 1) {
state.activeStroke.points.push(point);
}
render();
return;
}
if (!state.pointerAction) return;
if (state.pointerAction.target === "sticker") {
updateStickerTransform(state.pointerAction, point, event);
} else if (state.ratioKey === "free") updateFreeCrop(state.pointerAction, point);
else moveFixedCrop(state.pointerAction, point);
render();
});
canvas.addEventListener("pointermove", (event) => {
if (state.mode !== "sticker" || state.pointerAction || state.activeStroke) return;
const kind = stickerHit(sourcePoint(event));
canvas.style.cursor = kind === "move" ? "grab" : kind === "scale" ? "nwse-resize" : kind === "rotate" ? "crosshair" : "default";
});
const finishPointer = () => {
if (state.activeStroke) {
const points = state.activeStroke.points;
const validArrow = state.activeStroke.tool !== "arrow" || (
points.length > 1
&& Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y) >= 1
);
if (validArrow) state.strokes.push(state.activeStroke);
state.activeStroke = null;
if (validArrow) pushHistory();
} else if (state.pointerAction) pushHistory();
state.pointerAction = null;
render();
};
canvas.addEventListener("pointerup", finishPointer);
canvas.addEventListener("pointercancel", finishPointer);
const close = () => {
if (state.saving) return;
window.removeEventListener("resize", resizeCanvas);
for (const objectUrl of stickerObjectUrls) URL.revokeObjectURL(objectUrl);
stickerObjectUrls.clear();
overlay.remove();
previousFocus?.focus?.();
};
overlay._o1keyClose = close;
closeButton.addEventListener("click", close);
cancel.addEventListener("click", close);
overlay.addEventListener("mousedown", (event) => {
if (event.target === overlay) close();
});
overlay.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
close();
} else if (event.ctrlKey && event.key.toLowerCase() === "z") {
event.preventDefault();
restoreHistory(state.historyIndex + (event.shiftKey ? 1 : -1));
} else if (event.ctrlKey && event.key.toLowerCase() === "y") {
event.preventDefault();
restoreHistory(state.historyIndex + 1);
}
});
confirm.addEventListener("click", async () => {
if (state.saving) return;
state.saving = true;
confirm.disabled = true;
cancel.disabled = true;
confirm.textContent = "保存中…";
message.textContent = "";
try {
const crop = constrainCropRect(state.crop, sourceWidth, sourceHeight, 1);
const output = document.createElement("canvas");
output.width = Math.max(1, Math.round(crop.width));
output.height = Math.max(1, Math.round(crop.height));
const outputContext = output.getContext("2d");
outputContext.drawImage(
sourceImage,
crop.x, crop.y, crop.width, crop.height,
0, 0, output.width, output.height,
);
const scaleX = output.width / crop.width;
const scaleY = output.height / crop.height;
drawSticker(outputContext, state.sticker, scaleX, scaleY, crop.x, crop.y);
for (const stroke of state.strokes) drawStroke(outputContext, stroke, scaleX, scaleY, crop.x, crop.y);
const blob = await canvasBlob(output);
await onConfirm({
blob,
filename: editedReferenceFilename(filename),
width: output.width,
height: output.height,
});
state.saving = false;
close();
} catch (error) {
state.saving = false;
confirm.disabled = false;
cancel.disabled = false;
confirm.textContent = "应用编辑";
message.textContent = error?.message || String(error);
}
});
pushHistory();
window.addEventListener("resize", resizeCanvas);
requestAnimationFrame(resizeCanvas);
overlay.tabIndex = -1;
overlay.focus();
return overlay;
}