jspaint/src/OnCanvasSelection.js

293 lines
12 KiB
JavaScript

class OnCanvasSelection extends OnCanvasObject {
constructor(x, y, width, height, img_or_canvas) {
super(x, y, width, height, true);
this.$el.addClass("selection");
let last_tool_transparent_mode = tool_transparent_mode;
let last_background_color = selected_colors.background;
this._on_option_changed = () => {
if (!this.source_canvas) {
return;
}
if (last_tool_transparent_mode !== tool_transparent_mode ||
last_background_color !== selected_colors.background) {
last_tool_transparent_mode = tool_transparent_mode;
last_background_color = selected_colors.background;
this.update_tool_transparent_mode();
}
};
$G.on("option-changed", this._on_option_changed);
this.instantiate(img_or_canvas);
}
position() {
super.position(true);
update_helper_layer(); // @TODO: under-grid specific helper layer?
}
instantiate(img_or_canvas) {
this.$el.css({
cursor: make_css_cursor("move", [8, 8], "move"),
touchAction: "none",
});
this.position();
const instantiate = () => {
if (img_or_canvas) {
// (this applies when pasting a selection)
// NOTE: need to create a Canvas because something about imgs makes dragging not work with magnification
// (width vs naturalWidth?)
// and at least apply_image_transformation needs it to be a canvas now (and the property name says canvas anyways)
this.source_canvas = make_canvas(img_or_canvas);
// @TODO: is this width/height code needed? probably not! wouldn't it clear the canvas anyways?
// but maybe we should assert in some way that the widths are the same, or resize the selection?
if (this.source_canvas.width !== this.width) {
this.source_canvas.width = this.width;
}
if (this.source_canvas.height !== this.height) {
this.source_canvas.height = this.height;
}
this.canvas = make_canvas(this.source_canvas);
}
else {
this.source_canvas = make_canvas(this.width, this.height);
this.source_canvas.ctx.drawImage(main_canvas, this.x, this.y, this.width, this.height, 0, 0, this.width, this.height);
this.canvas = make_canvas(this.source_canvas);
this.cut_out_background();
}
this.$el.append(this.canvas);
this.handles = new Handles({
$handles_container: this.$el,
$object_container: $canvas_area,
outset: 2,
get_rect: () => ({ x: this.x, y: this.y, width: this.width, height: this.height }),
set_rect: ({ x, y, width, height }) => {
undoable({
name: "Resize Selection",
icon: get_icon_for_tool(get_tool_by_id(TOOL_SELECT)),
soft: true,
}, () => {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.position();
this.resize();
});
},
get_ghost_offset_left: () => parseFloat($canvas_area.css("padding-left")) + 1,
get_ghost_offset_top: () => parseFloat($canvas_area.css("padding-top")) + 1,
});
let mox, moy;
const pointermove = e => {
make_or_update_undoable({
match: (history_node) =>
(e.shiftKey && history_node.name.match(/^(Smear|Stamp|Move) Selection$/)) ||
(!e.shiftKey && history_node.name.match(/^Move Selection$/)),
name: e.shiftKey ? "Smear Selection" : "Move Selection",
update_name: true,
icon: get_icon_for_tool(get_tool_by_id(TOOL_SELECT)),
soft: true,
}, () => {
const m = to_canvas_coords(e);
this.x = Math.max(Math.min(m.x - mox, main_canvas.width), -this.width);
this.y = Math.max(Math.min(m.y - moy, main_canvas.height), -this.height);
this.position();
if (e.shiftKey) {
// Smear selection
this.draw();
}
});
};
this.canvas_pointerdown = e => {
e.preventDefault();
const rect = this.canvas.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
mox = ~~(cx / rect.width * this.canvas.width);
moy = ~~(cy / rect.height * this.canvas.height);
$G.on("pointermove", pointermove);
this.dragging = true;
update_helper_layer(); // for thumbnail, which draws textbox outline if it's not being dragged
$G.one("pointerup", () => {
$G.off("pointermove", pointermove);
this.dragging = false;
update_helper_layer(); // for thumbnail, which draws selection outline if it's not being dragged
});
if (e.shiftKey) {
// Stamp or start to smear selection
undoable({
name: "Stamp Selection",
icon: get_icon_for_tool(get_tool_by_id(TOOL_SELECT)),
soft: true,
}, () => {
this.draw();
});
}
// @TODO: how should this work for macOS? where ctrl+click = secondary click?
else if (e.ctrlKey) {
// Stamp selection
undoable({
name: "Stamp Selection",
icon: get_icon_for_tool(get_tool_by_id(TOOL_SELECT)),
soft: true,
}, () => {
this.draw();
});
}
};
$(this.canvas).on("pointerdown", this.canvas_pointerdown);
$canvas_area.trigger("resize");
$status_position.text("");
$status_size.text("");
};
instantiate();
}
cut_out_background() {
const cutout = this.canvas;
// doc/this or canvas/cutout, either of those pairs would result in variable names of equal length which is nice :)
const canvasImageData = main_ctx.getImageData(this.x, this.y, this.width, this.height);
const cutoutImageData = cutout.ctx.getImageData(0, 0, this.width, this.height);
// cutoutImageData is initialized with the shape to be cut out (whether rectangular or polygonal)
// and should end up as the cut out image data for the selection
// canvasImageData is initially the portion of image data on the canvas,
// and should end up as... the portion of image data on the canvas that it should end up as.
// @TODO: could simplify by making the later (shared) condition just if (colored_cutout)
// but might change how it works anyways so whatever
// if (!transparency) { // now if !transparency or if tool_transparent_mode
// this is mainly in order to support patterns as the background color
// NOTE: must come before cutout canvas is modified
const colored_cutout = make_canvas(cutout);
replace_colors_with_swatch(colored_cutout.ctx, selected_colors.background, this.x, this.y);
// const colored_cutout_image_data = colored_cutout.ctx.getImageData(0, 0, this.width, this.height);
// }
for (let i = 0; i < cutoutImageData.data.length; i += 4) {
const in_cutout = cutoutImageData.data[i + 3] > 0;
if (in_cutout) {
cutoutImageData.data[i + 0] = canvasImageData.data[i + 0];
cutoutImageData.data[i + 1] = canvasImageData.data[i + 1];
cutoutImageData.data[i + 2] = canvasImageData.data[i + 2];
cutoutImageData.data[i + 3] = canvasImageData.data[i + 3];
canvasImageData.data[i + 0] = 0;
canvasImageData.data[i + 1] = 0;
canvasImageData.data[i + 2] = 0;
canvasImageData.data[i + 3] = 0;
}
else {
cutoutImageData.data[i + 0] = 0;
cutoutImageData.data[i + 1] = 0;
cutoutImageData.data[i + 2] = 0;
cutoutImageData.data[i + 3] = 0;
}
}
main_ctx.putImageData(canvasImageData, this.x, this.y);
cutout.ctx.putImageData(cutoutImageData, 0, 0);
this.update_tool_transparent_mode();
// NOTE: in case you want to use the tool_transparent_mode
// in a document with transparency (for an operation in an area where there's a local background color)
// (and since currently switching to the opaque document mode makes the image opaque)
// (and it would be complicated to make it update the canvas when switching tool options (as opposed to just the selection))
// I'm having it use the tool_transparent_mode option here, so you could at least choose beforehand
// (and this might actually give you more options, although it could be confusingly inconsistent)
// @FIXME: yeah, this is confusing; if you have both transparency modes on and you try to clear an area to transparency, it doesn't work
// and there's no indication that you should try the other selection transparency mode,
// and even if you do, if you do it after creating a selection, it still won't work,
// because you will have already *not cut out* the selection from the canvas
if (!transparency || tool_transparent_mode) {
main_ctx.drawImage(colored_cutout, this.x, this.y);
}
$G.triggerHandler("session-update"); // autosave
update_helper_layer();
}
update_tool_transparent_mode() {
const sourceImageData = this.source_canvas.ctx.getImageData(0, 0, this.width, this.height);
const cutoutImageData = this.canvas.ctx.createImageData(this.width, this.height);
const background_color_rgba = get_rgba_from_color(selected_colors.background);
// NOTE: In b&w mode, mspaint treats the transparency color as white,
// regardless of the pattern selected, even if the selected background color is pure black.
// We allow any kind of image data while in our "b&w mode".
// Our b&w mode is essentially 'patterns in the palette'.
const match_threshold = 1; // 1 is just enough for a workaround for Brave browser's farbling: https://github.com/1j01/jspaint/issues/184
for (let i = 0; i < cutoutImageData.data.length; i += 4) {
let in_cutout = sourceImageData.data[i + 3] > 1;
if (tool_transparent_mode) {
// @FIXME: work with transparent selected background color
// (support treating partially transparent background colors as transparency)
if (
Math.abs(sourceImageData.data[i + 0] - background_color_rgba[0]) <= match_threshold &&
Math.abs(sourceImageData.data[i + 1] - background_color_rgba[1]) <= match_threshold &&
Math.abs(sourceImageData.data[i + 2] - background_color_rgba[2]) <= match_threshold &&
Math.abs(sourceImageData.data[i + 3] - background_color_rgba[3]) <= match_threshold
) {
in_cutout = false;
}
}
if (in_cutout) {
cutoutImageData.data[i + 0] = sourceImageData.data[i + 0];
cutoutImageData.data[i + 1] = sourceImageData.data[i + 1];
cutoutImageData.data[i + 2] = sourceImageData.data[i + 2];
cutoutImageData.data[i + 3] = sourceImageData.data[i + 3];
}
else {
// cutoutImageData.data[i+0] = 0;
// cutoutImageData.data[i+1] = 0;
// cutoutImageData.data[i+2] = 0;
// cutoutImageData.data[i+3] = 0;
}
}
this.canvas.ctx.putImageData(cutoutImageData, 0, 0);
update_helper_layer();
}
// @TODO: should Image > Invert apply to this.source_canvas or to this.canvas (replacing this.source_canvas with the result)?
replace_source_canvas(new_source_canvas) {
this.source_canvas = new_source_canvas;
const new_canvas = make_canvas(new_source_canvas);
$(this.canvas).replaceWith(new_canvas);
this.canvas = new_canvas;
const center_x = this.x + this.width / 2;
const center_y = this.y + this.height / 2;
const new_width = new_canvas.width;
const new_height = new_canvas.height;
// NOTE: flooring the coordinates to integers avoids blurring
// but it introduces "inching", where the selection can move along by pixels if you rotate it repeatedly
// could introduce an "error offset" just to avoid this but that seems overkill
// and then that would be weird hidden behavior, probably not worth it
// Math.round() might make it do it on fewer occasions(?),
// but then it goes down *and* to the right, 2 directions vs One Direction
// and Math.ceil() is the worst of both worlds
this.x = ~~(center_x - new_width / 2);
this.y = ~~(center_y - new_height / 2);
this.width = new_width;
this.height = new_height;
this.position();
$(this.canvas).on("pointerdown", this.canvas_pointerdown);
this.$el.triggerHandler("resize"); //?
this.update_tool_transparent_mode();
}
resize() {
const new_source_canvas = make_canvas(this.width, this.height);
new_source_canvas.ctx.drawImage(this.source_canvas, 0, 0, this.width, this.height);
this.replace_source_canvas(new_source_canvas);
}
scale(factor) {
const new_source_canvas = make_canvas(this.width * factor, this.height * factor);
new_source_canvas.ctx.drawImage(this.source_canvas, 0, 0, new_source_canvas.width, new_source_canvas.height);
this.replace_source_canvas(new_source_canvas);
}
draw() {
try {
main_ctx.drawImage(this.canvas, this.x, this.y);
}
// eslint-disable-next-line no-empty
catch (e) { }
}
destroy() {
super.destroy();
$G.off("option-changed", this._on_option_changed);
update_helper_layer(); // @TODO: under-grid specific helper layer?
}
}