// expresses order in the URL as well as type const param_types = { // settings "eye-gaze-mode": "bool", "vertical-color-box-mode": "bool", "speech-recognition-mode": "bool", // sessions "local": "string", "session": "string", "load": "string", }; const exclusive_params = [ "local", "session", "load", ]; function get_all_url_params() { const params = {}; location.hash.replace(/^#/, "").split(/,/).forEach((param_decl) => { // colon is used in param value for URLs so split(":") isn't good enough const colon_index = param_decl.indexOf(":"); if (colon_index === -1) { // boolean value, implicitly true because it's in the URL const param_name = param_decl; params[param_name] = true; } else { const param_name = param_decl.slice(0, colon_index); const param_value = param_decl.slice(colon_index + 1); params[param_name] = decodeURIComponent(param_value); } }); for (const [param_name, param_type] of Object.entries(param_types)) { if (param_type === "bool" && !params[param_name]) { params[param_name] = false; } } return params; } function get_url_param(param_name) { return get_all_url_params()[param_name]; } function change_url_param(param_name, value, { replace_history_state = false } = {}) { change_some_url_params({ [param_name]: value }, { replace_history_state }); } function change_some_url_params(updates, { replace_history_state = false } = {}) { for (const exclusive_param of exclusive_params) { if (updates[exclusive_param]) { exclusive_params.forEach((param) => { if (param !== exclusive_param) { updates[param] = null; // must be enumerated (for Object.assign) but falsy, to get removed from the URL } }); } } set_all_url_params(Object.assign({}, get_all_url_params(), updates), { replace_history_state }); } function set_all_url_params(params, { replace_history_state = false } = {}) { let new_hash = ""; for (const [param_name, param_type] of Object.entries(param_types)) { if (params[param_name]) { if (new_hash.length) { new_hash += ","; } new_hash += encodeURIComponent(param_name); if (param_type !== "bool") { new_hash += ":" + encodeURIComponent(params[param_name]); } } } // Note: gets rid of query string (?) portion of the URL // This is desired for upgrading backwards compatibility URLs; // may not be desired for future cases. const new_url = `${location.origin}${location.pathname}#${new_hash}`; try { // can fail when running from file: protocol if (replace_history_state) { history.replaceState(null, document.title, new_url); } else { history.pushState(null, document.title, new_url); } } catch (error) { location.hash = new_hash; } $G.triggerHandler("change-url-params"); } function update_magnified_canvas_size() { $canvas.css("width", main_canvas.width * magnification); $canvas.css("height", main_canvas.height * magnification); update_canvas_rect(); } function update_canvas_rect() { canvas_bounding_client_rect = main_canvas.getBoundingClientRect(); update_helper_layer(); } let helper_layer_update_queued; let info_for_updating_pointer; // for updating the brush preview when the mouse stays in the same place, // but its coordinates in the document change due to scrolling or browser zooming (handled with scroll and resize events) function update_helper_layer(e) { // e should be passed for pointer events, but not scroll or resize events // e may be a synthetic event without clientX/Y, so ignore that (using isFinite) // e may also be a timestamp from requestAnimationFrame callback; ignore that if (e && isFinite(e.clientX)) { info_for_updating_pointer = { clientX: e.clientX, clientY: e.clientY, devicePixelRatio }; } if (helper_layer_update_queued) { // window.console && console.log("update_helper_layer - nah, already queued"); return; } else { // window.console && console.log("update_helper_layer"); } helper_layer_update_queued = true; requestAnimationFrame(() => { helper_layer_update_queued = false; update_helper_layer_immediately(); }); } function update_helper_layer_immediately() { // window.console && console.log("Update helper layer NOW"); if (info_for_updating_pointer) { const rescale = info_for_updating_pointer.devicePixelRatio / devicePixelRatio; info_for_updating_pointer.clientX *= rescale; info_for_updating_pointer.clientY *= rescale; info_for_updating_pointer.devicePixelRatio = devicePixelRatio; pointer = to_canvas_coords(info_for_updating_pointer); } const scale = magnification * window.devicePixelRatio; if (!helper_layer) { helper_layer = new OnCanvasHelperLayer(0, 0, main_canvas.width, main_canvas.height, false, scale); } const margin = 15; const viewport_x = Math.floor(Math.max($canvas_area.scrollLeft() / magnification - margin, 0)); // Nevermind, canvas, isn't aligned to the right in RTL layout! // const viewport_x = // get_direction() === "rtl" ? // // Note: $canvas_area.scrollLeft() can return negative numbers for RTL layout // Math.floor(Math.max(($canvas_area.scrollLeft() - $canvas_area.innerWidth()) / magnification + canvas.width - margin, 0)) : // Math.floor(Math.max($canvas_area.scrollLeft() / magnification - margin, 0)); const viewport_y = Math.floor(Math.max($canvas_area.scrollTop() / magnification - margin, 0)); const viewport_x2 = Math.floor(Math.min(viewport_x + $canvas_area.width() / magnification + margin * 2, main_canvas.width)); const viewport_y2 = Math.floor(Math.min(viewport_y + $canvas_area.height() / magnification + margin * 2, main_canvas.height)); const viewport_width = viewport_x2 - viewport_x; const viewport_height = viewport_y2 - viewport_y; const resolution_width = viewport_width * scale; const resolution_height = viewport_height * scale; if ( helper_layer.canvas.width !== resolution_width || helper_layer.canvas.height !== resolution_height ) { helper_layer.canvas.width = resolution_width; helper_layer.canvas.height = resolution_height; helper_layer.canvas.ctx.disable_image_smoothing(); helper_layer.width = viewport_width; helper_layer.height = viewport_height; } helper_layer.x = viewport_x; helper_layer.y = viewport_y; helper_layer.position(); render_canvas_view(helper_layer.canvas, scale, viewport_x, viewport_y, true); if (thumbnail_canvas && $thumbnail_window.is(":visible")) { // The thumbnail can be bigger or smaller than the viewport, depending on the magnification and thumbnail window size. // So can the document. // Ideally it should show the very corner if scrolled all the way to the corner, // so that you can get a thumbnail of any location just by scrolling. // But it's impossible if the thumbnail is smaller than the viewport. You have to resize the thumbnail window in that case. // (And if the document is smaller than the viewport, there's no scrolling to indicate where you want to get a thumbnail of.) // It gets clipped to the top left portion of the viewport if the thumbnail is too small. // This works except for if there's a selection, it affects the scrollable area, and it shouldn't affect this calculation. // const scroll_width = $canvas_area[0].scrollWidth - $canvas_area[0].clientWidth; // const scroll_height = $canvas_area[0].scrollHeight - $canvas_area[0].clientHeight; // These padding terms are negligible in comparison to the margin reserved for canvas handles, // which I'm not accounting for (except for clamping below). const padding_left = parseFloat($canvas_area.css("padding-left")); const padding_top = parseFloat($canvas_area.css("padding-top")); const scroll_width = main_canvas.clientWidth + padding_left - $canvas_area[0].clientWidth; const scroll_height = main_canvas.clientHeight + padding_top - $canvas_area[0].clientHeight; // Don't divide by less than one, or the thumbnail with disappear off to the top/left (or completely for NaN). let scroll_x_fraction = $canvas_area[0].scrollLeft / Math.max(1, scroll_width); let scroll_y_fraction = $canvas_area[0].scrollTop / Math.max(1, scroll_height); // If the canvas is larger than the document view, but not by much, and you scroll to the bottom or right, // the margin for the canvas handles can lead to the thumbnail being cut off or even showing // just blank space without this clamping (due to the not quite accurate scrollable area calculation). scroll_x_fraction = Math.min(scroll_x_fraction, 1); scroll_y_fraction = Math.min(scroll_y_fraction, 1); let viewport_x = Math.floor(Math.max(scroll_x_fraction * (main_canvas.width - thumbnail_canvas.width), 0)); let viewport_y = Math.floor(Math.max(scroll_y_fraction * (main_canvas.height - thumbnail_canvas.height), 0)); render_canvas_view(thumbnail_canvas, 1, viewport_x, viewport_y, false); // devicePixelRatio? } } function render_canvas_view(hcanvas, scale, viewport_x, viewport_y, is_helper_layer) { update_fill_and_stroke_colors_and_lineWidth(selected_tool); const grid_visible = show_grid && magnification >= 4 && (window.devicePixelRatio * magnification) >= 4 && is_helper_layer; const hctx = hcanvas.ctx; hctx.clearRect(0, 0, hcanvas.width, hcanvas.height); if (!is_helper_layer) { // Draw the actual document canvas (for the thumbnail) // (For the main canvas view, the helper layer is separate from (and overlaid on top of) the document canvas) hctx.drawImage(main_canvas, viewport_x, viewport_y, hcanvas.width, hcanvas.height, 0, 0, hcanvas.width, hcanvas.height); } var tools_to_preview = [...selected_tools]; // Don't preview tools while dragging components/component windows // (The magnifier preview is especially confusing looking together with the component preview!) if ($("body").hasClass("dragging") && !pointer_active) { // tools_to_preview.length = 0; // Curve and Polygon tools have a persistent state over multiple gestures, // which is, as of writing, part of the "tool preview"; it's ugly, // but at least they don't have ALSO a brush like preview, right? // so we can just allow those thru tools_to_preview = tools_to_preview.filter((tool) => tool.id === TOOL_CURVE || tool.id === TOOL_POLYGON ); } // the select box previews draw the document canvas onto the preview canvas // so they have something to invert within the preview canvas // but this means they block out anything earlier // NOTE: sort Select after Free-Form Select, // Brush after Eraser, as they are from the toolbar ordering tools_to_preview.sort((a, b) => { if (a.selectBox && !b.selectBox) { return -1; } if (!a.selectBox && b.selectBox) { return 1; } return 0; }); // two select box previews would just invert and cancel each other out // so only render one if there's one or more var select_box_index = tools_to_preview.findIndex((tool) => tool.selectBox); if (select_box_index >= 0) { tools_to_preview = tools_to_preview.filter((tool, index) => !tool.selectBox || index == select_box_index); } tools_to_preview.forEach((tool) => { if (tool.drawPreviewUnderGrid && pointer && pointers.length < 2) { hctx.save(); tool.drawPreviewUnderGrid(hctx, pointer.x, pointer.y, grid_visible, scale, -viewport_x, -viewport_y); hctx.restore(); } }); if (selection) { hctx.save(); hctx.scale(scale, scale); hctx.translate(-viewport_x, -viewport_y); hctx.drawImage(selection.canvas, selection.x, selection.y); hctx.restore(); if (!is_helper_layer && !selection.dragging) { // Draw the selection outline (for the thumbnail) // (The main canvas view has the OnCanvasSelection object which has its own outline) draw_selection_box(hctx, selection.x, selection.y, selection.width, selection.height, scale, -viewport_x, -viewport_y); } } if (textbox) { hctx.save(); hctx.scale(scale, scale); hctx.translate(-viewport_x, -viewport_y); hctx.drawImage(textbox.canvas, textbox.x, textbox.y); hctx.restore(); if (!is_helper_layer && !textbox.dragging) { // Draw the textbox outline (for the thumbnail) // (The main canvas view has the OnCanvasTextBox object which has its own outline) draw_selection_box(hctx, textbox.x, textbox.y, textbox.width, textbox.height, scale, -viewport_x, -viewport_y); } } if (grid_visible) { draw_grid(hctx, scale); } tools_to_preview.forEach((tool) => { if (tool.drawPreviewAboveGrid && pointer && pointers.length < 2) { hctx.save(); tool.drawPreviewAboveGrid(hctx, pointer.x, pointer.y, grid_visible, scale, -viewport_x, -viewport_y); hctx.restore(); } }); } function update_disable_aa() { const dots_per_canvas_px = window.devicePixelRatio * magnification; const round = Math.floor(dots_per_canvas_px) === dots_per_canvas_px; $canvas_area.toggleClass("disable-aa-for-things-at-main-canvas-scale", dots_per_canvas_px >= 3 || round); } function set_magnification(new_scale, anchor_point) { // anchor_point is optional, and uses canvas coordinates; // the default is the top-left of the $canvas_area viewport // How this works is, you imagine "what if it was zoomed, where would the anchor point be?" // Then to make it end up where it started, you simply shift the viewport by the difference. // And actually you don't have to "imagine" zooming, you can just do the zoom. anchor_point = anchor_point ?? { x: $canvas_area.scrollLeft() / magnification, y: $canvas_area.scrollTop() / magnification, }; const anchor_on_page = from_canvas_coords(anchor_point); magnification = new_scale; if (new_scale !== 1) { return_to_magnification = new_scale; } update_magnified_canvas_size(); // also updates canvas_bounding_client_rect used by from_canvas_coords() const anchor_after_zoom = from_canvas_coords(anchor_point); // Note: scrollBy() not scrollTo() $canvas_area[0].scrollBy({ left: anchor_after_zoom.clientX - anchor_on_page.clientX, top: anchor_after_zoom.clientY - anchor_on_page.clientY, behavior: "instant", }); $G.triggerHandler("resize"); // updates handles & grid $G.trigger("option-changed"); // updates options area } let $custom_zoom_window; let dev_custom_zoom = false; try { dev_custom_zoom = localStorage.dev_custom_zoom === "true"; // eslint-disable-next-line no-empty } catch (error) { } if (dev_custom_zoom) { $(() => { show_custom_zoom_window(); $custom_zoom_window.css({ left: 80, top: 50, opacity: 0.5, }); }); } function show_custom_zoom_window() { if ($custom_zoom_window) { $custom_zoom_window.close(); } const $w = new $DialogWindow(localize("Custom Zoom")); $custom_zoom_window = $w; $w.addClass("custom-zoom-window"); // @TODO: update when zoom changes $w.$main.append(`
${localize("Current zoom:")} ${magnification * 100}%
`); const $fieldset = $(E("fieldset")).appendTo($w.$main); $fieldset.append(` ${localize("Zoom to")}
`); let is_custom = true; $fieldset.find("input[type=radio]").get().forEach((el) => { if (parseFloat(el.value) === magnification) { el.checked = true; is_custom = false; } }); const $really_custom_radio_option = $fieldset.find("input[value='really-custom']"); const $really_custom_input = $fieldset.find("input[name='really-custom-zoom-input']"); $really_custom_input.closest("label").on("click", () => { $really_custom_radio_option.prop("checked", true); $really_custom_input[0].focus(); }); if (is_custom) { $really_custom_input.val(magnification * 100); $really_custom_radio_option.prop("checked", true); } $fieldset.find("label").css({ display: "block" }); $w.$Button(localize("OK"), () => { let option_val = $fieldset.find("input[name='custom-zoom-radio']:checked").val(); let mag; if (option_val === "really-custom") { option_val = $really_custom_input.val(); if (`${option_val}`.match(/\dx$/)) { // ...you can't actually type an x; oh well... mag = parseFloat(option_val); } else if (`${option_val}`.match(/\d%?$/)) { mag = parseFloat(option_val) / 100; } if (isNaN(mag)) { please_enter_a_number(); return; } } else { mag = parseFloat(option_val); } set_magnification(mag); $w.close(); })[0].focus(); $w.$Button(localize("Cancel"), () => { $w.close(); }); $w.center(); } function toggle_grid() { show_grid = !show_grid; // $G.trigger("option-changed"); update_helper_layer(); } function toggle_thumbnail() { show_thumbnail = !show_thumbnail; if (!show_thumbnail) { $thumbnail_window.hide(); } else { if (!thumbnail_canvas) { thumbnail_canvas = make_canvas(108, 92); thumbnail_canvas.style.width = "100%"; thumbnail_canvas.style.height = "100%"; } if (!$thumbnail_window) { $thumbnail_window = new $Window({ title: localize("Thumbnail"), toolWindow: true, resizable: true, innerWidth: thumbnail_canvas.width + 4, // @TODO: should the border of $content be included in the definition of innerWidth/Height? innerHeight: thumbnail_canvas.height + 4, minInnerWidth: 52 + 4, minInnerHeight: 36 + 4, minOuterWidth: 0, // @FIXME: this shouldn't be needed minOuterHeight: 0, // @FIXME: this shouldn't be needed }); $thumbnail_window.addClass("thumbnail-window"); $thumbnail_window.$content.append(thumbnail_canvas); $thumbnail_window.$content.addClass("inset-deep"); $thumbnail_window.$content.css({ marginTop: "1px" }); // @TODO: should this (or equivalent on titlebar) be for all windows? $thumbnail_window.maximize = () => { }; // @TODO: disable maximize with an option new ResizeObserver((entries) => { const entry = entries[0]; let width, height; if ("devicePixelContentBoxSize" in entry) { // console.log("devicePixelContentBoxSize", entry.devicePixelContentBoxSize); // Firefox seems to support this, although I can't find any documentation that says it should // I can't find an implementation bug or anything. // So I had to disable this case to test the fallback case (in Firefox 94.0) width = entry.devicePixelContentBoxSize[0].inlineSize; height = entry.devicePixelContentBoxSize[0].blockSize; } else if ("contentBoxSize" in entry) { // console.log("contentBoxSize", entry.contentBoxSize); // round() seems to line up with what Firefox does for device pixel alignment, which is great. // In Chrome it's blurry at some zoom levels with round(), ceil(), or floor(), but it (documentedly) supports devicePixelContentBoxSize. width = Math.round(entry.contentBoxSize[0].inlineSize * devicePixelRatio); height = Math.round(entry.contentBoxSize[0].blockSize * devicePixelRatio); } else { // Safari on iPad doesn't support either of the above as of iOS 15.0.2 width = Math.round(entry.contentRect.width * devicePixelRatio); height = Math.round(entry.contentRect.height * devicePixelRatio); } if (width && height) { // If it's hidden, and then shown, it gets a width and height of 0 briefly on iOS. (This would give IndexSizeError in drawImage.) thumbnail_canvas.width = width; thumbnail_canvas.height = height; } update_helper_layer_immediately(); // updates thumbnail (but also unnecessarily the helper layer) }).observe(thumbnail_canvas, { box: ['device-pixel-content-box'] }); } $thumbnail_window.show(); $thumbnail_window.on("close", (e) => { e.preventDefault(); $thumbnail_window.hide(); show_thumbnail = false; }); } // Currently the thumbnail updates with the helper layer. But it's not part of the helper layer, so this is a bit of a misnomer for now. update_helper_layer(); } function reset_selected_colors() { selected_colors = { foreground: "#000000", background: "#ffffff", ternary: "", }; $G.trigger("option-changed"); } function reset_file() { system_file_handle = null; file_name = localize("untitled"); file_format = "image/png"; saved = true; update_title(); } function reset_canvas_and_history() { undos.length = 0; redos.length = 0; current_history_node = root_history_node = make_history_node({ name: localize("New"), icon: get_help_folder_icon("p_blank.png"), }); history_node_to_cancel_to = null; main_canvas.width = Math.max(1, my_canvas_width); main_canvas.height = Math.max(1, my_canvas_height); main_ctx.disable_image_smoothing(); main_ctx.fillStyle = selected_colors.background; main_ctx.fillRect(0, 0, main_canvas.width, main_canvas.height); current_history_node.image_data = main_ctx.getImageData(0, 0, main_canvas.width, main_canvas.height); $canvas_area.trigger("resize"); $G.triggerHandler("history-update"); // update history view } function make_history_node({ parent = null, futures = [], timestamp = Date.now(), soft = false, image_data = null, selection_image_data = null, selection_x, selection_y, textbox_text, textbox_x, textbox_y, textbox_width, textbox_height, text_tool_font = null, tool_transparent_mode, foreground_color, background_color, ternary_color, name, icon = null, }) { return { parent, futures, timestamp, soft, image_data, selection_image_data, selection_x, selection_y, textbox_text, textbox_x, textbox_y, textbox_width, textbox_height, text_tool_font, tool_transparent_mode, foreground_color, background_color, ternary_color, name, icon, }; } function update_title() { document.title = `${file_name} - ${is_pride_month ? "June Solidarity " : ""}${localize("Paint")}`; if (is_pride_month) { $("link[rel~='icon']").attr("href", "./images/icons/gay-es-paint-16x16-light-outline.png"); } if (window.setRepresentedFilename) { window.setRepresentedFilename(system_file_handle ?? ""); } if (window.setDocumentEdited) { window.setDocumentEdited(!saved); } } function get_uris(text) { // parse text/uri-list // get lines, discarding comments const lines = text.split(/[\n\r]+/).filter(line => line[0] !== "#" && line); // discard text with too many lines (likely pasted HTML or something) - may want to revisit this if (lines.length > 15) { return []; } // parse URLs, discarding anything that parses as a relative URL const uris = []; for (let i = 0; i < lines.length; i++) { // Relative URLs will throw when no base URL is passed to the URL constructor. try { const url = new URL(lines[i]); uris.push(url.href); // eslint-disable-next-line no-empty } catch (e) { } } return uris; } async function load_image_from_uri(uri) { // Cases to consider: // - data URI // - blob URI // - blob URI from another domain // - file URI // - http URI // - https URI // - unsupported protocol, e.g. "ftp://example.com/image.png" // - invalid URI // - no protocol specified, e.g. "example.com/image.png" // --> We can fix these up! // - The user may be just trying to paste text, not an image. // - non-CORS-enabled URI // --> Use a CORS proxy! :) // - In electron, using a CORS proxy 1. is silly, 2. maybe isn't working. // --> Either proxy requests to the main process, // or configure headers in the main process to make requests work. // Probably the latter. @TODO // https://stackoverflow.com/questions/51254618/how-do-you-handle-cors-in-an-electron-app // - invalid image / unsupported image format // - image is no longer available on the live web // --> try loading from WayBack Machine :) // - often swathes of URLs are redirected to a new site, and do not give a 404. // --> make sure the flow of fallbacks accounts for this, and doesn't just see it as an unsupported file format. // - localhost URI, e.g. "http://127.0.0.1/" or "http://localhost/" // --> Don't try to proxy these, as it will just fail. // - Some domain extensions are reserved, e.g. .localdomain (how official is this?) // - There can also be arbitrary hostnames mapped to local servers, which we can't test for // - already a proxy URI, e.g. "https://cors.bridged.cc/https://example.com/image.png" // - file already downloaded // --> maybe should cache downloads? maybe HTTP caching is good enough? maybe uncommon enough that it doesn't matter. // - Pasting (Edit > Paste or Ctrl+V) vs Opening (drag & drop, File > Open, Ctrl+O, or File > Load From URL) // --> make wording generic or specific to the context const is_blob_uri = uri.match(/^blob:/i); const is_download = !uri.match(/^(blob|data|file):/i); const is_localhost = uri.match(/^(http|https):\/\/((127\.0\.0\.1|localhost)|.*(\.(local|localdomain|domain|lan|home|host|corp|invalid)))\b/i); if (is_blob_uri && uri.indexOf(`blob:${location.origin}`) === -1) { const error = new Error("can't load blob: URI from another domain"); error.code = "cross-origin-blob-uri"; throw error; } const uris_to_try = (is_download && !is_localhost) ? [ uri, // work around CORS headers not sent by whatever server `https://cors.bridged.cc/${uri}`, `https://jspaint-cors-proxy.herokuapp.com/${uri}`, // if the image isn't available on the live web, see if it's archived `https://web.archive.org/${uri}`, ] : [uri]; const fails = []; for (let index_to_try = 0; index_to_try < uris_to_try.length; index_to_try += 1) { const uri_to_try = uris_to_try[index_to_try]; try { if (is_download) { $status_text.text("Downloading picture..."); } const show_progress = ({ loaded, total }) => { if (is_download) { $status_text.text(`Downloading picture... (${Math.round(loaded / total * 100)}%)`); } }; if (is_download) { console.log(`Try loading image from URI (${index_to_try + 1}/${uris_to_try.length}): "${uri_to_try}"`); } const original_response = await fetch(uri_to_try); let response_to_read = original_response; if (!original_response.ok) { fails.push({ status: original_response.status, statusText: original_response.statusText, url: uri_to_try }); continue; } if (!original_response.body) { if (is_download) { console.log("ReadableStream not yet supported in this browser. Progress won't be shown for image requests."); } } else { // to access headers, server must send CORS header "Access-Control-Expose-Headers: content-encoding, content-length x-file-size" // server must send custom x-file-size header if gzip or other content-encoding is used const contentEncoding = original_response.headers.get("content-encoding"); const contentLength = original_response.headers.get(contentEncoding ? "x-file-size" : "content-length"); if (contentLength === null) { if (is_download) { console.log("Response size header unavailable. Progress won't be shown for this image request."); } } else { const total = parseInt(contentLength, 10); let loaded = 0; response_to_read = new Response( new ReadableStream({ start(controller) { const reader = original_response.body.getReader(); read(); function read() { reader.read().then(({ done, value }) => { if (done) { controller.close(); return; } loaded += value.byteLength; show_progress({ loaded, total }) controller.enqueue(value); read(); }).catch(error => { console.error(error); controller.error(error) }) } } }) ); } } const blob = await response_to_read.blob(); if (is_download) { console.log("Download complete."); $status_text.text("Download complete."); } // @TODO: use headers to detect HTML, since a doctype is not guaranteed // @TODO: fall back to WayBack Machine still for decode errors, // since a website might start redirecting swathes of URLs regardless of what they originally pointed to, // at which point they would likely point to a web page instead of an image. // (But still show an error about it not being an image, if WayBack also fails.) const info = await new Promise((resolve, reject) => { read_image_file(blob, (error, info) => { if (error) { reject(error); } else { resolve(info); } }); }); return info; } catch (error) { fails.push({ url: uri_to_try, error }); } } if (is_download) { $status_text.text("Failed to download picture."); } const error = new Error(`failed to fetch image from any of ${uris_to_try.length} URI(s):\n ${fails.map((fail) => (fail.statusText ? `${fail.status} ${fail.statusText} ` : "") + fail.url + (fail.error ? `\n ${fail.error}` : "") ).join("\n ")}`); error.code = "access-failure"; error.fails = fails; throw error; } function open_from_image_info(info, callback, canceled, into_existing_session, from_session_load) { are_you_sure(({ canvas_modified_while_loading } = {}) => { deselect(); cancel(); if (!into_existing_session) { $G.triggerHandler("session-update"); // autosave old session new_local_session(); } reset_file(); reset_selected_colors(); reset_canvas_and_history(); // (with newly reset colors) set_magnification(default_magnification); main_ctx.copy(info.image || info.image_data); apply_file_format_and_palette_info(info); transparency = has_any_transparency(main_ctx); $canvas_area.trigger("resize"); current_history_node.name = localize("Open"); current_history_node.image_data = main_ctx.getImageData(0, 0, main_canvas.width, main_canvas.height); current_history_node.icon = get_help_folder_icon("p_open.png"); if (canvas_modified_while_loading || !from_session_load) { // normally we don't want to autosave if we're loading a session, // as this is redundant, but if the user has modified the canvas while loading a session, // right now how it works is the session would be overwritten, so if you reloaded, it'd be lost, // so we'd better save it. // (and we want to save if this is a new session being initialized with an image) $G.triggerHandler("session-update"); // autosave } $G.triggerHandler("history-update"); // update history view if (info.source_blob instanceof File) { file_name = info.source_blob.name; // file.path is available in Electron (see https://www.electronjs.org/docs/api/file-object#file-object) system_file_handle = info.source_blob.path; } if (info.source_file_handle) { system_file_handle = info.source_file_handle; } saved = true; update_title(); callback && callback(); }, canceled, from_session_load); } // Note: This function is part of the API. function open_from_file(file, source_file_handle) { // The browser isn't very smart about MIME types. // It seems to look at the file extension, but not the actual file contents. // This is particularly problematic for files with no extension, where file.type gives an empty string. // And the File Access API currently doesn't let us automatically append a file extension, // so the user is likely to end up with files with no extension. // It's better to look at the file content to determine file type. // We do this for image files in read_image_file, and palette files in AnyPalette.js. if (file.name.match(/\.theme(pack)?$/i)) { file.text().then(load_theme_from_text, (error) => { show_error_message(localize("Paint cannot open this file."), error); }); return } // Try loading as an image file first, then as a palette file, but show a combined error message if both fail. read_image_file(file, (as_image_error, image_info) => { if (as_image_error) { AnyPalette.loadPalette(file, (as_palette_error, new_palette) => { if (as_palette_error) { show_file_format_errors({ as_image_error, as_palette_error }); return; } palette = new_palette.map((color) => color.toString()); $colorbox.rebuild_palette(); window.console && console.log(`Loaded palette: ${palette.map(() => `%c█`).join("")}`, ...palette.map((color) => `color: ${color};`)); }); return; } image_info.source_file_handle = source_file_handle open_from_image_info(image_info); }); } function apply_file_format_and_palette_info(info) { file_format = info.file_format; if (!enable_palette_loading_from_indexed_images) { return; } if (info.palette) { window.console && console.log(`Loaded palette from image file: ${info.palette.map(() => `%c█`).join("")}`, ...info.palette.map((color) => `color: ${color};`)); palette = info.palette; selected_colors.foreground = palette[0]; selected_colors.background = palette.length === 14 * 2 ? palette[14] : palette[1]; // first in second row for default sized palette, else second color (debatable behavior; should it find a dark and a light color?) $G.trigger("option-changed"); } else if (monochrome && !info.monochrome) { palette = default_palette; reset_selected_colors(); } $colorbox.rebuild_palette(); monochrome = info.monochrome; } function load_theme_from_text(fileText) { var cssProperties = parseThemeFileString(fileText); if (!cssProperties) { show_error_message(localize("Paint cannot open this file.")); return; } applyCSSProperties(cssProperties, { recurseIntoIframes: true }); window.themeCSSProperties = cssProperties; $G.triggerHandler("theme-load"); } function file_new() { are_you_sure(() => { deselect(); cancel(); $G.triggerHandler("session-update"); // autosave old session new_local_session(); reset_file(); reset_selected_colors(); reset_canvas_and_history(); // (with newly reset colors) set_magnification(default_magnification); $G.triggerHandler("session-update"); // autosave }); } async function file_open() { const { file, fileHandle } = await systemHooks.showOpenFileDialog({ formats: image_formats }) open_from_file(file, fileHandle); } let $file_load_from_url_window; function file_load_from_url() { if ($file_load_from_url_window) { $file_load_from_url_window.close(); } const $w = new $DialogWindow().addClass("horizontal-buttons"); $file_load_from_url_window = $w; $w.title("Load from URL"); // @TODO: URL validation (input has to be in a form (and we don't want the form to submit)) $w.$main.html(`
`); const $input = $w.$main.find("#url-input"); // $w.$Button("Load", () => { $w.$Button(localize("Open"), () => { const uris = get_uris($input.val()); if (uris.length > 0) { // @TODO: retry loading if same URL entered // actually, make it change the hash only after loading successfully // (but still load from the hash when necessary) // make sure it doesn't overwrite the old session before switching $w.close(); change_url_param("load", uris[0]); } else { show_error_message("Invalid URL. It must include a protocol (https:// or http://)"); } }); $w.$Button(localize("Cancel"), () => { $w.close(); }); $w.center(); $input[0].focus(); } // Native FS API / File Access API allows you to overwrite files, but people are not used to it. // So we ask them to confirm it the first time. let acknowledged_overwrite_capability = false; const confirmed_overwrite_key = "jspaint confirmed overwrite capable"; try { acknowledged_overwrite_capability = localStorage[confirmed_overwrite_key] === "true"; } catch (error) { // no localStorage // In the year 2033, people will be more used to it, right? // This will be known as the "Y2T bug" acknowledged_overwrite_capability = Date.now() >= 2000000000000; } async function confirm_overwrite_capability() { if (acknowledged_overwrite_capability) { return true; } const { $window, promise } = showMessageBox({ messageHTML: `

JS Paint can now save over existing files.

Do you want to overwrite the file?

`, buttons: [ { label: localize("Yes"), value: "overwrite", default: true }, { label: localize("Cancel"), value: "cancel" }, ], }); const result = await promise; if (result === "overwrite") { acknowledged_overwrite_capability = $window.$content.find("#dont-ask-me-again-checkbox").prop("checked"); try { localStorage[confirmed_overwrite_key] = acknowledged_overwrite_capability; } catch (error) { // no localStorage... @TODO: don't show the checkbox in this case } return true; } return false; } function file_save(maybe_saved_callback = () => { }, update_from_saved = true) { deselect(); // store and use file handle at this point in time, to avoid race conditions const save_file_handle = system_file_handle; // if (!save_file_handle || file_name.match(/\.(svg|pdf)$/i)) { // return file_save_as(maybe_saved_callback, update_from_saved); // } write_image_file(main_canvas, file_format, async (blob) => { await systemHooks.writeBlobToHandle(save_file_handle, blob); if (update_from_saved) { update_from_saved_file(blob); } maybe_saved_callback(); }); } function file_save_as(maybe_saved_callback = () => { }, update_from_saved = true) { deselect(); systemHooks.showSaveFileDialog({ dialogTitle: localize("Save As"), formats: image_formats, defaultFileName: file_name, defaultPath: typeof system_file_handle === "string" ? system_file_handle : null, defaultFileFormatID: file_format, getBlob: (new_file_type) => { return new Promise((resolve) => { write_image_file(main_canvas, new_file_type, (blob) => { resolve(blob); }); }); }, savedCallbackUnreliable: ({ newFileName, newFileFormatID, newFileHandle, newBlob }) => { saved = true; system_file_handle = newFileHandle; file_name = newFileName; file_format = newFileFormatID; update_title(); maybe_saved_callback(); if (update_from_saved) { update_from_saved_file(newBlob); } } }); } function are_you_sure(action, canceled, from_session_load) { if (saved) { action(); } else if (from_session_load) { showMessageBox({ message: localize("You've modified the document while an existing document was loading.\nSave the new document?", file_name), buttons: [ { // label: localize("Save"), label: localize("Yes"), value: "save", default: true, }, { // label: "Discard", label: localize("No"), value: "discard", }, ], // @TODO: not closable with Escape or close button }).then((result) => { if (result === "save") { file_save(() => { action(); }, false); } else if (result === "discard") { action({ canvas_modified_while_loading: true }); } else { // should not ideally happen // but prefer to preserve the previous document, // as the user has only (probably) as small window to make changes while loading, // whereas there could be any amount of work put into the document being loaded. // @TODO: could show dialog again, but making it un-cancelable would be better. action(); } }); } else { showMessageBox({ message: localize("Save changes to %1?", file_name), buttons: [ { // label: localize("Save"), label: localize("Yes"), value: "save", default: true, }, { // label: "Discard", label: localize("No"), value: "discard", }, { label: localize("Cancel"), value: "cancel", }, ], }).then((result) => { if (result === "save") { file_save(() => { action(); }, false); } else if (result === "discard") { action(); } else { canceled?.(); } }); } } function please_enter_a_number() { showMessageBox({ // title: "Invalid Value", message: localize("Please enter a number."), }); } // Note: This function is part of the API. function show_error_message(message, error) { // Test global error handling resiliency by enabling one or both of these: // Promise.reject(new Error("EMIT EMIT EMIT")); // throw new Error("EMIT EMIT EMIT"); // It should fall back to an alert. // EMIT stands for "Error Message Itself Test". const { $message } = showMessageBox({ iconID: "error", message, // windowOptions: { // innerWidth: 600, // }, }); // $message.css("max-width", "600px"); if (error) { const $details = $("
Details
") .appendTo($message); // Chrome includes the error message in the error.stack string, whereas Firefox doesn't. // Also note that there can be Exception objects that don't have a message (empty string) but a name, // for instance Exception { message: "", name: "NS_ERROR_FAILURE", ... } for out of memory when resizing the canvas too large in Firefox. // Chrome just lets you bring the system to a grating halt by trying to grab too much memory. // Firefox does too sometimes. let error_string = error.stack; if (!error_string) { error_string = error.toString(); } else if (error.message && error_string.indexOf(error.message) === -1) { error_string = `${error.toString()}\n\n${error_string}`; } else if (error.name && error_string.indexOf(error.name) === -1) { error_string = `${error.name}\n\n${error_string}`; } $(E("pre")) .text(error_string) .appendTo($details) .css({ background: "white", color: "#333", // background: "#A00", // color: "white", fontFamily: "monospace", width: "500px", maxWidth: "100%", overflow: "auto", }); } if (error) { window.console?.error?.(message, error); } else { window.console?.error?.(message); } } // @TODO: close are_you_sure windows and these Error windows when switching sessions // because it can get pretty confusing function show_resource_load_error_message(error) { const { $window, $message } = showMessageBox({}); const firefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1; // @TODO: copy & paste vs download & open, more specific guidance if (error.code === "cross-origin-blob-uri") { $message.html(`

Can't load image from address starting with "blob:".

${firefox ? `

Try "Copy Image" instead of "Copy Image Location".

` : `

Try "Copy image" instead of "Copy image address".

` } `); } else if (error.code === "html-not-image") { $message.html(`

Address points to a web page, not an image file.

Try copying and pasting an image instead of a URL.

`); } else if (error.code === "decoding-failure") { $message.html(`

Address doesn't point to an image file of a supported format.

Try copying and pasting an image instead of a URL.

`); } else if (error.code === "access-failure") { if (navigator.onLine) { $message.html(`

Failed to download image.

Try copying and pasting an image instead of a URL.

`); if (error.fails) { $("