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; }