jspaint/src/app.js

2182 lines
72 KiB
JavaScript

const default_magnification = 1;
const default_tool = get_tool_by_id(TOOL_PENCIL);
const default_canvas_width = 683;
const default_canvas_height = 384;
let my_canvas_width = default_canvas_width;
let my_canvas_height = default_canvas_height;
let aliasing = true;
let transparency = false;
let monochrome = false;
let magnification = default_magnification;
let return_to_magnification = 4;
const main_canvas = make_canvas();
main_canvas.classList.add("main-canvas");
const main_ctx = main_canvas.ctx;
const default_palette = [
"rgb(0,0,0)", // Black
"rgb(128,128,128)", // Dark Gray
"rgb(128,0,0)", // Dark Red
"rgb(128,128,0)", // Pea Green
"rgb(0,128,0)", // Dark Green
"rgb(0,128,128)", // Slate
"rgb(0,0,128)", // Dark Blue
"rgb(128,0,128)", // Lavender
"rgb(128,128,64)", //
"rgb(0,64,64)", //
"rgb(0,128,255)", //
"rgb(0,64,128)", //
"rgb(64,0,255)", //
"rgb(128,64,0)", //
"rgb(255,255,255)", // White
"rgb(192,192,192)", // Light Gray
"rgb(255,0,0)", // Bright Red
"rgb(255,255,0)", // Yellow
"rgb(0,255,0)", // Bright Green
"rgb(0,255,255)", // Cyan
"rgb(0,0,255)", // Bright Blue
"rgb(255,0,255)", // Magenta
"rgb(255,255,128)", //
"rgb(0,255,128)", //
"rgb(128,255,255)", //
"rgb(128,128,255)", //
"rgb(255,0,128)", //
"rgb(255,128,64)", //
];
const monochrome_palette_as_colors = [
"rgb(0,0,0)",
"rgb(9,9,9)",
"rgb(18,18,18)",
"rgb(27,27,27)",
"rgb(37,37,37)",
"rgb(46,46,46)",
"rgb(55,55,55)",
"rgb(63,63,63)",
"rgb(73,73,73)",
"rgb(82,82,82)",
"rgb(92,92,92)",
"rgb(101,101,101)",
"rgb(110,110,110)",
"rgb(119,119,119)",
"rgb(255,255,255)",
"rgb(250,250,250)",
"rgb(242,242,242)",
"rgb(212,212,212)",
"rgb(201,201,201)",
"rgb(191,191,191)",
"rgb(182,182,182)",
"rgb(159,159,159)",
"rgb(128,128,128)",
"rgb(173,173,173)",
"rgb(164,164,164)",
"rgb(155,155,155)",
"rgb(146,146,146)",
"rgb(137,137,137)",
];
let palette = default_palette;
let polychrome_palette = palette;
let monochrome_palette = make_monochrome_palette();
// https://github.com/kouzhudong/win2k/blob/ce6323f76d5cd7d136b74427dad8f94ee4c389d2/trunk/private/shell/win16/comdlg/color.c#L38-L43
// These are a fallback in case colors are not received from some driver.
// const default_basic_colors = [
// "#8080FF", "#80FFFF", "#80FF80", "#80FF00", "#FFFF80", "#FF8000", "#C080FF", "#FF80FF",
// "#0000FF", "#00FFFF", "#00FF80", "#40FF00", "#FFFF00", "#C08000", "#C08080", "#FF00FF",
// "#404080", "#4080FF", "#00FF00", "#808000", "#804000", "#FF8080", "#400080", "#8000FF",
// "#000080", "#0080FF", "#008000", "#408000", "#FF0000", "#A00000", "#800080", "#FF0080",
// "#000040", "#004080", "#004000", "#404000", "#800000", "#400000", "#400040", "#800040",
// "#000000", "#008080", "#408080", "#808080", "#808040", "#C0C0C0", "#400040", "#FFFFFF",
// ];
// Grabbed with Color Cop from the screen with Windows 98 SE running in VMWare
const basic_colors = [
"#FF8080", "#FFFF80", "#80FF80", "#00FF80", "#80FFFF", "#0080FF", "#FF80C0", "#FF80FF",
"#FF0000", "#FFFF00", "#80FF00", "#00FF40", "#00FFFF", "#0080C0", "#8080C0", "#FF00FF",
"#804040", "#FF8040", "#00FF00", "#008080", "#004080", "#8080FF", "#800040", "#FF0080",
"#800000", "#FF8000", "#008000", "#008040", "#0000FF", "#0000A0", "#800080", "#8000FF",
"#400000", "#804000", "#004000", "#004040", "#000080", "#000040", "#400040", "#400080",
"#000000", "#808000", "#808040", "#808080", "#408080", "#C0C0C0", "#400040", "#FFFFFF",
];
let custom_colors = [
"#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF",
"#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF",
];
// The methods in systemHooks can be overridden by a containing page like 98.js.org which hosts jspaint in a same-origin iframe.
// This allows integrations like setting the wallpaper as the background of the host page, or saving files to a server.
// This API may be removed at any time (and perhaps replaced by something based around postMessage)
window.systemHooks = window.systemHooks || {};
window.systemHookDefaults = {
// named to be distinct from various platform APIs (showSaveFilePicker, saveAs, electron's showSaveDialog; and saveFile is too ambiguous)
// could call it saveFileAs maybe but then it'd be weird that you don't pass in the file directly
showSaveFileDialog: async ({formats, defaultFileName, defaultPath, defaultFileFormatID, getBlob, savedCallbackUnreliable, dialogTitle})=> {
// Note: showSaveFilePicker currently doesn't support suggesting a filename,
// or retrieving which file type was selected in the dialog (you have to get it (guess it) from the file name)
// In particular, some formats are ambiguous with the file name, e.g. different bit depths of BMP files.
// So, it's a tradeoff with the benefit of overwriting on Save.
// https://developer.mozilla.org/en-US/docs/Web/API/Window/showSaveFilePicker
// Also, if you're using accessibility options Speech Recognition or Eye Gaze Mode,
// `showSaveFilePicker` fails based on a notion of it not being a "user gesture".
// `saveAs` will likely also fail on the same basis,
// but at least in chrome, there's a "Downloads Blocked" icon with a popup where you can say Always Allow.
// I can't detect when it's allowed or blocked, but `saveAs` has a better chance of working,
// so in Speech Recognition and Eye Gaze Mode, I set a global flag temporarily to disable File System Access API (window.untrusted_gesture).
if (window.showSaveFilePicker && !window.untrusted_gesture) {
// We can't get the selected file type, not even from newHandle.getFile()
// so limit formats shown to a set that can all be used by their unique file extensions
// formats = formats_unique_per_file_extension(formats);
// OR, show two dialogs, one for the format and then one for the save location.
const {newFileFormatID} = await save_as_prompt({dialogTitle, defaultFileName, defaultFileFormatID, formats, promptForName: false});
const new_format = formats.find((format)=> format.formatID === newFileFormatID);
const blob = await getBlob(new_format && new_format.formatID);
formats = [new_format];
let newHandle;
let newFileName;
try {
newHandle = await showSaveFilePicker({
types: formats.map((format) => {
return {
description: format.name,
accept: {
[format.mimeType]: format.extensions.map((extension) => "." + extension)
}
}
})
});
newFileName = newHandle.name;
const newFileExtension = get_file_extension(newFileName);
if (!newFileExtension) {
show_error_message(`Missing file extension.\n\nTry adding .${new_format.extensions[0]} to the name.`);
return;
}
if (!new_format.extensions.includes(newFileExtension)) {
// Closest translation: "Paint cannot save to the same filename with a different file type."
show_error_message(`Wrong file extension for selected file type.\n\nTry adding .${new_format.extensions[0]} to the name.`);
return;
}
// const new_format =
// get_format_from_extension(formats, newHandle.name) ||
// formats.find((format)=> format.formatID === defaultFileFormatID);
// const blob = await getBlob(new_format && new_format.formatID);
const writableStream = await newHandle.createWritable();
await writableStream.write(blob);
await writableStream.close();
} catch (error) {
if (error.name === "AbortError") {
// user canceled save
return;
}
// console.warn("Error during showSaveFileDialog (for showSaveFilePicker; now falling back to saveAs)", error);
// newFileName = (newFileName || file_name || localize("untitled"))
// .replace(/\.(bmp|dib|a?png|gif|jpe?g|jpe|jfif|tiff?|webp|raw)$/i, "") +
// "." + new_format.extensions[0];
// saveAs(blob, newFileName);
if (error.message.match(/gesture|activation/)) {
// show_error_message("Your browser blocked the file from being saved, because you didn't use the mouse or keyboard directly to save. Try looking for a Downloads Blocked icon and say Always Allow, or save again with the keyboard or mouse.", error);
show_error_message("Sorry, due to browser security measures, you must use the keyboard or mouse directly to save.");
return;
}
show_error_message(localize("Failed to save document."), error);
return;
}
savedCallbackUnreliable && savedCallbackUnreliable({
newFileName: newFileName,
newFileFormatID: new_format && new_format.formatID,
newFileHandle: newHandle,
newBlob: blob,
});
} else {
const {newFileName, newFileFormatID} = await save_as_prompt({dialogTitle, defaultFileName, defaultFileFormatID, formats});
const blob = await getBlob(newFileFormatID);
saveAs(blob, newFileName);
savedCallbackUnreliable && savedCallbackUnreliable({
newFileName,
newFileFormatID,
newFileHandle: null,
newBlob: blob,
});
}
},
showOpenFileDialog: async ({ formats }) => {
if (window.untrusted_gesture) {
// We can't show a file picker RELIABLY.
show_error_message("Sorry, a file picker cannot be shown when using Speech Recognition or Eye Gaze Mode. You must click File > Open directly with the mouse, or press Ctrl+O on the keyboard.");
throw new Error("can't show file picker reliably");
}
if (window.showOpenFilePicker) {
const [fileHandle] = await window.showOpenFilePicker({
types: formats.map((format)=> {
return {
description: format.name,
accept: {
[format.mimeType]: format.extensions.map((extension)=> "." + extension)
}
}
})
});
const file = await fileHandle.getFile();
return {file, fileHandle};
} else {
// @TODO: specify mime types?
return new Promise((resolve)=> {
const $input = $("<input type='file'>")
.on("change", ()=> {
resolve({file: $input[0].files[0]});
$input.remove();
})
.appendTo($app)
.hide()
.trigger("click");
});
}
},
writeBlobToHandle: async (save_file_handle, blob) => {
if (save_file_handle && save_file_handle.createWritable) {
await confirm_overwrite();
try {
const writableStream = await save_file_handle.createWritable();
await writableStream.write(blob);
await writableStream.close();
} catch (error) {
if (error.name === "AbortError") {
// user canceled save (this might not be a real error code that can occur here)
return;
}
if (error.name === "NotAllowedError") {
// use didn't give permission to save
// is this too much of a warning?
show_error_message(localize("Save was interrupted, so your file has not been saved."), error);
return;
}
if (error.name === "SecurityError") {
// not in a user gesture ("User activation is required to request permissions.")
saveAs(blob, file_name);
return;
}
}
} else {
saveAs(blob, file_name);
// hopefully if the page reloads/closes the save dialog/download will persist and succeed?
}
},
readBlobFromHandle: async (file_handle) => {
if (file_handle && file_handle.getFile) {
const file = await file_handle.getFile();
return file;
} else {
throw new Error(`Unknown file handle (${file_handle})`);
// show_error_message(`${localize("Failed to open document.")}\n${localize("An unsupported operation was attempted.")}`, error);
}
},
setWallpaperTiled: (canvas)=> {
const wallpaperCanvas = make_canvas(screen.width, screen.height);
const pattern = wallpaperCanvas.ctx.createPattern(canvas, "repeat");
wallpaperCanvas.ctx.fillStyle = pattern;
wallpaperCanvas.ctx.fillRect(0, 0, wallpaperCanvas.width, wallpaperCanvas.height);
systemHooks.setWallpaperCentered(wallpaperCanvas);
},
setWallpaperCentered: (canvas)=> {
systemHooks.showSaveFileDialog({
dialogTitle: localize("Save As"),
defaultName: `${file_name.replace(/\.(bmp|dib|a?png|gif|jpe?g|jpe|jfif|tiff?|webp|raw)$/i, "")} wallpaper.png`,
defaultFileFormatID: "image/png",
formats: image_formats,
getBlob: (new_file_type)=> {
return new Promise((resolve)=> {
write_image_file(canvas, new_file_type, (blob)=> {
resolve(blob);
});
});
},
});
},
};
for (const [key, defaultValue] of Object.entries(window.systemHookDefaults)) {
window.systemHooks[key] = window.systemHooks[key] || defaultValue;
}
function get_file_extension(file_path_or_name) {
// does NOT accept a file extension itself as input - if input does not have a dot, returns empty string
return file_path_or_name.match(/\.([^./]+)$/)?.[1] || "";
}
function get_format_from_extension(formats, file_path_or_name_or_ext) {
// accepts a file extension as input, or a file name, or path
const ext_match = file_path_or_name_or_ext.match(/\.([^.]+)$/);
const ext = ext_match ? ext_match[1].toLowerCase() : file_path_or_name_or_ext; // excluding dot
for (const format of formats) {
if (format.extensions.includes(ext)) {
return format;
}
}
}
const image_formats = [];
// const ext_to_image_formats = {}; // there can be multiple with the same extension, e.g. different bit depth BMP files
// const mime_type_to_image_formats = {};
const add_image_format = (mime_type, name_and_exts)=> {
// Note: some localizations have commas instead of semicolons to separate file extensions
// Assumption: file extensions are never localized
const format = {
formatID: mime_type,
mimeType: mime_type,
name: localize(name_and_exts).replace(/\s+\([^(]+$/, ""),
nameWithExtensions: localize(name_and_exts),
extensions: [],
};
const ext_regexp = /\*\.([^);,]+)/g;
if (get_direction() === "rtl") {
const rlm = "\u200F";
const lrm = "\u200E";
format.nameWithExtensions = format.nameWithExtensions.replace(ext_regexp, `${rlm}*.${lrm}$1${rlm}`);
}
let match;
// eslint-disable-next-line no-cond-assign
while (match = ext_regexp.exec(name_and_exts)) {
const ext = match[1];
// ext_to_image_formats[ext] = ext_to_image_formats[ext] || [];
// ext_to_image_formats[ext].push(format);
// mime_type_to_image_formats[mime_type] = mime_type_to_image_formats[mime_type] || [];
// mime_type_to_image_formats[mime_type].push(format);
format.extensions.push(ext);
}
image_formats.push(format);
};
// First file extension in a parenthetical defines default for the format.
// Strings are localized in add_image_format, don't need localize() here.
add_image_format("image/png", "PNG (*.png)");
add_image_format("image/webp", "WebP (*.webp)");
add_image_format("image/gif", "GIF (*.gif)");
add_image_format("image/tiff", "TIFF (*.tif;*.tiff)");
add_image_format("image/jpeg", "JPEG (*.jpg;*.jpeg;*.jpe;*.jfif)");
add_image_format("image/x-bmp-1bpp", "Monochrome Bitmap (*.bmp;*.dib)");
add_image_format("image/x-bmp-4bpp", "16 Color Bitmap (*.bmp;*.dib)");
add_image_format("image/x-bmp-8bpp", "256 Color Bitmap (*.bmp;*.dib)");
add_image_format("image/bmp", "24-bit Bitmap (*.bmp;*.dib)");
// add_image_format("image/x-bmp-32bpp", "32-bit Transparent Bitmap (*.bmp;*.dib)");
// Only support 24bpp BMP files for File System Access API and Electron save dialog,
// as these APIs don't allow you to access the selected file type.
// You can only guess it from the file extension the user types.
const formats_unique_per_file_extension = (formats)=> {
// first handle BMP format specifically to make sure the 24-bpp is the selected BMP format
formats = formats.filter((format)=>
format.extensions.includes("bmp") ? format.mimeType === "image/bmp" : true
)
// then generally uniquify on extensions
// (this could be overzealous in case of partial overlap in extensions of different formats,
// but in general it needs special care anyways, to decide which format should win)
// This can't be simply chained with the above because it needs to use the intermediate, partially filtered formats array.
return formats.filter((format, format_index)=>
!format.extensions.some((extension)=>
formats.some((other_format, other_format_index)=>
other_format_index < format_index &&
other_format.extensions.includes(extension)
)
)
);
};
const palette_formats = [];
for (const [format_id, format] of Object.entries(AnyPalette.formats)) {
if (format.write) {
palette_formats.push({
formatID: format_id,
name: format.name,
nameWithExtensions: `${format.name} (${
format.fileExtensions.map((extension)=> `*.${extension}`).join(";")
})`,
extensions: format.fileExtensions,
});
}
}
palette_formats.sort((a, b)=>
// Order important formats first, starting with RIFF PAL format:
(b.formatID === "RIFF_PALETTE") - (a.formatID === "RIFF_PALETTE") ||
(b.formatID === "GIMP_PALETTE") - (a.formatID === "GIMP_PALETTE") ||
0
);
// declared like this for Cypress tests
window.default_brush_shape = "circle";
window.default_brush_size = 4;
window.default_eraser_size = 8;
window.default_airbrush_size = 9;
window.default_pencil_size = 1;
window.default_stroke_size = 1; // applies to lines, curves, shape outlines
// declared like this for Cypress tests
window.brush_shape = default_brush_shape;
window.brush_size = default_brush_size
window.eraser_size = default_eraser_size;
window.airbrush_size = default_airbrush_size;
window.pencil_size = default_pencil_size;
window.stroke_size = default_stroke_size; // applies to lines, curves, shape outlines
let tool_transparent_mode = false;
let stroke_color;
let fill_color;
let stroke_color_k = "foreground"; // enum of "foreground", "background", "ternary"
let fill_color_k = "background"; // enum of "foreground", "background", "ternary"
let selected_tool = default_tool;
let selected_tools = [selected_tool];
let return_to_tools = [selected_tool];
window.selected_colors = { // declared like this for Cypress tests
foreground: "",
background: "",
ternary: "",
};
let selection; //the one and only OnCanvasSelection
let textbox; //the one and only OnCanvasTextBox
let helper_layer; //the OnCanvasHelperLayer for the grid and tool previews
let show_grid = false;
let text_tool_font = {
family: '"Arial"', // should be an exact value detected by Font Detective
size: 12,
line_scale: 20 / 12,
bold: false,
italic: false,
underline: false,
vertical: false,
color: "",
background: "",
};
let root_history_node = make_history_node({name: "App Not Loaded Properly - Please send a bug report."}); // will be replaced
let current_history_node = root_history_node;
let history_node_to_cancel_to = null;
/** array of history nodes */
let undos = [];
/** array of history nodes */
let redos = [];
let file_name;
let system_file_handle; // For saving over opened file on Save. Can be different type for File System Access API vs Electron.
let saved = true;
/** works in canvas coordinates */
let pointer;
/** works in canvas coordinates */
let pointer_start;
/** works in canvas coordinates */
let pointer_previous;
let pointer_active = false;
let pointer_type, pointer_buttons;
let reverse;
let ctrl;
let button;
let pointer_over_canvas = false;
let update_helper_layer_on_pointermove_active = false;
/** works in client coordinates */
let pointers = [];
const update_from_url_params = ()=> {
if (location.hash.match(/eye-gaze-mode/i)) {
if (!$("body").hasClass("eye-gaze-mode")) {
$("body").addClass("eye-gaze-mode");
$G.triggerHandler("eye-gaze-mode-toggled");
$G.triggerHandler("theme-load"); // signal layout change
}
} else {
if ($("body").hasClass("eye-gaze-mode")) {
$("body").removeClass("eye-gaze-mode");
$G.triggerHandler("eye-gaze-mode-toggled");
$G.triggerHandler("theme-load"); // signal layout change
}
}
if (location.hash.match(/vertical-color-box-mode|eye-gaze-mode/i)) {
if (!$("body").hasClass("vertical-color-box-mode")) {
$("body").addClass("vertical-color-box-mode");
$G.triggerHandler("vertical-color-box-mode-toggled");
$G.triggerHandler("theme-load"); // signal layout change
}
} else {
if ($("body").hasClass("vertical-color-box-mode")) {
$("body").removeClass("vertical-color-box-mode");
$G.triggerHandler("vertical-color-box-mode-toggled");
$G.triggerHandler("theme-load"); // signal layout change
}
}
if (location.hash.match(/speech-recognition-mode/i)) {
window.enable_speech_recognition && enable_speech_recognition();
} else {
window.disable_speech_recognition && disable_speech_recognition();
}
$("body").toggleClass("compare-reference", !!location.hash.match(/compare-reference/i));
$("body").toggleClass("compare-reference-tool-windows", !!location.hash.match(/compare-reference-tool-windows/i));
setTimeout(() => {
if (location.hash.match(/compare-reference/i)) { // including compare-reference-tool-windows
select_tool(get_tool_by_id(TOOL_SELECT));
const test_canvas_width = 576;
const test_canvas_height = 432;
if (main_canvas.width !== test_canvas_width || main_canvas.height !== test_canvas_height) {
// Unfortunately, right now this can cause a reverse "Save changes?" dialog,
// where Discard will restore your drawing, Cancel will discard it, and Save will save a blank canvas,
// because the load from storage happens after this resize.
// But this is just a helper for development, so it's not a big deal.
// are_you_sure here doesn't help, either.
// are_you_sure(() => {
resize_canvas_without_saving_dimensions(test_canvas_width, test_canvas_height);
// });
}
if (!location.hash.match(/compare-reference-tool-windows/i)) {
$toolbox.dock($left);
$colorbox.dock($bottom);
window.debugKeepMenusOpen = false;
}
}
if (location.hash.match(/compare-reference-tool-windows/i)) {
$toolbox.undock_to(84, 35);
$colorbox.undock_to(239, 195);
window.debugKeepMenusOpen = true;
// $(".help-menu-button").click(); // have to trigger pointerdown/up, it doesn't respond to click
// $(".help-menu-button").trigger("pointerdown").trigger("pointerup"); // and it doesn't use jQuery
$(".help-menu-button")[0].dispatchEvent(new Event("pointerdown"));
$(".help-menu-button")[0].dispatchEvent(new Event("pointerup"));
$('[aria-label="About Paint"]')[0].dispatchEvent(new Event("pointerenter"));
}
}, 500);
};
update_from_url_params();
$G.on("hashchange popstate change-url-params", update_from_url_params);
// handle backwards compatibility URLs
if (location.search.match(/eye-gaze-mode/)) {
change_url_param("eye-gaze-mode", true, {replace_history_state: true});
update_from_url_params();
}
if (location.search.match(/vertical-colors?-box/)) {
change_url_param("vertical-color-box", true, {replace_history_state: true});
update_from_url_params();
}
const $app = $(E("div")).addClass("jspaint").appendTo("body");
const $V = $(E("div")).addClass("vertical").appendTo($app);
const $H = $(E("div")).addClass("horizontal").appendTo($V);
const $canvas_area = $(E("div")).addClass("canvas-area inset-deep").appendTo($H);
const $canvas = $(main_canvas).appendTo($canvas_area);
$canvas.css("touch-action", "none");
let canvas_bounding_client_rect = main_canvas.getBoundingClientRect(); // cached for performance, updated later
const $canvas_handles = $Handles($canvas_area, $canvas_area, {
get_rect: ()=> ({x: 0, y: 0, width: main_canvas.width, height: main_canvas.height}),
set_rect: ({width, height})=> resize_canvas_and_save_dimensions(width, height),
outset: 4,
get_handles_offset_left: ()=> parseFloat($canvas_area.css("padding-left")) + 1,
get_handles_offset_top: ()=> parseFloat($canvas_area.css("padding-top")) + 1,
get_ghost_offset_left: ()=> parseFloat($canvas_area.css("padding-left")) + 1,
get_ghost_offset_top: ()=> parseFloat($canvas_area.css("padding-top")) + 1,
size_only: true,
});
// hack: fix canvas handles causing document to scroll when selecting/deselecting
// by overriding these methods
$canvas_handles.hide = ()=> { $canvas_handles.css({opacity: 0, pointerEvents: "none"}); };
$canvas_handles.show = ()=> { $canvas_handles.css({opacity: "", pointerEvents: ""}); };
const $top = $(E("div")).addClass("component-area top").prependTo($V);
const $bottom = $(E("div")).addClass("component-area bottom").appendTo($V);
const $left = $(E("div")).addClass("component-area left").prependTo($H);
const $right = $(E("div")).addClass("component-area right").appendTo($H);
// there's also probably a CSS solution alternative to this
if (get_direction() === "rtl") {
$left.appendTo($H);
$right.prependTo($H);
}
const $status_area = $(E("div")).addClass("status-area").appendTo($V);
const $status_text = $(E("div")).addClass("status-text status-field inset-shallow").appendTo($status_area);
const $status_position = $(E("div")).addClass("status-coordinates status-field inset-shallow").appendTo($status_area);
const $status_size = $(E("div")).addClass("status-coordinates status-field inset-shallow").appendTo($status_area);
const $news_indicator = $(`
<a class='news-indicator' href='#project-news'>
<img src='images/winter/present.png' width='24' height='22' alt=''/>
<span class='marquee' dir='ltr' style='--text-width: 52ch; --animation-duration: 5s;'>
<span>
<strong>New!</strong>&nbsp;Localization, Eye Gaze Mode, and Speech Recognition!
</span>
</span>
</a>
`);
$news_indicator.on("click auxclick", (event)=> {
event.preventDefault();
show_news();
});
// @TODO: use localStorage to show until clicked, if available
// and show for a longer period of time after the update, if available
if (Date.now() < Date.parse("Jan 5 2021 23:42:42 GMT-0500")) {
$status_area.append($news_indicator);
}
$status_text.default = () => {
$status_text.text(localize("For Help, click Help Topics on the Help Menu."));
};
$status_text.default();
// menu bar
let menu_bar_outside_frame = false;
if(frameElement){
try{
if(parent.MenuBar){
MenuBar = parent.MenuBar;
menu_bar_outside_frame = true;
}
// eslint-disable-next-line no-empty
}catch(e){}
}
const menu_bar = MenuBar(menus);
if(menu_bar_outside_frame){
$(menu_bar.element).insertBefore(frameElement);
}else{
$(menu_bar.element).prependTo($V);
}
$(menu_bar.element).on("info", (event) => {
$status_text.text(event.detail?.description ?? "");
});
$(menu_bar.element).on("default-info", ()=> {
$status_text.default();
});
// </menu bar>
let $toolbox = $ToolBox(tools);
// let $toolbox2 = $ToolBox(extra_tools, true);//.hide();
// Note: a second $ToolBox doesn't work because they use the same tool options (which could be remedied)
// If there's to be extra tools, they should probably get a window, with different UI
// so it can display names of the tools, and maybe authors and previews (and not necessarily icons)
let $colorbox = $ColorBox($("body").hasClass("vertical-color-box-mode"));
$G.on("vertical-color-box-mode-toggled", ()=> {
$colorbox.destroy();
$colorbox = $ColorBox($("body").hasClass("vertical-color-box-mode"));
prevent_selection($colorbox);
});
$G.on("eye-gaze-mode-toggled", ()=> {
$colorbox.destroy();
$colorbox = $ColorBox($("body").hasClass("vertical-color-box-mode"));
prevent_selection($colorbox);
$toolbox.destroy();
$toolbox = $ToolBox(tools);
prevent_selection($toolbox);
// $toolbox2.destroy();
// $toolbox2 = $ToolBox(extra_tools, true);
// prevent_selection($toolbox2);
});
$G.on("resize", () => { // for browser zoom, and in-app zoom of the canvas
update_canvas_rect();
update_disable_aa();
});
$canvas_area.on("scroll", () => {
update_canvas_rect();
});
$canvas_area.on("resize", () => {
update_magnified_canvas_size();
});
// Despite overflow:hidden on html and body,
// focusing elements that are partially offscreen can still scroll the page.
// For example, with Edit Colors dialog partially offscreen, navigating the color grid.
// We need to prevent (reset) scroll on focus, and also avoid scrollIntoView().
// Listening for scroll here is mainly in case a case is forgotten, like scrollIntoView,
// in which case it will flash sometimes but at least not end up with part of
// the application scrolled off the screen with no scrollbar to get it back.
$G.on("scroll focusin", () => {
window.scrollTo(0, 0);
});
$("body").on("dragover dragenter", (event) => {
const dt = event.originalEvent.dataTransfer;
const has_files = dt && Array.from(dt.types).includes("Files");
if (has_files) {
event.preventDefault();
}
}).on("drop", async (event) => {
if (event.isDefaultPrevented()) {
return;
}
const dt = event.originalEvent.dataTransfer;
const has_files = dt && Array.from(dt.types).includes("Files");
if (has_files) {
event.preventDefault();
// @TODO: sort files/items in priority of image, theme, palette
// and then try loading them in series, with async await to avoid race conditions?
// or maybe support opening multiple documents in tabs
// Note: don't use FS Access API in Electron app because:
// 1. it's faulty (permissions problems, 0 byte files maybe due to the perms problems)
// 2. we want to save the file.path, which the dt.files code path takes care of
if (window.FileSystemHandle && !window.is_electron_app) {
for (const item of dt.items) {
// kind will be 'file' for file/directory entries.
if (item.kind === 'file') {
let handle;
try {
handle = await item.getAsFileSystemHandle();
} catch (error) {
// I'm not sure when this happens.
// should this use "An invalid file handle was associated with %1." message?
show_error_message(localize("File not found."), error);
return;
}
if (handle.kind === 'file') {
let file;
try {
file = await handle.getFile();
} catch (error) {
// NotFoundError can happen when the file was moved or deleted,
// then dragged and dropped via the browser's downloads bar, or some other outdated file listing.
show_error_message(localize("File not found."), error);
return;
}
open_from_file(file, handle);
if (window._open_images_serially) {
// For testing a suite of files:
await new Promise(resolve => setTimeout(resolve, 500));
} else {
// Normal behavior: only open one file.
return;
}
}
// else if (handle.kind === 'directory') {}
}
}
} else if (dt.files && dt.files.length) {
if (window._open_images_serially) {
// For testing a suite of files, such as http://www.schaik.com/pngsuite/
let i = 0;
const iid = setInterval(() => {
console.log("opening", dt.files[i].name);
open_from_file(dt.files[i]);
i++;
if (i >= dt.files.length) {
clearInterval(iid);
}
}, 1500);
} else {
// Normal behavior: only open one file.
open_from_file(dt.files[0]);
}
}
}
});
$G.on("keydown", e => {
if(e.isDefaultPrevented()){
return;
}
if (e.key === "Escape") { // Note: Escape handled below too! (after input/textarea return condition)
if (textbox && textbox.$editor.is(e.target)) {
deselect();
}
}
if (
// Ctrl+Shift+Y
(e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey &&
e.key.toUpperCase() === "Y"
) {
show_document_history();
e.preventDefault();
return;
}
// @TODO: return if menus/menubar focused or focus in dialog window
// or maybe there's a better way to do this that works more generally
// maybe it should only handle the event if document.activeElement is the body or html element?
// (or $app could have a tabIndex and no focus style and be focused under various conditions,
// if that turned out to make more sense for some reason)
if(
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
){
return;
}
// @TODO: preventDefault in all cases where the event is handled
// also, ideally check that modifiers *aren't* pressed
// probably best to use a library at this point!
if(selection){
const nudge_selection = (delta_x, delta_y) => {
selection.x += delta_x;
selection.y += delta_y;
selection.position();
};
switch(e.key){
case "ArrowLeft":
nudge_selection(-1, 0);
e.preventDefault();
break;
case "ArrowRight":
nudge_selection(+1, 0);
e.preventDefault();
break;
case "ArrowDown":
nudge_selection(0, +1);
e.preventDefault();
break;
case "ArrowUp":
nudge_selection(0, -1);
e.preventDefault();
break;
}
}
if (e.key === "Escape") { // Note: Escape handled above too!
if (selection) {
deselect();
} else {
cancel();
}
window.stopSimulatingGestures && window.stopSimulatingGestures();
window.trace_and_sketch_stop && window.trace_and_sketch_stop();
} else if (e.key === "Enter") {
if(selection){
deselect();
}
} else if (e.key === "F4") {
redo();
} else if (e.key === "Delete") {
delete_selection();
} else if (e.code === "NumpadAdd" || e.code === "NumpadSubtract") {
const plus = e.code === "NumpadAdd";
const minus = e.code === "NumpadSubtract";
const delta = plus - minus; // const delta = +plus++ -minus--; // Δ = ±±±±
if(selection){
selection.scale(2 ** delta);
}else{
if(selected_tool.id === TOOL_BRUSH){
brush_size = Math.max(1, Math.min(brush_size + delta, 500));
}else if(selected_tool.id === TOOL_ERASER){
eraser_size = Math.max(1, Math.min(eraser_size + delta, 500));
}else if(selected_tool.id === TOOL_AIRBRUSH){
airbrush_size = Math.max(1, Math.min(airbrush_size + delta, 500));
}else if(selected_tool.id === TOOL_PENCIL){
pencil_size = Math.max(1, Math.min(pencil_size + delta, 50));
}else if(
selected_tool.id === TOOL_LINE ||
selected_tool.id === TOOL_CURVE ||
selected_tool.id === TOOL_RECTANGLE ||
selected_tool.id === TOOL_ROUNDED_RECTANGLE ||
selected_tool.id === TOOL_ELLIPSE ||
selected_tool.id === TOOL_POLYGON
) {
stroke_size = Math.max(1, Math.min(stroke_size + delta, 500));
}
$G.trigger("option-changed");
if(button !== undefined && pointer){ // pointer may only be needed for tests
selected_tools.forEach((selected_tool)=> {
tool_go(selected_tool);
});
}
update_helper_layer();
}
e.preventDefault();
return;
}else if(e.ctrlKey || e.metaKey){
if(textbox){
switch(e.key.toUpperCase()){
case "A":
case "Z":
case "Y":
case "I":
case "B":
case "U":
// Don't prevent the default. Allow text editing commands.
return;
}
}
switch (e.key.toUpperCase()) {
case ",": // '<' without Shift
case "<":
case "[":
case "{":
rotate(-TAU/4);
$canvas_area.trigger("resize");
break;
case ".": // '>' without Shift
case ">":
case "]":
case "}":
rotate(+TAU/4);
$canvas_area.trigger("resize");
break;
case "Z":
e.shiftKey ? redo() : undo();
break;
case "Y":
// Ctrl+Shift+Y handled above
redo();
break;
case "G":
e.shiftKey ? render_history_as_gif() : toggle_grid();
break;
case "F":
view_bitmap();
break;
case "O":
file_open();
break;
case "N":
e.shiftKey ? clear() : file_new();
break;
case "S":
e.shiftKey ? file_save_as() : file_save();
break;
case "A":
select_all();
break;
case "I":
image_invert_colors();
break;
case "E":
image_attributes();
break;
default:
return; // don't preventDefault
}
e.preventDefault();
}
});
// $G.on("wheel", (e) => {
addEventListener("wheel", (e) => {
if (e.ctrlKey || e.metaKey) {
return;
}
// for reference screenshot mode (development helper):
if (location.hash.match(/compare-reference/i)) { // including compare-reference-tool-windows
// const delta_opacity = Math.sign(e.originalEvent.deltaY) * -0.1; // since attr() is not supported other than for content, this increment must match CSS
const delta_opacity = Math.sign(e.deltaY) * -0.2; // since attr() is not supported other than for content, this increment must match CSS
let old_opacity = parseFloat($("body").attr("data-reference-opacity"));
if (!isFinite(old_opacity)) {
old_opacity = 0.5;
}
const new_opacity = Math.max(0, Math.min(1, old_opacity + delta_opacity));
$("body").attr("data-reference-opacity", new_opacity);
// prevent scrolling, keeping the screenshot lined up
// e.preventDefault(); // doesn't work
// $canvas_area.scrollTop(0); // doesn't work with smooth scrolling
// $canvas_area.scrollLeft(0);
}
});
$G.on("cut copy paste", e => {
if(e.isDefaultPrevented()){
return;
}
if(
document.activeElement instanceof HTMLInputElement ||
document.activeElement instanceof HTMLTextAreaElement ||
!window.getSelection().isCollapsed
){
// Don't prevent cutting/copying/pasting within inputs or textareas, or if there's a selection
return;
}
e.preventDefault();
const cd = e.originalEvent.clipboardData || window.clipboardData;
if(!cd){ return; }
if(e.type === "copy" || e.type === "cut"){
if(selection && selection.canvas){
const do_sync_clipboard_copy_or_cut = () => {
// works only for pasting within a jspaint instance
const data_url = selection.canvas.toDataURL();
cd.setData("text/x-data-uri; type=image/png", data_url);
cd.setData("text/uri-list", data_url);
cd.setData("URL", data_url);
if(e.type === "cut"){
delete_selection({
name: localize("Cut"),
icon: get_help_folder_icon("p_cut.png"),
});
}
};
if (!navigator.clipboard || !navigator.clipboard.write) {
return do_sync_clipboard_copy_or_cut();
}
try {
if (e.type === "cut") {
edit_cut();
} else {
edit_copy();
}
} catch(e) {
do_sync_clipboard_copy_or_cut();
}
}
}else if(e.type === "paste"){
for (const item of cd.items) {
if(item.type.match(/^text\/(?:x-data-uri|uri-list|plain)|URL$/)){
item.getAsString(text => {
const uris = get_uris(text);
if (uris.length > 0) {
load_image_from_uri(uris[0]).then((info) => {
paste(info.image || make_canvas(info.image_data));
}, (error) => {
show_resource_load_error_message(error);
});
} else {
show_error_message("The information on the Clipboard can't be inserted into Paint.");
}
});
break;
}else if(item.type.match(/^image\//)){
paste_image_from_file(item.getAsFile());
break;
}
}
}
});
reset_file();
reset_selected_colors();
reset_canvas_and_history(); // (with newly reset colors)
set_magnification(default_magnification);
// this is synchronous for now, but @TODO: handle possibility of loading a document before callback
// when switching to asynchronous storage, e.g. with localforage
storage.get({
width: default_canvas_width,
height: default_canvas_height,
}, (err, stored_values) => {
if(err){return;}
my_canvas_width = stored_values.width;
my_canvas_height = stored_values.height;
make_or_update_undoable({
match: (history_node)=> history_node.name === localize("New"),
name: "Resize Canvas For New Document",
icon: get_help_folder_icon("p_stretch_both.png"),
}, ()=> {
main_canvas.width = Math.max(1, my_canvas_width);
main_canvas.height = Math.max(1, my_canvas_height);
main_ctx.disable_image_smoothing();
if(!transparency){
main_ctx.fillStyle = selected_colors.background;
main_ctx.fillRect(0, 0, main_canvas.width, main_canvas.height);
}
$canvas_area.trigger("resize");
});
});
if (window.initial_system_file_handle) {
systemHooks.readBlobFromHandle(window.initial_system_file_handle).then(file => {
if (file) {
open_from_file(file, window.initial_system_file_handle);
}
}, (error) => {
// this handler is not always called, sometimes error message is shown from readBlobFromHandle
show_error_message(`Failed to open file ${window.initial_system_file_handle}`, error);
});
}
const lerp = (a, b, b_ness)=> a + (b - a) * b_ness;
const color_ramp = (num_colors, start_hsla, end_hsla)=>
Array(num_colors).fill().map((_undefined, index, array)=>
`hsla(${
lerp(start_hsla[0], end_hsla[0], index/array.length)
}deg, ${
lerp(start_hsla[1], end_hsla[1], index/array.length)
}%, ${
lerp(start_hsla[2], end_hsla[2], index/array.length)
}%, ${
lerp(start_hsla[3], end_hsla[3], index/array.length)
}%)`
);
const update_palette_from_theme = ()=> {
if (get_theme() === "winter.css") {
const make_stripe_patterns = (reverse)=> [
make_stripe_pattern(reverse, [
"hsl(166, 93%, 38%)",
"white",
]),
make_stripe_pattern(reverse, [
"white",
"hsl(355, 78%, 46%)",
]),
make_stripe_pattern(reverse, [
"hsl(355, 78%, 46%)",
"white",
"white",
"hsl(355, 78%, 46%)",
"hsl(355, 78%, 46%)",
"hsl(355, 78%, 46%)",
"white",
"white",
"hsl(355, 78%, 46%)",
"white",
], 2),
make_stripe_pattern(reverse, [
"hsl(166, 93%, 38%)",
"white",
"white",
"hsl(166, 93%, 38%)",
"hsl(166, 93%, 38%)",
"hsl(166, 93%, 38%)",
"white",
"white",
"hsl(166, 93%, 38%)",
"white",
], 2),
make_stripe_pattern(reverse, [
"hsl(166, 93%, 38%)",
"white",
"hsl(355, 78%, 46%)",
"white",
], 2),
];
palette = [
"black",
// green
"hsl(91, 55%, 81%)",
"hsl(142, 57%, 64%)",
"hsl(166, 93%, 38%)",
"#04ce1f", // elf green
"hsl(159, 93%, 16%)",
// red
"hsl(2, 77%, 27%)",
"hsl(350, 100%, 50%)",
"hsl(356, 97%, 64%)",
// brown
"#ad4632",
"#5b3b1d",
// stripes
...make_stripe_patterns(false),
// white to blue
...color_ramp(
6,
[200, 100, 100, 100],
[200, 100, 10, 100],
),
// pink
"#fcbaf8",
// silver
"hsl(0, 0%, 90%)",
"hsl(22, 5%, 71%)",
// gold
"hsl(48, 82%, 54%)",
"hsl(49, 82%, 72%)",
// stripes
...make_stripe_patterns(true),
];
$colorbox.rebuild_palette();
} else {
palette = default_palette;
$colorbox.rebuild_palette();
}
};
$G.on("theme-load", update_palette_from_theme);
update_palette_from_theme();
function to_canvas_coords({clientX, clientY}) {
const rect = canvas_bounding_client_rect;
const cx = clientX - rect.left;
const cy = clientY - rect.top;
return {
x: ~~(cx / rect.width * main_canvas.width),
y: ~~(cy / rect.height * main_canvas.height),
};
}
function update_fill_and_stroke_colors_and_lineWidth(selected_tool) {
main_ctx.lineWidth = stroke_size;
const reverse_because_fill_only = selected_tool.$options && selected_tool.$options.fill && !selected_tool.$options.stroke;
main_ctx.fillStyle = fill_color =
main_ctx.strokeStyle = stroke_color =
selected_colors[
(ctrl && selected_colors.ternary && pointer_active) ? "ternary" :
((reverse ^ reverse_because_fill_only) ? "background" : "foreground")
];
fill_color_k =
stroke_color_k =
ctrl ? "ternary" : ((reverse ^ reverse_because_fill_only) ? "background" : "foreground");
if(selected_tool.shape || selected_tool.shape_colors){
if(!selected_tool.stroke_only){
if((reverse ^ reverse_because_fill_only)){
fill_color_k = "foreground";
stroke_color_k = "background";
}else{
fill_color_k = "background";
stroke_color_k = "foreground";
}
}
main_ctx.fillStyle = fill_color = selected_colors[fill_color_k];
main_ctx.strokeStyle = stroke_color = selected_colors[stroke_color_k];
}
}
function tool_go(selected_tool, event_name){
update_fill_and_stroke_colors_and_lineWidth(selected_tool);
if(selected_tool[event_name]){
selected_tool[event_name](main_ctx, pointer.x, pointer.y);
}
if(selected_tool.paint){
selected_tool.paint(main_ctx, pointer.x, pointer.y);
}
}
function canvas_pointer_move(e){
ctrl = e.ctrlKey;
shift = e.shiftKey;
pointer = to_canvas_coords(e);
// Quick Undo
// (Note: pointermove also occurs when the set of buttons pressed changes,
// except when another event would fire like pointerdown)
if(pointers.length && e.button != -1){
// compare buttons other than middle mouse button by using bitwise OR to make that bit of the number the same
const MMB = 4;
if(e.pointerType != pointer_type || (e.buttons | MMB) != (pointer_buttons | MMB)){
cancel();
pointer_active = false; // NOTE: pointer_active used in cancel()
return;
}
}
if(e.shiftKey){
if(
selected_tool.id === TOOL_LINE ||
selected_tool.id === TOOL_CURVE
) {
// snap to eight directions
const dist = Math.sqrt(
(pointer.y - pointer_start.y) * (pointer.y - pointer_start.y) +
(pointer.x - pointer_start.x) * (pointer.x - pointer_start.x)
);
const eighth_turn = TAU / 8;
const angle_0_to_8 = Math.atan2(pointer.y - pointer_start.y, pointer.x - pointer_start.x) / eighth_turn;
const angle = Math.round(angle_0_to_8) * eighth_turn;
pointer.x = Math.round(pointer_start.x + Math.cos(angle) * dist);
pointer.y = Math.round(pointer_start.y + Math.sin(angle) * dist);
}else if(selected_tool.shape){
// snap to four diagonals
const w = Math.abs(pointer.x - pointer_start.x);
const h = Math.abs(pointer.y - pointer_start.y);
if(w < h){
if(pointer.y > pointer_start.y){
pointer.y = pointer_start.y + w;
}else{
pointer.y = pointer_start.y - w;
}
}else{
if(pointer.x > pointer_start.x){
pointer.x = pointer_start.x + h;
}else{
pointer.x = pointer_start.x - h;
}
}
}
}
selected_tools.forEach((selected_tool)=> {
tool_go(selected_tool);
});
pointer_previous = pointer;
}
$canvas.on("pointermove", e => {
pointer = to_canvas_coords(e);
$status_position.text(`${pointer.x},${pointer.y}`);
});
$canvas.on("pointerenter", ()=> {
pointer_over_canvas = true;
update_helper_layer();
if (!update_helper_layer_on_pointermove_active) {
$G.on("pointermove", update_helper_layer);
update_helper_layer_on_pointermove_active = true;
}
});
$canvas.on("pointerleave", ()=> {
pointer_over_canvas = false;
$status_position.text("");
update_helper_layer();
if (!pointer_active && update_helper_layer_on_pointermove_active) {
$G.off("pointermove", update_helper_layer);
update_helper_layer_on_pointermove_active = false;
}
});
let clean_up_eye_gaze_mode = ()=> {};
$G.on("eye-gaze-mode-toggled", ()=> {
if ($("body").hasClass("eye-gaze-mode")) {
init_eye_gaze_mode();
} else {
clean_up_eye_gaze_mode();
}
});
if ($("body").hasClass("eye-gaze-mode")) {
init_eye_gaze_mode();
}
const eye_gaze_mode_config = {
targets: `
button:not([disabled]),
input,
textarea,
label,
a,
.flip-and-rotate .sub-options .radio-wrapper,
.current-colors,
.color-button,
.edit-colors-window .swatch,
.edit-colors-window .rainbow-canvas,
.edit-colors-window .luminosity-canvas,
.tool:not(.selected),
.chooser-option,
.menu-button:not(.active),
.menu-item,
.main-canvas,
.selection canvas,
.handle,
.grab-region,
.window:not(.maximized) .window-titlebar,
.history-entry
`,
noCenter: (target) => (
target.matches(`
.main-canvas,
.selection canvas,
.window-titlebar,
.rainbow-canvas,
.luminosity-canvas,
input[type="range"]
`)
),
retarget: [
// Nudge hovers near the edges of the canvas onto the canvas
{ from: ".canvas-area", to: ".main-canvas", withinMargin: 50 },
// Top level menus are just immediately switched between for now.
// Prevent awkward hover clicks on top level menu buttons while menus are open.
{
from: (target) => (
(target.closest(".menu-button") || target.matches(".menu-container")) &&
document.querySelector(".menu-button.active") != null
),
to: null,
},
// Can we make it easier to click on help topics with short names?
// { from: ".help-window li", to: (target) => target.querySelector(".item")},
],
isEquivalentTarget: (apparent_hover_target, hover_target) => (
apparent_hover_target.closest("label") === hover_target ||
apparent_hover_target.closest(".radio-wrapper") === hover_target
),
dwellClickEvenIfPaused: (target) => (
target.matches(".toggle-dwell-clicking")
),
shouldDrag: (target)=> (
target.matches(".window-titlebar, .window-titlebar *:not(button)") ||
target.matches(".selection, .selection *, .handle, .grab-region") ||
(
target === main_canvas &&
selected_tool.id !== TOOL_PICK_COLOR &&
selected_tool.id !== TOOL_FILL &&
selected_tool.id !== TOOL_MAGNIFIER &&
selected_tool.id !== TOOL_POLYGON &&
selected_tool.id !== TOOL_CURVE
)
),
click: ({ target, x, y }) => {
if (target.matches("button:not(.toggle)")) {
target.style.borderImage = "var(--inset-deep-border-image)";
setTimeout(() => {
target.style.borderImage = "";
// delay the button.click() as well, so the pressed state is
// visible even if the button closes a dialog
window.untrusted_gesture = true;
target.click();
window.untrusted_gesture = false;
}, 100);
} else if (target.matches("input[type='range']")) {
const rect = target.getBoundingClientRect();
const vertical =
target.getAttribute("orient") === "vertical" ||
(getCurrentRotation(target) !== 0) ||
rect.height > rect.width;
const min = Number(target.min);
const max = Number(target.max);
const v = (
vertical ?
(y - rect.top) / rect.height :
(x - rect.left) / rect.width
) * (max - min) + min;
target.value = v;
window.untrusted_gesture = true;
target.dispatchEvent(new Event("input", { bubbles: true }));
target.dispatchEvent(new Event("change", { bubbles: true }));
window.untrusted_gesture = false;
} else {
window.untrusted_gesture = true;
target.click();
if (target.matches("input, textarea")) {
target.focus();
}
window.untrusted_gesture = false;
}
// Source: https://stackoverflow.com/a/54492696/2624876
function getCurrentRotation(el) {
const st = window.getComputedStyle(el, null);
const tm = st.getPropertyValue("-webkit-transform") ||
st.getPropertyValue("-moz-transform") ||
st.getPropertyValue("-ms-transform") ||
st.getPropertyValue("-o-transform") ||
st.getPropertyValue("transform") ||
"none";
if (tm !== "none") {
const [a, b] = tm.split('(')[1].split(')')[0].split(',');
return Math.round(Math.atan2(a, b) * (180 / Math.PI));
}
return 0;
}
},
};
var enable_tracky_mouse = false;
var tracky_mouse_deps_promise;
async function init_eye_gaze_mode() {
if (enable_tracky_mouse) {
if (!tracky_mouse_deps_promise) {
TrackyMouse.dependenciesRoot = "lib/tracky-mouse";
tracky_mouse_deps_promise = TrackyMouse.loadDependencies();
}
await tracky_mouse_deps_promise;
const $tracky_mouse_window = $Window({
title: "Tracky Mouse",
icon: "tracky-mouse",
});
$tracky_mouse_window.addClass("tracky-mouse-window");
const tracky_mouse_container = $tracky_mouse_window.$content[0];
TrackyMouse.init(tracky_mouse_container);
TrackyMouse.useCamera();
$tracky_mouse_window.center();
let last_el_over;
TrackyMouse.onPointerMove = (x, y) => {
const target = document.elementFromPoint(x, y) || document.body;
if (target !== last_el_over) {
if (last_el_over) {
window.untrusted_gesture = true;
const event = new /*PointerEvent*/$.Event("pointerleave", Object.assign(get_event_options({ x, y }), {
button: 0,
buttons: 1,
bubbles: false,
cancelable: false,
}));
// last_el_over.dispatchEvent(event);
$(last_el_over).trigger(event);
window.untrusted_gesture = false;
}
window.untrusted_gesture = true;
const event = new /*PointerEvent*/$.Event("pointerenter", Object.assign(get_event_options({ x, y }), {
button: 0,
buttons: 1,
bubbles: false,
cancelable: false,
}));
// target.dispatchEvent(event);
$(target).trigger(event);
window.untrusted_gesture = false;
last_el_over = target;
}
window.untrusted_gesture = true;
const event = new PointerEvent/*$.Event*/("pointermove", Object.assign(get_event_options({ x, y }), {
button: 0,
buttons: 1,
}));
target.dispatchEvent(event);
// $(target).trigger(event);
window.untrusted_gesture = false;
};
// tracky_mouse_container.querySelector(".tracky-mouse-canvas").classList.add("inset-deep");
}
const circle_radius_max = 50; // dwell indicator size in pixels
const hover_timespan = 500; // how long between the dwell indicator appearing and triggering a click
const averaging_window_timespan = 500;
const inactive_at_startup_timespan = 1500; // (should be at least averaging_window_timespan, but more importantly enough to make it not awkward when enabling eye gaze mode)
const inactive_after_release_timespan = 1000; // after click or drag release (from dwell or otherwise)
const inactive_after_hovered_timespan = 1000; // after dwell click indicator appears; does not control the time to finish that dwell click, only to click on something else after this is canceled (but it doesn't control that directly)
const inactive_after_invalid_timespan = 1000; // after a dwell click is canceled due to an element popping up in front, or existing in front at the center of the other element
const inactive_after_focused_timespan = 1000; // after page becomes focused after being unfocused
let recent_points = [];
let inactive_until_time = Date.now();
let paused = false;
let hover_candidate;
let gaze_dragging = null;
const deactivate_for_at_least = (timespan)=> {
inactive_until_time = Math.max(inactive_until_time, Date.now() + timespan);
};
deactivate_for_at_least(inactive_at_startup_timespan);
const halo = document.createElement("div");
halo.className = "hover-halo";
halo.style.display = "none";
document.body.appendChild(halo);
const dwell_indicator = document.createElement("div");
dwell_indicator.className = "dwell-indicator";
dwell_indicator.style.width = `${circle_radius_max}px`;
dwell_indicator.style.height = `${circle_radius_max}px`;
dwell_indicator.style.display = "none";
document.body.appendChild(dwell_indicator);
const on_pointer_move = (e)=> {
recent_points.push({x: e.clientX, y: e.clientY, time: Date.now()});
};
const on_pointer_up_or_cancel = (e)=> {
deactivate_for_at_least(inactive_after_release_timespan);
gaze_dragging = null;
};
let page_focused = document.visibilityState === "visible"; // guess/assumption
let mouse_inside_page = true; // assumption
const on_focus = ()=> {
page_focused = true;
deactivate_for_at_least(inactive_after_focused_timespan);
};
const on_blur = ()=> {
page_focused = false;
};
const on_mouse_leave_page = ()=> {
mouse_inside_page = false;
};
const on_mouse_enter_page = ()=> {
mouse_inside_page = true;
};
window.addEventListener("pointermove", on_pointer_move);
window.addEventListener("pointerup", on_pointer_up_or_cancel);
window.addEventListener("pointercancel", on_pointer_up_or_cancel);
window.addEventListener("focus", on_focus);
window.addEventListener("blur", on_blur);
document.addEventListener("mouseleave", on_mouse_leave_page);
document.addEventListener("mouseenter", on_mouse_enter_page);
const get_hover_candidate = (clientX, clientY)=> {
if (!page_focused || !mouse_inside_page) return null;
let target = document.elementFromPoint(clientX, clientY);
if (!target) {
return null;
}
let hover_candidate = {
x: clientX,
y: clientY,
time: Date.now(),
};
let retargeted = false;
for (const {from, to, withinMargin=Infinity} of eye_gaze_mode_config.retarget) {
if (
from instanceof Element ? from === target :
typeof from === "function" ? from(target) :
target.matches(from)
) {
const to_element =
(to instanceof Element || to === null) ? to :
typeof to === "function" ? to(target) :
(target.closest(to) || target.querySelector(to));
if (to_element === null) {
return null;
} else if (to_element) {
const to_rect = to_element.getBoundingClientRect();
if (
hover_candidate.x > to_rect.left - withinMargin &&
hover_candidate.y > to_rect.top - withinMargin &&
hover_candidate.x < to_rect.right + withinMargin &&
hover_candidate.y < to_rect.bottom + withinMargin
) {
target = to_element;
hover_candidate.x = Math.min(
to_rect.right - 1,
Math.max(
to_rect.left,
hover_candidate.x,
),
);
hover_candidate.y = Math.min(
to_rect.bottom - 1,
Math.max(
to_rect.top,
hover_candidate.y,
),
);
retargeted = true;
}
}
}
}
if (!retargeted) {
target = target.closest(eye_gaze_mode_config.targets);
if (!target) {
return null;
}
}
if (!eye_gaze_mode_config.noCenter(target)) {
// Nudge hover previews to the center of buttons and things
const rect = target.getBoundingClientRect();
hover_candidate.x = rect.left + rect.width / 2;
hover_candidate.y = rect.top + rect.height / 2;
}
hover_candidate.target = target;
return hover_candidate;
};
const get_event_options = ({x, y})=> {
return {
view: window, // needed for offsetX/Y calculation
clientX: x,
clientY: y,
pointerId: 1234567890,
pointerType: "mouse",
isPrimary: true,
bubbles: true,
cancelable: true,
};
};
const update = ()=> {
const time = Date.now();
recent_points = recent_points.filter((point_record)=> time < point_record.time + averaging_window_timespan);
if (recent_points.length) {
const latest_point = recent_points[recent_points.length - 1];
recent_points.push({x: latest_point.x, y: latest_point.y, time});
const average_point = average_points(recent_points);
// debug
// const canvas_point = to_canvas_coords({clientX: average_point.x, clientY: average_point.y});
// ctx.fillStyle = "red";
// ctx.fillRect(canvas_point.x, canvas_point.y, 10, 10);
const recent_movement_amount = Math.hypot(latest_point.x - average_point.x, latest_point.y - average_point.y);
// Invalidate in case an element pops up in front of the element you're hovering over, e.g. a submenu
// (that use case doesn't actually work because the menu pops up before the hover_candidate exists)
// (TODO: disable hovering to open submenus in eye gaze mode)
// or an element occludes the center of an element you're hovering over, in which case it
// could be confusing if it showed a dwell click indicator over a different element than it would click
// (but TODO: just move the indicator off center in that case)
if (hover_candidate && !gaze_dragging) {
const apparent_hover_candidate = get_hover_candidate(hover_candidate.x, hover_candidate.y);
const show_occluder_indicator = (occluder)=> {
const occluder_indicator = document.createElement("div");
const occluder_rect = occluder.getBoundingClientRect();
const outline_width = 4;
occluder_indicator.style.pointerEvents = "none";
occluder_indicator.style.zIndex = 1000001;
occluder_indicator.style.display = "block";
occluder_indicator.style.position = "fixed";
occluder_indicator.style.left = `${occluder_rect.left + outline_width}px`;
occluder_indicator.style.top = `${occluder_rect.top + outline_width}px`;
occluder_indicator.style.width = `${occluder_rect.width - outline_width * 2}px`;
occluder_indicator.style.height = `${occluder_rect.height - outline_width * 2}px`;
occluder_indicator.style.outline = `${outline_width}px dashed red`;
occluder_indicator.style.boxShadow = `0 0 ${outline_width}px ${outline_width}px maroon`;
document.body.appendChild(occluder_indicator);
setTimeout(() => {
occluder_indicator.remove();
}, inactive_after_invalid_timespan * 0.5);
};
if (apparent_hover_candidate) {
if (
apparent_hover_candidate.target !== hover_candidate.target &&
// !retargeted &&
!eye_gaze_mode_config.isEquivalentTarget(
apparent_hover_candidate.target, hover_candidate.target
)
) {
hover_candidate = null;
deactivate_for_at_least(inactive_after_invalid_timespan);
show_occluder_indicator(apparent_hover_candidate.target);
}
} else {
let occluder = document.elementFromPoint(hover_candidate.x, hover_candidate.y);
hover_candidate = null;
deactivate_for_at_least(inactive_after_invalid_timespan);
show_occluder_indicator(occluder || document.body);
}
}
let circle_position = latest_point;
let circle_opacity = 0;
let circle_radius = 0;
if (hover_candidate) {
circle_position = hover_candidate;
circle_opacity = 0.4;
circle_radius =
(hover_candidate.time - time + hover_timespan) / hover_timespan
* circle_radius_max;
if (time > hover_candidate.time + hover_timespan) {
if (pointer_active || gaze_dragging) {
window.untrusted_gesture = true;
hover_candidate.target.dispatchEvent(new PointerEvent("pointerup",
Object.assign(get_event_options(hover_candidate), {
button: 0,
buttons: 0,
})
));
window.untrusted_gesture = false;
} else {
pointers = []; // prevent multi-touch panning
window.untrusted_gesture = true;
hover_candidate.target.dispatchEvent(new PointerEvent("pointerdown",
Object.assign(get_event_options(hover_candidate), {
button: 0,
buttons: 1,
})
));
window.untrusted_gesture = false;
if (eye_gaze_mode_config.shouldDrag(hover_candidate.target)) {
gaze_dragging = hover_candidate.target;
} else {
window.untrusted_gesture = true;
hover_candidate.target.dispatchEvent(new PointerEvent("pointerup",
Object.assign(get_event_options(hover_candidate), {
button: 0,
buttons: 0,
})
));
eye_gaze_mode_config.click(hover_candidate);
window.untrusted_gesture = false;
}
}
hover_candidate = null;
deactivate_for_at_least(inactive_after_hovered_timespan);
}
}
if (gaze_dragging) {
dwell_indicator.classList.add("for-release");
} else {
dwell_indicator.classList.remove("for-release");
}
dwell_indicator.style.display = "";
dwell_indicator.style.opacity = circle_opacity;
dwell_indicator.style.transform = `scale(${circle_radius / circle_radius_max})`;
dwell_indicator.style.left = `${circle_position.x - circle_radius_max/2}px`;
dwell_indicator.style.top = `${circle_position.y - circle_radius_max/2}px`;
let halo_target =
gaze_dragging ||
(hover_candidate || get_hover_candidate(latest_point.x, latest_point.y) || {}).target;
if (halo_target && (!paused || eye_gaze_mode_config.dwellClickEvenIfPaused(halo_target))) {
let rect = halo_target.getBoundingClientRect();
const computed_style = getComputedStyle(halo_target);
let ancestor = halo_target;
let border_radius_scale = 1; // for border radius mimicry, given parents with transform: scale()
while (ancestor instanceof HTMLElement) {
const ancestor_computed_style = getComputedStyle(ancestor);
if (ancestor_computed_style.transform) {
// Collect scale transforms
const match = ancestor_computed_style.transform.match(/(?:scale|matrix)\((\d+(?:\.\d+)?)/);
if (match) {
border_radius_scale *= Number(match[1]);
}
}
if (ancestor_computed_style.overflow !== "visible") {
// Clamp to visible region if in scrollable area
// This lets you see the hover halo when scrolled to the middle of a large canvas
const scroll_area_rect = ancestor.getBoundingClientRect();
rect = {
left: Math.max(rect.left, scroll_area_rect.left),
top: Math.max(rect.top, scroll_area_rect.top),
right: Math.min(rect.right, scroll_area_rect.right),
bottom: Math.min(rect.bottom, scroll_area_rect.bottom),
};
rect.width = rect.right - rect.left;
rect.height = rect.bottom - rect.top;
}
ancestor = ancestor.parentNode;
}
halo.style.display = "block";
halo.style.position = "fixed";
halo.style.left = `${rect.left}px`;
halo.style.top = `${rect.top}px`;
halo.style.width = `${rect.width}px`;
halo.style.height = `${rect.height}px`;
// shorthand properties might not work in all browsers (not tested)
// this is so overkill...
halo.style.borderTopRightRadius = `${parseFloat(computed_style.borderTopRightRadius) * border_radius_scale}px`;
halo.style.borderTopLeftRadius = `${parseFloat(computed_style.borderTopLeftRadius) * border_radius_scale}px`;
halo.style.borderBottomRightRadius = `${parseFloat(computed_style.borderBottomRightRadius) * border_radius_scale}px`;
halo.style.borderBottomLeftRadius = `${parseFloat(computed_style.borderBottomLeftRadius) * border_radius_scale}px`;
} else {
halo.style.display = "none";
}
if (time < inactive_until_time) {
return;
}
if (recent_movement_amount < 5) {
if (!hover_candidate) {
hover_candidate = {
x: average_point.x,
y: average_point.y,
time: Date.now(),
target: gaze_dragging || null,
};
if (!gaze_dragging) {
hover_candidate = get_hover_candidate(hover_candidate.x, hover_candidate.y);
}
if (hover_candidate && (paused && !eye_gaze_mode_config.dwellClickEvenIfPaused(hover_candidate.target))) {
hover_candidate = null;
}
}
}
if (recent_movement_amount > 100) {
if (gaze_dragging) {
window.untrusted_gesture = true;
window.dispatchEvent(new PointerEvent("pointerup",
Object.assign(get_event_options(average_point), {
button: 0,
buttons: 0,
})
));
window.untrusted_gesture = false;
pointers = []; // prevent multi-touch panning
}
}
if (recent_movement_amount > 60) {
hover_candidate = null;
}
}
};
let raf_id;
const animate = ()=> {
raf_id = requestAnimationFrame(animate);
update();
};
raf_id = requestAnimationFrame(animate);
const $floating_buttons =
$("<div/>")
.appendTo("body")
.css({
position: "fixed",
bottom: 0,
left: 0,
transformOrigin: "bottom left",
transform: "scale(3)",
});
$("<button title='Undo'/>")
.on("click", undo)
.appendTo($floating_buttons)
.css({
width: 28,
height: 28,
verticalAlign: "bottom",
position: "relative", // to make the icon's "absolute" relative to here
})
.append(
$("<div>")
.css({
position: "absolute",
left: 0,
top: 0,
width: 24,
height: 24,
backgroundImage: "url(images/classic/undo.svg)",
})
);
// These are matched on exactly, for code that provides speech command synonyms
const pause_button_text = "Pause Dwell Clicking";
const resume_button_text = "Resume Dwell Clicking";
const $pause_button = $(`<button title="${pause_button_text}" class="toggle-dwell-clicking"/>`)
.on("click", ()=> {
paused = !paused;
$pause_button
.attr("title", paused ? resume_button_text : pause_button_text)
.find("div").css({
backgroundImage:
paused ?
"url(images/classic/eye-gaze-unpause.svg)" :
"url(images/classic/eye-gaze-pause.svg)",
});
})
.appendTo($floating_buttons)
.css({
width: 28,
height: 28,
verticalAlign: "bottom",
position: "relative", // to make the icon's "absolute" relative to here
})
.append(
$("<div>")
.css({
position: "absolute",
left: 0,
top: 0,
width: 24,
height: 24,
backgroundImage: "url(images/classic/eye-gaze-pause.svg)",
})
);
clean_up_eye_gaze_mode = ()=> {
console.log("Cleaning up / disabling eye gaze mode");
cancelAnimationFrame(raf_id);
halo.remove();
dwell_indicator.remove();
$floating_buttons.remove();
window.removeEventListener("pointermove", on_pointer_move);
window.removeEventListener("pointerup", on_pointer_up_or_cancel);
window.removeEventListener("pointercancel", on_pointer_up_or_cancel);
window.removeEventListener("focus", on_focus);
window.removeEventListener("blur", on_blur);
document.removeEventListener("mouseleave", on_mouse_leave_page);
document.removeEventListener("mouseenter", on_mouse_enter_page);
clean_up_eye_gaze_mode = ()=> {};
};
}
let pan_start_pos;
let pan_start_scroll_top;
let pan_start_scroll_left;
function average_points(points) {
const average = {x: 0, y: 0};
for (const pointer of points) {
average.x += pointer.x;
average.y += pointer.y;
}
average.x /= points.length;
average.y /= points.length;
return average;
}
$canvas_area.on("pointerdown", (event)=> {
if (document.activeElement && document.activeElement !== document.body && document.activeElement !== document.documentElement) {
// Allow unfocusing dialogs etc. in order to use keyboard shortcuts
document.activeElement.blur();
}
if (pointers.every((pointer)=>
// prevent multitouch panning in case of synthetic events from eye gaze mode
pointer.pointerId !== 1234567890 &&
// prevent multitouch panning in case of dragging across iframe boundary with a mouse/pen
// Note: there can be multiple active primary pointers, one per pointer type
!(pointer.isPrimary && (pointer.pointerType === "mouse" || pointer.pointerType === "pen"))
// @TODO: handle case of dragging across iframe boundary with touch
)) {
pointers.push({
pointerId: event.pointerId,
pointerType: event.pointerType,
// isPrimary not available on jQuery.Event, and originalEvent not available in synthetic case
isPrimary: event.originalEvent && event.originalEvent.isPrimary || event.isPrimary,
x: event.clientX,
y: event.clientY,
});
}
if (pointers.length == 2) {
pan_start_pos = average_points(pointers);
pan_start_scroll_top = $canvas_area.scrollTop();
pan_start_scroll_left = $canvas_area.scrollLeft();
}
// Quick Undo when there are multiple pointers (i.e. for touch)
// see pointermove for other pointer types
if (pointers.length >= 2) {
cancel();
pointer_active = false; // NOTE: pointer_active used in cancel()
return;
}
});
$G.on("pointerup pointercancel", (event)=> {
pointers = pointers.filter((pointer)=>
pointer.pointerId !== event.pointerId
);
});
$G.on("pointermove", (event)=> {
for (const pointer of pointers) {
if (pointer.pointerId === event.pointerId) {
pointer.x = event.clientX;
pointer.y = event.clientY;
}
}
if (pointers.length >= 2) {
const current_pos = average_points(pointers);
const difference_in_x = current_pos.x - pan_start_pos.x;
const difference_in_y = current_pos.y - pan_start_pos.y;
$canvas_area.scrollLeft(pan_start_scroll_left - difference_in_x);
$canvas_area.scrollTop(pan_start_scroll_top - difference_in_y);
}
});
// window.onerror = show_error_message;
$canvas.on("pointerdown", e => {
update_canvas_rect();
// Quick Undo when there are multiple pointers (i.e. for touch)
// see pointermove for other pointer types
// NOTE: this relies on event handler order for pointerdown
// pointer is not added to pointers yet
if(pointers.length >= 1){
cancel();
pointer_active = false; // NOTE: pointer_active used in cancel()
// in eye gaze mode, allow drawing with mouse after canceling gaze gesture with mouse
pointers = pointers.filter((pointer)=>
pointer.pointerId !== 1234567890
);
return;
}
history_node_to_cancel_to = current_history_node;
pointer_active = !!(e.buttons & (1 | 2)); // as far as tools are concerned
pointer_type = e.pointerType;
pointer_buttons = e.buttons;
$G.one("pointerup", ()=> {
pointer_active = false;
update_helper_layer();
if (!pointer_over_canvas && update_helper_layer_on_pointermove_active) {
$G.off("pointermove", update_helper_layer);
update_helper_layer_on_pointermove_active = false;
}
});
if(e.button === 0){
reverse = false;
}else if(e.button === 2){
reverse = true;
}else{
return;
}
button = e.button;
ctrl = e.ctrlKey;
shift = e.shiftKey;
pointer_start = pointer_previous = pointer = to_canvas_coords(e);
const pointerdown_action = () => {
let interval_ids = [];
selected_tools.forEach((selected_tool)=> {
if(selected_tool.paint || selected_tool.pointerdown){
tool_go(selected_tool, "pointerdown");
}
if(selected_tool.paint_on_time_interval != null){
interval_ids.push(setInterval(()=> {
tool_go(selected_tool);
}, selected_tool.paint_on_time_interval));
}
});
$G.on("pointermove", canvas_pointer_move);
$G.one("pointerup", (e, canceling) => {
button = undefined;
reverse = false;
pointer = to_canvas_coords(e);
selected_tools.forEach((selected_tool)=> {
selected_tool.pointerup && selected_tool.pointerup(main_ctx, pointer.x, pointer.y);
});
if (selected_tools.length === 1) {
if (selected_tool.deselect) {
select_tools(return_to_tools);
}
}
$G.off("pointermove", canvas_pointer_move);
for (const interval_id of interval_ids) {
clearInterval(interval_id);
}
if (!canceling) {
history_node_to_cancel_to = null;
}
});
};
pointerdown_action();
update_helper_layer();
});
$canvas_area.on("pointerdown", e => {
if(e.button === 0){
if($canvas_area.is(e.target)){
if(selection){
deselect();
}
}
}
});
function prevent_selection($el) {
$el.on("mousedown selectstart contextmenu", (e) => {
if(e.isDefaultPrevented()){
return;
}
if(
e.target instanceof HTMLSelectElement ||
e.target instanceof HTMLTextAreaElement ||
(e.target instanceof HTMLLabelElement && e.type !== "contextmenu") ||
(e.target instanceof HTMLInputElement && e.target.type !== "color")
){
return;
}
if(e.button === 1){
return; // allow middle-click scrolling
}
e.preventDefault();
// we're just trying to prevent selection
// but part of the default for mousedown is *deselection*
// so we have to do that ourselves explicitly
window.getSelection().removeAllRanges();
});
}
prevent_selection($app);
prevent_selection($toolbox);
// prevent_selection($toolbox2);
prevent_selection($colorbox);
// Stop drawing (or dragging or whatever) if you Alt+Tab or whatever
$G.on("blur", () => {
$G.triggerHandler("pointerup");
});