· 5 years ago · Oct 09, 2020, 07:04 AM
1
2// expresses order in the URL as well as type
3const param_types = {
4 // settings
5 "eye-gaze-mode": "bool",
6 "vertical-color-box-mode": "bool",
7 "speech-recognition-mode": "bool",
8 // sessions
9 "local": "string",
10 "session": "string",
11 "load": "string",
12};
13
14const exclusive_params = [
15 "local",
16 "session",
17 "load",
18];
19
20function get_all_url_params() {
21 const params = {};
22 location.hash.replace(/^#/, "").split(/,/).forEach((param_decl)=> {
23 // colon is used in param value for URLs so split(":") isn't good enough
24 const colon_index = param_decl.indexOf(":");
25 if (colon_index === -1) {
26 // boolean value, implicitly true because it's in the URL
27 const param_name = param_decl;
28 params[param_name] = true;
29 } else {
30 const param_name = param_decl.slice(0, colon_index);
31 const param_value = param_decl.slice(colon_index + 1);
32 params[param_name] = decodeURIComponent(param_value);
33 }
34 });
35 for (const [param_name, param_type] of Object.entries(param_types)) {
36 if (param_type === "bool" && !params[param_name]) {
37 params[param_name] = false;
38 }
39 }
40 return params;
41}
42
43function get_url_param(param_name) {
44 return get_all_url_params()[param_name];
45}
46
47function change_url_param(param_name, value, {replace_history_state=false}={}) {
48 change_some_url_params({[param_name]: value}, {replace_history_state});
49}
50
51function change_some_url_params(updates, {replace_history_state=false}={}) {
52 for (const exclusive_param of exclusive_params) {
53 if (updates[exclusive_param]) {
54 exclusive_params.forEach((param)=> {
55 if (param !== exclusive_param) {
56 updates[param] = null; // must be enumerated (for Object.assign) but falsey, to get removed from the URL
57 }
58 });
59 }
60 }
61 set_all_url_params(Object.assign({}, get_all_url_params(), updates), {replace_history_state});
62}
63
64function set_all_url_params(params, {replace_history_state=false}={}) {
65
66 let new_hash = "";
67 for (const [param_name, param_type] of Object.entries(param_types)) {
68 if (params[param_name]) {
69 if (new_hash.length) {
70 new_hash += ",";
71 }
72 new_hash += encodeURIComponent(param_name);
73 if (param_type !== "bool") {
74 new_hash += ":" + encodeURIComponent(params[param_name]);
75 }
76 }
77 }
78 // Note: gets rid of query string (?) portion of the URL
79 // This is desired for upgrading backwards compatibility URLs;
80 // may not be desired for future cases.
81 const new_url = `${location.origin}${location.pathname}#${new_hash}`;
82 if (replace_history_state) {
83 history.replaceState(null, document.title, new_url);
84 } else {
85 history.pushState(null, document.title, new_url);
86 }
87
88 $G.triggerHandler("change-url-params");
89}
90
91function update_magnified_canvas_size(){
92 $canvas.css("width", canvas.width * magnification);
93 $canvas.css("height", canvas.height * magnification);
94
95 update_canvas_rect();
96}
97
98function update_canvas_rect() {
99 canvas_bounding_client_rect = canvas.getBoundingClientRect();
100
101 update_helper_layer();
102}
103
104let helper_layer_update_queued;
105let info_for_updating_pointer; // for updating on scroll or resize, where the mouse stays in the same place but its coordinates in the document change
106function update_helper_layer(e){
107 // e may be a number from requestAnimationFrame callback; ignore that
108 if (e && isFinite(e.clientX)) {
109 info_for_updating_pointer = {clientX: e.clientX, clientY: e.clientY, devicePixelRatio};
110 }
111 if (helper_layer_update_queued) {
112 // window.console && console.log("update_helper_layer - nah, already queued");
113 return;
114 } else {
115 // window.console && console.log("update_helper_layer");
116 }
117 helper_layer_update_queued = true;
118 requestAnimationFrame(()=> {
119 helper_layer_update_queued = false;
120 update_helper_layer_immediately();
121 });
122}
123function update_helper_layer_immediately() {
124 // window.console && console.log("Update helper layer NOW");
125 if (info_for_updating_pointer) {
126 const rescale = info_for_updating_pointer.devicePixelRatio / devicePixelRatio;
127 info_for_updating_pointer.clientX *= rescale;
128 info_for_updating_pointer.clientY *= rescale;
129 info_for_updating_pointer.devicePixelRatio = devicePixelRatio;
130 pointer = to_canvas_coords(info_for_updating_pointer);
131 }
132
133 update_fill_and_stroke_colors_and_lineWidth(selected_tool);
134
135 const grid_visible = show_grid && magnification >= 4 && (window.devicePixelRatio * magnification) >= 4;
136
137 const scale = magnification * window.devicePixelRatio;
138
139 if (!helper_layer) {
140 helper_layer = new OnCanvasHelperLayer(0, 0, canvas.width, canvas.height, false, scale);
141 }
142
143 const hcanvas = helper_layer.canvas;
144 const hctx = hcanvas.ctx;
145
146 const margin = 15;
147 const viewport_x = Math.floor(Math.max($canvas_area.scrollLeft() / magnification - margin, 0));
148 const viewport_y = Math.floor(Math.max($canvas_area.scrollTop() / magnification - margin, 0));
149 const viewport_x2 = Math.floor(Math.min(viewport_x + $canvas_area.width() / magnification + margin*2, canvas.width));
150 const viewport_y2 = Math.floor(Math.min(viewport_y + $canvas_area.height() / magnification + margin*2, canvas.height));
151 const viewport_width = viewport_x2 - viewport_x;
152 const viewport_height = viewport_y2 - viewport_y;
153 const resolution_width = viewport_width * scale;
154 const resolution_height = viewport_height * scale;
155 if (
156 hcanvas.width !== resolution_width ||
157 hcanvas.height !== resolution_height
158 ) {
159 hcanvas.width = resolution_width;
160 hcanvas.height = resolution_height;
161 hcanvas.ctx.disable_image_smoothing();
162 helper_layer.width = viewport_width;
163 helper_layer.height = viewport_height;
164 }
165 helper_layer.x = viewport_x;
166 helper_layer.y = viewport_y;
167 helper_layer.position();
168
169 hctx.clearRect(0, 0, hcanvas.width, hcanvas.height);
170
171 var tools_to_preview = [...selected_tools];
172 // the select box previews draw the document canvas onto the preview canvas
173 // so they have something to invert within the preview canvas
174 // but this means they block out anything earlier
175 // NOTE: sort Select after Free-Form Select,
176 // Brush after Eraser, as they are from the toolbar ordering
177 tools_to_preview.sort((a, b)=> {
178 if (a.selectBox && !b.selectBox) {
179 return -1;
180 }
181 if (!a.selectBox && b.selectBox) {
182 return 1;
183 }
184 return 0;
185 });
186 // two select box previews would just invert and cancel each other out
187 // so only render one if there's one or more
188 var select_box_index = tools_to_preview.findIndex((tool)=> tool.selectBox);
189 if (select_box_index >= 0) {
190 tools_to_preview = tools_to_preview.filter((tool, index)=> !tool.selectBox || index == select_box_index);
191 }
192
193 tools_to_preview.forEach((tool)=> {
194 if(tool.drawPreviewUnderGrid && pointer && pointers.length < 2){
195 hctx.save();
196 tool.drawPreviewUnderGrid(hctx, pointer.x, pointer.y, grid_visible, scale, -viewport_x, -viewport_y);
197 hctx.restore();
198 }
199 });
200
201 if (selection) {
202 hctx.save();
203
204 hctx.scale(scale, scale);
205 hctx.translate(-viewport_x, -viewport_y);
206
207 hctx.drawImage(selection.canvas, selection.x, selection.y);
208
209 hctx.restore();
210 }
211
212 if (textbox) {
213 hctx.save();
214
215 hctx.scale(scale, scale);
216 hctx.translate(-viewport_x, -viewport_y);
217
218 hctx.drawImage(textbox.canvas, textbox.x, textbox.y);
219
220 hctx.restore();
221 }
222
223 if (grid_visible) {
224 draw_grid(hctx, scale);
225 }
226
227 tools_to_preview.forEach((tool)=> {
228 if(tool.drawPreviewAboveGrid && pointer && pointers.length < 2){
229 hctx.save();
230 tool.drawPreviewAboveGrid(hctx, pointer.x, pointer.y, grid_visible, scale, -viewport_x, -viewport_y);
231 hctx.restore();
232 }
233 });
234}
235function update_disable_aa() {
236 const dots_per_canvas_px = window.devicePixelRatio * magnification;
237 const round = Math.floor(dots_per_canvas_px) === dots_per_canvas_px;
238 $canvas_area.toggleClass("disable-aa-for-things-at-main-canvas-scale", dots_per_canvas_px >= 3 || round);
239}
240
241function set_magnification(scale){
242 const prev_magnification = magnification;
243 let scroll_left = $canvas_area.scrollLeft();
244 let scroll_top = $canvas_area.scrollTop();
245
246 magnification = scale;
247 if(scale !== 1){
248 return_to_magnification = scale;
249 }
250 update_magnified_canvas_size();
251
252 // rescale viewport with top left as anchor
253 scroll_left *= magnification / prev_magnification;
254 scroll_top *= magnification / prev_magnification;
255
256 $canvas_area.scrollLeft(scroll_left);
257 $canvas_area.scrollTop(scroll_top);
258
259 $G.triggerHandler("resize"); // updates handles & grid
260 $G.trigger("option-changed"); // updates options area
261}
262
263let $custom_zoom_window;
264function show_custom_zoom_window() {
265 if ($custom_zoom_window) {
266 $custom_zoom_window.close();
267 }
268 const $w = new $FormToolWindow("Custom Zoom");
269 $custom_zoom_window = $w;
270
271 // @TODO: show Current zoom: blah% ?
272 const $fieldset = $(E("fieldset")).appendTo($w.$main);
273 $fieldset.append("<legend>Zoom to</legend>");
274 $fieldset.append("<label><input type='radio' name='custom-zoom-radio' value='1'/>100%</label>");
275 $fieldset.append("<label><input type='radio' name='custom-zoom-radio' value='2'/>200%</label>");
276 $fieldset.append("<label><input type='radio' name='custom-zoom-radio' value='4'/>400%</label>");
277 $fieldset.append("<label><input type='radio' name='custom-zoom-radio' value='6'/>600%</label>");
278 $fieldset.append("<label><input type='radio' name='custom-zoom-radio' value='8'/>800%</label>");
279 $fieldset.append("<label><input type='radio' name='custom-zoom-radio' value='really-custom'/><input type='number' min='10' max='1000' name='really-custom-zoom-input' value=''/>%</label>");
280 let is_custom = true;
281 $fieldset.find("input[type=radio]").get().forEach((el)=> {
282 if (parseFloat(el.value) === magnification) {
283 el.checked = true;
284 is_custom = false;
285 }
286 });
287 const $really_custom_radio_option = $fieldset.find("input[value='really-custom']");
288 const $really_custom_input = $fieldset.find("input[name='really-custom-zoom-input']");
289
290 $really_custom_input.closest("label").on("click", ()=> {
291 $really_custom_radio_option.prop("checked", true);
292 $really_custom_input[0].focus();
293 });
294
295 if (is_custom) {
296 $really_custom_input.val(magnification * 100);
297 $really_custom_radio_option.prop("checked", true);
298 }
299
300 $fieldset.find("label").css({display: "block"});
301
302 $w.$Button("Okay", () => {
303 let option_val = $fieldset.find("input[name='custom-zoom-radio']:checked").val();
304 let mag;
305 if(option_val === "really-custom"){
306 option_val = $really_custom_input.val();
307 if(`${option_val}`.match(/\dx$/)) { // ...you can't actually type an x; oh well...
308 mag = parseFloat(option_val);
309 }else if(`${option_val}`.match(/\d%?$/)) {
310 mag = parseFloat(option_val) / 100;
311 }
312 if(isNaN(mag)){
313 const $msgw = new $FormToolWindow("Invalid Value").addClass("dialogue-window");
314 $msgw.$main.text("The value specified for custom zoom was invalid.");
315 $msgw.$Button("Okay", () => {
316 $msgw.close();
317 });
318 return;
319 }
320 }else{
321 mag = parseFloat(option_val);
322 }
323
324 set_magnification(mag);
325
326 $w.close();
327 })[0].focus();
328 $w.$Button("Cancel", () => {
329 $w.close();
330 });
331
332 $w.center();
333}
334
335function toggle_grid() {
336 show_grid = !show_grid;
337 // $G.trigger("option-changed");
338 update_helper_layer();
339}
340
341function reset_colors(){
342 colors = {
343 foreground: "#000000",
344 background: "#ffffff",
345 ternary: "",
346 };
347 $G.trigger("option-changed");
348}
349
350function reset_file(){
351 document_file_path = null;
352 file_name = "untitled";
353 update_title();
354 saved = true;
355}
356
357function reset_canvas_and_history(){
358 undos.length = 0;
359 redos.length = 0;
360 current_history_node = root_history_node = make_history_node({
361 name: "New Document",
362 icon: get_help_folder_icon("p_blank.png"),
363 });
364 history_node_to_cancel_to = null;
365
366 canvas.width = Math.max(1, my_canvas_width);
367 canvas.height = Math.max(1, my_canvas_height);
368 ctx.disable_image_smoothing();
369 ctx.fillStyle = colors.background;
370 ctx.fillRect(0, 0, canvas.width, canvas.height);
371
372 current_history_node.image_data = ctx.getImageData(0, 0, canvas.width, canvas.height);
373
374 $canvas_area.trigger("resize");
375 $G.triggerHandler("history-update"); // update history view
376}
377
378function make_history_node({
379 parent = null,
380 futures = [],
381 timestamp = Date.now(),
382 soft = false,
383 image_data = null,
384 selection_image_data = null,
385 selection_x,
386 selection_y,
387 textbox_text,
388 textbox_x,
389 textbox_y,
390 textbox_width,
391 textbox_height,
392 text_tool_font = null,
393 tool_transparent_mode,
394 foreground_color,
395 background_color,
396 ternary_color,
397 name,
398 icon = null,
399}) {
400 return {
401 parent,
402 futures,
403 timestamp,
404 soft,
405 image_data,
406 selection_image_data,
407 selection_x,
408 selection_y,
409 textbox_text,
410 textbox_x,
411 textbox_y,
412 textbox_width,
413 textbox_height,
414 text_tool_font,
415 tool_transparent_mode,
416 foreground_color,
417 background_color,
418 ternary_color,
419 name,
420 icon,
421 };
422}
423
424function update_title(){
425 document.title = `${file_name} - ${is_pride_month ? "Gay es " : ""}Paint`;
426
427 if (is_pride_month) {
428 $("link[rel~='icon']").attr("href", "./images/icons/gay-es-paint-16x16-light-outline.png");
429 }
430}
431
432function create_and_trigger_input(attrs, callback){
433 const $input = $(E("input")).attr(attrs)
434 .on("change", ()=> {
435 callback($input[0]);
436 $input.remove();
437 })
438 .appendTo($app)
439 .hide()
440 .trigger("click");
441 return $input;
442}
443
444// @TODO: rename these functions to lowercase (and maybe say "files" in this case)
445function get_FileList_from_file_select_dialog(callback){
446 // @TODO: specify mime types?
447 create_and_trigger_input({type: "file"}, input => {
448 callback(input.files);
449 });
450}
451
452function open_from_Image(img, callback, canceled){
453 are_you_sure(() => {
454 // @TODO: shouldn't open_from_* start a new session?
455
456 deselect();
457 cancel();
458 saved = false;
459
460 reset_file();
461 reset_colors();
462 reset_canvas_and_history(); // (with newly reset colors)
463 set_magnification(default_magnification);
464
465 ctx.copy(img);
466 detect_transparency();
467 $canvas_area.trigger("resize");
468
469 current_history_node.name = "Load Document";
470 current_history_node.image_data = ctx.getImageData(0, 0, canvas.width, canvas.height);
471 current_history_node.icon = null; // @TODO
472
473 $G.triggerHandler("session-update"); // autosave
474 $G.triggerHandler("history-update"); // update history view
475
476 callback && callback();
477 }, canceled);
478}
479function get_URIs(text) {
480 // parse text/uri-list
481 // get lines, discarding comments
482 const lines = text.split(/[\n\r]+/).filter(line => line[0] !== "#" && line);
483 // discard text with too many lines (likely pasted HTML or something) - may want to revisit this
484 if (lines.length > 15) {
485 return [];
486 }
487 // parse URLs, discarding anything that parses as a relative URL
488 const uris = [];
489 for (let i=0; i<lines.length; i++) {
490 try {
491 const url = new URL(lines[i]);
492 uris.push(url.href);
493 // eslint-disable-next-line no-empty
494 } catch(e) {}
495 }
496 return uris;
497}
498function load_image_from_URI(uri, callback){
499 const is_blob_uri = uri.match(/^blob:/);
500 const is_download = !uri.match(/^(blob|data):/);
501
502 /*
503 if (is_blob_uri && uri.indexOf(`blob:${location.origin}`) === -1) {
504 const error = new Error("can't load blob: URI from another domain");
505 error.code = "cors-blob-uri";
506 callback(error);
507 return;
508 }
509 */
510 console.log("ZZZZZZZZZZZZZZZZ URI= uri="+uri)
511 const uris_to_try = is_download ? [
512 uri,
513 // work around CORS headers not sent by whatever server
514 //// `https://jspaint-cors-proxy.herokuapp.com/${uri}`,
515 // if the image isn't available on the live web, see if it's archived
516 ////`https://web.archive.org/${uri}`,
517 ] : [uri];
518
519 let index = 0;
520 const try_next_uri = ()=> {
521 const uri_to_try = uris_to_try[index];
522 if (is_download) {
523 $status_text.text("Downloading picture...");
524 }
525
526 const handle_fetch_fail = ()=> {
527 index += 1;
528 if (index >= uris_to_try.length) {
529 if (is_download) {
530 $status_text.text("Failed to download picture.");
531 }
532 callback && callback(new Error(`failed to download image from any of three URIs (${JSON.stringify(uris_to_try)}).`));
533 } else {
534 try_next_uri();
535 }
536 };
537 const show_progress = ({loaded, total})=> {
538 if (is_download) {
539 $status_text.text(`Downloading picture... (${Math.round(loaded/total*100)}%)`);
540 }
541 };
542
543 if (is_download) {
544 console.log(`Try loading image from URI (${index + 1}/${uris_to_try.length}): "${uri_to_try}"`);
545 }
546 const myoptions = {
547 mode: 'no-cors'
548 };
549 fetch(uri_to_try,myoptions)
550 .then(response => {
551 if (!response.ok) {
552 throw Error(`${response.status} ${response.statusText}`);
553 }
554 if (!response.body) {
555 if (is_download) {
556 console.log("ReadableStream not yet supported in this browser. Progress won't be shown for image requests.");
557 }
558 return response;
559 }
560
561 // to access headers, server must send CORS header "Access-Control-Expose-Headers: content-encoding, content-length x-file-size"
562 // server must send custom x-file-size header if gzip or other content-encoding is used
563 const contentEncoding = response.headers.get("content-encoding");
564 const contentLength = response.headers.get(contentEncoding ? "x-file-size" : "content-length");
565 if (contentLength === null) {
566 if (is_download) {
567 console.log("Response size header unavailable. Progress won't be shown for this image request.");
568 }
569 return response;
570 }
571
572 const total = parseInt(contentLength, 10);
573 let loaded = 0;
574
575 return new Response(
576 new ReadableStream({
577 start(controller) {
578 const reader = response.body.getReader();
579
580 read();
581 function read() {
582 reader.read().then(({done, value}) => {
583 if (done) {
584 controller.close();
585 return;
586 }
587 loaded += value.byteLength;
588 show_progress({loaded, total})
589 controller.enqueue(value);
590 read();
591 }).catch(error => {
592 console.error(error);
593 controller.error(error)
594 })
595 }
596 }
597 })
598 );
599 })
600 .then(response => response.blob())
601 .then(blob => {
602 if (is_download) {
603 console.log("Download complete.");
604 $status_text.text("Download complete.");
605 }
606 const img = new Image();
607 img.crossOrigin = "Anonymous";
608 const handle_decode_fail = ()=> {
609 // @TODO: use headers to detect HTML instead, since a doctype is not guaranteed
610 // @TODO: fall back to WayBack Machine still for decode errors,
611 // since a website might start redirecting swathes of URLs regardless of what they originally pointed to,
612 // at which point they would likely point to a web page instead of an image.
613 // (But still show an error about it not being an image, if WayBack also fails.)
614 var fr = new FileReader();
615 fr.onerror = ()=> {
616 const error = new Error("failed to decode blob as image or text");
617 error.code = "decode-fail";
618 callback(error);
619 };
620 fr.onload = (e)=> {
621 const error = new Error("failed to decode blob as an image");
622 error.code = e.target.result.match(/^\s*<!doctype\s+html/i) ? "html-not-image" : "decode-fail";
623 callback(error);
624 };
625 fr.readAsText(blob);
626 };
627 img.onload = ()=> {
628 if (!img.complete || typeof img.naturalWidth == "undefined" || img.naturalWidth === 0) {
629 handle_decode_fail();
630 return;
631 }
632 callback(null, img);
633 };
634 img.onerror = handle_decode_fail;
635 img.src = window.URL.createObjectURL(blob);
636 })
637 .catch(handle_fetch_fail);
638 };
639 try_next_uri();
640}
641function open_from_URI(uri, callback, canceled){
642 load_image_from_URI(uri, (error, img) => {
643 if(error){ return callback(error); }
644 open_from_Image(img, callback, canceled);
645 });
646}
647function open_from_File(file, callback, canceled){
648 const blob_url = URL.createObjectURL(file);
649 load_image_from_URI(blob_url, (error, img) => {
650 // revoke object URL regardless of error
651 URL.revokeObjectURL(file);
652 if(error){ return callback(error); }
653
654 open_from_Image(img, () => {
655 file_name = file.name;
656 document_file_path = file.path; // available in Electron
657 update_title();
658 saved = true;
659 callback();
660 }, canceled);
661 });
662}
663function open_from_FileList(files, user_input_method_verb_past_tense){
664 for (const file of files) {
665 if (file.type.match(/^image/)) {
666 open_from_File(file, err => {
667 if(err){ return show_error_message("Failed to open file:", err); }
668 });
669 return;
670 } else if (file.name.match(/\.theme(pack)?$/i)) {
671 loadThemeFile(file);
672 return;
673 }
674 }
675 if(files.length > 1){
676 show_error_message(`None of the files ${user_input_method_verb_past_tense} appear to be images.`);
677 }else{
678 show_error_message(`File ${user_input_method_verb_past_tense} does not appear to be an image.`);
679 }
680}
681
682function loadThemeFile(file) {
683 var reader = new FileReader();
684 reader.onload = ()=> {
685 loadThemeFromText(reader.result);
686 };
687 reader.readAsText(file);
688}
689function loadThemeFromText(fileText) {
690 var cssProperties = parseThemeFileString(fileText);
691 applyCSSProperties(cssProperties);
692
693 window.themeCSSProperties = cssProperties;
694 $("iframe").each((i, iframe)=> {
695 try {
696 applyCSSProperties(cssProperties, iframe.contentDocument.documentElement);
697 } catch(error) {
698 console.log("error applying theme to iframe", iframe, error);
699 }
700 })
701
702 $G.triggerHandler("theme-load");
703}
704
705function file_new(){
706 are_you_sure(() => {
707 deselect();
708 cancel();
709 saved = false;
710
711 reset_file();
712 reset_colors();
713 reset_canvas_and_history(); // (with newly reset colors)
714 set_magnification(default_magnification);
715
716 $G.triggerHandler("session-update"); // autosave
717 });
718}
719
720// @TODO: factor out open_select/choose_file_dialog or get_file_from_file_select_dialog or whatever
721// all these open_from_* things are done backwards, basically
722// there's this little thing called Inversion of Control...
723// also paste_from_file_select_dialog
724function file_open(){
725 get_FileList_from_file_select_dialog(files => {
726 open_from_FileList(files, "selected");
727 });
728}
729
730let $file_load_from_url_window;
731function file_load_from_url(){
732 if($file_load_from_url_window){
733 $file_load_from_url_window.close();
734 }
735 const $w = new $FormToolWindow().addClass("dialogue-window");
736 $file_load_from_url_window = $w;
737 $w.title("Load from URL");
738 // @TODO: URL validation (input has to be in a form (and we don't want the form to submit))
739 $w.$main.html("<label>URL: <input type='url' required value='' class='url-input'/></label>");
740 const $input = $w.$main.find(".url-input");
741 $w.$Button("Load", () => {
742 const uris = get_URIs($input.val());
743 if (uris.length > 0) {
744 // @TODO: retry loading if same URL entered
745 // actually, make it change the hash only after loading successfully
746 // (but still load from the hash when necessary)
747 // make sure it doesn't overwrite the old session before switching
748 $w.close();
749 change_url_param("load", uris[0]);
750 } else {
751 show_error_message("Invalid URL. It must include a protocol (https:// or http://)");
752 }
753 });
754 $w.$Button("Cancel", () => {
755 $w.close();
756 });
757 $w.center();
758 $input[0].focus();
759}
760
761function file_save(){
762 deselect();
763 if(file_name.match(/\.svg$/)){
764 // @TODO: only affect suggested name in save dialog, don't change file_name
765 file_name = `${file_name.replace(/\.svg$/, "")}.png`;
766 return file_save_as();
767 }
768 if(document_file_path){
769 // @TODO: save as JPEG by default if the previously opened/saved file was a JPEG?
770 return save_to_file_path(document_file_path, "PNG", (saved_file_path, saved_file_name) => {
771 saved = true;
772 document_file_path = saved_file_path;
773 file_name = saved_file_name;
774 update_title();
775 });
776 }
777 file_save_as();
778}
779
780function file_save_as(){
781 deselect();
782 save_canvas_as(canvas, `${file_name.replace(/\.(bmp|dib|a?png|gif|jpe?g|jpe|jfif|tiff?|webp|raw)$/, "")}.png`, (saved_file_path, saved_file_name) => {
783 saved = true;
784 document_file_path = saved_file_path;
785 file_name = saved_file_name;
786 update_title();
787 });
788}
789
790
791function are_you_sure(action, canceled){
792 if(saved){
793 action();
794 }else{
795 const $w = new $FormToolWindow().addClass("dialogue-window");
796 $w.title("Paint");
797 $w.$main.text(`Save changes to ${file_name}?`);
798 $w.$Button("Save", () => {
799 $w.close();
800 file_save();
801 action();
802 })[0].focus();
803 $w.$Button("Discard", () => {
804 $w.close();
805 action();
806 });
807 $w.$Button("Cancel", () => {
808 $w.close();
809 canceled && canceled();
810 });
811 $w.$x.on("click", () => {
812 canceled && canceled();
813 });
814 $w.center();
815 }
816}
817
818function show_error_message(message, error){
819 const $w = $FormToolWindow().title("Error").addClass("dialogue-window");
820 $w.$main.text(message);
821 $w.$main.css("max-width", "600px");
822 if(error){
823 $(E("pre"))
824 .appendTo($w.$main)
825 .text(error.stack || error.toString())
826 .css({
827 background: "white",
828 color: "#333",
829 // background: "#A00",
830 // color: "white",
831 fontFamily: "monospace",
832 width: "500px",
833 overflow: "auto",
834 });
835 }
836 $w.$Button("OK", () => {
837 $w.close();
838 });
839 $w.center();
840 if (error) {
841 window.console && console.error(message, error);
842 } else {
843 window.console && console.error(message);
844 }
845}
846
847// @TODO: close are_you_sure windows and these Error windows when switching sessions
848// because it can get pretty confusing
849function show_resource_load_error_message(error){
850 const $w = $FormToolWindow().title("Error").addClass("dialogue-window");
851 const firefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
852 if (error.code === "cors-blob-uri") {
853 $w.$main.html(`
854 <p>Can't load image from address starting with "blob:".</p>
855 ${
856 firefox ?
857 `<p>Try "Copy Image" instead of "Copy Image Location".</p>` :
858 `<p>Try "Copy image" instead of "Copy image address".</p>`
859 }
860 `);
861 } else if (error.code === "html-not-image") {
862 $w.$main.html(`
863 <p>Address points to a web page, not an image file.</p>
864 <p>Try copying and pasting an image instead of a URL.</p>
865 `);
866 } else if (error.code === "decode-fail") {
867 $w.$main.html(`
868 <p>Address doesn't point to an image file of a supported format.</p>
869 <p>Try copying and pasting an image instead of a URL.</p>
870 `);
871 } else {
872 $w.$main.html(`
873 <p>Failed to load image from URL.</p>
874 <p>Check your browser's devtools for details.</p>
875 `);
876 }
877 $w.$main.css({maxWidth: "500px"});
878 $w.$Button("OK", () => {
879 $w.close();
880 });
881 $w.center();
882}
883
884let $about_paint_window;
885const $about_paint_content = $("#about-paint");
886let $news_window;
887const $this_version_news = $("#news");
888let $latest_news = $this_version_news;
889
890// not included directly in the HTML as a simple way of not showing it if it's loaded with fetch
891// (...not sure how to phrase this clearly and concisely...)
892// "Showing the news as of this version of JS Paint. For the latest, see <a href='https://jspaint.app'>jspaint.app</a>"
893if (location.origin !== "https://jspaint.app") {
894 $this_version_news.prepend(
895 $("<p>For the latest news, visit <a href='https://jspaint.app'>jspaint.app</a></p>")
896 .css({padding: "8px 15px"})
897 );
898}
899
900function show_about_paint(){
901 if($about_paint_window){
902 $about_paint_window.close();
903 }
904 $about_paint_window = $ToolWindow().title("About Paint");
905 if (is_pride_month) {
906 $("#paint-32x32").attr("src", "./images/icons/gay-es-paint-32x32-light-outline.png");
907 }
908
909 $about_paint_window.$content.append($about_paint_content.show()).css({padding: "15px"});
910
911 $("#maybe-outdated-view-project-news").removeAttr("hidden");
912
913 $("#failed-to-check-if-outdated").attr("hidden", "hidden");
914 $("#outdated").attr("hidden", "hidden");
915
916 $about_paint_window.center();
917 $about_paint_window.center(); // @XXX - but it helps tho
918
919 $("#refresh-to-update").on("click", (event)=> {
920 event.preventDefault();
921 location.reload();
922 });
923
924 $("#view-project-news").on("click", ()=> {
925 show_news();
926 });
927
928 $("#checking-for-updates").removeAttr("hidden");
929
930 const url =
931 // ".";
932 // "test-news-newer.html";
933 "https://jspaint.app";
934 fetch(url)
935 .then((response)=> response.text())
936 .then((text)=> {
937 const parser = new DOMParser();
938 const htmlDoc = parser.parseFromString(text, "text/html");
939 $latest_news = $(htmlDoc).find("#news");
940
941 const $latest_entries = $latest_news.find(".news-entry");
942 const $this_version_entries = $this_version_news.find(".news-entry");
943
944 if (!$latest_entries.length) {
945 $latest_news = $this_version_news;
946 throw new Error(`No news found at fetched site (${url})`);
947 }
948
949 function entries_contains_update($entries, id) {
950 return $entries.get().some((el_from_this_version)=>
951 id === el_from_this_version.id
952 );
953 }
954
955 // @TODO: visibly mark entries that overlap
956 entries_newer_than_this_version =
957 $latest_entries.get().filter((el_from_latest)=>
958 !entries_contains_update($this_version_entries, el_from_latest.id)
959 );
960
961 entries_new_in_this_version = // i.e. in development, when updating the news
962 $this_version_entries.get().filter((el_from_latest)=>
963 !entries_contains_update($latest_entries, el_from_latest.id)
964 );
965
966 if (entries_newer_than_this_version.length > 0) {
967 $("#outdated").removeAttr("hidden");
968 } else if(entries_new_in_this_version.length > 0) {
969 $latest_news = $this_version_news; // show this version's news for development
970 }
971
972 $("#checking-for-updates").attr("hidden", "hidden");
973 update_css_classes_for_conditional_messages();
974 }).catch((exception)=> {
975 $("#failed-to-check-if-outdated").removeAttr("hidden");
976 $("#checking-for-updates").attr("hidden", "hidden");
977 update_css_classes_for_conditional_messages();
978 window.console && console.log("Couldn't check for updates.", exception);
979 });
980}
981// show_about_paint(); // for testing
982
983function update_css_classes_for_conditional_messages() {
984
985 $(".on-dev-host, .on-third-party-host, .on-official-host").hide();
986 if (location.hostname.match(/localhost|127.0.0.1/)) {
987 $(".on-dev-host").show();
988 } else if (location.hostname.match(/jspaint.app/)) {
989 $(".on-official-host").show();
990 } else {
991 $(".on-third-party-host").show();
992 }
993
994 $(".navigator-online, .navigator-offline").hide();
995 if (navigator.onLine) {
996 $(".navigator-online").show();
997 } else {
998 $(".navigator-offline").show();
999 }
1000}
1001
1002function show_news(){
1003 if($news_window){
1004 $news_window.close();
1005 }
1006 $news_window = $ToolWindow().title("Project News");
1007
1008 // const $latest_entries = $latest_news.find(".news-entry");
1009 // const latest_entry = $latest_entries[$latest_entries.length - 1];
1010 // window.console && console.log("LATEST MEWS:", $latest_news);
1011 // window.console && console.log("LATEST ENTRY:", latest_entry);
1012
1013 const $latest_news_style = $latest_news.find("style");
1014 $this_version_news.find("style").remove();
1015 $latest_news.append($latest_news_style); // in case $this_version_news is $latest_news
1016
1017 $news_window.$content.append($latest_news.removeAttr("hidden"));
1018
1019 $news_window.center();
1020 $news_window.center(); // @XXX - but it helps tho
1021}
1022
1023
1024// @TODO: DRY between these functions and open_from_* functions further?
1025
1026// function paste_image_from_URI(uri, callback){
1027// load_image_from_URI(uri, (err, img)=> {
1028// if(err){ return callback(err); }
1029// paste(img);
1030// });
1031// };
1032
1033function paste_image_from_file(file){
1034 const blob_url = URL.createObjectURL(file);
1035 // paste_image_from_URI(blob_url);
1036 load_image_from_URI(blob_url, (error, img) => {
1037 if(error){ return show_resource_load_error_message(error); }
1038 paste(img);
1039 URL.revokeObjectURL(blob_url);
1040 });
1041}
1042
1043function paste_from_file_select_dialog(){
1044 get_FileList_from_file_select_dialog(files => {
1045 for (const file of files) {
1046 if(file.type.match(/^image/)){
1047 paste_image_from_file(file);
1048 return;
1049 }
1050 }
1051 if(files.length > 1){
1052 show_error_message(`None of the files selected appear to be images.`);
1053 }else{
1054 show_error_message(`File selected does not appear to be an image.`);
1055 }
1056 });
1057}
1058
1059function paste(img){
1060
1061 if(img.width > canvas.width || img.height > canvas.height){
1062 const $w = new $FormToolWindow().addClass("dialogue-window");
1063 $w.title("Paint");
1064 $w.$main.html(
1065 "The image is bigger than the canvas.<br>" +
1066 "Would you like the canvas to be enlarged?<br>"
1067 );
1068 $w.$Button("Enlarge", () => {
1069 $w.close();
1070 // The resize gets its own undoable, as in mspaint
1071 resize_canvas_and_save_dimensions(img.width, img.height, {
1072 name: "Enlarge Canvas For Paste",
1073 icon: get_help_folder_icon("p_stretch_both.png"),
1074 });
1075 do_the_paste();
1076 $canvas_area.trigger("resize");
1077 })[0].focus();
1078 $w.$Button("Crop", () => {
1079 $w.close();
1080 do_the_paste();
1081 });
1082 $w.$Button("Cancel", () => {
1083 $w.close();
1084 });
1085 $w.center();
1086 }else{
1087 do_the_paste();
1088 }
1089
1090 function do_the_paste(){
1091 deselect();
1092 select_tool(get_tool_by_name("Select"));
1093 const x = Math.max(0, Math.ceil($canvas_area.scrollLeft() / magnification));
1094 const y = Math.max(0, Math.ceil($canvas_area.scrollTop() / magnification));
1095
1096 undoable({
1097 name: "Paste",
1098 icon: get_help_folder_icon("p_paste.png"),
1099 soft: true,
1100 }, ()=> {
1101 selection = new OnCanvasSelection(x, y, img.width, img.height, img);
1102 });
1103 }
1104}
1105
1106function render_history_as_gif(){
1107 const $win = $FormToolWindow();
1108 $win.title("Rendering GIF");
1109 $win.center();
1110 const $output = $win.$main;
1111 const $progress = $(E("progress")).appendTo($output);
1112 const $progress_percent = $(E("span")).appendTo($output).css({
1113 width: "2.3em",
1114 display: "inline-block",
1115 textAlign: "center",
1116 });
1117 $win.$main.css({padding: 5});
1118
1119 const $cancel = $win.$Button('Cancel', () => {
1120 $win.close();
1121 });
1122
1123 try{
1124 const width = canvas.width;
1125 const height = canvas.height;
1126 const gif = new GIF({
1127 //workers: Math.min(5, Math.floor(undos.length/50)+1),
1128 workerScript: "lib/gif.js/gif.worker.js",
1129 width,
1130 height,
1131 });
1132
1133 $win.on('close', () => {
1134 gif.abort();
1135 });
1136
1137 gif.on("progress", p => {
1138 $progress.val(p);
1139 $progress_percent.text(`${~~(p*100)}%`);
1140 });
1141
1142 gif.on("finished", blob => {
1143 $win.title("Rendered GIF");
1144 const url = URL.createObjectURL(blob);
1145 $output.empty().append(
1146 $(E("img")).attr({
1147 src: url,
1148 width,
1149 height,
1150 })
1151 );
1152 $win.$Button("Upload to Imgur", () => {
1153 $win.close();
1154 sanity_check_blob(blob, () => {
1155 show_imgur_uploader(blob);
1156 });
1157 });
1158 $win.$Button("Save", () => {
1159 $win.close();
1160 sanity_check_blob(blob, () => {
1161 saveAs(blob, `${file_name.replace(/\.(bmp|dib|a?png|gif|jpe?g|jpe|jfif|tiff?|webp|raw)$/, "")} history.gif`);
1162 });
1163 });
1164 $cancel.appendTo($win.$buttons);
1165 $win.center();
1166 });
1167
1168 const gif_canvas = make_canvas(width, height);
1169 const frame_history_nodes = [...undos, current_history_node];
1170 for(const frame_history_node of frame_history_nodes){
1171 gif_canvas.ctx.clearRect(0, 0, gif_canvas.width, gif_canvas.height);
1172 gif_canvas.ctx.putImageData(frame_history_node.image_data, 0, 0);
1173 if (frame_history_node.selection_image_data) {
1174 const selection_canvas = make_canvas(frame_history_node.selection_image_data);
1175 gif_canvas.ctx.drawImage(selection_canvas, frame_history_node.selection_x, frame_history_node.selection_y);
1176 }
1177 gif.addFrame(gif_canvas, {delay: 200, copy: true});
1178 }
1179 gif.render();
1180
1181 }catch(err){
1182 $win.close();
1183 show_error_message("Failed to render GIF:", err);
1184 }
1185}
1186
1187function go_to_history_node(target_history_node, canceling) {
1188 const from_history_node = current_history_node;
1189
1190 if (!target_history_node.image_data) {
1191 if (!canceling) {
1192 show_error_message("History entry has no image data.");
1193 window.console && console.log("Target history entry has no image data:", target_history_node);
1194 }
1195 return;
1196 }
1197 const current_image_data = ctx.getImageData(0, 0, canvas.width, canvas.height);
1198 if (!current_history_node.image_data || !image_data_match(current_history_node.image_data, current_image_data, 5)) {
1199 window.console && console.log("Canvas image data changed outside of undoable", current_history_node, "current_history_node.image_data:", current_history_node.image_data, "document's current image data:", current_image_data);
1200 undoable({name: "Unknown [GTHN]", use_loose_canvas_changes: true}, ()=> {});
1201 }
1202 current_history_node = target_history_node;
1203
1204 deselect(true);
1205 if (!canceling) {
1206 cancel(true);
1207 }
1208 saved = false;
1209
1210 ctx.copy(target_history_node.image_data);
1211 if (target_history_node.selection_image_data) {
1212 if (selection) {
1213 selection.destroy();
1214 }
1215 // @TODO maybe: could store whether a selection is from Free-Form Select
1216 // so it selects Free-Form Select when you jump to e.g. Move Selection
1217 // (or could traverse history to figure it out)
1218 if (target_history_node.name === "Free-Form Select") {
1219 select_tool(get_tool_by_name("Free-Form Select"));
1220 } else {
1221 select_tool(get_tool_by_name("Select"));
1222 }
1223 selection = new OnCanvasSelection(
1224 target_history_node.selection_x,
1225 target_history_node.selection_y,
1226 target_history_node.selection_image_data.width,
1227 target_history_node.selection_image_data.height,
1228 target_history_node.selection_image_data,
1229 );
1230 }
1231 if (target_history_node.textbox_text != null) {
1232 if (textbox) {
1233 textbox.destroy();
1234 }
1235 // @# text_tool_font =
1236 for (const [k, v] of Object.entries(target_history_node.text_tool_font)) {
1237 text_tool_font[k] = v;
1238 }
1239
1240 colors.foreground = target_history_node.foreground_color;
1241 colors.background = target_history_node.background_color;
1242 tool_transparent_mode = target_history_node.tool_transparent_mode;
1243 $G.trigger("option-changed");
1244
1245 select_tool(get_tool_by_name("Text"));
1246 textbox = new OnCanvasTextBox(
1247 target_history_node.textbox_x,
1248 target_history_node.textbox_y,
1249 target_history_node.textbox_width,
1250 target_history_node.textbox_height,
1251 target_history_node.textbox_text,
1252 );
1253 }
1254
1255 const ancestors_of_target = get_history_ancestors(target_history_node);
1256
1257 undos = [...ancestors_of_target];
1258 undos.reverse();
1259
1260 const old_history_path =
1261 redos.length > 0 ?
1262 [redos[0], ...get_history_ancestors(redos[0])] :
1263 [from_history_node, ...get_history_ancestors(from_history_node)];
1264
1265 // window.console && console.log("target_history_node:", target_history_node);
1266 // window.console && console.log("ancestors_of_target:", ancestors_of_target);
1267 // window.console && console.log("old_history_path:", old_history_path);
1268 redos.length = 0;
1269
1270 let latest_node = target_history_node;
1271 while (latest_node.futures.length > 0) {
1272 const futures = [...latest_node.futures];
1273 futures.sort((a, b)=> {
1274 if(old_history_path.indexOf(a) > -1) {
1275 return -1;
1276 }
1277 if(old_history_path.indexOf(b) > -1) {
1278 return +1;
1279 }
1280 return 0;
1281 });
1282 latest_node = futures[0];
1283 redos.unshift(latest_node);
1284 }
1285 // window.console && console.log("new undos:", undos);
1286 // window.console && console.log("new redos:", redos);
1287
1288 $canvas_area.trigger("resize");
1289 $G.triggerHandler("session-update"); // autosave
1290 $G.triggerHandler("history-update"); // update history view
1291}
1292function undoable({name, icon, use_loose_canvas_changes, soft}, callback){
1293 if (!use_loose_canvas_changes) {
1294 const current_image_data = ctx.getImageData(0, 0, canvas.width, canvas.height);
1295 if (!current_history_node.image_data || !image_data_match(current_history_node.image_data, current_image_data, 5)) {
1296 window.console && console.log("Canvas image data changed outside of undoable", current_history_node, "current_history_node.image_data:", current_history_node.image_data, "document's current image data:", current_image_data);
1297 undoable({name: "Unknown [undoable]", use_loose_canvas_changes: true}, ()=> {});
1298 }
1299 }
1300
1301 saved = false;
1302
1303 const before_callback_history_node = current_history_node;
1304 callback && callback();
1305 if (current_history_node !== before_callback_history_node) {
1306 show_error_message(`History node switched during undoable callback for ${name}. This shouldn't happen.`);
1307 window.console && console.log(`History node switched during undoable callback for ${name}, from`, before_callback_history_node, "to", current_history_node);
1308 }
1309
1310 const image_data = ctx.getImageData(0, 0, canvas.width, canvas.height);
1311
1312 redos.length = 0;
1313 undos.push(current_history_node);
1314
1315 const new_history_node = make_history_node({
1316 image_data,
1317 selection_image_data: selection && selection.canvas.ctx.getImageData(0, 0, selection.canvas.width, selection.canvas.height),
1318 selection_x: selection && selection.x,
1319 selection_y: selection && selection.y,
1320 textbox_text: textbox && textbox.$editor.val(),
1321 textbox_x: textbox && textbox.x,
1322 textbox_y: textbox && textbox.y,
1323 textbox_width: textbox && textbox.width,
1324 textbox_height: textbox && textbox.height,
1325 text_tool_font: JSON.parse(JSON.stringify(text_tool_font)),
1326 tool_transparent_mode,
1327 foreground_color: colors.foreground,
1328 background_color: colors.background,
1329 ternary_color: colors.ternary,
1330 parent: current_history_node,
1331 name,
1332 icon,
1333 soft,
1334 });
1335 current_history_node.futures.push(new_history_node);
1336 current_history_node = new_history_node;
1337
1338 $G.triggerHandler("history-update"); // update history view
1339
1340 $G.triggerHandler("session-update"); // autosave
1341}
1342function make_or_update_undoable(undoable_meta, undoable_action) {
1343 if (current_history_node.futures.length === 0 && undoable_meta.match(current_history_node)) {
1344 undoable_action();
1345 current_history_node.image_data = ctx.getImageData(0, 0, canvas.width, canvas.height);
1346 current_history_node.selection_image_data = selection && selection.canvas.ctx.getImageData(0, 0, selection.canvas.width, selection.canvas.height);
1347 current_history_node.selection_x = selection && selection.x;
1348 current_history_node.selection_y = selection && selection.y;
1349 if (undoable_meta.update_name) {
1350 current_history_node.name = undoable_meta.name;
1351 }
1352 $G.triggerHandler("history-update"); // update history view
1353 } else {
1354 undoable(undoable_meta, undoable_action);
1355 }
1356}
1357function undo(){
1358 if(undos.length<1){ return false; }
1359
1360 redos.push(current_history_node);
1361 let target_history_node = undos.pop();
1362
1363 while (target_history_node.soft && undos.length) {
1364 redos.push(target_history_node);
1365 target_history_node = undos.pop();
1366 }
1367
1368 go_to_history_node(target_history_node);
1369
1370 return true;
1371}
1372
1373let $document_history_prompt_window;
1374function redo(){
1375 if(redos.length<1){
1376 if ($document_history_prompt_window) {
1377 $document_history_prompt_window.close();
1378 }
1379 if (!$document_history_window || $document_history_window.closed) {
1380 const $w = $document_history_prompt_window = new $ToolWindow();
1381 $w.title("Redo");
1382 $w.$content.html("Press <b>Ctrl+Shift+Y</b> at any time to open the History window.");
1383 $w.$Button("Show History", show_document_history);
1384 }
1385 return false;
1386 }
1387
1388 undos.push(current_history_node);
1389 let target_history_node = redos.pop();
1390
1391 while (target_history_node.soft && redos.length) {
1392 undos.push(target_history_node);
1393 target_history_node = redos.pop();
1394 }
1395
1396 go_to_history_node(target_history_node);
1397
1398 return true;
1399}
1400
1401function get_history_ancestors(node) {
1402 const ancestors = [];
1403 for (node = node.parent; node; node = node.parent) {
1404 ancestors.push(node);
1405 }
1406 return ancestors;
1407}
1408
1409let $document_history_window;
1410function show_document_history() {
1411 if ($document_history_prompt_window) {
1412 $document_history_prompt_window.close();
1413 }
1414 if ($document_history_window) {
1415 $document_history_window.close();
1416 }
1417 const $w = $document_history_window = new $ToolWindow();
1418 $w.title("Document History");
1419 $w.$content.html(`
1420 <div class="history-view"></div>
1421 <p>Press <b>Ctrl+Shift+Y</b> at any time to open this window.</p>
1422 `);
1423
1424 const $history_view = $w.$content.find(".history-view");
1425
1426 let previous_scroll_position = 0;
1427
1428 let rendered_$entries = [];
1429
1430 function render_tree_from_node(node) {
1431 const $entry = $(`
1432 <div class="history-entry">
1433 <div class="history-entry-icon-area"></div>
1434 <div class="history-entry-name"></div>
1435 </div>
1436 `);
1437 // $entry.find(".history-entry-name").text((node.name || "Unknown") + (node.soft ? " (soft)" : ""));
1438 $entry.find(".history-entry-name").text(node.name || "Unknown");
1439 $entry.find(".history-entry-icon-area").append(node.icon);
1440 if (node === current_history_node) {
1441 $entry.addClass("current");
1442 requestAnimationFrame(()=> {
1443 $history_view.scrollTop(previous_scroll_position);
1444 $entry[0].scrollIntoView({block: "nearest"});
1445 });
1446 } else {
1447 const history_ancestors = get_history_ancestors(current_history_node);
1448 if (history_ancestors.indexOf(node) > -1) {
1449 $entry.addClass("ancestor-of-current");
1450 }
1451 }
1452 for (const sub_node of node.futures) {
1453 render_tree_from_node(sub_node);
1454 }
1455 $entry.on("click", ()=> {
1456 go_to_history_node(node);
1457 });
1458 $entry.history_node = node;
1459 rendered_$entries.push($entry);
1460 }
1461 const render_tree = ()=> {
1462 previous_scroll_position = $history_view.scrollTop();
1463 $history_view.empty();
1464 rendered_$entries = [];
1465 render_tree_from_node(root_history_node);
1466 rendered_$entries.sort(($a, $b)=> {
1467 if ($a.history_node.timestamp < $b.history_node.timestamp) {
1468 return -1;
1469 }
1470 if ($b.history_node.timestamp < $a.history_node.timestamp) {
1471 return +1;
1472 }
1473 return 0;
1474 });
1475 rendered_$entries.forEach(($entry)=> {
1476 $history_view.append($entry);
1477 });
1478 };
1479 render_tree();
1480
1481 $G.on("history-update", render_tree);
1482 $w.on("close", ()=> {
1483 $G.off("history-update", render_tree);
1484 });
1485
1486 $w.center();
1487}
1488
1489function cancel(going_to_history_node){
1490 // Note: this function should be idempotent.
1491 // `cancel(); cancel();` should do the same thing as `cancel();`
1492 history_node_to_cancel_to = history_node_to_cancel_to || current_history_node;
1493 $G.triggerHandler("pointerup", ["canceling"]);
1494 for (const selected_tool of selected_tools) {
1495 selected_tool.cancel && selected_tool.cancel();
1496 }
1497 if (!going_to_history_node) {
1498 // Note: this will revert any changes from other users in multi-user sessions
1499 // which isn't good, but there's no real conflict resolution in multi-user mode anyways
1500 go_to_history_node(history_node_to_cancel_to, true);
1501 }
1502 history_node_to_cancel_to = null;
1503 update_helper_layer();
1504}
1505function meld_selection_into_canvas(going_to_history_node) {
1506 selection.draw();
1507 selection.destroy();
1508 selection = null;
1509 if (!going_to_history_node) {
1510 undoable({
1511 name: "Deselect",
1512 icon: get_icon_for_tool(get_tool_by_name("Select")),
1513 use_loose_canvas_changes: true, // HACK; @TODO: make OnCanvasSelection not change the canvas outside undoable, same rules as tools
1514 }, ()=> { });
1515 }
1516}
1517function meld_textbox_into_canvas(going_to_history_node) {
1518 const text = textbox.$editor.val();
1519 if (text && !going_to_history_node) {
1520 undoable({
1521 name: "Text",
1522 icon: get_icon_for_tool(get_tool_by_name("Text")),
1523 soft: true,
1524 }, ()=> { });
1525 undoable({
1526 name: "Finish Text",
1527 icon: get_icon_for_tool(get_tool_by_name("Text")),
1528 }, () => {
1529 ctx.drawImage(textbox.canvas, textbox.x, textbox.y);
1530 textbox.destroy();
1531 textbox = null;
1532 });
1533 } else {
1534 textbox.destroy();
1535 textbox = null;
1536 }
1537}
1538function deselect(going_to_history_node){
1539 if(selection){
1540 meld_selection_into_canvas(going_to_history_node);
1541 }
1542 if(textbox){
1543 meld_textbox_into_canvas(going_to_history_node);
1544 }
1545 for (const selected_tool of selected_tools) {
1546 selected_tool.end && selected_tool.end(ctx);
1547 }
1548}
1549function delete_selection(meta={}){
1550 if(selection){
1551 undoable({
1552 name: meta.name || "Delete",
1553 icon: meta.icon || get_help_folder_icon("p_delete.png"),
1554 // soft: @TODO: conditionally soft?,
1555 }, ()=> {
1556 selection.destroy();
1557 selection = null;
1558 });
1559 }
1560}
1561function select_all(){
1562 deselect();
1563 select_tool(get_tool_by_name("Select"));
1564
1565 undoable({
1566 name: "Select All",
1567 icon: get_icon_for_tool(get_tool_by_name("Select")),
1568 soft: true,
1569 }, ()=> {
1570 selection = new OnCanvasSelection(0, 0, canvas.width, canvas.height);
1571 });
1572}
1573
1574const browserRecommendationForClipboardAccess = "Try using Chrome 76+";
1575function try_exec_command(commandId) {
1576 if (document.queryCommandEnabled(commandId)) { // not a reliable source for whether it'll work, if I recall
1577 document.execCommand(commandId);
1578 if (!navigator.userAgent.includes("Firefox") || commandId === "paste") {
1579 return show_error_message(`That ${commandId} probably didn't work. ${browserRecommendationForClipboardAccess}`);
1580 }
1581 } else {
1582 return show_error_message(`Cannot perform ${commandId}. ${browserRecommendationForClipboardAccess}`);
1583 }
1584}
1585
1586function getSelectionText() {
1587 let text = "";
1588 const activeEl = document.activeElement;
1589 const activeElTagName = activeEl ? activeEl.tagName.toLowerCase() : null;
1590 if (
1591 (activeElTagName == "textarea") || (activeElTagName == "input" &&
1592 /^(?:text|search|password|tel|url)$/i.test(activeEl.type)) &&
1593 (typeof activeEl.selectionStart == "number")
1594 ) {
1595 text = activeEl.value.slice(activeEl.selectionStart, activeEl.selectionEnd);
1596 } else if (window.getSelection) {
1597 text = window.getSelection().toString();
1598 }
1599 return text;
1600}
1601
1602async function edit_copy(execCommandFallback){
1603 const text = getSelectionText();
1604
1605 if (text.length > 0) {
1606 if (!navigator.clipboard || !navigator.clipboard.writeText) {
1607 if (execCommandFallback) {
1608 return try_exec_command("copy");
1609 } else {
1610 throw new Error(`The Async Clipboard API is not supported by this browser. ${browserRecommendationForClipboardAccess}`);
1611 }
1612 }
1613 navigator.clipboard.writeText(text);
1614 } else if(selection && selection.canvas) {
1615 if (!navigator.clipboard || !navigator.clipboard.write) {
1616 if (execCommandFallback) {
1617 return try_exec_command("copy");
1618 } else {
1619 throw new Error(`The Async Clipboard API is not supported by this browser. ${browserRecommendationForClipboardAccess}`);
1620 }
1621 }
1622 selection.canvas.toBlob(blob => {
1623 sanity_check_blob(blob, () => {
1624 navigator.clipboard.write([
1625 new ClipboardItem(Object.defineProperty({}, blob.type, {
1626 value: blob,
1627 enumerable: true,
1628 }))
1629 ]).then(() => {
1630 window.console && console.log("Copied image to the clipboard.");
1631 }, error => {
1632 show_error_message("Failed to copy to the Clipboard.", error);
1633 });
1634 });
1635 });
1636 }
1637}
1638function edit_cut(execCommandFallback){
1639 if (!navigator.clipboard || !navigator.clipboard.write) {
1640 if (execCommandFallback) {
1641 return try_exec_command("cut");
1642 } else {
1643 throw new Error(`The Async Clipboard API is not supported by this browser. ${browserRecommendationForClipboardAccess}`);
1644 }
1645 }
1646 edit_copy();
1647 delete_selection({
1648 name: "Cut",
1649 icon: get_help_folder_icon("p_cut.png"),
1650 });
1651}
1652async function edit_paste(execCommandFallback){
1653 if(
1654 document.activeElement instanceof HTMLInputElement ||
1655 document.activeElement instanceof HTMLTextAreaElement
1656 ){
1657 if (!navigator.clipboard || !navigator.clipboard.readText) {
1658 if (execCommandFallback) {
1659 return try_exec_command("paste");
1660 } else {
1661 throw new Error(`The Async Clipboard API is not supported by this browser. ${browserRecommendationForClipboardAccess}`);
1662 }
1663 }
1664 const clipboardText = await navigator.clipboard.readText();
1665 document.execCommand("InsertText", false, clipboardText);
1666 return;
1667 }
1668 if (!navigator.clipboard || !navigator.clipboard.read) {
1669 if (execCommandFallback) {
1670 return try_exec_command("paste");
1671 } else {
1672 throw new Error(`The Async Clipboard API is not supported by this browser. ${browserRecommendationForClipboardAccess}`);
1673 }
1674 }
1675 try {
1676 const clipboardItems = await navigator.clipboard.read();
1677 const blob = await clipboardItems[0].getType("image/png");
1678 paste_image_from_file(blob);
1679 } catch(error) {
1680 if (error.name === "NotFoundError") {
1681 try {
1682 const clipboardText = await navigator.clipboard.readText();
1683 if(clipboardText) {
1684 const uris = get_URIs(clipboardText);
1685 if (uris.length > 0) {
1686 load_image_from_URI(uris[0], (error, img) => {
1687 if(error){ return show_resource_load_error_message(error); }
1688 paste(img);
1689 });
1690 } else {
1691 show_error_message("The information on the Clipboard can't be inserted into Paint.");
1692 }
1693 } else {
1694 show_error_message("The information on the Clipboard can't be inserted into Paint.");
1695 }
1696 } catch(error) {
1697 show_error_message("Failed to read from the Clipboard.", error);
1698 }
1699 } else {
1700 show_error_message("Failed to read from the Clipboard.", error);
1701 }
1702 }
1703}
1704
1705function image_invert_colors(){
1706 apply_image_transformation({
1707 name: "Invert Colors",
1708 icon: get_help_folder_icon("p_invert.png"),
1709 }, (original_canvas, original_ctx, new_canvas, new_ctx) => {
1710 invert_rgb(original_ctx, new_ctx);
1711 });
1712}
1713
1714function clear(){
1715 deselect();
1716 cancel();
1717 undoable({
1718 name: "Clear Image",
1719 icon: get_help_folder_icon("p_blank.png"),
1720 }, () => {
1721 saved = false;
1722
1723 if(transparency){
1724 ctx.clearRect(0, 0, canvas.width, canvas.height);
1725 }else{
1726 ctx.fillStyle = colors.background;
1727 ctx.fillRect(0, 0, canvas.width, canvas.height);
1728 }
1729 });
1730}
1731
1732function view_bitmap(){
1733 if(canvas.requestFullscreen){ canvas.requestFullscreen(); }
1734 if(canvas.webkitRequestFullscreen){ canvas.webkitRequestFullscreen(); }
1735}
1736
1737function get_tool_by_name(name){
1738 for(let i=0; i<tools.length; i++){
1739 if(tools[i].name == name){
1740 return tools[i];
1741 }
1742 }
1743 for(let i=0; i<extra_tools.length; i++){
1744 if(extra_tools[i].name == name){
1745 return extra_tools[i];
1746 }
1747 }
1748}
1749
1750// hacky but whatever
1751// this whole "multiple tools" thing is hacky for now
1752function select_tools(tools) {
1753 for (let i=0; i<tools.length; i++) {
1754 select_tool(tools[i], i > 0);
1755 }
1756 update_helper_layer();
1757}
1758
1759function select_tool(tool, toggle){
1760 deselect();
1761
1762 if(!(selected_tools.length === 1 && selected_tool.deselect)){
1763 return_to_tools = [...selected_tools];
1764 }
1765 if (toggle) {
1766 const index = selected_tools.indexOf(tool);
1767 if (index === -1) {
1768 selected_tools.push(tool);
1769 selected_tools.sort((a, b)=> {
1770 if (tools.indexOf(a) < tools.indexOf(b)) {
1771 return -1;
1772 }
1773 if (tools.indexOf(a) > tools.indexOf(b)) {
1774 return +1;
1775 }
1776 return 0;
1777 });
1778 } else {
1779 selected_tools.splice(index, 1);
1780 }
1781 if (selected_tools.length > 0) {
1782 selected_tool = selected_tools[selected_tools.length - 1];
1783 } else {
1784 selected_tool = default_tool;
1785 selected_tools = [selected_tool];
1786 }
1787 } else {
1788 selected_tool = tool;
1789 selected_tools = [tool];
1790 }
1791
1792 if(tool.preload){
1793 tool.preload();
1794 }
1795
1796 $toolbox.update_selected_tool();
1797 // $toolbox2.update_selected_tool();
1798}
1799
1800function has_any_transparency(ctx) {
1801 // @TODO Optimization: Assume JPEGs and some other file types are opaque.
1802 // Raster file formats that SUPPORT transparency include GIF, PNG, BMP and TIFF
1803 // (Yes, even BMPs support transparency!)
1804 const id = ctx.getImageData(0, 0, canvas.width, canvas.height);
1805 for(let i=0, l=id.data.length; i<l; i+=4){
1806 if(id.data[i+3] < 255){
1807 return true;
1808 }
1809 }
1810 return false;
1811}
1812
1813function detect_transparency(){
1814 transparency = has_any_transparency(ctx);
1815}
1816
1817function is_all_black_and_white(ctx) {
1818 const id = ctx.getImageData(0, 0, canvas.width, canvas.height);
1819 for(let i=0, l=id.data.length; i<l; i+=4){
1820 if(id.data[i+3] < 255){
1821 return false;
1822 }
1823 if(!(
1824 (id.data[i] === 255 && id.data[i+1] === 255 && id.data[i+2] === 255) ||
1825 (id.data[i] === 0 && id.data[i+1] === 0 && id.data[i+2] === 0)
1826 )){
1827 return false;
1828 }
1829 }
1830 return true;
1831}
1832
1833function make_monochrome_pattern(lightness){
1834
1835 const dither_threshold_table = Array.from({length: 64}, (_undefined, p) => {
1836 const q = p ^ (p >> 3);
1837 return (
1838 ((p & 4) >> 2) | ((q & 4) >> 1) |
1839 ((p & 2) << 1) | ((q & 2) << 2) |
1840 ((p & 1) << 4) | ((q & 1) << 5)
1841 ) / 64;
1842 });
1843
1844 const pattern_canvas = document.createElement("canvas");
1845 const pattern_ctx = pattern_canvas.getContext("2d");
1846
1847 pattern_canvas.width = 8;
1848 pattern_canvas.height = 8;
1849
1850 const pattern_image_data = ctx.createImageData(pattern_canvas.width, pattern_canvas.height);
1851
1852 for(let x = 0; x < pattern_canvas.width; x += 1){
1853 for(let y = 0; y < pattern_canvas.height; y += 1){
1854 const map_value = dither_threshold_table[(x & 7) + ((y & 7) << 3)];
1855 const px_white = lightness > map_value;
1856 const index = ((y * pattern_image_data.width) + x) * 4;
1857 pattern_image_data.data[index + 0] = px_white * 255;
1858 pattern_image_data.data[index + 1] = px_white * 255;
1859 pattern_image_data.data[index + 2] = px_white * 255;
1860 pattern_image_data.data[index + 3] = 255;
1861 }
1862 }
1863
1864 pattern_ctx.putImageData(pattern_image_data, 0, 0);
1865
1866 return ctx.createPattern(pattern_canvas, "repeat");
1867}
1868
1869function make_monochrome_palette(){
1870 const palette = [];
1871 const n_colors_per_row = 14;
1872 const n_colors = n_colors_per_row * 2;
1873 for(let i=0; i<n_colors_per_row; i++){
1874 let lightness = i / n_colors;
1875 palette.push(make_monochrome_pattern(lightness));
1876 }
1877 for(let i=0; i<n_colors_per_row; i++){
1878 let lightness = 1 - i / n_colors;
1879 palette.push(make_monochrome_pattern(lightness));
1880 }
1881
1882 return palette;
1883}
1884
1885function make_stripe_pattern(reverse, colors, stripe_size=4){
1886 const rgba_colors = colors.map(get_rgba_from_color);
1887
1888 const pattern_canvas = document.createElement("canvas");
1889 const pattern_ctx = pattern_canvas.getContext("2d");
1890
1891 pattern_canvas.width = colors.length * stripe_size;
1892 pattern_canvas.height = colors.length * stripe_size;
1893
1894 const pattern_image_data = ctx.createImageData(pattern_canvas.width, pattern_canvas.height);
1895
1896 for(let x = 0; x < pattern_canvas.width; x += 1){
1897 for(let y = 0; y < pattern_canvas.height; y += 1){
1898 const pixel_index = ((y * pattern_image_data.width) + x) * 4;
1899 // +1000 to avoid remainder on negative numbers
1900 const pos = reverse ? (x - y) : (x + y);
1901 const color_index = Math.floor((pos + 1000) / stripe_size) % colors.length;
1902 const rgba = rgba_colors[color_index];
1903 pattern_image_data.data[pixel_index + 0] = rgba[0];
1904 pattern_image_data.data[pixel_index + 1] = rgba[1];
1905 pattern_image_data.data[pixel_index + 2] = rgba[2];
1906 pattern_image_data.data[pixel_index + 3] = rgba[3];
1907 }
1908 }
1909
1910 pattern_ctx.putImageData(pattern_image_data, 0, 0);
1911
1912 return ctx.createPattern(pattern_canvas, "repeat");
1913}
1914
1915function switch_to_polychrome_palette(){
1916
1917}
1918
1919function make_opaque() {
1920 undoable({
1921 name: "Make Opaque",
1922 icon: get_help_folder_icon("p_make_opaque.png"),
1923 }, ()=> {
1924 ctx.save();
1925 ctx.globalCompositeOperation = "destination-atop";
1926
1927 ctx.fillStyle = colors.background;
1928 ctx.fillRect(0, 0, canvas.width, canvas.height);
1929
1930 // in case the selected background color is transparent/translucent
1931 ctx.fillStyle = "white";
1932 ctx.fillRect(0, 0, canvas.width, canvas.height);
1933
1934 ctx.restore();
1935 });
1936}
1937
1938function resize_canvas_without_saving_dimensions(unclamped_width, unclamped_height, undoable_meta={}) {
1939 const new_width = Math.max(1, unclamped_width);
1940 const new_height = Math.max(1, unclamped_height);
1941 if (canvas.width !== new_width || canvas.height !== new_height) {
1942 undoable({
1943 name: undoable_meta.name || "Resize Canvas",
1944 icon: undoable_meta.icon || get_help_folder_icon("p_stretch_both.png"),
1945 }, () => {
1946 const image_data = ctx.getImageData(0, 0, new_width, new_height);
1947 canvas.width = new_width;
1948 canvas.height = new_height;
1949 ctx.disable_image_smoothing();
1950
1951 if(!transparency){
1952 ctx.fillStyle = colors.background;
1953 ctx.fillRect(0, 0, canvas.width, canvas.height);
1954 }
1955
1956 const temp_canvas = make_canvas(image_data);
1957 ctx.drawImage(temp_canvas, 0, 0);
1958
1959 $canvas_area.trigger("resize");
1960 });
1961 }
1962}
1963
1964function resize_canvas_and_save_dimensions(unclamped_width, unclamped_height, undoable_meta={}) {
1965 resize_canvas_without_saving_dimensions(unclamped_width, unclamped_height, undoable_meta);
1966 storage.set({
1967 width: canvas.width,
1968 height: canvas.height,
1969 }, (/*error*/) => {
1970 // oh well
1971 })
1972}
1973
1974function image_attributes(){
1975 if(image_attributes.$window){
1976 image_attributes.$window.close();
1977 }
1978 const $w = image_attributes.$window = new $FormToolWindow("Attributes");
1979
1980 const $main = $w.$main;
1981
1982 // Information
1983
1984 const table = {
1985 "File last saved": "Not available", // @TODO
1986 "Size on disk": "Not available", // @TODO
1987 "Resolution": "72 x 72 dots per inch",
1988 };
1989 const $table = $(E("table")).appendTo($main);
1990 for(const k in table){
1991 const $tr = $(E("tr")).appendTo($table);
1992 const $key = $(E("td")).appendTo($tr).text(`${k}:`);
1993 const $value = $(E("td")).appendTo($tr).text(table[k]);
1994 }
1995
1996 // Dimensions
1997
1998 const unit_sizes_in_px = {px: 1, in: 72, cm: 28.3465};
1999 let current_unit = image_attributes.unit = image_attributes.unit || "px";
2000 let width_in_px = canvas.width;
2001 let height_in_px = canvas.height;
2002
2003 const $width_label = $(E("label")).appendTo($main).text("Width:");
2004 const $height_label = $(E("label")).appendTo($main).text("Height:");
2005 const $width = $(E("input")).attr({type: "number", min: 1}).addClass("no-spinner").appendTo($width_label);
2006 const $height = $(E("input")).attr({type: "number", min: 1}).addClass("no-spinner").appendTo($height_label);
2007
2008 $main.find("input")
2009 .css({width: "40px"})
2010 .on("change keyup keydown keypress pointerdown pointermove paste drop", ()=> {
2011 width_in_px = $width.val() * unit_sizes_in_px[current_unit];
2012 height_in_px = $height.val() * unit_sizes_in_px[current_unit];
2013 });
2014
2015 // Fieldsets
2016
2017 const $units = $(E("fieldset")).appendTo($main).append('<legend>Units</legend>');
2018 $units.append('<label><input type="radio" name="units" value="in">Inches</label>');
2019 $units.append('<label><input type="radio" name="units" value="cm">Cm</label>');
2020 $units.append('<label><input type="radio" name="units" value="px">Pixels</label>');
2021 $units.find(`[value=${current_unit}]`).attr({checked: true});
2022 $units.on("change", () => {
2023 const new_unit = $units.find(":checked").val();
2024 $width.val(width_in_px / unit_sizes_in_px[new_unit]);
2025 $height.val(height_in_px / unit_sizes_in_px[new_unit]);
2026 current_unit = new_unit;
2027 }).triggerHandler("change");
2028
2029 const $colors = $(E("fieldset")).appendTo($main).append('<legend>Colors</legend>');
2030 $colors.append('<label><input type="radio" name="colors" value="monochrome">Black and White</label>');
2031 $colors.append('<label><input type="radio" name="colors" value="polychrome">Color</label>');
2032 $colors.find(`[value=${monochrome ? "monochrome" : "polychrome"}]`).attr({checked: true});
2033
2034 const $transparency = $(E("fieldset")).appendTo($main).append('<legend>Transparency</legend>');
2035 $transparency.append('<label><input type="radio" name="transparency" value="transparent">Transparent</label>');
2036 $transparency.append('<label><input type="radio" name="transparency" value="opaque">Opaque</label>');
2037 $transparency.find(`[value=${transparency ? "transparent" : "opaque"}]`).attr({checked: true});
2038
2039 // Buttons on the right
2040
2041 $w.$Button("Okay", () => {
2042 const transparency_option = $transparency.find(":checked").val();
2043 const colors_option = $colors.find(":checked").val();
2044 const unit = $units.find(":checked").val();
2045
2046 const was_monochrome = monochrome;
2047
2048 image_attributes.unit = unit;
2049 transparency = (transparency_option == "transparent");
2050 monochrome = (colors_option == "monochrome");
2051
2052 if(monochrome != was_monochrome){
2053 if(monochrome){
2054 palette = monochrome_palette;
2055 }else{
2056 palette = polychrome_palette;
2057 }
2058
2059 $colorbox.rebuild_palette();
2060 reset_colors();
2061 }
2062 if (monochrome && !is_all_black_and_white(ctx)) {
2063 show_convert_to_black_and_white();
2064 }
2065
2066 const unit_to_px = unit_sizes_in_px[unit];
2067 const width = $width.val() * unit_to_px;
2068 const height = $height.val() * unit_to_px;
2069 resize_canvas_and_save_dimensions(~~width, ~~height);
2070
2071 if (!transparency && has_any_transparency(ctx)) {
2072 make_opaque();
2073 }
2074
2075 image_attributes.$window.close();
2076 })[0].focus();
2077
2078 $w.$Button("Cancel", () => {
2079 image_attributes.$window.close();
2080 });
2081
2082 $w.$Button("Default", () => {
2083 width_in_px = default_canvas_width;
2084 height_in_px = default_canvas_height;
2085 $width.val(width_in_px / unit_sizes_in_px[current_unit]);
2086 $height.val(height_in_px / unit_sizes_in_px[current_unit]);
2087 });
2088
2089 // Reposition the window
2090
2091 image_attributes.$window.center();
2092}
2093
2094function show_convert_to_black_and_white() {
2095 const $w = new $FormToolWindow("Convert to Black and White");
2096 $w.addClass("convert-to-black-and-white");
2097 $w.$main.append("<fieldset><legend>Threshold</legend><input type='range' min='0' max='1' step='0.01' value='0.5'></fieldset>");
2098 const $slider = $w.$main.find("input[type='range']");
2099 const original_canvas = make_canvas(canvas);
2100 let threshold;
2101 const update_threshold = ()=> {
2102 make_or_update_undoable({
2103 name: "Make Monochrome",
2104 match: (history_node)=> history_node.name === "Make Monochrome",
2105 icon: get_help_folder_icon("p_monochrome.png"),
2106 }, ()=> {
2107 threshold = $slider.val();
2108 ctx.copy(original_canvas);
2109 threshold_black_and_white(ctx, threshold);
2110 });
2111 };
2112 update_threshold();
2113 $slider.on("input", debounce(update_threshold, 100));
2114
2115 $w.$Button("Okay", ()=> {
2116 $w.close();
2117 });
2118 $w.$Button("Cancel", ()=> {
2119 if (current_history_node.name === "Make Monochrome") {
2120 undo();
2121 } else {
2122 undoable({
2123 name: "Cancel Make Monochrome",
2124 icon: get_help_folder_icon("p_monochrome_undo.png"),
2125 }, ()=> {
2126 ctx.copy(original_canvas);
2127 });
2128 }
2129 $w.close();
2130 });
2131 $w.center();
2132}
2133
2134function image_flip_and_rotate(){
2135 const $w = new $FormToolWindow("Flip and Rotate");
2136 $w.addClass("flip-and-rotate");
2137
2138 const $fieldset = $(E("fieldset")).appendTo($w.$main);
2139 $fieldset.append(`
2140 <legend>Flip or rotate</legend>
2141 <label><input type="radio" name="flip-or-rotate" value="flip-horizontal" checked/>Flip horizontal</label>
2142 <label><input type="radio" name="flip-or-rotate" value="flip-vertical"/>Flip vertical</label>
2143 <label><input type="radio" name="flip-or-rotate" value="rotate-by-angle"/>Rotate by angle</label>
2144 `);
2145
2146 const $rotate_by_angle = $(E("div")).appendTo($fieldset);
2147 $rotate_by_angle.css({paddingLeft: "30px"});
2148 $rotate_by_angle.append(`
2149 <label><input type="radio" name="rotate-by-angle" value="90" checked/>90°</label>
2150 <label><input type="radio" name="rotate-by-angle" value="180"/>180°</label>
2151 <label><input type="radio" name="rotate-by-angle" value="270"/>270°</label>
2152 <label><input type="radio" name="rotate-by-angle" value="arbitrary"/><input type="number" min="-360" max="360" name="rotate-by-arbitrary-angle" value="" class="no-spinner" style="width: 50px"/> Degrees</label>
2153 `);
2154 $rotate_by_angle.find("input").attr({disabled: true});
2155
2156 $fieldset.find("input").on("change", () => {
2157 const action = $fieldset.find("input[name='flip-or-rotate']:checked").val();
2158 $rotate_by_angle.find("input").attr({
2159 disabled: action !== "rotate-by-angle"
2160 });
2161 });
2162 $rotate_by_angle.find("label, input").on("click", (e)=> {
2163 // Select "Rotate by angle" and enable subfields
2164 $fieldset.find("input[value='rotate-by-angle']").prop("checked", true);
2165 $fieldset.find("input").triggerHandler("change");
2166
2167 const $label = $(e.target).closest("label");
2168 // Focus the numerical input if this field has one
2169 const num_input = $label.find("input[type='number']")[0];
2170 if (num_input) {
2171 num_input.focus();
2172 }
2173 // Select the radio for this field
2174 $label.find("input[type='radio']").prop("checked", true);
2175 });
2176 // @TODO: enable all controls that are accessable to the pointer
2177
2178 $fieldset.find("label").css({display: "block"});
2179
2180 $w.$Button("Okay", () => {
2181 const action = $fieldset.find("input[name='flip-or-rotate']:checked").val();
2182 let angle_val = $fieldset.find("input[name='rotate-by-angle']:checked").val();
2183 if(angle_val === "arbitrary"){
2184 angle_val = $fieldset.find("input[name='rotate-by-arbitrary-angle']").val();
2185 }
2186 const angle_deg = parseFloat(angle_val);
2187 const angle = angle_deg / 360 * TAU;
2188
2189 if(isNaN(angle)){
2190 const $msgw = new $FormToolWindow("Invalid Value").addClass("dialogue-window");
2191 $msgw.$main.text("The value specified for Degrees was invalid.");
2192 $msgw.$Button("Okay", () => {
2193 $msgw.close();
2194 });
2195 return;
2196 }
2197
2198 switch(action){
2199 case "flip-horizontal":
2200 flip_horizontal();
2201 break;
2202 case "flip-vertical":
2203 flip_vertical();
2204 break;
2205 case "rotate-by-angle":
2206 rotate(angle);
2207 break;
2208 }
2209
2210 $canvas_area.trigger("resize");
2211
2212 $w.close();
2213 })[0].focus();
2214 $w.$Button("Cancel", () => {
2215 $w.close();
2216 });
2217
2218 $w.center();
2219}
2220
2221function image_stretch_and_skew(){
2222 const $w = new $FormToolWindow("Stretch and Skew");
2223
2224 const $fieldset_stretch = $(E("fieldset")).appendTo($w.$main);
2225 $fieldset_stretch.append("<legend>Stretch</legend><table></table>");
2226 const $fieldset_skew = $(E("fieldset")).appendTo($w.$main);
2227 $fieldset_skew.append("<legend>Skew</legend><table></table>");
2228
2229 const $RowInput = ($table, img_src, label_text, default_value, label_unit, min, max) => {
2230 const $tr = $(E("tr")).appendTo($table);
2231 const $img = $(E("img")).attr({
2232 src: `images/transforms/${img_src}.png`,
2233 width: 32,
2234 height: 32,
2235 }).css({
2236 marginRight: "20px"
2237 });
2238 const input_id = ("input" + Math.random() + Math.random()).replace(/\./, "");
2239 const $input = $(E("input")).attr({
2240 type: "number",
2241 min,
2242 max,
2243 value: default_value,
2244 id: input_id,
2245 }).css({
2246 width: "40px"
2247 }).addClass("no-spinner");
2248 $(E("td")).appendTo($tr).append($img);
2249 $(E("td")).appendTo($tr).append($(E("label")).text(label_text).attr("for", input_id));
2250 $(E("td")).appendTo($tr).append($input);
2251 $(E("td")).appendTo($tr).text(label_unit);
2252
2253 return $input;
2254 };
2255
2256 const stretch_x = $RowInput($fieldset_stretch.find("table"), "stretch-x", "Horizontal:", 100, "%", 1, 5000);
2257 const stretch_y = $RowInput($fieldset_stretch.find("table"), "stretch-y", "Vertical:", 100, "%", 1, 5000);
2258 const skew_x = $RowInput($fieldset_skew.find("table"), "skew-x", "Horizontal:", 0, "Degrees", -90, 90);
2259 const skew_y = $RowInput($fieldset_skew.find("table"), "skew-y", "Vertical:", 0, "Degrees", -90, 90);
2260
2261 $w.$Button("Okay", () => {
2262 const xscale = parseFloat(stretch_x.val())/100;
2263 const yscale = parseFloat(stretch_y.val())/100;
2264 const hskew = parseFloat(skew_x.val())/360*TAU;
2265 const vskew = parseFloat(skew_y.val())/360*TAU;
2266 stretch_and_skew(xscale, yscale, hskew, vskew);
2267 $canvas_area.trigger("resize");
2268 $w.close();
2269 })[0].focus();
2270
2271 $w.$Button("Cancel", () => {
2272 $w.close();
2273 });
2274
2275 $w.center();
2276}
2277
2278// @TODO: establish a better pattern for this (platform-specific functions, with browser-generic fallbacks)
2279// Note: we can't just poke in a different save_canvas_as function in electron-injected.js because electron-injected.js is loaded first
2280function save_canvas_as(canvas, fileName, savedCallbackUnreliable){
2281 if(window.systemSaveCanvasAs){
2282 return systemSaveCanvasAs(canvas, fileName, savedCallbackUnreliable);
2283 }
2284
2285 // @TODO: file name + type dialog
2286 canvas.toBlob(blob => {
2287 sanity_check_blob(blob, () => {
2288 const file_saver = saveAs(blob, `${file_name.replace(/\.(bmp|dib|a?png|gif|jpe?g|jpe|jfif|tiff?|webp|raw)$/, "")}.png`);
2289 file_saver.onwriteend = () => {
2290 // this won't fire in chrome
2291 savedCallbackUnreliable();
2292 };
2293 });
2294 });
2295}
2296
2297function set_as_wallpaper_tiled(c = canvas) {
2298 // Note: we can't just poke in a different set_as_wallpaper_tiled function, because it's stored by reference in menus.js
2299 if(window.systemSetAsWallpaperTiled){
2300 return window.systemSetAsWallpaperTiled(c);
2301 }
2302
2303 const wallpaperCanvas = make_canvas(screen.width, screen.height);
2304 const pattern = wallpaperCanvas.ctx.createPattern(c, "repeat");
2305 wallpaperCanvas.ctx.fillStyle = pattern;
2306 wallpaperCanvas.ctx.fillRect(0, 0, wallpaperCanvas.width, wallpaperCanvas.height);
2307
2308 set_as_wallpaper_centered(wallpaperCanvas);
2309}
2310
2311function set_as_wallpaper_centered(c = canvas) {
2312 // Note: we can't just poke in a different set_as_wallpaper_centered function, because it's stored by reference in menus.js
2313 if(window.systemSetAsWallpaperCentered){
2314 return window.systemSetAsWallpaperCentered(c);
2315 }
2316
2317 c.toBlob(blob => {
2318 sanity_check_blob(blob, () => {
2319 saveAs(blob, `${file_name.replace(/\.(bmp|dib|a?png|gif|jpe?g|jpe|jfif|tiff?|webp|raw)$/, "")} wallpaper.png`);
2320 });
2321 });
2322}
2323
2324/**
2325 * @param {HTMLElement} canvas
2326 * @return {Promise}
2327 */
2328function get_array_buffer_from_canvas(canvas) {
2329 return new Promise((resolve, reject) => {
2330 const file_reader = new FileReader();
2331
2332 file_reader.onloadend = () => {
2333 resolve(file_reader.result);
2334 };
2335
2336 file_reader.onerror = () => {
2337 reject(new Error("Failed to read canvas image to array buffer"));
2338 };
2339
2340 canvas.toBlob(blob => {
2341 sanity_check_blob(blob, () => {
2342 file_reader.readAsArrayBuffer(blob);
2343 });
2344 });
2345 });
2346}
2347
2348function save_selection_to_file(){
2349 if(selection && selection.canvas){
2350 selection.canvas.toBlob(blob => {
2351 sanity_check_blob(blob, () => {
2352 saveAs(blob, "selection.png");
2353 });
2354 });
2355 }
2356}
2357
2358function sanity_check_blob(blob, okay_callback){
2359 if(blob.size > 0){
2360 okay_callback();
2361 }else{
2362 const $w = $FormToolWindow().title("Warning").addClass("dialogue-window");
2363 $w.$main.html(`
2364 <p>Tried to save file, but file was empty.</p>
2365 <p>Try again, or if the problem persists, report here:
2366 <a href='https://github.com/1j01/jspaint/issues/118'>Issue #118</a>
2367 `);
2368 $w.$main.css({maxWidth: "500px"});
2369 $w.$Button("OK", () => {
2370 $w.close();
2371 });
2372 $w.center();
2373 }
2374}
2375
2376function show_multi_user_setup_dialog(from_current_document){
2377 const $w = $FormToolWindow().title("Multi-User Setup").addClass("dialogue-window");
2378 $w.$main.html(`
2379 ${from_current_document ? "<p>This will make the current document public.</p>" : ""}
2380 <p>
2381 <!-- Choose a name for the multi-user session, included in the URL for sharing: -->
2382 Enter the session name that will be used in the URL for sharing:
2383 </p>
2384 <p>
2385 <label>
2386 <span class="partial-url-label">jspaint.app/#session:</span>
2387 <input
2388 type="text"
2389 id="session-name"
2390 aria-label="session name"
2391 pattern="[-0-9A-Za-z\\u00c0-\\u00d6\\u00d8-\\u00f6\\u00f8-\\u02af\\u1d00-\\u1d25\\u1d62-\\u1d65\\u1d6b-\\u1d77\\u1d79-\\u1d9a\\u1e00-\\u1eff\\u2090-\\u2094\\u2184-\\u2184\\u2488-\\u2490\\u271d-\\u271d\\u2c60-\\u2c7c\\u2c7e-\\u2c7f\\ua722-\\ua76f\\ua771-\\ua787\\ua78b-\\ua78c\\ua7fb-\\ua7ff\\ufb00-\\ufb06]+"
2392 title="Numbers, letters, and hyphens are allowed."
2393 >
2394 </label>
2395 </p>
2396 `);
2397 const $session_name = $w.$main.find("#session-name");
2398 $w.$main.css({maxWidth: "500px"});
2399 $w.$Button("Start", () => {
2400 let name = $session_name.val().trim();
2401
2402 if(name == ""){
2403 show_error_message("The session name cannot be empty.");
2404 // }else if(name.match(/[./[\]#$]/)){
2405 // show_error_message("The session name cannot contain any of ./[]#$");
2406 // }else if(name.match(/\s/)){
2407 // show_error_message("The session name cannot contain spaces.");
2408 }else if($session_name.is(":invalid")){
2409 show_error_message("The session name must be made from only numbers, letters, and hyphens.");
2410 }else{
2411 if (from_current_document) {
2412 change_url_param("session", name);
2413 } else {
2414 // @TODO: load new empty session in the same browser tab
2415 // (or at least... keep settings like vertical-color-box-mode?)
2416 window.open(`${location.origin}${location.pathname}#session:${name}`);
2417 }
2418 $w.close();
2419 }
2420 });
2421 $w.$Button("Cancel", () => {
2422 $w.close();
2423 });
2424 $w.center();
2425 $session_name.focus();
2426}
2427