const default_magnification = 1; const default_tool = get_tool_by_name("Pencil"); const default_canvas_width = 683; const default_canvas_height = 384; let my_canvas_width = default_canvas_width; let my_canvas_height = default_canvas_height; let aliasing = true; let transparency = false; let monochrome = false; let magnification = default_magnification; let return_to_magnification = 4; const canvas = make_canvas(); canvas.classList.add("main-canvas"); const ctx = canvas.ctx; let palette = [ "#000000","#787878","#790300","#757A01","#007902","#007778","#0A0078","#7B0077","#767A38","#003637","#286FFE","#083178","#4C00FE","#783B00", "#FFFFFF","#BBBBBB","#FF0E00","#FAFF08","#00FF0B","#00FEFF","#3400FE","#FF00FE","#FBFF7A","#00FF7B","#76FEFF","#8270FE","#FF0677","#FF7D36", ]; let polychrome_palette = palette; let monochrome_palette = make_monochrome_palette(); let stroke_color; let fill_color; let stroke_color_k = "foreground"; // enum of "foreground", "background", "ternary" let fill_color_k = "background"; // enum of "foreground", "background", "ternary" let selected_tool = default_tool; let selected_tools = [selected_tool]; let return_to_tools = [selected_tool]; let colors = { foreground: "", background: "", ternary: "", }; let selection; //the one and only OnCanvasSelection let textbox; //the one and only OnCanvasTextBox let helper_layer; //the OnCanvasHelperLayer for the grid and tool previews let show_grid = false; let text_tool_font = { family: '"Arial"', // should be an exact value detected by Font Detective size: 12, line_scale: 20 / 12, bold: false, italic: false, underline: false, vertical: false, color: "", background: "", }; const undos = []; //array of ImageData const redos = []; //array of ImageData //const frames = []; //array of {delay: N, undos: [ImageData], redos: [ImageData], image: ImageData}? array of Frames? let file_name; let document_file_path; let saved = true; let pointer, pointer_start, pointer_previous, pointer_type, pointer_buttons; let reverse; let ctrl; let button; let pointer_active = false; let pointer_over_canvas = false; let update_helper_layer_on_pointermove_active = false; const $app = $(E("div")).addClass("jspaint").appendTo("body"); const $V = $(E("div")).addClass("vertical").appendTo($app); const $H = $(E("div")).addClass("horizontal").appendTo($V); const $canvas_area = $(E("div")).addClass("canvas-area").appendTo($H); $canvas_area.attr("touch-action", "pan-x pan-y"); const $canvas = $(canvas).appendTo($canvas_area); $canvas.attr("touch-action", "none"); let canvas_bounding_client_rect = canvas.getBoundingClientRect(); // cached for performance, updated later const getRect = ()=> ({left: 0, top: 0, width: canvas.width, height: canvas.height, right: canvas.width, bottom: canvas.height}) const $canvas_handles = $Handles($canvas_area, getRect, { outset: 4, get_offset_left: ()=> parseFloat($canvas_area.css("padding-left")) + 1, get_offset_top: ()=> parseFloat($canvas_area.css("padding-top")) + 1, size_only: true, }); // hack: fix canvas handles causing document to scroll when selecting/deselecting // by overriding these methods $canvas_handles.hide = ()=> { $canvas_handles.css({opacity: 0, pointerEvents: "none"}); }; $canvas_handles.show = ()=> { $canvas_handles.css({opacity: "", pointerEvents: ""}); }; const $top = $(E("div")).addClass("component-area").prependTo($V); const $bottom = $(E("div")).addClass("component-area").appendTo($V); const $left = $(E("div")).addClass("component-area").prependTo($H); const $right = $(E("div")).addClass("component-area").appendTo($H); const $status_area = $(E("div")).addClass("status-area").appendTo($V); const $status_text = $(E("div")).addClass("status-text").appendTo($status_area); const $status_position = $(E("div")).addClass("status-coordinates").appendTo($status_area); const $status_size = $(E("div")).addClass("status-coordinates").appendTo($status_area); $status_text.default = () => { $status_text.text("For Help, click Help Topics on the Help Menu."); }; $status_text.default(); // menu bar let menu_bar_outside_frame = false; if(frameElement){ try{ if(parent.$MenuBar){ $MenuBar = parent.$MenuBar; menu_bar_outside_frame = true; } // eslint-disable-next-line no-empty }catch(e){} } const $menu_bar = $MenuBar(menus); if(menu_bar_outside_frame){ $menu_bar.insertBefore(frameElement); }else{ $menu_bar.prependTo($V); } $menu_bar.on("info", (e, info) => { $status_text.text(info); }); $menu_bar.on("default-info", e => { $status_text.default(); }); const $extras_menu_button = $menu_bar.get(0).ownerDocument.defaultView.$(".extras-menu-button"); // TODO: DRY with $MenuBar // if localStorage is not available, the default setting is visible let extras_menu_should_start_visible = true; try{ // if localStorage is available, the default setting is invisible (for now) // TODO: refactor shared key string extras_menu_should_start_visible = localStorage["jspaint extras menu visible"] == "true" // eslint-disable-next-line no-empty }catch(e){} if(!extras_menu_should_start_visible){ $extras_menu_button.hide(); } // const $toolbox = $ToolBox(tools); // const $toolbox2 = $ToolBox(extra_tools, true);//.hide(); // Note: a second $ToolBox doesn't work because they use the same tool options (which could be remedied) // and also the UI isn't designed for multiple vertical components (or horizontal ones) // If there's to be extra tools, they should probably get a window, with different UI // so it can display names of the tools, and maybe authors and previews (and not necessarily icons) const $colorbox = $ColorBox(); $canvas_area.on("user-resized", (e, _x, _y, width, height) => { undoable(0, () => { canvas.width = Math.max(1, width); canvas.height = Math.max(1, height); ctx.disable_image_smoothing(); if(!transparency){ ctx.fillStyle = colors.background; ctx.fillRect(0, 0, canvas.width, canvas.height); } const previous_imagedata = undos[undos.length-1]; if(previous_imagedata){ const temp_canvas = make_canvas(previous_imagedata); ctx.drawImage(temp_canvas, 0, 0); } $canvas_area.trigger("resize"); storage.set({ width: canvas.width, height: canvas.height, }, err => { // oh well }) }); }); $G.on("resize", () => { // for browser zoom, and in-app zoom of the canvas update_canvas_rect(); update_disable_aa(); }); $canvas_area.on("scroll", () => { update_canvas_rect(); }); $canvas_area.on("resize", () => { update_magnified_canvas_size(); }); $("body").on("dragover dragenter", e => { const dt = e.originalEvent.dataTransfer; const has_files = Array.from(dt.types).includes("Files"); if(has_files){ e.preventDefault(); } }).on("drop", e => { if(e.isDefaultPrevented()){ return; } const dt = e.originalEvent.dataTransfer; const has_files = Array.from(dt.types).includes("Files"); if(has_files){ e.preventDefault(); if(dt && dt.files && dt.files.length){ open_from_FileList(dt.files, "dropped"); } } }); $G.on("keydown", e => { if(e.isDefaultPrevented()){ return; } if (e.keyCode === 27) { // Esc if (textbox && textbox.$editor.is(e.target)) { deselect(); } } // TODO: return if menus/menubar focused or focus in dialog window // or maybe there's a better way to do this that works more generally // maybe it should only handle the event if document.activeElement is the body or html element? // (or $app could have a tabIndex and no focus style and be focused under various conditions, // if that turned out to make more sense for some reason) if( e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement ){ return; } // TODO: preventDefault in all cases where the event is handled // also, ideally check that modifiers *aren't* pressed // probably best to use a library at this point! if(selection){ const nudge_selection = (delta_x, delta_y) => { selection.x += delta_x; selection.y += delta_y; selection.position(); }; switch(e.keyCode){ case 37: // Left nudge_selection(-1, 0); e.preventDefault(); break; case 39: // Right nudge_selection(+1, 0); e.preventDefault(); break; case 40: // Down nudge_selection(0, +1); e.preventDefault(); break; case 38: // Up nudge_selection(0, -1); e.preventDefault(); break; } } if(e.keyCode === 27){ //Escape if(selection){ deselect(); }else{ cancel(); } stopSimulatingGestures(); }else if(e.keyCode === 13){ //Enter if(selection){ deselect(); } }else if(e.keyCode === 115){ //F4 redo(); }else if(e.keyCode === 46){ //Delete delete_selection(); }else if(e.keyCode === 107 || e.keyCode === 109){ // Numpad Plus and Minus const plus = e.keyCode === 107; const minus = e.keyCode === 109; const delta = plus - minus; // const delta = +plus++ -minus--; // Δ = ±±±± if(selection){ selection.scale(2 ** delta); }else{ if(selected_tool.name === "Brush"){ brush_size = Math.max(1, Math.min(brush_size + delta, 500)); }else if(selected_tool.name === "Eraser/Color Eraser"){ eraser_size = Math.max(1, Math.min(eraser_size + delta, 500)); }else if(selected_tool.name === "Airbrush"){ airbrush_size = Math.max(1, Math.min(airbrush_size + delta, 500)); }else if(selected_tool.name === "Pencil"){ pencil_size = Math.max(1, Math.min(pencil_size + delta, 50)); }else if(selected_tool.name.match(/Line|Curve|Rectangle|Ellipse|Polygon/)){ stroke_size = Math.max(1, Math.min(stroke_size + delta, 500)); } $G.trigger("option-changed"); if(button !== undefined){ selected_tools.forEach((selected_tool)=> { tool_go(selected_tool); }); } } e.preventDefault(); return; }else if(e.ctrlKey || e.metaKey){ const key = String.fromCharCode(e.keyCode).toUpperCase(); if(textbox){ switch(key){ case "A": case "Z": case "Y": case "I": case "B": case "U": // Don't prevent the default. Allow text editing commands. return; } } switch(e.keyCode){ case 188: // , < case 219: // [ { rotate(-TAU/4); $canvas_area.trigger("resize"); break; case 190: // . > case 221: // ] } rotate(+TAU/4); $canvas_area.trigger("resize"); break; } switch(key){ case "Z": e.shiftKey ? redo() : undo(); break; case "Y": redo(); break; case "G": e.shiftKey ? render_history_as_gif() : toggle_grid(); break; case "F": view_bitmap(); break; case "O": file_open(); break; case "N": e.shiftKey ? clear() : file_new(); break; case "S": e.shiftKey ? file_save_as() : file_save(); break; case "A": select_all(); break; case "I": image_invert(); break; case "E": image_attributes(); break; default: return; // don't preventDefault } e.preventDefault(); } }); $G.on("cut copy paste", e => { if(e.isDefaultPrevented()){ return; } if( document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement || !window.getSelection().isCollapsed ){ // Don't prevent cutting/copying/pasting within inputs or textareas, or if there's a selection return; } e.preventDefault(); const cd = e.originalEvent.clipboardData || window.clipboardData; if(!cd){ return; } if(e.type === "copy" || e.type === "cut"){ if(selection && selection.canvas){ const do_sync_clipboard_copy_or_cut = () => { // works only for pasting within a jspaint instance const data_url = selection.canvas.toDataURL(); cd.setData("text/x-data-uri; type=image/png", data_url); cd.setData("text/uri-list", data_url); cd.setData("URL", data_url); if(e.type === "cut"){ delete_selection(); } }; if (!navigator.clipboard || !navigator.clipboard.write) { return do_sync_clipboard_copy_or_cut(); } try { if (e.type === "cut") { edit_cut(); } else { edit_copy(); } } catch(e) { do_sync_clipboard_copy_or_cut(); } } }else if(e.type === "paste"){ for (const item of cd.items) { if(item.type.match(/^text\/(?:x-data-uri|uri-list|plain)|URL$/)){ item.getAsString(text => { const uris = get_URIs(text); if (uris.length > 0) { load_image_from_URI(uris[0], (err, img) => { if(err){ return show_resource_load_error_message(); } paste(img); }); } else { show_error_message("The information on the Clipboard can't be inserted into Paint."); } }); break; }else if(item.type.match(/^image\//)){ paste_image_from_file(item.getAsFile()); break; } } } }); reset_file(); reset_colors(); reset_canvas_and_history(); // (with newly reset colors) set_magnification(default_magnification); storage.get({ width: default_canvas_width, height: default_canvas_height, }, (err, stored_values) => { if(err){return;} my_canvas_width = stored_values.width; my_canvas_height = stored_values.height; canvas.width = Math.max(1, my_canvas_width); canvas.height = Math.max(1, my_canvas_height); ctx.disable_image_smoothing(); if(!transparency){ ctx.fillStyle = colors.background; ctx.fillRect(0, 0, canvas.width, canvas.height); } $canvas_area.trigger("resize"); }); if(window.document_file_path_to_open){ open_from_file_path(document_file_path_to_open, err => { if(err){ return show_error_message(`Failed to open file ${document_file_path_to_open}`, err); } }); } function to_canvas_coords({clientX, clientY}) { const rect = canvas_bounding_client_rect; const cx = clientX - rect.left; const cy = clientY - rect.top; return { x: ~~(cx / rect.width * canvas.width), y: ~~(cy / rect.height * canvas.height), }; } function update_fill_and_stroke_colors_and_lineWidth(selected_tool) { ctx.lineWidth = stroke_size; const reverse_because_fill_only = selected_tool.$options && selected_tool.$options.fill && !selected_tool.$options.stroke; ctx.fillStyle = fill_color = ctx.strokeStyle = stroke_color = colors[ (ctrl && colors.ternary && pointer_active) ? "ternary" : ((reverse ^ reverse_because_fill_only) ? "background" : "foreground") ]; fill_color_k = stroke_color_k = ctrl ? "ternary" : ((reverse ^ reverse_because_fill_only) ? "background" : "foreground"); if(selected_tool.shape || selected_tool.shape_colors){ if(!selected_tool.stroke_only){ if((reverse ^ reverse_because_fill_only)){ fill_color_k = "foreground"; stroke_color_k = "background"; }else{ fill_color_k = "background"; stroke_color_k = "foreground"; } } ctx.fillStyle = fill_color = colors[fill_color_k]; ctx.strokeStyle = stroke_color = colors[stroke_color_k]; } } function tool_go(selected_tool, event_name){ update_fill_and_stroke_colors_and_lineWidth(selected_tool); if(selected_tool[event_name]){ selected_tool[event_name](ctx, pointer.x, pointer.y); } if(selected_tool.paint){ if(selected_tool.continuous === "space"){ const ham = brush_shape.match(/diagonal/) ? brosandham_line : bresenham_line; ham(pointer_previous.x, pointer_previous.y, pointer.x, pointer.y, (x, y) => { selected_tool.paint(ctx, x, y); }); }else{ selected_tool.paint(ctx, pointer.x, pointer.y); } } } function canvas_pointer_move(e){ ctrl = e.ctrlKey; shift = e.shiftKey; pointer = to_canvas_coords(e); // Quick Undo // (Note: pointermove also occurs when the set of buttons pressed changes, // except when another event would fire like pointerdown) if(pointer_active && e.button != -1){ // compare buttons other than middle mouse button by using bitwise OR to make that bit of the number the same const MMB = 4; if(e.pointerType != pointer_type || (e.buttons | MMB) != (pointer_buttons | MMB)){ cancel(); pointer_active = false; // NOTE: pointer_active used in cancel() return; } } if(e.shiftKey){ if(selected_tool.name.match(/Line|Curve/)){ // snap to eight directions const dist = Math.sqrt( (pointer.y - pointer_start.y) * (pointer.y - pointer_start.y) + (pointer.x - pointer_start.x) * (pointer.x - pointer_start.x) ); const eighth_turn = TAU / 8; const angle_0_to_8 = Math.atan2(pointer.y - pointer_start.y, pointer.x - pointer_start.x) / eighth_turn; const angle = Math.round(angle_0_to_8) * eighth_turn; pointer.x = Math.round(pointer_start.x + Math.cos(angle) * dist); pointer.y = Math.round(pointer_start.y + Math.sin(angle) * dist); }else if(selected_tool.shape){ // snap to four diagonals const w = Math.abs(pointer.x - pointer_start.x); const h = Math.abs(pointer.y - pointer_start.y); if(w < h){ if(pointer.y > pointer_start.y){ pointer.y = pointer_start.y + w; }else{ pointer.y = pointer_start.y - w; } }else{ if(pointer.x > pointer_start.x){ pointer.x = pointer_start.x + h; }else{ pointer.x = pointer_start.x - h; } } } } selected_tools.forEach((selected_tool)=> { tool_go(selected_tool); }); pointer_previous = pointer; } $canvas.on("pointermove", e => { pointer = to_canvas_coords(e); $status_position.text(`${pointer.x},${pointer.y}`); }); $canvas.on("pointerenter", e => { pointer_over_canvas = true; update_helper_layer(); if (!update_helper_layer_on_pointermove_active) { $G.on("pointermove", update_helper_layer); update_helper_layer_on_pointermove_active = true; } }); $canvas.on("pointerleave", e => { pointer_over_canvas = false; $status_position.text(""); update_helper_layer(); if (!pointer_active && update_helper_layer_on_pointermove_active) { $G.off("pointermove", update_helper_layer); update_helper_layer_on_pointermove_active = false; } }); $canvas.on("pointerdown", e => { update_canvas_rect(); // Quick Undo when there are multiple pointers (i.e. for touch) // see pointermove for other pointer types if(pointer_active && (reverse ? (button === 2) : (button === 0))){ cancel(); pointer_active = false; // NOTE: pointer_active used in cancel() return; } pointer_active = !!(e.buttons & (1 | 2)); pointer_type = e.pointerType; pointer_buttons = e.buttons; $G.one("pointerup", e => { pointer_active = false; update_helper_layer(); if (!pointer_over_canvas && update_helper_layer_on_pointermove_active) { $G.off("pointermove", update_helper_layer); update_helper_layer_on_pointermove_active = false; } }); if(e.button === 0){ reverse = false; }else if(e.button === 2){ reverse = true; }else{ return; } button = e.button; ctrl = e.ctrlKey; shift = e.shiftKey; pointer_start = pointer_previous = pointer = to_canvas_coords(e); const pointerdown_action = () => { let interval_ids = []; selected_tools.forEach((selected_tool)=> { if(selected_tool.paint || selected_tool.pointerdown){ tool_go(selected_tool, "pointerdown"); } if(selected_tool.continuous === "time"){ interval_ids.push(setInterval(()=> { tool_go(selected_tool); }, 5)); } }); $G.on("pointermove", canvas_pointer_move); $G.one("pointerup", (e, canceling) => { button = undefined; reverse = false; if(canceling){ // calling selected_tool.cancel() handled elsewhere }else{ pointer = to_canvas_coords(e); selected_tools.forEach((selected_tool)=> { selected_tool.pointerup && selected_tool.pointerup(ctx, pointer.x, pointer.y); }); } if (selected_tools.length === 1) { if (selected_tool.deselect) { select_tools(return_to_tools); } } $G.off("pointermove", canvas_pointer_move); for (const interval_id of interval_ids) { clearInterval(interval_id); } }); }; if(shouldMakeUndoableOnPointerDown(selected_tools)){ undoable(pointerdown_action); }else{ pointerdown_action(); } update_helper_layer(); }); $canvas_area.on("pointerdown", e => { if(e.button === 0){ if($canvas_area.is(e.target)){ if(selection){ deselect(); } } } }); $app .add($toolbox) // .add($toolbox2) .add($colorbox) .on("mousedown selectstart contextmenu", e => { if(e.isDefaultPrevented()){ return; } if( e.target instanceof HTMLSelectElement || e.target instanceof HTMLTextAreaElement || (e.target instanceof HTMLLabelElement && e.type !== "contextmenu") || (e.target instanceof HTMLInputElement && e.target.type !== "color") ){ return; } if(e.button === 1){ return; // allow middle-click scrolling } e.preventDefault(); // we're just trying to prevent selection // but part of the default for mousedown is *deselection* // so we have to do that ourselves explicitly window.getSelection().removeAllRanges(); }); // Stop drawing (or dragging or whatver) if you Alt+Tab or whatever $G.on("blur", e => { $G.triggerHandler("pointerup"); });