1// @ts-check 2// eslint-disable-next-line no-unused-vars 3/* global airbrush_size:writable, brush_shape:writable, brush_size:writable, button:writable, ctrl:writable, eraser_size:writable, fill_color:writable, pick_color_slot:writable, history_node_to_cancel_to:writable, MenuBar:writable, my_canvas_height:writable, my_canvas_width:writable, palette:writable, pencil_size:writable, pointer:writable, pointer_active:writable, pointer_buttons:writable, pointer_over_canvas:writable, pointer_previous:writable, pointer_start:writable, pointer_type:writable, pointers:writable, reverse:writable, shift:writable, stroke_color:writable, stroke_size:writable, update_helper_layer_on_pointermove_active:writable */ 4/* global AccessKeys, current_history_node, default_airbrush_size, default_brush_shape, default_brush_size, default_canvas_height, default_canvas_width, default_eraser_size, default_magnification, default_pencil_size, default_stroke_size, enable_fs_access_api, file_name, get_direction, localize, magnification, main_canvas, main_ctx, return_to_tools, selected_colors, selected_tool, selected_tools, selection, systemHooks, textbox, transparency */ 5 6import { $ColorBox } from "./$ColorBox.js"; 7import { $ToolBox } from "./$ToolBox.js"; 8import { Handles } from "./Handles.js"; 9// import { get_direction, localize } from "./app-localization.js"; 10import { default_palette, get_winter_palette } from "./color-data.js"; 11import { image_formats } from "./file-format-data.js"; 12import { $this_version_news, cancel, change_some_url_params, change_url_param, clear, confirm_overwrite_capability, delete_selection, deselect, edit_copy, edit_cut, edit_paste, file_new, file_open, file_save, file_save_as, get_tool_by_id, get_uris, image_attributes, image_flip_and_rotate, image_invert_colors, image_stretch_and_skew, load_image_from_uri, make_or_update_undoable, open_from_file, paste, paste_image_from_file, redo, render_history_as_gif, reset_canvas_and_history, reset_file, reset_selected_colors, resize_canvas_and_save_dimensions, resize_canvas_without_saving_dimensions, save_as_prompt, select_all, select_tool, select_tools, set_magnification, show_document_history, show_error_message, show_news, show_resource_load_error_message, toggle_grid, undo, update_canvas_rect, update_disable_aa, update_helper_layer, update_magnified_canvas_size, view_bitmap, write_image_file } from "./functions.js"; 13import { show_help } from "./help.js"; 14import { $G, E, TAU, get_file_extension, get_help_folder_icon, is_discord_embed, make_canvas, to_canvas_coords } from "./helpers.js"; 15import { init_webgl_stuff, rotate } from "./image-manipulation.js"; 16import { menus } from "./menus.js"; 17import { showMessageBox } from "./msgbox.js"; 18import { stopSimulatingGestures } from "./simulate-random-gestures.js"; 19import { disable_speech_recognition, enable_speech_recognition, trace_and_sketch_stop } from "./speech-recognition.js"; 20import { localStore } from "./storage.js"; 21import { get_theme, set_theme } from "./theme.js"; 22import { TOOL_AIRBRUSH, TOOL_BRUSH, TOOL_CURVE, TOOL_ELLIPSE, TOOL_ERASER, TOOL_LINE, TOOL_PENCIL, TOOL_POLYGON, TOOL_RECTANGLE, TOOL_ROUNDED_RECTANGLE, TOOL_SELECT, tools } from "./tools.js"; 23 24// #region Exports 25 26// Q: Why are the exports at the top of the file? 27// A: The exports are scattered throughout the file, exactly where they 28// were implicitly being exported before transitioning to ESM. 29// For function declarations at the top level, this means the start of the file. 30// For let/const, this means the line after the declaration. 31// This is a temporary solution to resolve circular dependencies. 32// For instance, $ToolBox uses $left/$right. If $left/$right were exported 33// at the bottom of this file, an error would occur. This is because $ToolBox 34// is instantiated in the middle of this file (but after $left/$right are declared). 35// @TODO: Minimize global variables and exports from app.js 36window.update_fill_and_stroke_colors_and_lineWidth = update_fill_and_stroke_colors_and_lineWidth; 37window.tool_go = tool_go; 38 39// #endregion 40 41 42// #region System Hooks and default implementations 43 44/** 45 * @param {string} extension 46 * @returns {`.${string}`} 47 */ 48const prependDot = (extension) => `.${extension}`; 49/** 50 * @param {FileFormat} format 51 * @returns {string} 52 */ 53const getMimeType = (format) => "mimeType" in format ? format.mimeType : `application/x-${format.formatID}`; 54 55// Note: JSDoc type annotations don't seem to actually work on window.* 56/** 57 * @type {SystemHooks} 58 * The methods in systemHooks can be overridden by a containing page like 98.js.org which hosts jspaint in a same-origin iframe. 59 * This allows integrations like setting the wallpaper as the background of the host page, or saving files to a server. 60 * This API may be removed at any time (and perhaps replaced by something based around postMessage) 61 * The API is documented in the README.md file. 62 */ 63window.systemHooks = window.systemHooks || {}; 64/** @type {SystemHooks} */ 65window.systemHookDefaults = {
66 // named to be distinct from various platform APIs (showSaveFilePicker, saveAs, electron's showSaveDialog; and saveFile is too ambiguous) 67 // could call it saveFileAs maybe but then it'd be weird that you don't pass in the file directly 68 showSaveFileDialog: async ({ formats, defaultFileName, defaultPath, defaultFileFormatID, getBlob, savedCallbackUnreliable, dialogTitle }) => { 69 70 // Note: showSaveFilePicker currently doesn't support suggesting a filename, 71 // or retrieving which file type was selected in the dialog (you have to get it (guess it) from the file name) 72 // In particular, some formats are ambiguous with the file name, e.g. different bit depths of BMP files. 73 // So, it's a tradeoff with the benefit of overwriting on Save. 74 // https://developer.mozilla.org/en-US/docs/Web/API/Window/showSaveFilePicker 75 // Also, if you're using accessibility options Speech Recognition or Dwell Clicker, 76 // `showSaveFilePicker` fails based on a notion of it not being a "user gesture". 77 // `saveAs` will likely also fail on the same basis, 78 // but at least in chrome, there's a "Downloads Blocked" icon with a popup where you can say Always Allow. 79 // I can't detect when it's allowed or blocked, but `saveAs` has a better chance of working, 80 // so for Speech Recognition and Dwell Clicker, I set a global flag temporarily to disable File System Access API (window.untrusted_gesture). 81 if (window.showSaveFilePicker && !window.untrusted_gesture && enable_fs_access_api) { 82 // We can't get the selected file type, not even from newHandle.getFile() 83 // so limit formats shown to a set that can all be used by their unique file extensions 84 // formats = formats_unique_per_file_extension(formats); 85 // OR, show two dialogs, one for the format and then one for the save location. 86 const { newFileFormatID } = await save_as_prompt({ dialogTitle, defaultFileName, defaultFileFormatID, formats, promptForName: false }); 87 const new_format = formats.find((format) => format.formatID === newFileFormatID); 88 const blob = await getBlob(new_format && new_format.formatID); 89 formats = [new_format]; 90 let newHandle; 91 let newFileName; 92 try { 93 newHandle = await showSaveFilePicker({ 94 types: formats.map((format) => { 95 return { 96 description: format.name, 97 accept: { 98 [getMimeType(format)]: format.extensions.map(prependDot), 99 }, 100 }; 101 }), 102 }); 103 newFileName = newHandle.name; 104 const newFileExtension = get_file_extension(newFileName); 105 const doItAgain = async (message) => { 106 const button_value = await showMessageBox({ 107 message: `${message}\n\nTry adding .${new_format.extensions[0]} to the name. Sorry about this.`, 108 iconID: "error", 109 buttons: [ 110 { 111 label: localize("Save As"), // or "Retry" 112 value: "show-save-as-dialog-again", 113 default: true, 114 }, 115 { 116 label: localize("Save"), // or "Ignore" 117 value: "save-without-extension", 118 }, 119 { 120 label: localize("Cancel"), // or "Abort" 121 value: "cancel", 122 }, 123 ], 124 }); 125 if (button_value === "show-save-as-dialog-again") { 126 return window.systemHookDefaults.showSaveFileDialog({ 127 formats, 128 defaultFileName, 129 defaultPath, 130 defaultFileFormatID, 131 getBlob, 132 savedCallbackUnreliable, 133 dialogTitle, 134 }); 135 } else if (button_value === "save-without-extension") { 136 // @TODO: DRY 137 const writableStream = await newHandle.createWritable(); 138 await writableStream.write(blob); 139 await writableStream.close(); 140 savedCallbackUnreliable?.({ 141 newFileName: newFileName, 142 newFileFormatID: new_format && new_format.formatID, 143 newFileHandle: newHandle, 144 newBlob: blob, 145 }); 146 } else { 147 // user canceled save 148 } 149 }; 150 if (!newFileExtension) { 151 // return await doItAgain(`Missing file extension.`); 152 return await doItAgain(`'${newFileName}' doesn't have an extension.`); 153 } 154 if (!new_format.extensions.includes(newFileExtension)) { 155 // Closest translation: "Paint cannot save to the same filename with a different file type." 156 // return await doItAgain(`Wrong file extension for selected file type.`); 157 return await doItAgain(`File extension '.${newFileExtension}' does not match the selected file type ${new_format.name}.`); 158 } 159 // const new_format = 160 // get_format_from_extension(formats, newHandle.name) || 161 // formats.find((format)=> format.formatID === defaultFileFormatID); 162 // const blob = await getBlob(new_format && new_format.formatID); 163 const writableStream = await newHandle.createWritable(); 164 await writableStream.write(blob); 165 await writableStream.close(); 166 } catch (error) { 167 if (error.name === "AbortError") { 168 // user canceled save 169 return; 170 } 171 // console.warn("Error during showSaveFileDialog (for showSaveFilePicker; now falling back to saveAs)", error); 172 // newFileName = (newFileName || file_name || localize("untitled")) 173 // .replace(/\.(bmp|dib|a?png|gif|jpe?g|jpe|jfif|tiff?|webp|raw)$/i, "") + 174 // "." + new_format.extensions[0]; 175 // saveAs(blob, newFileName); 176 if (error.message.match(/gesture|activation/)) { 177 // 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); 178 show_error_message("Sorry, due to browser security measures, you must use the keyboard or mouse directly to save."); 179 return; 180 } 181 show_error_message(localize("Failed to save document."), error); 182 return; 183 } 184 savedCallbackUnreliable?.({ 185 newFileName: newFileName, 186 newFileFormatID: new_format && new_format.formatID, 187 newFileHandle: newHandle, 188 newBlob: blob, 189 }); 190 } else { 191 192 const { newFileName, newFileFormatID } = await save_as_prompt({ dialogTitle, defaultFileName, defaultFileFormatID, formats }); 193 const blob = await getBlob(newFileFormatID); 194 saveAs(blob, newFileName); 195 savedCallbackUnreliable?.({ 196 newFileName, 197 newFileFormatID, 198 newFileHandle: null, 199 newBlob: blob, 200 }); 201 } 202 }, 203 showOpenFileDialog: async ({ formats }) => { 204 if (window.untrusted_gesture) { 205 // We can't show a file picker RELIABLY. 206 // FIXME: double error message 207 show_error_message("Sorry, a file picker cannot be shown when using Speech Recognition or Dwell Clicker. You must click File > Open directly with the mouse, or press Ctrl+O on the keyboard."); 208 throw new Error("can't show file picker reliably"); 209 } 210 if (window.showOpenFilePicker && enable_fs_access_api) { 211 const [fileHandle] = await window.showOpenFilePicker({ 212 types: formats.map((format) => { 213 return { 214 description: format.name, 215 accept: { 216 [getMimeType(format)]: format.extensions.map(prependDot), 217 }, 218 }; 219 }), 220 }); 221 const file = await fileHandle.getFile(); 222 return { file, fileHandle }; 223 } else { 224 // @TODO: specify mime types? 225 return new Promise((resolve) => { 226 const $input = /** @type {JQuery<HTMLInputElement>} */($("<input type='file'>") 227 .on("change", () => { 228 resolve({ file: $input[0].files[0] }); 229 $input.remove(); 230 }) 231 .appendTo($app) 232 .hide() 233 .trigger("click") 234 ); 235 }); 236 } 237 }, 238 writeBlobToHandle: async (save_file_handle, blob) => { 239 if (save_file_handle && save_file_handle.createWritable && enable_fs_access_api) { 240 const acknowledged = await confirm_overwrite_capability(); 241 if (!acknowledged) { 242 return false;
243 } 244 try { 245 const writableStream = await save_file_handle.createWritable(); 246 await writableStream.write(blob); 247 await writableStream.close(); 248 return true; 249 } catch (error) { 250 if (error.name === "AbortError") { 251 // user canceled save (this might not be a real error code that can occur here) 252 return false; 253 } 254 if (error.name === "NotAllowedError") { 255 // use didn't give permission to save 256 // is this too much of a warning? 257 show_error_message(localize("Save was interrupted, so your file has not been saved."), error); 258 return false; 259 } 260 if (error.name === "SecurityError") { 261 // not in a user gesture ("User activation is required to request permissions.") 262 saveAs(blob, file_name); 263 return undefined; 264 } 265 } 266 } else { 267 saveAs(blob, file_name); 268 // hopefully if the page reloads/closes the save dialog/download will persist and succeed? 269 return undefined; 270 } 271 }, 272 readBlobFromHandle: async (file_handle) => { 273 if (file_handle && file_handle.getFile) { 274 const file = await file_handle.getFile(); 275 return file; 276 } else { 277 throw new Error(`Unknown file handle (${file_handle})`); 278 // show_error_message(`${localize("Failed to open document.")}\n${localize("An unsupported operation was attempted.")}`, error); 279 } 280 }, 281 setWallpaperTiled: (canvas) => { 282 const wallpaperCanvas = make_canvas(screen.width, screen.height); 283 const pattern = wallpaperCanvas.ctx.createPattern(canvas, "repeat"); 284 wallpaperCanvas.ctx.fillStyle = pattern; 285 wallpaperCanvas.ctx.fillRect(0, 0, wallpaperCanvas.width, wallpaperCanvas.height); 286 287 systemHooks.setWallpaperCentered(wallpaperCanvas); 288 }, 289 setWallpaperCentered: (canvas) => { 290 systemHooks.showSaveFileDialog({ 291 dialogTitle: localize("Save As"), 292 defaultFileName: `${file_name.replace(/\.(bmp|dib|a?png|gif|jpe?g|jpe|jfif|tiff?|webp|raw)$/i, "")} wallpaper.png`, 293 defaultFileFormatID: "image/png", 294 formats: image_formats, 295 getBlob: (new_file_type) => { 296 return new Promise((resolve) => { 297 write_image_file(canvas, new_file_type, (blob) => { 298 resolve(blob); 299 }); 300 }); 301 }, 302 }); 303 }, 304}; 305 306for (const [key, defaultValue] of Object.entries(window.systemHookDefaults)) { 307 window.systemHooks[key] = window.systemHooks[key] || defaultValue; 308} 309 310// #endregion 311 312// #region URL Params 313const update_from_url_params = () => { 314 // Dwell Clicker 315 // (Head Tracker implies Dwell Clicker for now, but could be made independent if Tracky Mouse supports other modes in the future.) 316 if (location.hash.match(/dwell-clicker|head-tracker/i)) { 317 if (!$("body").hasClass("dwell-clicker-mode")) { 318 $("body").addClass("dwell-clicker-mode"); 319 $G.triggerHandler("dwell-clicker-toggled"); 320 } 321 } else { 322 if ($("body").hasClass("dwell-clicker-mode")) { 323 $("body").removeClass("dwell-clicker-mode"); 324 $G.triggerHandler("dwell-clicker-toggled"); 325 } 326 } 327 328 // Enlarge UI 329 if (location.hash.match(/enlarge-ui/i)) { 330 if (!$("body").hasClass("enlarge-ui")) { 331 $("body").addClass("enlarge-ui"); 332 $G.triggerHandler("enlarge-ui-toggled"); 333 $G.triggerHandler("theme-load"); // signal layout change 334 } 335 } else { 336 if ($("body").hasClass("enlarge-ui")) { 337 $("body").removeClass("enlarge-ui"); 338 $G.triggerHandler("enlarge-ui-toggled"); 339 $G.triggerHandler("theme-load"); // signal layout change 340 } 341 } 342 343 // Vertical Color Box Mode 344 if (location.hash.match(/vertical-color-box-mode/i)) { 345 if (!$("body").hasClass("vertical-color-box-mode")) { 346 $("body").addClass("vertical-color-box-mode"); 347 $G.triggerHandler("vertical-color-box-mode-toggled"); 348 $G.triggerHandler("theme-load"); // signal layout change 349 } 350 } else { 351 if ($("body").hasClass("vertical-color-box-mode")) { 352 $("body").removeClass("vertical-color-box-mode"); 353 $G.triggerHandler("vertical-color-box-mode-toggled"); 354 $G.triggerHandler("theme-load"); // signal layout change 355 } 356 } 357 358 // Quick Undo Button 359 if (location.hash.match(/easy-undo/i)) { 360 if (!$("body").hasClass("easy-undo-mode")) { 361 $("body").addClass("easy-undo-mode"); 362 $G.triggerHandler("easy-undo-mode-toggled"); 363 $G.triggerHandler("theme-load"); // signal layout change (just copying pattern thoughtlessly) 364 } 365 } else { 366 if ($("body").hasClass("easy-undo-mode")) { 367 $("body").removeClass("easy-undo-mode"); 368 $G.triggerHandler("easy-undo-mode-toggled"); 369 $G.triggerHandler("theme-load"); // signal layout change (just copying pattern thoughtlessly) 370 } 371 } 372 373 // Head Tracker Mode 374 if (location.hash.match(/head-tracker/i)) { 375 if (!$("body").hasClass("head-tracker-mode")) { 376 $("body").addClass("head-tracker-mode"); 377 $G.triggerHandler("head-tracker-toggled"); 378 } 379 } else { 380 if ($("body").hasClass("head-tracker-mode")) { 381 $("body").removeClass("head-tracker-mode"); 382 $G.triggerHandler("head-tracker-toggled"); 383 } 384 } 385 386 // Speech Recognition Mode 387 if (location.hash.match(/speech-recognition-mode/i)) { 388 enable_speech_recognition(); 389 } else { 390 disable_speech_recognition(); 391 } 392 393 // Developer helpers to compare with reference screenshots of MS Paint 394 // (Seems like the color box is getting (un)shifted when this is enabled, making it line up less than it should? 395 // like the code "// Nudge the Colors component over a tiny bit" is applying and then being reset.) 396 $("body").toggleClass("compare-reference", !!location.hash.match(/compare-reference/i)); 397 $("body").toggleClass("compare-reference-tool-windows", !!location.hash.match(/compare-reference-tool-windows/i)); 398 setTimeout(() => { 399 if (location.hash.match(/compare-reference/i)) { // including compare-reference-tool-windows 400 select_tool(get_tool_by_id(TOOL_SELECT)); 401 const test_canvas_width = 576; 402 const test_canvas_height = 432; 403 if (main_canvas.width !== test_canvas_width || main_canvas.height !== test_canvas_height) { 404 // Unfortunately, right now this can cause a reverse "Save changes?" dialog, 405 // where Discard will restore your drawing, Cancel will discard it, and Save will save a blank canvas, 406 // because the load from storage happens after this resize. 407 // But this is just a helper for development, so it's not a big deal. 408 // are_you_sure here doesn't help, either. 409 // are_you_sure(() => { 410 resize_canvas_without_saving_dimensions(test_canvas_width, test_canvas_height); 411 // }); 412 } 413 if (!location.hash.match(/compare-reference-tool-windows/i)) { 414 $toolbox.dock($left); 415 $colorbox.dock($bottom); 416 window.debugKeepMenusOpen = false;
417 } 418 } 419 if (location.hash.match(/compare-reference-tool-windows/i)) { 420 $toolbox.undock_to(84, 35); 421 $colorbox.undock_to(239, 195); 422 window.debugKeepMenusOpen = true; 423 // $(".help-menu-button").click(); // have to trigger pointerdown/up, it doesn't respond to click 424 // $(".help-menu-button").trigger("pointerdown").trigger("pointerup"); // and it doesn't use jQuery 425 $(".help-menu-button")[0].dispatchEvent(new Event("pointerdown")); 426 $(".help-menu-button")[0].dispatchEvent(new Event("pointerup")); 427 $("[aria-label='About Paint']")[0].dispatchEvent(new Event("pointerenter")); 428 } 429 }, 500); 430 431 // dev helper to open Project News window to preview news write-up 432 // I'm naming this "force-open-project-news" and not simply "project-news" 433 // because I'm not handling closing the window, and I don't want it to sound 434 // super friendly. 435 // I did go on a tangent of making it a proper UI navigation URL hash, 436 // and binding the window state to the URL state (bidirectionally), 437 // but I'm not sure how it should work with the back button. 438 // It's probably nice on mobile for the back button to close windows, 439 // but I'd want it to be consistent between all the windows of the app. 440 if (location.hash.match(/force-open-project-news/i)) { 441 if (!$(".news-window:visible").length) { 442 show_news(); 443 } 444 } 445}; 446update_from_url_params(); 447$G.on("hashchange popstate change-url-params", update_from_url_params); 448 449// handle backwards compatibility URLs 450// Eye Gaze Mode was a monolithic feature that has been since been split into smaller features. 451// We can maintain backwards compatibility with the old URL param by mapping it to the new features. 452// (BTW: Eye Gaze Mode never included an actual eye tracker (instead relying on external software), 453// but if I added that as a feature I could call the feature Eye Tracker, so it wouldn't be too confusing.) 454if (location.search.match(/eye-gaze-mode/) || location.hash.match(/eye-gaze-mode/)) { 455 change_some_url_params({ 456 "eye-gaze-mode": false, 457 "enlarge-ui": true, 458 "dwell-clicker": true, 459 "vertical-color-box-mode": true, 460 "easy-undo": true, 461 }, { replace_history_state: true }); 462 update_from_url_params(); 463} 464if (location.search.match(/vertical-colors?-box/)) { 465 change_url_param("vertical-color-box", true, { replace_history_state: true }); 466 update_from_url_params(); 467} 468 469// #endregion 470 471// #region App UI 472 473const $app = $(E("div")).addClass("jspaint").appendTo("body"); 474window.$app = $app; 475 476const $V = $(E("div")).addClass("vertical").appendTo($app); 477const $H = $(E("div")).addClass("horizontal").appendTo($V); 478 479const $canvas_area = $(E("div")).addClass("canvas-area inset-deep").appendTo($H); 480window.$canvas_area = $canvas_area; 481 482const $canvas = $(main_canvas).appendTo($canvas_area); 483window.$canvas = $canvas; 484$canvas.css("touch-action", "none"); 485window.canvas_bounding_client_rect = main_canvas.getBoundingClientRect(); // cached for performance, updated later 486const canvas_handles = new Handles({ 487 $handles_container: $canvas_area, 488 $object_container: $canvas_area, 489 get_rect: () => ({ x: 0, y: 0, width: main_canvas.width, height: main_canvas.height }), 490 set_rect: ({ width, height }) => resize_canvas_and_save_dimensions(width, height), 491 outset: 4, 492 get_handles_offset_left: () => parseFloat($canvas_area.css("padding-left")) + 1, 493 get_handles_offset_top: () => parseFloat($canvas_area.css("padding-top")) + 1, 494 get_ghost_offset_left: () => parseFloat($canvas_area.css("padding-left")) + 1, 495 get_ghost_offset_top: () => parseFloat($canvas_area.css("padding-top")) + 1, 496 size_only: true, 497}); 498window.canvas_handles = canvas_handles; 499 500const $top = $(E("div")).addClass("component-area top").prependTo($V); 501window.$top = $top; 502const $bottom = $(E("div")).addClass("component-area bottom").appendTo($V); 503window.$bottom = $bottom; 504const $left = $(E("div")).addClass("component-area left").prependTo($H); 505window.$left = $left; 506const $right = $(E("div")).addClass("component-area right").appendTo($H); 507window.$right = $right; 508 509 510// there's also probably a CSS solution alternative to this 511if (get_direction() === "rtl") { 512 $left.appendTo($H); 513 $right.prependTo($H); 514} 515 516// #endregion
517// (arguably still App UI stuff below, but it becomes a fuzzy line later on) 518 519// #region Status Bar 520const $status_area = $(E("div")).addClass("status-area").appendTo($V); 521window.$status_area = $status_area; 522const $status_text = /** @type {JQuery<HTMLDivElement> & {default: ()=> void}} */($(E("div")).addClass("status-text status-field inset-shallow").appendTo($status_area)); 523window.$status_text = $status_text; 524const $status_position = $(E("div")).addClass("status-coordinates status-field inset-shallow").appendTo($status_area); 525window.$status_position = $status_position; 526const $status_size = $(E("div")).addClass("status-coordinates status-field inset-shallow").appendTo($status_area); 527window.$status_size = $status_size; 528 529// #region News Indicator 530const news_seen_key = "jspaint latest news seen"; 531const latest_news_datetime = $this_version_news.find("time").attr("datetime"); 532const $news_indicator = $(` 533 <a class="news-indicator" href="#project-news"> 534 <!--<img src="images/winter/present.png" width="24" height="22" alt=""/>--> 535 <!--<img src="images/about/news.gif" width="40" height="16" alt=""/>--> 536 <img src="images/new.gif" width="40" height="16" alt=""/> 537 <span> 538 <b>Font Finesse</b> 539 </span> 540 <!--<span class="marquee" dir="ltr" style="--text-width: 44ch; --animation-duration: 3s;"> 541 <span> 542 <b>Cool new things</b> — One thing! Another thing! Something else! 543 </span> 544 </span> 545 <span> 546 <b>Just One Thing</b> 547 </span>--> 548 </a> 549`); 550$news_indicator.on("click auxclick", (event) => { 551 event.preventDefault(); 552 show_news(); 553 $news_indicator.remove(); 554 try { 555 localStorage[news_seen_key] = latest_news_datetime; 556 } catch (_error) { /* ignore */ } 557}); 558let news_seen; 559let local_storage_unavailable; 560try { 561 news_seen = localStorage[news_seen_key]; 562} catch (_error) { 563 local_storage_unavailable = true; 564} 565const day = 24 * 60 * 60 * 1000; 566const news_period_if_can_dismiss = 15 * day; 567const news_period_if_cannot_dismiss = 5 * day; 568const news_period = local_storage_unavailable ? news_period_if_cannot_dismiss : news_period_if_can_dismiss; 569if (Date.now() < Date.parse(latest_news_datetime) + news_period && news_seen !== latest_news_datetime) { 570 $status_area.append($news_indicator); 571} 572if ($news_indicator.text().includes("Bubblegum")) { 573 let bubbles_raf_id = -1; 574 const bubbles = []; 575 const make_bubble = () => { 576 const $bubble = $(E("img")).attr({ 577 src: "images/bubblegum/bubble.png", 578 width: 24, 579 height: 24, 580 alt: "", 581 }).css({ 582 position: "absolute", 583 pointerEvents: "none", 584 top: 0, 585 left: 0, 586 zIndex: 10, 587 }).appendTo("body"); 588 const rect = $news_indicator[0].getBoundingClientRect(); 589 const x = rect.left + Math.random() * rect.width;
590 const y = rect.top + rect.height; 591 const scale = Math.random() * 0.5 + 0.5; 592 const bubble = { $bubble, x, y, scale, vx: Math.random() * 2 - 1, vy: -Math.random() * 2 }; 593 bubbles.push(bubble); 594 if (bubbles_raf_id === -1) { 595 animate_bubbles(); 596 } 597 setTimeout(() => { 598 $bubble.remove(); 599 bubbles.splice(bubbles.indexOf(bubble), 1); 600 if (bubbles.length === 0) { 601 cancelAnimationFrame(bubbles_raf_id); 602 bubbles_raf_id = -1; 603 } 604 }, 10000); 605 }; 606 let last_time = performance.now(); 607 const animate_bubbles = () => { 608 bubbles_raf_id = requestAnimationFrame(animate_bubbles); 609 const now = performance.now(); 610 const dt = now - last_time; 611 for (const bubble of bubbles) { 612 // not actually frame rate independent physics, I don't think 613 bubble.x += bubble.vx * dt / 16; 614 bubble.y += bubble.vy * dt / 16; 615 const wind_x = Math.sin(bubble.y / 100 + now / 3000) * 0.01; 616 const wind_y = Math.cos(bubble.x / 100 + now / 3000) * 0.01; 617 bubble.vx += wind_x; 618 bubble.vy += wind_y; 619 bubble.$bubble.css({ 620 transform: `translate(${bubble.x}px, ${bubble.y}px) scale(${bubble.scale})`, 621 }); 622 } 623 last_time = now; 624 }; 625 $news_indicator.on("pointerenter", () => { 626 for (let i = 0; i < 10; i++) { 627 setTimeout(make_bubble, i * 100); 628 } 629 }); 630 $news_indicator.on("pointerdown", () => { 631 for (let i = 0; i < 50; i++) { 632 setTimeout(make_bubble, i * 1); 633 } 634 }); 635} 636// #endregion 637 638$status_text.default = () => { 639 $status_text.text(localize("For Help, click Help Topics on the Help Menu.")); 640}; 641$status_text.default(); 642 643// #endregion 644 645// #region Menu Bar 646let menu_bar_outside_frame = false; 647if (frameElement) { 648 try { 649 if (parent.MenuBar) { 650 // @ts-ignore 651 MenuBar = parent.MenuBar; 652 menu_bar_outside_frame = true; 653 } 654 } catch (_error) { /* ignore */ } 655} 656const menu_bar = MenuBar(menus); 657window.menu_bar = menu_bar; 658if (menu_bar_outside_frame) { 659 $(menu_bar.element).insertBefore(frameElement); 660} else { 661 $(menu_bar.element).prependTo($V); 662} 663 664$(menu_bar.element).on("info", (event) => { 665 // @ts-ignore 666 $status_text.text(event.detail?.description ?? ""); 667}); 668$(menu_bar.element).on("default-info", () => { 669 $status_text.default(); 670}); 671 672// Hidden in a menu, these GIFs are not as obtrusive even though they can't be dismissed 673const theme_updated_period = 20 * day; 674const theme_new_period = 40 * day; 675const theme_soon_period = 40 * day; 676if (Date.now() < Date.parse("2024-02-22") + theme_new_period) { 677 $("[role=menuitem][aria-label*='Modern Dark'] .menu-item-shortcut").append("<img src='images/new2.gif' alt='New!'/>"); 678} 679if (Date.now() < Date.parse("2024-02-24") + theme_soon_period) { 680 // $("[role=menuitem][aria-label*='Bubblegum'] .menu-item-shortcut").append("<img src='images/soon-twist-anim.gif' alt='Coming Soon!' class='too-big-soon-gif'/>"); 681 // $("[role=menuitem][aria-label*='Retro Futurist'] .menu-item-shortcut").append("<img src='images/soon.gif' alt='Coming Soon!'/>"); 682 // $("[role=menuitem][aria-label*='Picnic'] .menu-item-shortcut").append("<img src='images/soon.gif' alt='Coming Soon!'/>"); 683} 684if (Date.now() < Date.parse("2024-02-22") + theme_updated_period) { 685 $("[role=menuitem][aria-label*='Modern Light'] .menu-item-shortcut").append("<img src='images/updated.gif' alt='Updated!'/>"); 686 $("[role=menuitem][aria-label*='Classic Dark'] .menu-item-shortcut").append("<img src='images/updated.gif' alt='Updated!'/>"); 687 $("[role=menuitem][aria-label*='Occult'] .menu-item-shortcut").append("<img src='images/updated.gif' alt='Updated!'/>"); 688} 689 690// Extras menu emoji icons 691// (OS-GUI.js doesn't support icons yet but I wanted to spruce it up a bit.) 692// Originally I defined the emoji as part of the label, which worked well for a while. 693// Now I'm rendering the emoji as pseudo elements. 694// - It allows for matching on the menu item text exactly, without including emoji in my tests, 695// which will hopefully be replaced with custom icons in the future. 696// - It makes it easier to replace the emoji with custom icons in the future. 697// - It hides the emoji from `aria-label`, for screen reader users. 698// - It makes the menu data cleaner. 699// - It allows aligning the emoji nicely, even when some don't show as emoji, depending on the platform. 700 701/** 702 * @param {OSGUIMenuFragment[]} menu_items 703 * @param {HTMLElement} menu_element 704 * @yields {[OSGUIMenuItem, HTMLElement]} 705 * @returns {Generator<[OSGUIMenuItem, HTMLElement], void, void>} 706 */ 707function* traverse_menu(menu_items, menu_element) { 708 // Traverse menu data and elements in tandem, yielding pairs of menu item specifications and elements. 709 // This approach handles identically named menu items in separate menus, 710 // as is the case with "File > Manage Storage" and "Edit > History", both present in the Extras menu, 711 // but also in the other menus for discoverability. 712 // However, it doesn't handle identically named menu items in the same menu, 713 // as it still matches up items within the menu using aria-label. 714 715 // Menu structure: 716 // - Menu popups are not descendants of the menu bar or other menu popups; they are always direct children of the body. 717 // - Menu items that open submenus have "aria-controls" pointing to the ID of the submenu. 718 // - (Menu popups also have "data-semantic-parent" pointing to the ID of the menu item that opens them.) 719 // - `submenu` is an array, but the top level (menu bar) is represented as an object, which is a bit awkward. 720 // However, this function doesn't deal with the top level. 721 722 const menu_item_elements = /** @type {HTMLElement[]} */([...menu_element.querySelectorAll(".menu-item")]); 723 for (const menu_item of menu_items) { 724 if (typeof menu_item !== "object" || !("label" in menu_item)) { 725 continue; 726 } 727 const aria_label = AccessKeys.toText(menu_item.label); 728 const menu_item_element = menu_item_elements.filter((el) => 729 el.getAttribute("aria-label") === aria_label 730 )[0]; 731 if (!menu_item_element) { 732 console.warn("Couldn't find menu item", menu_item, "with aria-label", aria_label); 733 continue; 734 } 735 yield [menu_item, menu_item_element]; 736 if (menu_item.submenu) { 737 yield* traverse_menu(menu_item.submenu, menu_document.getElementById(menu_item_element.getAttribute("aria-controls"))); 738 } 739 // if (menu_item.radioItems) { 740 // yield* traverse_menu(menu_item.radioItems, menu_element); 741 // } 742 } 743} 744 745const menu_document = menu_bar.element.ownerDocument; 746const extras_menu_button = menu_document.querySelector(".extras-menu-button"); 747const extras_menu_popup = menu_document.getElementById(extras_menu_button.getAttribute("aria-controls")); 748 749let emoji_css = ` 750 .menu-item .menu-item-label::before { 751 display: inline-block; 752 width: 1.8em; 753 margin-right: 0.2em; 754 text-align: center; 755 } 756`; 757for (const [menu_item, menu_item_element] of traverse_menu(menus["E&xtras"], extras_menu_popup)) { 758 if (menu_item.emoji_icon) { 759 emoji_css += `
760 #${menu_item_element.id} .menu-item-label::before { 761 content: "${menu_item.emoji_icon}"; 762 } 763 `; 764 } 765} 766$("<style>").text(emoji_css).appendTo(menu_document.head); 767 768// Electron menu integration 769if (window.is_electron_app) { 770 window.setMenus(menus); 771} 772 773// #endregion 774 775let $toolbox = $ToolBox(tools); 776window.$toolbox = $toolbox; 777// let $toolbox2 = $ToolBox(extra_tools, true);//.hide(); 778// Note: a second $ToolBox doesn't work because they use the same tool options (which could be remedied) 779// If there's to be extra tools, they should probably get a window, with different UI 780// so it can display names of the tools, and maybe authors and previews (and not necessarily icons) 781 782let $colorbox = $ColorBox($("body").hasClass("vertical-color-box-mode")); 783window.$colorbox = $colorbox; 784 785$G.on("vertical-color-box-mode-toggled", () => { 786 // Destroy and recreate the color box because it uses a constructor parameter 787 // for this state and this handles re-docking to the correct edge 788 $colorbox.destroy(); 789 $colorbox = $ColorBox($("body").hasClass("vertical-color-box-mode")); 790 window.$colorbox = $colorbox; 791 prevent_selection($colorbox); 792}); 793 794$G.on("resize", () => { // for browser zoom, and in-app zoom of the canvas 795 update_canvas_rect(); 796 update_disable_aa(); 797}); 798$canvas_area.on("scroll", () => { 799 update_canvas_rect(); 800}); 801$canvas_area.on("resize", () => { 802 update_magnified_canvas_size(); 803}); 804 805// Despite overflow:hidden on html and body, 806// focusing elements that are partially offscreen can still scroll the page. 807// For example, with Edit Colors dialog partially offscreen, navigating the color grid. 808// We need to prevent (reset) scroll on focus, and also avoid scrollIntoView(). 809// Listening for scroll here is mainly in case a case is forgotten, like scrollIntoView, 810// in which case it will flash sometimes but at least not end up with part of 811// the application scrolled off the screen with no scrollbar to get it back. 812$G.on("scroll focusin", () => { 813 window.scrollTo(0, 0); 814}); 815 816// #region Drag and Drop 817 818// jQuery's multiple event handling is not that useful in the first place, but when adding type info... it's downright ugly. 819$("body").on("dragover dragenter", (/** @type {JQuery.DragOverEvent | JQuery.DragEnterEvent} */event) => { 820 const dt = event.originalEvent.dataTransfer; 821 const has_files = dt && Array.from(dt.types).includes("Files"); 822 if (has_files) { 823 event.preventDefault(); 824 } 825}).on("drop", async (event) => { 826 if (event.isDefaultPrevented()) { 827 return; 828 } 829 const dt = event.originalEvent.dataTransfer; 830 const has_files = dt && Array.from(dt.types).includes("Files"); 831 if (has_files) { 832 event.preventDefault(); 833 // @TODO: sort files/items in priority of image, theme, palette 834 // and then try loading them in series, with async await to avoid race conditions? 835 // or maybe support opening multiple documents in tabs 836 // Note: don't use FS Access API in Electron app because: 837 // 1. it's faulty (permissions problems, 0 byte files maybe due to the perms problems) 838 // 2. we want to save the file.path, which the dt.files code path takes care of 839 if (window.FileSystemHandle && !window.is_electron_app) { 840 for (const item of dt.items) { 841 // kind will be "file" for file/directory entries. 842 if (item.kind === "file") { 843 let handle; 844 try { 845 // Experimental API, not supported on Firefox as of 2024-02-17 846 if ("getAsFileSystemHandle" in item) { 847 // @ts-ignore 848 handle = await item.getAsFileSystemHandle(); 849 } 850 } catch (error) { 851 // I'm not sure when this happens. 852 // should this use "An invalid file handle was associated with %1." message? 853 show_error_message(localize("File not found."), error); 854 return; 855 } 856 if (!handle || handle.kind === "file") { 857 let file; 858 try { 859 // instanceof is for the type checker; it should be guaranteed since kind is "file" 860 if (handle && handle instanceof FileSystemFileHandle) { 861 file = await handle.getFile(); 862 } else { 863 file = item.getAsFile(); 864 } 865 } catch (error) { 866 // NotFoundError can happen when the file was moved or deleted, 867 // then dragged and dropped via the browser's downloads bar, or some other outdated file listing. 868 show_error_message(localize("File not found."), error); 869 return; 870 } 871 open_from_file(file, handle); 872 if (window._open_images_serially) { 873 // For testing a suite of files: 874 await new Promise((resolve) => setTimeout(resolve, 500)); 875 } else { 876 // Normal behavior: only open one file. 877 return; 878 } 879 } 880 // else if (handle.kind === "directory") {} 881 } 882 } 883 } else if (dt.files && dt.files.length) { 884 if (window._open_images_serially) { 885 // For testing a suite of files, such as http://www.schaik.com/pngsuite/ 886 let i = 0; 887 const iid = setInterval(() => { 888 console.log("opening", dt.files[i].name);
889 open_from_file(dt.files[i]); 890 i++; 891 if (i >= dt.files.length) { 892 clearInterval(iid); 893 } 894 }, 1500); 895 } else { 896 // Normal behavior: only open one file. 897 open_from_file(dt.files[0]); 898 } 899 } 900 } 901}); 902 903// #endregion 904 905// #region Keyboard Shortcuts 906$G.on("keydown", (e) => { 907 // typecast to HTMLElement because e.target is incorrectly given as Window, due to $G wrapping window 908 const target = /** @type {HTMLElement} */ (/** @type {unknown} */ (e.target)); 909 910 if (e.isDefaultPrevented()) { 911 return; 912 } 913 if (e.key === "Escape") { // Note: Escape handled below too! (after input/textarea return condition) 914 if (textbox && textbox.$editor.is(target)) { 915 deselect(); 916 } 917 } 918 if ( 919 // Ctrl+Shift+Y for history window, 920 // chosen because it's related to the undo/redo shortcuts 921 // and it looks like a branching symbol. 922 (e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey && 923 e.key.toUpperCase() === "Y" 924 ) { 925 show_document_history(); 926 e.preventDefault(); 927 return; 928 } 929 // @TODO: return if menus/menubar focused or focus in dialog window 930 // or maybe there's a better way to do this that works more generally 931 // maybe it should only handle the event if document.activeElement is the body or html element? 932 // (or $app could have a tabIndex and no focus style and be focused under various conditions, 933 // if that turned out to make more sense for some reason) 934 if ( 935 e.target instanceof HTMLInputElement || 936 e.target instanceof HTMLTextAreaElement 937 ) { 938 return; 939 } 940 941 // @TODO: preventDefault in all cases where the event is handled 942 // also, ideally check that modifiers *aren't* pressed 943 // probably best to use a library at this point! 944 945 if (selection) { 946 const nudge_selection = (delta_x, delta_y) => { 947 selection.x += delta_x; 948 selection.y += delta_y; 949 selection.position(); 950 }; 951 switch (e.key) { 952 case "ArrowLeft": 953 nudge_selection(-1, 0); 954 e.preventDefault(); 955 break; 956 case "ArrowRight": 957 nudge_selection(+1, 0); 958 e.preventDefault(); 959 break; 960 case "ArrowDown": 961 nudge_selection(0, +1); 962 e.preventDefault(); 963 break; 964 case "ArrowUp": 965 nudge_selection(0, -1); 966 e.preventDefault(); 967 break; 968 } 969 } 970 971 if (e.key === "Escape") { // Note: Escape handled above too! 972 if (selection) { 973 deselect(); 974 } else { 975 cancel(); 976 } 977 stopSimulatingGestures(); 978 trace_and_sketch_stop(); 979 } else if (e.key === "Enter") { 980 if (selection) { 981 deselect(); 982 } 983 } else if (e.key === "F1") { 984 show_help(); 985 e.preventDefault(); 986 } else if (e.key === "F4") { 987 redo(); 988 } else if (e.key === "Delete" || e.key === "Backspace") { 989 // alt+backspace: undo 990 // shift+delete: cut 991 // delete/backspace: delete selection 992 if (e.key === "Delete" && e.shiftKey) { 993 edit_cut(); 994 } else if (e.key === "Backspace" && e.altKey) { 995 undo(); 996 } else { 997 delete_selection(); 998 } 999 e.preventDefault(); 1000 } else if (e.key === "Insert") { 1001 // ctrl+insert: copy 1002 // shift+insert: paste 1003 if (e.ctrlKey) { 1004 edit_copy(); 1005 e.preventDefault(); 1006 } else if (e.shiftKey) { 1007 edit_paste(); 1008 e.preventDefault(); 1009 } 1010 } else if ( 1011 e.code === "NumpadAdd" || 1012 e.code === "NumpadSubtract" || 1013 // normal + and - keys 1014 e.key === "+" || 1015 e.key === "-" || 1016 e.key === "=" 1017 ) { 1018 const plus = e.code === "NumpadAdd" || e.key === "+" || e.key === "="; 1019 const minus = e.code === "NumpadSubtract" || e.key === "-"; 1020 const delta = Number(plus) - Number(minus); // const delta = +plus++ -minus--; // Δ = ±±±± 1021 1022 if (selection) { 1023 selection.scale(2 ** delta); 1024 } else { 1025 if (selected_tool.id === TOOL_BRUSH) { 1026 brush_size = Math.max(1, Math.min(brush_size + delta, 500)); 1027 } else if (selected_tool.id === TOOL_ERASER) { 1028 eraser_size = Math.max(1, Math.min(eraser_size + delta, 500)); 1029 } else if (selected_tool.id === TOOL_AIRBRUSH) { 1030 airbrush_size = Math.max(1, Math.min(airbrush_size + delta, 500)); 1031 } else if (selected_tool.id === TOOL_PENCIL) { 1032 pencil_size = Math.max(1, Math.min(pencil_size + delta, 50)); 1033 } else if ( 1034 selected_tool.id === TOOL_LINE || 1035 selected_tool.id === TOOL_CURVE || 1036 selected_tool.id === TOOL_RECTANGLE || 1037 selected_tool.id === TOOL_ROUNDED_RECTANGLE || 1038 selected_tool.id === TOOL_ELLIPSE || 1039 selected_tool.id === TOOL_POLYGON 1040 ) { 1041 stroke_size = Math.max(1, Math.min(stroke_size + delta, 500)); 1042 } 1043 1044 $G.trigger("option-changed"); 1045 if (button !== undefined && pointer) { // pointer may only be needed for tests
1046 selected_tools.forEach((selected_tool) => { 1047 tool_go(selected_tool); 1048 }); 1049 } 1050 update_helper_layer(); 1051 } 1052 e.preventDefault(); 1053 return; 1054 } else if (e.ctrlKey || e.metaKey) { 1055 if (textbox) { 1056 switch (e.key.toUpperCase()) { 1057 case "A": 1058 case "Z": 1059 case "Y": 1060 case "I": 1061 case "B": 1062 case "U": 1063 // Don't prevent the default. Allow text editing commands. 1064 return; 1065 } 1066 } 1067 // Ctrl+PageDown: zoom to 400% 1068 // Ctrl+PageUp: zoom to 100% 1069 // In Chrome and Firefox, these switch to the next/previous tab, 1070 // but it's allowed to be overridden in fullscreen in Chrome. 1071 if (e.key === "PageDown") { 1072 set_magnification(4); 1073 e.preventDefault(); 1074 return; 1075 } else if (e.key === "PageUp") { 1076 set_magnification(1); 1077 e.preventDefault(); 1078 return; 1079 } 1080 switch (e.key.toUpperCase()) { 1081 case ",": // "<" without Shift 1082 case "<": 1083 case "[": 1084 case "{": 1085 rotate(-TAU / 4); 1086 break; 1087 case ".": // ">" without Shift 1088 case ">": 1089 case "]": 1090 case "}": 1091 rotate(+TAU / 4); 1092 break; 1093 case "Z": 1094 if (e.shiftKey) { 1095 redo(); 1096 } else { 1097 undo(); 1098 } 1099 break; 1100 case "Y": 1101 // Ctrl+Shift+Y handled above 1102 redo(); 1103 break; 1104 case "G": 1105 if (e.shiftKey) { 1106 render_history_as_gif(); 1107 } else { 1108 toggle_grid(); 1109 } 1110 break; 1111 case "F": 1112 // @ts-ignore (repeat doesn't exist on jQuery.Event, I guess, but this is fine) 1113 if (!e.repeat && !e.originalEvent?.repeat) { 1114 view_bitmap(); 1115 } 1116 break; 1117 case "O": 1118 file_open(); 1119 break; 1120 case "S": 1121 if (e.shiftKey) { 1122 file_save_as(); 1123 } else { 1124 file_save(); 1125 } 1126 break; 1127 case "A": 1128 select_all(); 1129 break; 1130 case "I": 1131 image_invert_colors(); 1132 break; 1133 case "E": 1134 image_attributes(); 1135 break; 1136 1137 // These shortcuts are mostly reserved by browsers, 1138 // but they are allowed in Electron. 1139 // The shortcuts are hidden in the menus (or changed) when not in Electron, 1140 // to prevent accidental closing/refreshing. 1141 // I'm supporting Alt+<shortcut> here (implicitly) as a workaround (and showing this in the menus in some cases). 1142 // Also, note that Chrome allows some shortcuts to be overridden in fullscreen (but showing/hiding the shortcuts would be confusing). 1143 case "N": 1144 if (e.shiftKey) { 1145 clear(); 1146 } else { 1147 file_new(); 1148 } 1149 break; 1150 case "T": 1151 $toolbox.toggle(); 1152 break; 1153 case "L": // allowed to override in Firefox 1154 $colorbox.toggle(); 1155 break; 1156 case "R": 1157 image_flip_and_rotate(); 1158 break; 1159 case "W": 1160 image_stretch_and_skew(); 1161 break; 1162 1163 default: 1164 return; // don't preventDefault 1165 } 1166 e.preventDefault(); 1167 // put nothing below! note return above 1168 } 1169}); 1170// #endregion 1171 1172// #region Alt+Mousewheel Zooming (and also some dev helper that I haven't used in years) 1173let alt_zooming = false; 1174addEventListener("keyup", (e) => { 1175 if (e.key === "Alt" && alt_zooming) { 1176 e.preventDefault(); // prevent menu bar from activating in Firefox from zooming 1177 } 1178 if (!e.altKey) { 1179 alt_zooming = false; 1180 } 1181}); 1182// $G.on("wheel", (e) => { 1183addEventListener("wheel", (e) => { 1184 if (e.altKey) { 1185 e.preventDefault(); 1186 let new_magnification = magnification; 1187 const factor = 1 + Math.min(0.5, Math.abs(e.deltaY) / 100); 1188 if (e.deltaY < 0) { 1189 new_magnification *= factor; 1190 } else { 1191 new_magnification /= factor; 1192 } 1193 new_magnification = Math.max(0.5, Math.min(new_magnification, 80)); 1194 set_magnification(new_magnification, to_canvas_coords(e)); 1195 alt_zooming = true; 1196 return; 1197 } 1198 if (e.ctrlKey || e.metaKey) { 1199 return; 1200 } 1201 // for reference screenshot mode (development helper): 1202 if (location.hash.match(/compare-reference/i)) { // including compare-reference-tool-windows 1203 // const delta_opacity = Math.sign(e.originalEvent.deltaY) * -0.1; // since attr() is not supported other than for content, this increment must match CSS 1204 const delta_opacity = Math.sign(e.deltaY) * -0.2; // since attr() is not supported other than for content, this increment must match CSS 1205 let old_opacity = parseFloat($("body").attr("data-reference-opacity")); 1206 if (!isFinite(old_opacity)) { 1207 old_opacity = 0.5; 1208 } 1209 const new_opacity = Math.max(0, Math.min(1, old_opacity + delta_opacity)); 1210 $("body").attr("data-reference-opacity", new_opacity); 1211 // prevent scrolling, keeping the screenshot lined up 1212 // e.preventDefault(); // doesn't work 1213 // $canvas_area.scrollTop(0); // doesn't work with smooth scrolling 1214 // $canvas_area.scrollLeft(0); 1215 } 1216}, { passive: false }); 1217// #endregion 1218 1219// #region Clipboard Handling 1220$G.on("cut copy paste", (e) => { 1221 if (e.isDefaultPrevented()) { 1222 return; 1223 } 1224 if ( 1225 document.activeElement instanceof HTMLInputElement || 1226 document.activeElement instanceof HTMLTextAreaElement || 1227 !window.getSelection().isCollapsed 1228 ) { 1229 // Don't prevent cutting/copying/pasting within inputs or textareas, or if there's a selection 1230 return; 1231 } 1232 1233 e.preventDefault(); 1234 // @ts-ignore 1235 const cd = e.originalEvent.clipboardData || window.clipboardData; 1236 if (!cd) { return; } 1237 1238 if (e.type === "copy" || e.type === "cut") { 1239 if (selection && selection.canvas) { 1240 const do_sync_clipboard_copy_or_cut = () => { 1241 // works only for pasting within a jspaint instance 1242 const data_url = selection.canvas.toDataURL(); 1243 cd.setData("text/x-data-uri; type=image/png", data_url);
1244 cd.setData("text/uri-list", data_url); 1245 cd.setData("URL", data_url); 1246 if (e.type === "cut") { 1247 delete_selection({ 1248 name: localize("Cut"), 1249 icon: get_help_folder_icon("p_cut.png"), 1250 }); 1251 } 1252 }; 1253 if (!navigator.clipboard || !navigator.clipboard.write || is_discord_embed) { 1254 return do_sync_clipboard_copy_or_cut(); 1255 } 1256 try { 1257 if (e.type === "cut") { 1258 edit_cut(); 1259 } else { 1260 edit_copy(); 1261 } 1262 } catch (_error) { 1263 do_sync_clipboard_copy_or_cut(); 1264 } 1265 } 1266 } else if (e.type === "paste") { 1267 for (const item of cd.items) { 1268 if (item.type.match(/^text\/(?:x-data-uri|uri-list|plain)|URL$/)) { 1269 item.getAsString((text) => { 1270 const uris = get_uris(text); 1271 if (uris.length > 0) { 1272 load_image_from_uri(uris[0]).then((info) => { 1273 paste(info.image || make_canvas(info.image_data)); 1274 }, (error) => { 1275 show_resource_load_error_message(error); 1276 }); 1277 } else { 1278 show_error_message("The information on the Clipboard can't be inserted into Paint."); 1279 } 1280 }); 1281 break; 1282 } else if (item.type.match(/^image\//)) { 1283 paste_image_from_file(item.getAsFile()); 1284 break; 1285 } 1286 } 1287 } 1288}); 1289// #endregion 1290 1291// #region Initialization 1292// This sort of thing should really be at the END of the file. 1293 1294reset_file(); 1295reset_selected_colors(); 1296reset_canvas_and_history(); // (with newly reset colors) 1297set_magnification(default_magnification); 1298 1299// this is synchronous for now, but @TODO: handle possibility of loading a document before callback 1300// when switching to asynchronous storage, e.g. with localforage 1301localStore.get({ 1302 width: default_canvas_width, 1303 height: default_canvas_height, 1304}, (err, stored_values) => { 1305 if (err) { return; } 1306 my_canvas_width = Number(stored_values.width); 1307 my_canvas_height = Number(stored_values.height); 1308 1309 make_or_update_undoable({ 1310 match: (history_node) => history_node.name === localize("New"), 1311 name: "Resize Canvas For New Document", 1312 icon: get_help_folder_icon("p_stretch_both.png"), 1313 }, () => { 1314 main_canvas.width = Math.max(1, my_canvas_width); 1315 main_canvas.height = Math.max(1, my_canvas_height); 1316 main_ctx.disable_image_smoothing(); 1317 if (!transparency) { 1318 main_ctx.fillStyle = selected_colors.background; 1319 main_ctx.fillRect(0, 0, main_canvas.width, main_canvas.height); 1320 } 1321 $canvas_area.trigger("resize"); 1322 }); 1323}); 1324 1325if (window.initial_system_file_handle) { 1326 systemHooks.readBlobFromHandle(window.initial_system_file_handle).then((file) => { 1327 if (file) { 1328 open_from_file(file, window.initial_system_file_handle); 1329 } 1330 }, (error) => { 1331 // this handler is not always called, sometimes error message is shown from readBlobFromHandle 1332 show_error_message(`Failed to open file ${window.initial_system_file_handle}`, error); 1333 }); 1334} 1335// #endregion 1336 1337// #region Palette Updating From Theme 1338 1339const update_palette_from_theme = () => { 1340 if (get_theme() === "winter.css") { 1341 palette = get_winter_palette(); 1342 $colorbox.rebuild_palette(); 1343 } else { 1344 palette = default_palette; 1345 $colorbox.rebuild_palette(); 1346 } 1347}; 1348 1349$G.on("theme-load", update_palette_from_theme); 1350// #region Initialization (continued) 1351update_palette_from_theme(); 1352// #endregion 1353 1354// #endregion 1355 1356function update_fill_and_stroke_colors_and_lineWidth(selected_tool) { 1357 main_ctx.lineWidth = stroke_size; 1358 1359 const reverse_because_fill_only = !!(selected_tool.$options && selected_tool.$options.fill && !selected_tool.$options.stroke); 1360 /** @type {ColorSelectionSlot} */ 1361 const color_k = 1362 (ctrl && selected_colors.ternary && pointer_active) ? "ternary" : 1363 ((reverse !== reverse_because_fill_only) ? "background" : "foreground"); 1364 main_ctx.fillStyle = fill_color = 1365 main_ctx.strokeStyle = stroke_color = 1366 selected_colors[color_k]; 1367 1368 /** @type {ColorSelectionSlot} */ 1369 let fill_color_k = 1370 ctrl ? "ternary" : ((reverse !== reverse_because_fill_only) ? "background" : "foreground"); 1371 /** @type {ColorSelectionSlot} */ 1372 let stroke_color_k = fill_color_k; 1373 1374 if (selected_tool.shape || selected_tool.shape_colors) { 1375 if (!selected_tool.stroke_only) { 1376 if ((reverse !== reverse_because_fill_only)) { 1377 fill_color_k = "foreground"; 1378 stroke_color_k = "background"; 1379 } else { 1380 fill_color_k = "background"; 1381 stroke_color_k = "foreground"; 1382 } 1383 } 1384 main_ctx.fillStyle = fill_color = selected_colors[fill_color_k]; 1385 main_ctx.strokeStyle = stroke_color = selected_colors[stroke_color_k]; 1386 } 1387 pick_color_slot = fill_color_k; 1388} 1389 1390// #region Primary Canvas Interaction 1391function tool_go(selected_tool, event_name) { 1392 update_fill_and_stroke_colors_and_lineWidth(selected_tool); 1393 1394 if (selected_tool[event_name]) { 1395 selected_tool[event_name](main_ctx, pointer.x, pointer.y); 1396 } 1397 if (selected_tool.paint) { 1398 selected_tool.paint(main_ctx, pointer.x, pointer.y); 1399 } 1400} 1401function canvas_pointer_move(e) { 1402 ctrl = e.ctrlKey; 1403 shift = e.shiftKey; 1404 pointer = to_canvas_coords(e); 1405 1406 // Quick Undo (for mouse/pen) 1407 // (Note: pointermove also occurs when the set of buttons pressed changes, 1408 // except when another event would fire like pointerdown) 1409 if (pointers.length && e.button != -1) { 1410 // compare buttons other than middle mouse button by using bitwise OR to make that bit of the number the same 1411 const MMB = 4; 1412 if (e.pointerType != pointer_type || (e.buttons | MMB) != (pointer_buttons | MMB)) { 1413 cancel(); 1414 pointer_active = false; // NOTE: pointer_active used in cancel() 1415 return; 1416 } 1417 } 1418 1419 if (e.shiftKey) { 1420 // TODO: snap to 45 degrees for Pencil and Polygon tools 1421 // TODO: manipulating the pointer object directly is a bit of a hack 1422 if ( 1423 selected_tool.id === TOOL_LINE || 1424 selected_tool.id === TOOL_CURVE 1425 ) { 1426 // snap to eight directions 1427 const dist = Math.sqrt( 1428 (pointer.y - pointer_start.y) * (pointer.y - pointer_start.y) + 1429 (pointer.x - pointer_start.x) * (pointer.x - pointer_start.x) 1430 ); 1431 const eighth_turn = TAU / 8; 1432 const angle_0_to_8 = Math.atan2(pointer.y - pointer_start.y, pointer.x - pointer_start.x) / eighth_turn; 1433 const angle = Math.round(angle_0_to_8) * eighth_turn;
1434 pointer.x = Math.round(pointer_start.x + Math.cos(angle) * dist); 1435 pointer.y = Math.round(pointer_start.y + Math.sin(angle) * dist); 1436 } else if (selected_tool.shape) { 1437 // snap to four diagonals 1438 const w = Math.abs(pointer.x - pointer_start.x); 1439 const h = Math.abs(pointer.y - pointer_start.y); 1440 if (w < h) { 1441 if (pointer.y > pointer_start.y) { 1442 pointer.y = pointer_start.y + w; 1443 } else { 1444 pointer.y = pointer_start.y - w; 1445 } 1446 } else { 1447 if (pointer.x > pointer_start.x) { 1448 pointer.x = pointer_start.x + h; 1449 } else { 1450 pointer.x = pointer_start.x - h; 1451 } 1452 } 1453 } 1454 } 1455 selected_tools.forEach((selected_tool) => { 1456 tool_go(selected_tool); 1457 }); 1458 pointer_previous = pointer; 1459} 1460$canvas.on("pointermove", (e) => { 1461 pointer = to_canvas_coords(e); 1462 $status_position.text(`${pointer.x},${pointer.y}`); 1463}); 1464$canvas.on("pointerenter", (e) => { 1465 pointer_over_canvas = true; 1466 1467 update_helper_layer(e); 1468 1469 if (!update_helper_layer_on_pointermove_active) { 1470 $G.on("pointermove", update_helper_layer); 1471 update_helper_layer_on_pointermove_active = true; 1472 } 1473}); 1474$canvas.on("pointerleave", (e) => { 1475 pointer_over_canvas = false; 1476 1477 $status_position.text(""); 1478 1479 update_helper_layer(e); 1480 1481 if (!pointer_active && update_helper_layer_on_pointermove_active) { 1482 $G.off("pointermove", update_helper_layer); 1483 update_helper_layer_on_pointermove_active = false; 1484 } 1485}); 1486// #endregion 1487 1488// #region Panning and Zooming 1489let last_zoom_pointer_distance; 1490let pan_last_pos; 1491// let pan_start_magnification; // for panning and zooming in the same gesture (...was this ever used?) 1492let first_pointer_time; 1493const discard_quick_undo_period = 500; // milliseconds in which to treat gesture as just a pan/zoom if you use two fingers, rather than treating it as a brush stroke you might care about 1494function average_points(points) { 1495 const average = { x: 0, y: 0 }; 1496 for (const pointer of points) { 1497 average.x += pointer.x; 1498 average.y += pointer.y; 1499 } 1500 average.x /= points.length; 1501 average.y /= points.length; 1502 return average; 1503} 1504$canvas_area.on("pointerdown", (event) => { 1505 if ( 1506 document.activeElement instanceof HTMLElement && // exists and (for type checker:) has blur() 1507 document.activeElement !== document.body && 1508 document.activeElement !== document.documentElement 1509 ) { 1510 // Allow unfocusing dialogs etc. in order to use keyboard shortcuts 1511 document.activeElement.blur(); 1512 } 1513 1514 if (pointers.every((pointer) => 1515 // prevent multitouch panning in case of synthetic events from Dwell Clicker 1516 pointer.pointerId !== 1234567890 && 1517 // prevent multitouch panning in case of dragging across iframe boundary with a mouse/pen 1518 // Note: there can be multiple active primary pointers, one per pointer type 1519 !(pointer.isPrimary && (pointer.pointerType === "mouse" || pointer.pointerType === "pen")) 1520 // @TODO: handle case of dragging across iframe boundary with touch 1521 )) { 1522 pointers.push({ 1523 pointerId: event.pointerId, 1524 pointerType: event.pointerType, 1525 // isPrimary not available on jQuery.Event, and originalEvent not available in synthetic case 1526 // @ts-ignore 1527 isPrimary: event.originalEvent && event.originalEvent.isPrimary || event.isPrimary, 1528 x: event.clientX, 1529 y: event.clientY, 1530 }); 1531 } 1532 if (pointers.length === 1) { 1533 first_pointer_time = performance.now(); 1534 } 1535 if (pointers.length == 2) { 1536 last_zoom_pointer_distance = Math.hypot(pointers[0].x - pointers[1].x, pointers[0].y - pointers[1].y); 1537 pan_last_pos = average_points(pointers); 1538 // pan_start_magnification = magnification; 1539 } 1540 // Quick Undo when there are multiple pointers (i.e. for touch) 1541 // See pointermove for other pointer types 1542 // SEE OTHER POINTERDOWN HANDLER ALSO 1543 if (pointers.length >= 2) { 1544 // If you press two fingers quickly, it shouldn't make a new history entry. 1545 // But if you draw something and then press a second finger to clear it, it should let you redo. 1546 const discard_document_state = first_pointer_time && performance.now() - first_pointer_time < discard_quick_undo_period; 1547 cancel(false, discard_document_state); 1548 pointer_active = false; // NOTE: pointer_active used in cancel(); must be set after cancel() 1549 return; 1550 } 1551}); 1552$G.on("pointerup pointercancel", (event) => { 1553 // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1583480 1554 // In Firefox on Windows, using a Wacom pen tablet, with Windows Ink disabled*, the pointerId changes constantly. 1555 // This was causing a problem where you could only interact with the canvas once before it would stop responding, 1556 // because `pointers` was non-empty so it was treated as a cancel/zoom/pan gesture. 1557 // Unfortunately, while this fix is effective, it reveals a related Firefox bug https://bugzilla.mozilla.org/show_bug.cgi?id=1584314 1558 // where the pointer events are out of order, causing a barbed wire effect on pencil strokes. 1559 // (Tested on Firefox 143 on Windows 11. Both bugs are 6+ years old.) 1560 // A workaround for the latter bug could be to buffer pointer events and reorder them by pointerId, keeping in mind that pointerIds will cycle, 1561 // although it's an implementation detail that shouldn't be depended on. This will necessarily add some latency, although if done smartly, 1562 // we could still draw the latest pointer positions available, revising the tail end of the stroke when out-of-order events arrive, 1563 // rather than keeping an invisible leading edge of the stroke. 1564 // (*Enabling Windows Ink means you can't select text in webpages, among other things.) 1565 if (event.pointerId !== undefined && !pointers.some((pointer) => pointer.pointerId === event.pointerId) && pointers.length > 0) { 1566 pointers = []; 1567 return; 1568 } 1569 1570 // Normal pointer release handling 1571 pointers = pointers.filter((pointer) => 1572 pointer.pointerId !== event.pointerId 1573 ); 1574}); 1575$G.on("pointermove", (event) => { 1576 for (const pointer of pointers) { 1577 if (pointer.pointerId === event.pointerId) { 1578 pointer.x = event.clientX; 1579 pointer.y = event.clientY; 1580 } 1581 } 1582 if (pointers.length >= 2) { 1583 const current_pos = average_points(pointers); 1584 const distance = Math.hypot(pointers[0].x - pointers[1].x, pointers[0].y - pointers[1].y); 1585 const difference_in_distance = distance - last_zoom_pointer_distance; 1586 let new_magnification = magnification; 1587 if (Math.abs(difference_in_distance) > 60) { 1588 last_zoom_pointer_distance = distance; 1589 if (difference_in_distance > 0) { 1590 new_magnification *= 1.5; 1591 } else { 1592 new_magnification /= 1.5; 1593 } 1594 } 1595 new_magnification = Math.max(0.5, Math.min(new_magnification, 40)); 1596 if (new_magnification != magnification) { 1597 set_magnification(new_magnification, to_canvas_coords({ clientX: current_pos.x, clientY: current_pos.y })); 1598 } 1599 const difference_in_x = current_pos.x - pan_last_pos.x; 1600 const difference_in_y = current_pos.y - pan_last_pos.y; 1601 $canvas_area.scrollLeft($canvas_area.scrollLeft() - difference_in_x); 1602 $canvas_area.scrollTop($canvas_area.scrollTop() - difference_in_y); 1603 pan_last_pos = current_pos; 1604 } 1605}); 1606// #endregion 1607 1608// #region Primary Canvas Interaction (continued) 1609$canvas.on("pointerdown", (e) => { 1610 update_canvas_rect(); 1611 1612 // Quick Undo when there are multiple pointers (i.e. for touch) 1613 // see pointermove for other pointer types 1614 // SEE OTHER POINTERDOWN HANDLER ALSO 1615 // NOTE: this relies on event handler order for pointerdown 1616 // pointer is not added to pointers yet 1617 if (pointers.length >= 1) { 1618 // If you press two fingers quickly, it shouldn't make a new history entry. 1619 // But if you draw something and then press a second finger to clear it, it should let you redo. 1620 const discard_document_state = first_pointer_time && performance.now() - first_pointer_time < discard_quick_undo_period; 1621 cancel(false, discard_document_state); 1622 pointer_active = false; // NOTE: pointer_active used in cancel(); must be set after cancel() 1623 1624 // in Dwell Clicker mode, allow drawing with mouse after canceling dwell gesture with mouse 1625 pointers = pointers.filter((pointer) => 1626 pointer.pointerId !== 1234567890 1627 ); 1628 return; 1629 } 1630
1631 history_node_to_cancel_to = current_history_node; 1632 1633 pointer_active = !!(e.buttons & (1 | 2)); // as far as tools are concerned 1634 pointer_type = e.pointerType; 1635 pointer_buttons = e.buttons; 1636 $G.one("pointerup", (e) => { 1637 pointer_active = false; 1638 update_helper_layer(e); 1639 1640 if (!pointer_over_canvas && update_helper_layer_on_pointermove_active) { 1641 $G.off("pointermove", update_helper_layer); 1642 update_helper_layer_on_pointermove_active = false; 1643 } 1644 }); 1645 1646 if (e.button === 0) { 1647 reverse = false; 1648 } else if (e.button === 2) { 1649 reverse = true; 1650 } else { 1651 return; 1652 } 1653 1654 button = e.button; 1655 ctrl = e.ctrlKey; 1656 shift = e.shiftKey; 1657 pointer_start = pointer_previous = pointer = to_canvas_coords(e); 1658 1659 const pointerdown_action = () => { 1660 let interval_ids = []; 1661 selected_tools.forEach((selected_tool) => { 1662 if (selected_tool.paint || selected_tool.pointerdown) { 1663 tool_go(selected_tool, "pointerdown"); 1664 } 1665 if (selected_tool.paint_on_time_interval != null) { 1666 interval_ids.push(setInterval(() => { 1667 tool_go(selected_tool); 1668 }, selected_tool.paint_on_time_interval)); 1669 } 1670 }); 1671 1672 $G.on("pointermove", canvas_pointer_move); 1673 1674 $G.one("pointerup", (e, canceling, no_undoable) => { 1675 button = undefined; 1676 reverse = false; 1677 1678 if (e.clientX !== undefined) { // may be synthetic event without coordinates 1679 pointer = to_canvas_coords(e); 1680 } 1681 // don't create undoables if you're two-finger-panning 1682 // @TODO: do any tools use pointerup for cleanup? 1683 if (!no_undoable) { 1684 selected_tools.forEach((selected_tool) => { 1685 selected_tool.pointerup?.(main_ctx, pointer.x, pointer.y); 1686 }); 1687 } 1688 1689 if (selected_tools.length === 1) { 1690 if (selected_tool.deselect) { 1691 select_tools(return_to_tools); 1692 } 1693 } 1694 $G.off("pointermove", canvas_pointer_move); 1695 for (const interval_id of interval_ids) { 1696 clearInterval(interval_id); 1697 } 1698 1699 if (!canceling) { 1700 history_node_to_cancel_to = null; 1701 } 1702 }); 1703 }; 1704 1705 pointerdown_action(); 1706 1707 update_helper_layer(e); 1708}); 1709// #endregion 1710 1711// #region Deselection / Selection Prevention 1712$canvas_area.on("pointerdown", (e) => { 1713 if (e.button === 0) { 1714 if ($canvas_area.is(e.target)) { 1715 if (selection) { 1716 deselect(); 1717 } 1718 } 1719 } 1720}); 1721 1722function prevent_selection($el) { 1723 $el.on("mousedown selectstart contextmenu", (e) => { 1724 if (e.isDefaultPrevented()) { 1725 return; 1726 } 1727 if ( 1728 e.target instanceof HTMLSelectElement || 1729 e.target instanceof HTMLTextAreaElement || 1730 (e.target instanceof HTMLLabelElement && e.type !== "contextmenu") || 1731 (e.target instanceof HTMLInputElement && e.target.type !== "color") 1732 ) { 1733 return; 1734 } 1735 if (e.button === 1) { 1736 return; // allow middle-click scrolling 1737 } 1738 e.preventDefault(); 1739 // we're just trying to prevent selection 1740 // but part of the default for mousedown is *deselection* 1741 // so we have to do that ourselves explicitly 1742 window.getSelection().removeAllRanges(); 1743 }); 1744} 1745 1746prevent_selection($app); 1747prevent_selection($toolbox); 1748// prevent_selection($toolbox2); 1749prevent_selection($colorbox); 1750// #endregion 1751 1752// Stop drawing (or dragging or whatever) if you Alt+Tab or whatever 1753$G.on("blur", () => { 1754 $G.triggerHandler("pointerup"); 1755}); 1756 1757// #region Fullscreen Handling for iOS 1758// For Safari on iPad, Fullscreen mode overlays the system bar, completely obscuring our menu bar. 1759// See CSS .fullscreen handling (and exit_fullscreen_if_ios) for more info. 1760function iOS() { 1761 return ( 1762 [ 1763 "iPad Simulator", 1764 "iPhone Simulator", 1765 "iPod Simulator", 1766 "iPad", 1767 "iPhone", 1768 "iPod", 1769 ].includes(navigator.platform) || 1770 // iPad on iOS 13 detection 1771 (navigator.userAgent.includes("Mac") && "ontouchend" in document) 1772 ); 1773} 1774$("html").toggleClass("ios", iOS()); 1775$G.on("fullscreenchange webkitfullscreenchange", () => { 1776 // const fullscreen = $G.is(":fullscreen") || $G.is(":-webkit-full-screen"); // gives "Script error." 1777 const fullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement); 1778 // $status_text.text(`fullscreen: ${fullscreen}`); 1779 $("html").toggleClass("fullscreen", fullscreen); 1780}); 1781// #endregion 1782 1783// #region Testing Helpers 1784// Note: this is defined here so the app is loaded when this is defined. 1785window.api_for_cypress_tests = { 1786 reset_for_next_test() { 1787 selected_colors.foreground = "#000"; 1788 selected_colors.background = "#fff"; 1789 brush_shape = default_brush_shape; 1790 brush_size = default_brush_size; 1791 eraser_size = default_eraser_size; 1792 airbrush_size = default_airbrush_size; 1793 pencil_size = default_pencil_size; 1794 stroke_size = default_stroke_size; 1795 clear(); 1796 }, 1797 selected_colors, 1798 set_theme, 1799 $, 1800}; 1801// #endregion 1802 1803init_webgl_stuff();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.