PageSourceSearch

https://jspaint.app/src/app.js

js jspaint.app collected 2026-10-02 16:46:36 UTC 66,065 bytes, 1,803 lines download raw bytes

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.