295 lines
12 KiB
JavaScript
295 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_width = Math.max(1, this.width * factor);
|
|
const new_height = Math.max(1, this.height * factor);
|
|
const new_source_canvas = make_canvas(new_width, new_height);
|
|
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?
|
|
}
|
|
}
|