openOutpaint/js/ui/tool/stamp.js

691 lines
19 KiB
JavaScript
Raw Normal View History

/**
* Generic wheel handler
*/
let _stamp_wheel_accum = 0;
const _stamp_onwheel = (evn, state) => {
if (evn.mode !== WheelEvent.DOM_DELTA_PIXEL) {
// We don't really handle non-pixel scrolling
return;
}
let delta = evn.delta;
if (evn.evn.shiftKey) delta *= 0.01;
// A simple but (I hope) effective fix for mouse wheel behavior
_stamp_wheel_accum += delta;
if (
!evn.evn.shiftKey &&
Math.abs(_stamp_wheel_accum) > config.wheelTickSize
) {
// Snap to next or previous position
const v =
state.scale - 0.1 * (_stamp_wheel_accum / Math.abs(_stamp_wheel_accum));
state.setScale(v + snap(v, 0, 0.1));
state.redraw(evn);
_stamp_wheel_accum = 0; // Zero accumulation
} else if (evn.evn.shiftKey && Math.abs(_stamp_wheel_accum) >= 1) {
const v = state.scale - _stamp_wheel_accum * 0.01;
state.setScale(v);
state.redraw(evn);
_stamp_wheel_accum = 0; // Zero accumulation
}
};
const stampTool = () =>
toolbar.registerTool(
2022-12-18 17:50:40 -06:00
"./res/icons/file-up.svg",
"Stamp Image",
(state, opt) => {
state.loaded = true;
// Draw new cursor immediately
ovLayer.clear();
state.movecb({...mouse.coords.world.pos});
// Start Listeners
mouse.listen.world.onmousemove.on(state.movecb);
mouse.listen.world.btn.left.onclick.on(state.drawcb);
mouse.listen.world.btn.right.onclick.on(state.cancelcb);
mouse.listen.world.btn.left.ondragstart.on(state.dragstartcb);
mouse.listen.world.btn.left.ondrag.on(state.dragcb);
mouse.listen.world.btn.left.ondragend.on(state.dragendcb);
mouse.listen.world.onwheel.on(state.onwheelcb);
keyboard.onShortcut({key: "Minus"}, state.toggleflip);
keyboard.onShortcut({key: "Equal"}, state.togglemirror);
// For calls from other tools to paste image
if (opt && opt.image) {
state.addResource(
opt.name || "Clipboard",
opt.image,
opt.temporary === undefined ? true : opt.temporary,
false
);
state.ctxmenu.uploadButton.disabled = true;
state.back = opt.back || null;
toolbar.lock();
} else if (opt) {
throw Error(
"Pasting from other tools must be in format {image, name?, temporary?, back?}"
);
} else {
state.ctxmenu.uploadButton.disabled = "";
}
},
(state, opt) => {
state.loaded = false;
// Clear Listeners
mouse.listen.world.onmousemove.clear(state.movecb);
mouse.listen.world.btn.left.onclick.clear(state.drawcb);
mouse.listen.world.btn.right.onclick.clear(state.cancelcb);
mouse.listen.world.btn.left.ondragstart.clear(state.dragstartcb);
mouse.listen.world.btn.left.ondrag.clear(state.dragcb);
mouse.listen.world.btn.left.ondragend.clear(state.dragendcb);
mouse.listen.world.onwheel.clear(state.onwheelcb);
keyboard.deleteShortcut(state.togglemirror, "Equal");
keyboard.deleteShortcut(state.toggleflip, "Minus");
ovLayer.clear();
},
{
init: (state) => {
state.loaded = false;
state.snapToGrid = true;
state.resources = [];
state.selected = null;
state.back = null;
state.lastMouseMove = {x: 0, y: 0};
state.block_res_change = true;
// Current Rotation
let rotation = 0;
let rotating = null;
// Current Scale
state.scale = 1;
state.togglemirror = () => {
state.mirrorSetValue(!state.mirrorStamp);
state.redraw();
};
state.toggleflip = () => {
state.flipSetValue(!state.flipStamp);
state.redraw();
};
state.selectResource = (resource, nolock = true, deselect = true) => {
rotation = 0;
state.setScale(1);
if (nolock && state.ctxmenu.uploadButton.disabled) return;
state.mirrorSetValue(false);
state.flipSetValue(false);
console.debug(
`[stamp] Selecting Resource '${resource && resource.name}'[${
resource && resource.id
}]`
);
const resourceWrapper = resource && resource.dom.wrapper;
const wasSelected =
resourceWrapper && resourceWrapper.classList.contains("active");
Array.from(state.ctxmenu.resourceList.children).forEach((child) => {
child.classList.remove("active");
});
// Select
if (!wasSelected) {
resourceWrapper && resourceWrapper.classList.add("active");
state.selected = resource;
}
// If already selected, clear selection (if deselection is enabled)
2022-12-23 19:19:11 -06:00
else if (deselect) {
resourceWrapper.classList.remove("active");
state.selected = null;
}
ovLayer.clear();
if (state.loaded) state.redraw();
};
// Open IndexedDB connection
// Synchronizes resources array with the DOM and Local Storage
const syncResources = () => {
// Saves to IndexedDB
const resources = db
.transaction("resources", "readwrite")
.objectStore("resources");
try {
const FetchKeysQuery = resources.getAllKeys();
FetchKeysQuery.onsuccess = () => {
const keys = FetchKeysQuery.result;
keys.forEach((key) => {
if (!state.resources.find((resource) => resource.id === key))
resources.delete(key);
});
};
state.resources
.filter((resource) => !resource.temporary && resource.dirty)
.forEach((resource) => {
const canvas = document.createElement("canvas");
canvas.width = resource.image.width;
canvas.height = resource.image.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(resource.image, 0, 0);
resources.put({
id: resource.id,
name: resource.name,
src: canvas.toDataURL(),
});
resource.dirty = false;
});
} catch (e) {
console.warn(
"[stamp] Failed to synchronize resources with IndexedDB"
);
console.warn(e);
}
// Creates DOM elements when needed
state.resources.forEach((resource) => {
if (
!state.ctxmenu.resourceList.querySelector(
`#resource-${resource.id}`
)
) {
console.debug(
`[stamp] Creating Resource Element [resource-${resource.id}]`
);
const resourceWrapper = document.createElement("div");
resourceWrapper.id = `resource-${resource.id}`;
resourceWrapper.title = resource.name;
resourceWrapper.classList.add("resource", "list-item");
const resourceTitle = document.createElement("input");
resourceTitle.value = resource.name;
resourceTitle.style.pointerEvents = "none";
resourceTitle.addEventListener("change", () => {
resource.name = resourceTitle.value;
resource.dirty = true;
resourceWrapper.title = resourceTitle.value;
syncResources();
});
resourceTitle.addEventListener("keyup", function (event) {
if (event.key === "Enter") {
resourceTitle.blur();
}
});
resourceTitle.addEventListener("blur", () => {
resourceTitle.style.pointerEvents = "none";
});
resourceTitle.classList.add("resource-title", "title");
resourceWrapper.appendChild(resourceTitle);
resourceWrapper.addEventListener("click", () =>
state.selectResource(resource)
);
resourceWrapper.addEventListener("dblclick", () => {
resourceTitle.style.pointerEvents = "auto";
resourceTitle.focus();
resourceTitle.select();
});
resourceWrapper.addEventListener("mouseover", () => {
state.ctxmenu.previewPane.style.display = "block";
state.ctxmenu.previewPane.style.backgroundImage = `url(${resource.image.src})`;
});
resourceWrapper.addEventListener("mouseleave", () => {
state.ctxmenu.previewPane.style.display = "none";
});
// Add action buttons
const actionArray = document.createElement("div");
actionArray.classList.add("actions");
const saveButton = document.createElement("button");
saveButton.addEventListener(
"click",
(evn) => {
evn.stopPropagation();
const canvas = document.createElement("canvas");
canvas.width = resource.image.width;
canvas.height = resource.image.height;
canvas.getContext("2d").drawImage(resource.image, 0, 0);
downloadCanvas({
canvas,
filename: `openOutpaint - resource '${resource.name}'.png`,
});
},
{passive: false}
);
saveButton.title = "Download Resource";
saveButton.appendChild(document.createElement("div"));
saveButton.classList.add("download-btn");
const trashButton = document.createElement("button");
trashButton.addEventListener(
"click",
(evn) => {
evn.stopPropagation();
state.ctxmenu.previewPane.style.display = "none";
state.deleteResource(resource.id);
},
{passive: false}
);
trashButton.title = "Delete Resource";
trashButton.appendChild(document.createElement("div"));
trashButton.classList.add("delete-btn");
actionArray.appendChild(saveButton);
actionArray.appendChild(trashButton);
resourceWrapper.appendChild(actionArray);
state.ctxmenu.resourceList.appendChild(resourceWrapper);
resource.dom = {wrapper: resourceWrapper};
}
});
// Removes DOM elements when needed
const elements = Array.from(state.ctxmenu.resourceList.children);
if (elements.length > state.resources.length)
elements.forEach((element) => {
let remove = true;
state.resources.some((resource) => {
if (element.id.endsWith(resource.id)) {
remove = false;
}
});
if (remove) {
console.debug(`[stamp] Sync Removing Element [${element.id}]`);
state.ctxmenu.resourceList.removeChild(element);
}
});
};
// Adds a image resource (temporary allows only one draw, used for pasting)
state.addResource = (name, image, temporary = false, nolock = true) => {
const id = guid();
const resource = {
id,
name,
image,
dirty: true,
temporary,
};
console.info(`[stamp] Adding Resource '${name}'[${id}]`);
state.resources.push(resource);
syncResources();
// Select this resource
state.selectResource(resource, nolock, false);
return resource;
};
// Used for temporary images too
state.deleteResource = (id) => {
const resourceIndex = state.resources.findIndex((v) => v.id === id);
const resource = state.resources[resourceIndex];
if (state.selected === resource) state.selected = null;
console.info(
`[stamp] Deleting Resource '${resource.name}'[${resource.id}]`
);
state.resources.splice(resourceIndex, 1);
syncResources();
};
state.onwheelcb = (evn) => {
_stamp_onwheel(evn, state);
};
state.dragstartcb = (evn) => {
const {x, y, sx, sy} = _tool._process_cursor(evn, state.snapToGrid);
rotating = {x: sx, y: sy};
};
state.dragcb = (evn) => {
if (rotating) {
rotation = Math.atan2(rotating.x - evn.x, evn.y - rotating.y);
if (evn.evn.shiftKey)
rotation =
config.rotationSnappingAngles.find(
(v) =>
Math.abs(v - rotation) < config.rotationSnappingDistance
) ?? rotation;
}
};
state.dragendcb = (evn) => {
rotating = null;
};
let erasePrevCursor = () => null;
state.movecb = (evn) => {
const {x, y, sx, sy} = _tool._process_cursor(evn, state.snapToGrid);
// Erase Previous Cursors
erasePrevCursor();
state.lastMouseMove = evn;
ovLayer.clear();
let px = sx;
let py = sy;
if (rotating) {
px = rotating.x;
py = rotating.y;
}
// Draw selected image
if (state.selected) {
ovCtx.save();
ovCtx.translate(px, py);
ovCtx.scale(
state.scale * (state.mirrorStamp ? -1 : 1),
state.scale * (state.flipStamp ? -1 : 1) //flip vertical?????
);
ovCtx.rotate(rotation * (state.mirrorStamp ? -1 : 1));
ovCtx.drawImage(state.selected.image, 0, 0);
ovCtx.restore();
}
// Draw current cursor location
erasePrevCursor = _tool._cursor_draw(px, py);
};
state.redraw = () => {
state.movecb(state.lastMouseMove);
};
state.drawcb = (evn) => {
const {x, y, sx, sy} = _tool._process_cursor(evn, state.snapToGrid);
const resource = state.selected;
if (resource) {
if (
localStorage.getItem("openoutpaint/settings.autolayer") == "true"
) {
commands.runCommand("addLayer", "Added Layer", {});
}
const {canvas, bb} = cropCanvas(ovCanvas, {border: 10});
let commandLog = "";
const addline = (v, newline = true) => {
commandLog += v;
if (newline) commandLog += "\n";
};
addline(
`Stamped image '${resource.name}' to x: ${bb.x} and y: ${bb.y}`
);
addline(` - scaling : ${state.scale}`);
addline(
` - rotation: ${
Math.round(1000 * ((180 * rotation) / Math.PI)) / 1000
} degrees`,
false
);
commands.runCommand(
"drawImage",
"Image Stamp",
{
image: canvas,
x: bb.x,
y: bb.y,
},
{
extra: {
log: commandLog,
},
}
);
if (resource.temporary) {
state.deleteResource(resource.id);
}
}
if (state.back) {
toolbar.unlock();
const backfn = state.back;
state.back = null;
backfn({message: "Returning from stamp", pasted: true});
}
};
state.cancelcb = (evn) => {
state.selectResource(null);
if (state.back) {
toolbar.unlock();
const backfn = state.back;
state.back = null;
backfn({message: "Returning from stamp", pasted: false});
}
};
/**
* Creates context menu
*/
if (!state.ctxmenu) {
state.ctxmenu = {};
// Snap To Grid Checkbox
const array = document.createElement("div");
array.classList.add("checkbox-array");
array.appendChild(
_toolbar_input.checkbox(
state,
"openoutpaint/stamp-snaptogrid",
"snapToGrid",
"Snap To Grid",
"icon-grid"
).checkbox
);
// Flip Stamp Checkbox
const {checkbox: flipCheckbox, setValue: flipSetValue} =
_toolbar_input.checkbox(
state,
"openoutpaint/stamp-flip",
"flipStamp",
"Flip Stamp",
"icon-flip-vertical"
);
array.appendChild(flipCheckbox);
state.flipSetValue = flipSetValue;
// Mirror Stamp Checkbox
const {checkbox: mirrorCheckbox, setValue: mirrorSetValue} =
_toolbar_input.checkbox(
state,
"openoutpaint/stamp-mirror",
"mirrorStamp",
"Mirror Stamp",
"icon-flip-horizontal"
);
array.appendChild(mirrorCheckbox);
state.mirrorSetValue = mirrorSetValue;
state.ctxmenu.buttonArray = array;
// Scale Slider
const scaleSlider = _toolbar_input.slider(
state,
null,
"scale",
"Scale",
{
min: 0.01,
max: 10,
step: 0.1,
textStep: 0.001,
}
);
state.ctxmenu.scaleSlider = scaleSlider.slider;
state.setScale = scaleSlider.setValue;
// Create resource list
const uploadButtonId = `upload-btn-${guid()}`;
const resourceManager = document.createElement("div");
resourceManager.classList.add("resource-manager");
const resourceList = document.createElement("div");
resourceList.classList.add("list");
const previewPane = document.createElement("div");
previewPane.classList.add("preview-pane");
const uploadLabel = document.createElement("label");
uploadLabel.classList.add("upload-button");
uploadLabel.classList.add("button");
uploadLabel.classList.add("tool");
uploadLabel.textContent = "Upload Image";
uploadLabel.htmlFor = uploadButtonId;
const uploadButton = document.createElement("input");
uploadButton.id = uploadButtonId;
uploadButton.type = "file";
uploadButton.accept = "image/*";
uploadButton.multiple = true;
uploadButton.style.display = "none";
uploadButton.addEventListener("change", (evn) => {
[...uploadButton.files].forEach((file) => {
if (file.type.startsWith("image/")) {
console.info("Uploading Image " + file.name);
const url = window.URL || window.webkitURL;
const image = document.createElement("img");
image.src = url.createObjectURL(file);
image.onload = () => state.addResource(file.name, image, false);
}
});
uploadButton.value = null;
});
uploadLabel.appendChild(uploadButton);
resourceManager.appendChild(resourceList);
resourceManager.appendChild(uploadLabel);
resourceManager.appendChild(previewPane);
resourceManager.addEventListener(
"drop",
(evn) => {
evn.preventDefault();
resourceManager.classList.remove("dragging");
if (evn.dataTransfer.items) {
Array.from(evn.dataTransfer.items).forEach((item) => {
if (item.kind === "file" && item.type.startsWith("image/")) {
const file = item.getAsFile();
const url = window.URL || window.webkitURL;
const image = document.createElement("img");
image.src = url.createObjectURL(file);
state.addResource(file.name, image, false);
}
});
}
},
{passive: false}
);
resourceManager.addEventListener(
"dragover",
(evn) => {
evn.preventDefault();
},
{passive: false}
);
resourceManager.addEventListener("dragover", (evn) => {
resourceManager.classList.add("dragging");
});
resourceManager.addEventListener("dragover", (evn) => {
resourceManager.classList.remove("dragging");
});
state.ctxmenu.uploadButton = uploadButton;
state.ctxmenu.previewPane = previewPane;
state.ctxmenu.resourceManager = resourceManager;
state.ctxmenu.resourceList = resourceList;
// Performs resource fetch from IndexedDB
const loadResources = async () => {
console.debug("[stamp] Connected to IndexedDB");
/** @type {IDBRequest<{id: string, name: string, src: string}[]>} */
const FetchAllTransaction = db
.transaction("resources")
.objectStore("resources")
.getAll();
FetchAllTransaction.onsuccess = async () => {
const data = FetchAllTransaction.result;
state.resources.push(
...(await Promise.all(
data.map((resource) => {
const image = document.createElement("img");
image.src = resource.src;
return new Promise((resolve, reject) => {
image.onload = () =>
resolve({
id: resource.id,
name: resource.name,
image,
});
});
})
))
);
syncResources();
};
};
if (db) loadResources();
else ondatabaseload.on(loadResources);
}
},
populateContextMenu: (menu, state) => {
menu.appendChild(state.ctxmenu.buttonArray);
menu.appendChild(state.ctxmenu.scaleSlider);
menu.appendChild(state.ctxmenu.resourceManager);
},
shortcut: "U",
}
);