Files

691 lines
24 KiB
JavaScript
Raw Permalink 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.
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;
}
}