Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48,479 changes: 14,848 additions & 33,631 deletions package-lock.json

Large diffs are not rendered by default.

167 changes: 167 additions & 0 deletions src/ReferenceImage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// @ts-check
/* global $canvas_area, magnification */
import { Handles } from "./Handles.js";
import { OnCanvasObject } from "./OnCanvasObject.js";
import { $G, E, make_canvas, make_css_cursor, to_canvas_coords } from "./helpers.js";

class ReferenceImage extends OnCanvasObject {
/**
* @param {number} x
* @param {number} y
* @param {HTMLImageElement | HTMLCanvasElement | ImageData} image_source
* @param {number} [opacity=0.5]
*/
constructor(x, y, image_source, opacity = 0.5) {
const img_canvas = make_canvas(image_source);
super(x, y, img_canvas.width, img_canvas.height, false);

this.$el.addClass("reference-image");
this._opacity = opacity;
this._visible = true;
this._original_canvas = img_canvas;
this.canvas = make_canvas(img_canvas);

this._update_canvas_opacity();
this.$el.append(this.canvas);
this.position();

this.$move_handle = $(E("div")).addClass("reference-move-handle");
this.$move_handle.css({
position: "absolute",
width: "20px",
height: "20px",
background: "#c0c0c0",
border: "2px outset #dfdfdf",
cursor: make_css_cursor("move", [8, 8], "move"),
touchAction: "none",
pointerEvents: "all",
zIndex: 1,
});
this.$el.append(this.$move_handle);

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 }) => {
this.x = x;
this.y = y;
this._resize(width, height);
this.position();
},
get_ghost_offset_left: () => parseFloat($canvas_area.css("padding-left")) + 1,
get_ghost_offset_top: () => parseFloat($canvas_area.css("padding-top")) + 1,
});
Comment on lines +42 to +55
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handles registers global $G.on("resize theme-load", update_handle) listeners but doesn’t provide a way to unregister them. Creating/destroying reference images repeatedly will accumulate window-level handlers (and this class also adds its own resize handler in addition to OnCanvasObject’s). Consider adding a destroy() method to Handles that removes its $G listeners and calling it from ReferenceImage.destroy(). Also consider avoiding the extra resize listener by overriding position() to also update the move-handle position, relying on OnCanvasObject’s resize handler.

Copilot uses AI. Check for mistakes.

let mox, moy;
const pointermove = (e) => {
const m = to_canvas_coords(e);
this.x = m.x - mox;
this.y = m.y - moy;
this.position();
this._update_move_handle_position();
};
this.$move_handle.on("pointerdown", (e) => {
e.preventDefault();
e.stopPropagation();
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);
Comment on lines +68 to +72
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The move-handle drag offset is calculated using this.canvas.getBoundingClientRect(), but the pointerdown occurs on .reference-move-handle (positioned outside the canvas). This can produce negative/incorrect offsets and cause the reference image to jump when dragging. Compute the offset from the reference image’s current position instead (e.g., on pointerdown: const m = to_canvas_coords(e); mox = m.x - this.x; moy = m.y - this.y;).

Suggested change
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);
const m = to_canvas_coords(e);
mox = m.x - this.x;
moy = m.y - this.y;

Copilot uses AI. Check for mistakes.
$G.on("pointermove", pointermove);
this.dragging = true;
$G.one("pointerup", () => {
$G.off("pointermove", pointermove);
this.dragging = false;
});
});

this._update_move_handle_position();

$G.on("resize theme-load", this._on_resize = () => {
this.position();
this._update_move_handle_position();
});
}

_update_move_handle_position() {
const handle_size = 20;
const outset = 2;
this.$move_handle.css({
left: -outset * magnification - handle_size - 4,
top: -outset * magnification,
});
}

/**
* @param {number} width
* @param {number} height
*/
_resize(width, height) {
width = Math.max(1, width);
height = Math.max(1, height);

const new_canvas = make_canvas(width, height);
new_canvas.ctx.imageSmoothingEnabled = true;
new_canvas.ctx.drawImage(this._original_canvas, 0, 0, width, height);

$(this.canvas).replaceWith(new_canvas);
this.canvas = new_canvas;
this._update_canvas_opacity();
this.width = width;
this.height = height;

this.$el.triggerHandler("resize");
this._update_move_handle_position();
}

_update_canvas_opacity() {
this.canvas.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.canvas.ctx.globalAlpha = this._opacity;
this.canvas.ctx.drawImage(this._original_canvas, 0, 0, this.width, this.height);
this.canvas.ctx.globalAlpha = 1;
}

/**
* @param {number} opacity - 0 to 1
*/
set_opacity(opacity) {
this._opacity = Math.max(0, Math.min(1, opacity));
this._update_canvas_opacity();
}

get_opacity() {
return this._opacity;
}

show() {
this._visible = true;
this.$el.removeClass("hidden");
}

hide() {
this._visible = false;
this.$el.addClass("hidden");
}

toggle() {
if (this._visible) {
this.hide();
} else {
this.show();
}
}

is_visible() {
return this._visible;
}

destroy() {
$G.off("resize theme-load", this._on_resize);
super.destroy();
}
}

export { ReferenceImage };
4 changes: 3 additions & 1 deletion src/app-state.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @ts-check
/* exported $thumbnail_window, airbrush_size, aliasing, brush_shape, brush_size, button, ctrl, current_history_node, enable_fs_access_api, enable_palette_loading_from_indexed_images, eraser_size, file_format, file_name, fill_color, helper_layer, history_node_to_cancel_to, magnification, main_ctx, monochrome, monochrome_palette, my_canvas_height, my_canvas_width, palette, pencil_size, pick_color_slot, pointer, pointer_active, pointer_buttons, pointer_over_canvas, pointer_previous, pointer_start, pointer_type, pointers, polychrome_palette, redos, return_to_magnification, return_to_tools, reverse, root_history_node, saved, selected_colors, selected_tool, selected_tools, selection, shift, show_grid, show_thumbnail, stroke_color, stroke_size, system_file_handle, show_font_box, text_tool_font, textbox, thumbnail_canvas, tool_transparent_mode, transparency, undos, update_helper_layer_on_pointermove_active */
/* exported $thumbnail_window, airbrush_size, aliasing, brush_shape, brush_size, button, ctrl, current_history_node, enable_fs_access_api, enable_palette_loading_from_indexed_images, eraser_size, file_format, file_name, fill_color, helper_layer, history_node_to_cancel_to, magnification, main_ctx, monochrome, monochrome_palette, my_canvas_height, my_canvas_width, palette, pencil_size, pick_color_slot, pointer, pointer_active, pointer_buttons, pointer_over_canvas, pointer_previous, pointer_start, pointer_type, pointers, polychrome_palette, redos, reference_image, return_to_magnification, return_to_tools, reverse, root_history_node, saved, selected_colors, selected_tool, selected_tools, selection, shift, show_grid, show_thumbnail, stroke_color, stroke_size, system_file_handle, show_font_box, text_tool_font, textbox, thumbnail_canvas, tool_transparent_mode, transparency, undos, update_helper_layer_on_pointermove_active */

// Can't import things until this file is a module...
// (Well, could use dynamic imports, but that's async and thus probably as complicated as getting it to work with all ESM.)
Expand Down Expand Up @@ -103,6 +103,8 @@ let selected_colors = {
let selection; // singleton
/** @type {OnCanvasTextBox} */
let textbox; // singleton
/** @type {any} */
let reference_image; // singleton
/** @type {boolean} */
let show_font_box = true;
/** @type {OnCanvasHelperLayer} */
Expand Down
99 changes: 95 additions & 4 deletions src/functions.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// @ts-check
// eslint-disable-next-line no-unused-vars
/* global $thumbnail_window:writable, canvas_bounding_client_rect:writable, current_history_node:writable, file_format:writable, file_name:writable, helper_layer:writable, history_node_to_cancel_to:writable, magnification:writable, monochrome:writable, palette:writable, pointer:writable, return_to_magnification:writable, return_to_tools:writable, root_history_node:writable, saved:writable, selected_colors:writable, selected_tool:writable, selected_tools:writable, selection:writable, show_grid:writable, show_thumbnail:writable, system_file_handle:writable, textbox:writable, thumbnail_canvas:writable, tool_transparent_mode:writable, transparency:writable, undos:writable */
/* global $thumbnail_window:writable, canvas_bounding_client_rect:writable, current_history_node:writable, file_format:writable, file_name:writable, helper_layer:writable, history_node_to_cancel_to:writable, magnification:writable, monochrome:writable, palette:writable, pointer:writable, reference_image:writable, return_to_magnification:writable, return_to_tools:writable, root_history_node:writable, saved:writable, selected_colors:writable, selected_tool:writable, selected_tools:writable, selection:writable, show_grid:writable, show_thumbnail:writable, system_file_handle:writable, textbox:writable, thumbnail_canvas:writable, tool_transparent_mode:writable, transparency:writable, undos:writable */
/* global $canvas, $canvas_area, $colorbox, $status_text, $toolbox, $Window, AccessKeys, applyCSSProperties, decodeBMP, default_canvas_height, default_canvas_width, default_magnification, default_tool, enable_palette_loading_from_indexed_images, encodeBMP, localize, main_canvas, main_ctx, monochrome_palette, my_canvas_height, my_canvas_width, new_local_session, parseThemeFileString, pointer_active, pointers, polychrome_palette, redos, systemHooks, text_tool_font, update_fill_and_stroke_colors_and_lineWidth, UPNG, UTIF */

import { $DialogWindow } from "./$ToolWindow.js";
import { OnCanvasHelperLayer } from "./OnCanvasHelperLayer.js";
import { OnCanvasSelection } from "./OnCanvasSelection.js";
import { OnCanvasTextBox } from "./OnCanvasTextBox.js";
import { ReferenceImage } from "./ReferenceImage.js";
// import { localize } from "./app-localization.js";
import { default_palette } from "./color-data.js";
import { image_formats } from "./file-format-data.js";
Expand Down Expand Up @@ -1813,6 +1814,96 @@ async function choose_file_to_paste() {
show_error_message(localize("This is not a valid bitmap file, or its format is not currently supported."));
}

async function load_reference_image() {
const { file } = await systemHooks.showOpenFileDialog({ formats: image_formats });
if (file.type.match(/^image|application\/pdf/)) {
Comment on lines +1817 to +1819
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

systemHooks.showOpenFileDialog can reject/throw when the user cancels (Electron implementation explicitly throws; showOpenFilePicker also rejects). This function doesn’t handle that, so canceling can cause an unhandled rejection. Wrap the dialog call in a try/catch and return early on cancel.

Copilot uses AI. Check for mistakes.
read_image_file(file, (error, info) => {
if (error) {
Comment on lines +1818 to +1821
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don’t gate reference image loading on file.type. In Electron, readBlobFromHandle constructs new File([...], fileName) without a MIME type, so file.type is an empty string and this will incorrectly show “not a valid bitmap” for valid images/PDFs. Prefer calling read_image_file(file, ...) directly and rely on its content-based detection/error handling (optionally fall back to checking file.name extension when needed).

Copilot uses AI. Check for mistakes.
show_file_format_errors({ as_image_error: error });
return;
}
const img_or_canvas = info.image || make_canvas(info.image_data);

if (reference_image) {
reference_image.destroy();
}

const default_opacity = 0.5;
reference_image = new ReferenceImage(
Math.max(0, Math.ceil($canvas_area.scrollLeft() / magnification)),
Math.max(0, Math.ceil($canvas_area.scrollTop() / magnification)),
img_or_canvas,
default_opacity
);
});
return;
}
show_error_message(localize("This is not a valid bitmap file, or its format is not currently supported."));
}

function toggle_reference_image() {
if (reference_image) {
reference_image.toggle();
}
}

function remove_reference_image() {
if (reference_image) {
reference_image.destroy();
reference_image = null;
}
}

function set_reference_image_opacity(opacity) {
if (reference_image) {
reference_image.set_opacity(opacity);
}
}

function get_reference_image_opacity() {
return reference_image ? reference_image.get_opacity() : 0.5;
}

function is_reference_image_visible() {
return reference_image ? reference_image.is_visible() : false;
}

function has_reference_image() {
return reference_image != null;
}

function show_reference_image_opacity_window() {
if (!reference_image) {
return;
}

const $w = $DialogWindow(localize("Reference Image Opacity"));

$w.$main.append(`
<div style="padding: 10px;">
<label for="reference-opacity-slider">${localize("Opacity:")}</label>
<input type="range" id="reference-opacity-slider" min="10" max="100" value="${Math.round(reference_image.get_opacity() * 100)}"
style="width: 200px; margin-left: 10px;" />
<span id="reference-opacity-value" style="margin-left: 10px;">${Math.round(reference_image.get_opacity() * 100)}%</span>
</div>
`);

const $slider = $w.$main.find("#reference-opacity-slider");
const $value = $w.$main.find("#reference-opacity-value");

$slider.on("input", () => {
const opacity = parseInt($slider.val()?.toString() || "50", 10) / 100;
reference_image.set_opacity(opacity);
$value.text(`${Math.round(opacity * 100)}%`);
});

$w.$Button(localize("OK"), () => {
$w.close();
});

$w.center();
}

/**
* @param {HTMLImageElement | HTMLCanvasElement} img_or_canvas
*/
Expand Down Expand Up @@ -4224,9 +4315,9 @@ export {
$this_version_news,
apply_file_format_and_palette_info, are_you_sure, cancel, change_some_url_params, change_url_param, choose_file_to_paste, cleanup_bitmap_view, clear, confirm_overwrite_capability, delete_selection, deselect, detect_monochrome,
edit_copy, edit_cut, edit_paste, exit_fullscreen_if_ios, file_load_from_url, file_new, file_open, file_print, file_save,
file_save_as, getSelectionText, get_all_url_params, get_history_ancestors, get_tool_by_id, get_uris, get_url_param, go_to_history_node, handle_keyshortcuts, has_any_transparency, image_attributes, image_flip_and_rotate, image_invert_colors, image_stretch_and_skew, load_image_from_uri, load_theme_from_text, make_history_node, make_monochrome_palette, make_monochrome_pattern, make_opaque, make_or_update_undoable, make_stripe_pattern, meld_selection_into_canvas,
meld_textbox_into_canvas, open_from_file, open_from_image_info, paste, paste_image_from_file, please_enter_a_number, read_image_file, redo, render_canvas_view, render_history_as_gif, reset_canvas_and_history, reset_file, reset_selected_colors, resize_canvas_and_save_dimensions, resize_canvas_without_saving_dimensions, sanity_check_blob, save_as_prompt, save_selection_to_file, select_all, select_tool, select_tools, set_all_url_params, set_magnification, show_about_paint, show_convert_to_black_and_white, show_custom_zoom_window, show_document_history, show_error_message, show_file_format_errors, show_multi_user_setup_dialog, show_news, show_resource_load_error_message, switch_to_polychrome_palette, toggle_grid,
toggle_thumbnail, try_exec_command, undo, undoable, update_canvas_rect, update_css_classes_for_conditional_messages, update_disable_aa, update_from_saved_file, update_helper_layer,
file_save_as, getSelectionText, get_all_url_params, get_history_ancestors, get_reference_image_opacity, get_tool_by_id, get_uris, get_url_param, go_to_history_node, handle_keyshortcuts, has_any_transparency, has_reference_image, image_attributes, image_flip_and_rotate, image_invert_colors, image_stretch_and_skew, is_reference_image_visible, load_image_from_uri, load_reference_image, load_theme_from_text, make_history_node, make_monochrome_palette, make_monochrome_pattern, make_opaque, make_or_update_undoable, make_stripe_pattern, meld_selection_into_canvas,
meld_textbox_into_canvas, open_from_file, open_from_image_info, paste, paste_image_from_file, please_enter_a_number, read_image_file, redo, remove_reference_image, render_canvas_view, render_history_as_gif, reset_canvas_and_history, reset_file, reset_selected_colors, resize_canvas_and_save_dimensions, resize_canvas_without_saving_dimensions, sanity_check_blob, save_as_prompt, save_selection_to_file, select_all, select_tool, select_tools, set_all_url_params, set_magnification, set_reference_image_opacity, show_about_paint, show_convert_to_black_and_white, show_custom_zoom_window, show_document_history, show_error_message, show_file_format_errors, show_multi_user_setup_dialog, show_news, show_reference_image_opacity_window, show_resource_load_error_message, switch_to_polychrome_palette, toggle_grid,
toggle_reference_image, toggle_thumbnail, try_exec_command, undo, undoable, update_canvas_rect, update_css_classes_for_conditional_messages, update_disable_aa, update_from_saved_file, update_helper_layer,
update_helper_layer_immediately, update_magnified_canvas_size, update_title, view_bitmap, write_image_file
};
// Temporary globals until all dependent code is converted to ES Modules
Expand Down
55 changes: 54 additions & 1 deletion src/menus.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { OnCanvasTextBox } from "./OnCanvasTextBox.js";
import { show_edit_colors_window } from "./edit-colors.js";
import { palette_formats } from "./file-format-data.js";
import { are_you_sure, change_url_param, choose_file_to_paste, clear, delete_selection, deselect, edit_copy, edit_cut, edit_paste, file_load_from_url, file_new, file_open, file_print, file_save, file_save_as, image_attributes, image_flip_and_rotate, image_invert_colors, image_stretch_and_skew, redo, render_history_as_gif, sanity_check_blob, save_selection_to_file, select_all, set_magnification, show_about_paint, show_custom_zoom_window, show_document_history, show_file_format_errors, show_multi_user_setup_dialog, show_news, toggle_grid, toggle_thumbnail, undo, view_bitmap } from "./functions.js";
import { are_you_sure, change_url_param, choose_file_to_paste, clear, delete_selection, deselect, edit_copy, edit_cut, edit_paste, file_load_from_url, file_new, file_open, file_print, file_save, file_save_as, has_reference_image, image_attributes, image_flip_and_rotate, image_invert_colors, image_stretch_and_skew, is_reference_image_visible, load_reference_image, redo, remove_reference_image, render_history_as_gif, sanity_check_blob, save_selection_to_file, select_all, set_magnification, show_about_paint, show_custom_zoom_window, show_document_history, show_file_format_errors, show_multi_user_setup_dialog, show_news, show_reference_image_opacity_window, toggle_grid, toggle_reference_image, toggle_thumbnail, undo, view_bitmap } from "./functions.js";
import { show_help } from "./help.js";
import { $G, get_rgba_from_color, is_discord_embed } from "./helpers.js";
import { show_imgur_uploader } from "./imgur.js";
Expand Down Expand Up @@ -607,6 +607,59 @@ const menus = {
},
description: localize("Makes the application take up the entire screen."),
},
MENU_DIVIDER,
{
emoji_icon: "📷",
label: localize("&Reference Image"),
submenu: [
{
label: localize("&Load Reference Image") + "...",
speech_recognition: [
"load reference image", "load reference",
"import reference image", "import reference",
"open reference image", "open reference",
],
action: () => { load_reference_image(); },
description: localize("Loads a reference image to trace over."),
},
{
label: localize("&Show Reference Image"),
speech_recognition: [
"toggle reference image", "toggle reference",
"show reference image", "show reference",
"hide reference image", "hide reference",
],
enabled: () => has_reference_image(),
checkbox: {
toggle: () => { toggle_reference_image(); },
check: () => is_reference_image_visible(),
},
description: localize("Shows or hides the reference image."),
},
{
label: localize("Reference Image &Opacity") + "...",
speech_recognition: [
"change reference image opacity", "change reference opacity",
"adjust reference image opacity", "adjust reference opacity",
"reference image transparency", "reference transparency",
],
enabled: () => has_reference_image(),
action: () => { show_reference_image_opacity_window(); },
description: localize("Adjusts the opacity of the reference image."),
},
{
label: localize("&Remove Reference Image"),
speech_recognition: [
"remove reference image", "remove reference",
"delete reference image", "delete reference",
"clear reference image", "clear reference",
],
enabled: () => has_reference_image(),
action: () => { remove_reference_image(); },
description: localize("Removes the reference image."),
},
],
},
Comment on lines +610 to +662
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New View > Reference Image menu actions (load/toggle/opacity/remove) aren’t covered by Cypress tests, while the repo already has menu interaction helpers in cypress/integration/visual-tests.spec.js. Add an e2e test that loads a reference image fixture, verifies it appears, toggles visibility, adjusts opacity, and removes it (and verifies menu items enable/disable based on has_reference_image()).

Copilot uses AI. Check for mistakes.
],
[localize("&Image")]: [
// @TODO: speech recognition: terms that apply to selection
Expand Down
Loading
Loading