import { CROP_PRESETS, cropAt } from "./crop_geometry.js?v=0.6.2"; import { T } from "./i18n.js"; import { syncCropDimensions, syncCurrentDimensions } from "./parameter_bridge.js?v=0.8.2"; import { installResultAdoption } from "./result_adoption.js?v=stability1"; const fields = ["mask", "baseMask", "history", "future", "lastMask", "dirty"]; const canvas = (w, h) => Object.assign(document.createElement("canvas"), { width: w, height: h }); const load = async (url) => { const image = new Image(); image.src = url; await image.decode(); return image; }; const make = (tag, cls) => { const e = document.createElement(tag); e.className = cls; return e; }; export class InlineCropController { constructor( node, editor, { find, hide, changed, imageUrl, beforeModeChange }, ) { Object.assign(this, { node, editor, changed, imageUrl, beforeModeChange }); this.widgets = Object.fromEntries( ["edit_mode", "crop_state", "blend_data", "layer_image"].map((name) => [ name, find(name), ]), ); Object.values(this.widgets).forEach(hide); this.contexts = {}; this.target = "mask"; this.layer = null; this.revision = 0; this.acceptVersion = 0; this.cropWidth = 1024; this.cropHeight = 1536; this.patchCanvas = canvas(1, 1); this.build(); installResultAdoption(node, this); // Only user uploads go through editor.upload; internal layer merges and // adopting an already-sized result call onUpload directly instead. const upload = editor.upload.bind(editor); editor.upload = async file => { const previous = editor.image; await upload(file); if (!editor.destroyed && editor.ready && editor.image !== previous && this.mode === "mask") { syncCurrentDimensions(node); } }; const originalFlush = editor.flush.bind(editor); editor.flush = () => { if (this.target !== "blend") return originalFlush(); if (!editor.ready || !this.region?.locked) return this.widgets.blend_data.value; if (!editor.dirty && !editor.stroke) return this.widgets.blend_data.value; const r = this.region, out = canvas(r.width, r.height), ctx = out.getContext("2d"); ctx.fillStyle = "black"; ctx.fillRect(0, 0, r.width, r.height); ctx.drawImage( editor.mask, r.x, r.y, r.width, r.height, 0, 0, r.width, r.height, ); this.widgets.blend_data.value = out.toDataURL("image/png"); out.width = out.height = 0; editor.dirty = false; return this.widgets.blend_data.value; }; editor.options.sceneRevision = () => `${this.mode}:${this.target}:${this.revision}`; editor.options.drawSource = (ctx) => this.drawSource(ctx, {preview:this.previewOriginal}); editor.options.displayView = () => this.previewOriginal ? "image" : editor.view; editor.options.showCursor = () => !this.previewOriginal; editor.options.showMask = () => this.mode !== "crop" || (this.target === "mask" && this.region?.locked); editor.options.drawOverlay = (ctx, d) => this.drawOverlay(ctx, d); editor.options.paintBounds = () => this.mode === "crop" && this.region?.locked ? this.region : undefined; const down = editor.pointerDown.bind(editor), move = editor.pointerMove.bind(editor); editor.pointerDown = (e) => { if (!editor.ready) return; if (this.previewOriginal) { e.preventDefault(); e.stopPropagation(); return; } if (this.busy) { e.preventDefault(); e.stopPropagation(); return; } if (this.mode === "crop" && e.button === 2) { e.preventDefault(); e.stopPropagation(); editor.root.focus({ preventScroll: true }); // Hold-right preview only makes sense while adjusting the blend layer. // After adoption the layer is already baked, so right-click reselects // (reselect flattens any remaining layer first). if (this.target === "blend" && this.layer && this.region.locked && !editor.stroke && editor.pointerId == null) { this.previewOriginal = true; this.originalPreviewPointer = e.pointerId; editor.canvas.setPointerCapture(e.pointerId); editor.cursor = null; this.revision++; editor.schedule(); } else if (this.region.locked) this.run(() => this.reselect()); return; } if ( this.mode === "crop" && !this.region?.locked && e.button === 0 && !editor.space ) { e.preventDefault(); e.stopPropagation(); editor.root.focus({ preventScroll: true }); const p = editor.point(e), r = cropAt( editor.image.width, editor.image.height, this.cropWidth, this.cropHeight, p.x, p.y, ); if (r) this.lock(r); return; } down(e); }; editor.pointerMove = (e) => { if (this.previewOriginal) { editor.cursor = null; return; } if ( this.mode === "crop" && editor.ready && !this.region?.locked && !editor.drag ) { const p = editor.point(e); this.hover = cropAt( editor.image.width, editor.image.height, this.cropWidth, this.cropHeight, p.x, p.y, ); } move(e); }; this.finishPreview = event => { if (event.pointerId !== this.originalPreviewPointer) return; if (event.type === "pointerup" && (event.buttons & 2)) return; event.preventDefault(); event.stopPropagation(); this.stopOriginalPreview(); }; for (const name of ["pointerup", "pointercancel"]) { window.addEventListener(name, this.finishPreview, true); } editor.canvas.addEventListener("lostpointercapture", this.finishPreview, true); this.previewBlur = () => this.stopOriginalPreview(); window.addEventListener("blur", this.previewBlur); editor.canvas.addEventListener("pointerleave", () => { this.hover = null; editor.schedule(); }); editor.root.addEventListener("keydown", (e) => { if (e.key === "Escape" && this.mode === "crop" && !editor.stroke) { e.preventDefault(); e.stopPropagation(); this.reselect(); } }); } get mode() { return this.widgets.edit_mode.value || "mask"; } stopOriginalPreview() { if (!this.previewOriginal) return; const pointer = this.originalPreviewPointer; this.previewOriginal = false; this.originalPreviewPointer = null; if (pointer != null && this.editor.canvas.hasPointerCapture(pointer)) { this.editor.canvas.releasePointerCapture(pointer); } this.revision++; this.editor.schedule(); } get region() { try { return JSON.parse(this.widgets.crop_state.value || "{}"); } catch { return {}; } } state(value) { this.widgets.crop_state.value = JSON.stringify(value); this.revision++; } snapshot() { return Object.fromEntries(fields.map((k) => [k, this.editor[k]])); } activate(target) { if (this.target === target) return; this.editor.flush(); this.contexts[this.target] = this.snapshot(); const next = this.contexts[target]; if (!next) return; Object.assign(this.editor, next); this.target = target; this.editor.maskRevision++; this.editor.updateButtons(); this.editor.schedule(); this.sync(); } build() { const e = this.editor; this.modeSelect = make("select", "dd-mode-select"); this.modeSelect.setAttribute("aria-label", T("Editing mode")); this.modeSelect.title = T("Mask: paint the full image. Crop & Blend: select a region, inpaint it, then adopt and blend the result."); for (const [value, label] of [ ["mask", "Mask"], ["crop", "Crop & Blend"], ]) { const option = document.createElement("option"); option.value = value; option.textContent = T(label); this.modeSelect.append(option); } this.modeSelect.onchange = () => this.setMode(this.modeSelect.value); e.root.append(this.modeSelect); this.bar = make("div", "dd-crop-bar"); this.preset = make("select", ""); this.preset.setAttribute("aria-label", T("Crop size")); this.preset.title = T("Crop resolution presets. Changing size releases the current selection; left-click the image to place a new region. Inpaint dimensions follow when selected."); for (const [w, h] of CROP_PRESETS) { const o = document.createElement("option"); o.value = `${w}x${h}`; o.textContent = `${w} × ${h}`; this.preset.append(o); } const custom = document.createElement("option"); custom.value = "custom"; custom.textContent = T("Custom"); this.preset.append(custom); this.preset.onchange = () => this.run(() => { const custom = this.preset.value === "custom"; const [width, height] = custom ? [this.cropWidth, this.cropHeight] : this.preset.value.split("x").map(Number); return this.changeSize(width, height, {custom}); }); this.bar.append(this.preset); const button = (text, title, action) => e.button(this.bar, text, T(title), () => this.run(action)); this.swapButton = button("⇄", "Swap width and height, release the current selection, then left-click to place the new region.", () => this.changeSize(this.cropHeight, this.cropWidth), ); // Shown in both modes: one click clears the mask of the active target. // It sits right after the swap control in Crop & Blend, and is the only // visible button in Mask mode. this.clearButton = button("🗑", "Clear the mask", () => e.action({ type: "clear" })); this.swapButton.after(this.clearButton); this.maskButton = button("◌", "Paint inside the selected region. Right-click releases the selection; left-click places a new one. Alt: erase. Ctrl+wheel: brush size; Shift+wheel: hardness.", () => { this.activate("mask"); this.changed(); }); this.blendButton = button( "◐", "Edit layer blending: brush keeps the new result; Alt restores original pixels. Hold right-click to preview the original, release to return.", () => this.run(async () => { // Blending is opt-in after adoption: build the blend mask on first // use, then switch the editor into the blend layer. if (!this.contexts.blend) await this.makeBlend(this.widgets.blend_data.value); this.activate("blend"); this.changed(); }), ); // Move the original live button, retaining its history and disabled state. this.undoButton = e.undoButton; this.undoButton.textContent = "↶"; this.undoButton.title = T("Undo the last stroke in the active repaint or blend mask (Ctrl+Z). Redo with Ctrl+Shift+Z."); this.custom = make("div", "dd-crop-custom"); this.custom.hidden = true; for (const key of ["cropWidth", "cropHeight"]) { const input = make("input", ""); input.type = "number"; input.min = 64; input.max = 16384; input.step = 64; input.value = this[key]; input.setAttribute( "aria-label", T(key === "cropWidth" ? "Width" : "Height"), ); input.title = T("Custom crop dimension in 64-pixel steps. Changing it releases the selected region."); input.onchange = () => this.run(() => { const value = Math.max(64, Math.min(16384, Math.round(Number(input.value) / 64) * 64)); return this.changeSize(key === "cropWidth" ? value : this.cropWidth, key === "cropHeight" ? value : this.cropHeight, {custom:true}); }); this.custom.append(input); } this.bar.append(this.custom); e.root.append(this.bar); this.sync(); } async run(action) { if ( this.busy || this.editor.stroke ) return; try { await action(); } catch (error) { this.editor.status(error.message, true); } } sync() { this.editor.root.classList.toggle("dd-crop-mode", this.mode === "crop"); this.modeSelect.value = this.mode; // No hover text on the painting canvas; the bottom controls carry the hints. this.editor.canvas.removeAttribute("title"); for (const control of [this.preset, this.swapButton, this.maskButton, this.blendButton]) { control.hidden = this.mode !== "crop"; } this.custom.hidden = this.mode !== "crop" || this.preset.value !== "custom"; this.undoButton.disabled = !!this.busy || !this.editor.history.length; this.clearButton.disabled = !!this.busy || !this.editor.ready; this.blendButton.disabled = !this.layer; this.maskButton.classList.toggle("dd-active", this.target === "mask"); this.blendButton.classList.toggle("dd-active", this.target === "blend"); this.modeSelect.disabled = !!this.busy; this.preset.disabled = !!this.busy; this.swapButton.disabled = !!this.busy; for (const input of this.custom.children) input.disabled = !!this.busy; } setMode(mode) { if ( this.editor.stroke || this.busy ) { this.sync(); return; } if (mode === this.mode) { this.sync(); return; } const apply = () => { this.stopOriginalPreview(); this.activate("mask"); this.widgets.edit_mode.value = mode; this.state({ ...this.region, job: crypto.randomUUID() }); this.editor.fit(); this.sync(); if (mode === "mask") syncCurrentDimensions(this.node); this.changed(); }; const recovery = this.beforeModeChange?.(); if (!recovery?.then) { if (recovery !== false) apply(); return; } this.busy = true; this.sync(); return recovery .then((ok) => { this.busy = false; if (ok !== false && !this.editor.destroyed) apply(); else this.sync(); }) .catch((error) => { this.busy = false; this.sync(); this.editor.status(error.message, true); }); } syncPreset() { const value = `${this.cropWidth}x${this.cropHeight}`; this.preset.value = [...this.preset.options].some((o) => o.value === value) ? value : "custom"; this.custom.hidden = this.preset.value !== "custom"; [...this.custom.children].forEach( (e, i) => (e.value = i ? this.cropHeight : this.cropWidth), ); } sizeChanged() { this.hover = null; this.editor.schedule(); this.syncPreset(); } async changeSize(width, height, {custom = false} = {}) { if (this.busy || this.editor.stroke) { this.syncPreset(); return; } try { // Use the same merge-before-reselect behavior as the reselect button. if (this.region.locked) await this.reselect(); this.cropWidth = width; this.cropHeight = height; this.sizeChanged(); if (custom) { this.preset.value = "custom"; this.custom.hidden = false; } this.sync(); this.changed(); } catch (error) { this.syncPreset(); throw error; } } reset() { this.stopOriginalPreview(); this.layer = null; this.widgets.layer_image.value = ""; this.widgets.blend_data.value = ""; this.contexts = {}; this.target = "mask"; this.hover = null; this.state({ locked: false, job: crypto.randomUUID() }); const { width, height } = this.editor.image; for (const o of this.preset.options) if (o.value !== "custom") { const [w, h] = o.value.split("x").map(Number); o.disabled = w > width || h > height; } if (this.cropWidth > width || this.cropHeight > height) { this.cropWidth = Math.min(1024, Math.floor(width / 64) * 64); this.cropHeight = Math.min(1536, Math.floor(height / 64) * 64); } this.syncPreset(); this.sync(); this.editor.schedule(); } lock(region) { this.activate("mask"); this.contexts.blend = null; this.layer = null; this.widgets.layer_image.value = ""; this.widgets.blend_data.value = ""; this.state({ ...region, locked: true, job: crypto.randomUUID() }); const e = this.editor; // A new crop only limits the painting area; the repaint mask starts empty. for (const c of [e.mask, e.baseMask]) c.getContext("2d").clearRect(0, 0, c.width, c.height); e.history = []; e.future = []; e.changed(); syncCropDimensions(this.node, region.width, region.height); this.sync(); this.changed(); } makeBlend(data = "") { const r = this.region, e = this.editor, mask = canvas(e.image.width, e.image.height), baseMask = canvas(e.image.width, e.image.height); const ctx = mask.getContext("2d"); ctx.fillStyle = "white"; ctx.fillRect(r.x, r.y, r.width, r.height); baseMask.getContext("2d").drawImage(mask, 0, 0); this.contexts.blend = { mask, baseMask, history: [], future: [], lastMask: "", dirty: false, }; if (!data) return Promise.resolve(); return load(data).then((image) => { const out = canvas(r.width, r.height), c = out.getContext("2d"); c.drawImage(image, 0, 0); const pixels = c.getImageData(0, 0, r.width, r.height); for (let i = 0; i < pixels.data.length; i += 4) { pixels.data[i + 3] = pixels.data[i]; pixels.data[i] = pixels.data[i + 1] = pixels.data[i + 2] = 255; } ctx.clearRect(0, 0, mask.width, mask.height); ctx.putImageData(pixels, r.x, r.y); const base = baseMask.getContext("2d"); base.clearRect(0, 0, baseMask.width, baseMask.height); base.drawImage(mask, 0, 0); out.width = out.height = 0; }); } async restore() { this.stopOriginalPreview(); this.target = "mask"; this.contexts = {}; this.layer = null; const r = this.region; if (r.locked) { this.cropWidth = r.width; this.cropHeight = r.height; this.syncPreset(); if (this.widgets.layer_image.value) { this.layer = await load(this.imageUrl(this.widgets.layer_image.value)); await this.makeBlend(this.widgets.blend_data.value); // Restore the state the user left: a non-empty blend mask means they were // still adjusting the blend layer, otherwise the adopted layer is already // baked and we stay on the repaint mask. if (this.widgets.blend_data.value && this.mode === "crop") this.activate("blend"); } } this.revision++; this.sync(); this.editor.schedule(); } async accept(record, {allowBusy = false, image = null} = {}) { if ( this.editor.destroyed || this.mode !== "crop" || !this.region.locked || record.job !== this.region.job || (this.busy && !allowBusy) ) return; if (this.editor.stroke) { this.pendingResult = record; clearTimeout(this.resultTimer); this.resultTimer = setTimeout(() => { const next = this.pendingResult; this.pendingResult = null; this.accept(next, {allowBusy}).catch((error) => this.editor.status(error.message, true), ); }, 50); return; } const name = record.image.subfolder ? `${record.image.subfolder}/${record.image.filename}` : record.image.filename; if (name === this.widgets.layer_image.value) return; const version = ++this.acceptVersion; // The adoption path already decoded this image once; reuse it instead of // downloading and decoding the full-size result a second time. const decoded = image || await load(this.imageUrl(name)); if ( this.editor.destroyed || version !== this.acceptVersion || record.job !== this.region.job || this.mode !== "crop" ) return; this.layer = decoded; this.widgets.layer_image.value = name; // Adopt then return: with an empty blend mask the backend hard-composites // the whole crop from layer_image, so the result is immediately baked into // the source and the repaint mask is reused on the next generation. Blending // is no longer entered automatically; the ◐ button opens it on demand. this.activate("mask"); this.revision++; this.sync(); this.editor.schedule(); this.changed(); } drawSource(ctx, {preview = false} = {}) { if (this.mode !== "crop") return false; const e = this.editor, r = this.region; ctx.drawImage(e.image, 0, 0); if (!preview && this.layer && r.locked) { const key = `${this.revision}:${e.maskRevision}`; if (this.patchKey !== key) { this.patchCanvas.width = r.width; this.patchCanvas.height = r.height; const c = this.patchCanvas.getContext("2d"); c.drawImage(this.layer, 0, 0); const mask = this.target === "blend" ? e.mask : this.contexts.blend?.mask; if (mask) { c.globalCompositeOperation = "destination-in"; c.drawImage( mask, r.x, r.y, r.width, r.height, 0, 0, r.width, r.height, ); c.globalCompositeOperation = "source-over"; } this.patchKey = key; } ctx.drawImage(this.patchCanvas, r.x, r.y); } return true; } drawOverlay(ctx, d) { if (this.mode !== "crop") return; const r = this.region.locked ? this.region : this.hover; if (!r) return; ctx.save(); if (this.region.locked) { ctx.fillStyle = "#0008"; ctx.beginPath(); ctx.rect(0, 0, this.editor.image.width, this.editor.image.height); ctx.rect(r.x, r.y, r.width, r.height); ctx.fill("evenodd"); } ctx.strokeStyle = "#000000"; ctx.lineWidth = 1 / d.scale; ctx.setLineDash([6 / d.scale, 4 / d.scale]); ctx.strokeRect(r.x, r.y, r.width, r.height); ctx.restore(); } async flatten() { if (!this.layer) return; this.stopOriginalPreview(); this.editor.flush(); this.busy = true; this.sync(); try { const e = this.editor, c = canvas(e.image.width, e.image.height); this.drawSource(c.getContext("2d")); const blob = await new Promise((resolve) => c.toBlob(resolve, "image/png"), ); c.width = c.height = 0; this.activate("mask"); await e.options.onUpload( new File([blob], `merged-${crypto.randomUUID()}.png`, { type: "image/png", }), { preserveMask: true, preserveCrop: true }, ); this.layer = null; this.widgets.layer_image.value = ""; this.widgets.blend_data.value = ""; for(const key of ['mask','baseMask']){const raster=this.contexts.blend?.[key];if(raster && raster!==e.mask && raster!==e.baseMask)raster.width=raster.height=0;} this.contexts.blend = null; this.state({ ...this.region, job: crypto.randomUUID() }); } finally { this.busy = false; this.sync(); this.editor.schedule(); this.changed(); } } async reselect() { if ( this.busy || this.editor.stroke ) return; await this.flatten(); this.activate("mask"); this.state({ locked: false, job: crypto.randomUUID() }); this.hover = null; this.sync(); this.editor.schedule(); this.changed(); } destroy() { this.adoptController?.abort(); this.stopOriginalPreview(); window.removeEventListener("blur", this.previewBlur); for (const name of ["pointerup", "pointercancel"]) { window.removeEventListener(name, this.finishPreview, true); } this.editor.canvas.removeEventListener("lostpointercapture", this.finishPreview, true); clearTimeout(this.resultTimer); this.pendingResult = null; this.acceptVersion++; for (const context of Object.values(this.contexts)) for (const key of ["mask", "baseMask"]) if (context?.[key] !== this.editor[key] && context?.[key]) context[key].width = context[key].height = 0; this.patchCanvas.width = this.patchCanvas.height = 0; } }