init the awkward code

This commit is contained in:
Bao Nguyen
2023-02-13 19:32:10 +07:00
commit 27170afcac
5426 changed files with 1244579 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
((exports) => {
/** Used by the Colors Box and by the Edit Colors dialog */
function $Swatch(color) {
const $swatch = $(E("div")).addClass("swatch");
const swatch_canvas = make_canvas();
$(swatch_canvas).css({ pointerEvents: "none" }).appendTo($swatch);
// @TODO: clean up event listener
$G.on("theme-load", () => { update_$swatch($swatch); });
$swatch.data("swatch", color);
update_$swatch($swatch, color);
return $swatch;
}
function update_$swatch($swatch, new_color) {
if (new_color instanceof CanvasPattern) {
$swatch.addClass("pattern");
$swatch[0].dataset.color = "";
} else if (typeof new_color === "string") {
$swatch.removeClass("pattern");
$swatch[0].dataset.color = new_color;
} else if (new_color !== undefined) {
throw new TypeError(`argument to update must be CanvasPattern or string (or undefined); got type ${typeof new_color}`);
}
new_color = new_color || $swatch.data("swatch");
$swatch.data("swatch", new_color);
const swatch_canvas = $swatch.find("canvas")[0];
requestAnimationFrame(() => {
swatch_canvas.width = $swatch.innerWidth();
swatch_canvas.height = $swatch.innerHeight();
if (new_color) {
swatch_canvas.ctx.fillStyle = new_color;
swatch_canvas.ctx.fillRect(0, 0, swatch_canvas.width, swatch_canvas.height);
}
});
}
function $ColorBox(vertical) {
const $cb = $(E("div")).addClass("color-box");
const $current_colors = $Swatch(selected_colors.ternary).addClass("current-colors");
const $palette = $(E("div")).addClass("palette");
$cb.append($current_colors, $palette);
const $foreground_color = $Swatch(selected_colors.foreground).addClass("color-selection foreground-color");
const $background_color = $Swatch(selected_colors.background).addClass("color-selection background-color");
$current_colors.append($background_color, $foreground_color);
$G.on("option-changed", () => {
update_$swatch($foreground_color, selected_colors.foreground);
update_$swatch($background_color, selected_colors.background);
update_$swatch($current_colors, selected_colors.ternary);
});
$current_colors.on("pointerdown", () => {
const new_bg = selected_colors.foreground;
selected_colors.foreground = selected_colors.background;
selected_colors.background = new_bg;
$G.triggerHandler("option-changed");
});
const make_color_button = (color) => {
const $b = $Swatch(color).addClass("color-button");
$b.appendTo($palette);
const double_click_period_ms = 400;
let within_double_click_period = false;
let double_click_button = null;
let double_click_tid;
// @TODO: handle left+right click at same time
// can do this with mousedown instead of pointerdown, but may need to improve eye gaze mode click simulation
$b.on("pointerdown", e => {
// @TODO: allow metaKey for ternary color, and selection cropping, on macOS?
ctrl = e.ctrlKey;
button = e.button;
if (button === 0) {
$c.data("$last_fg_color_button", $b);
}
const color_selection_slot = ctrl ? "ternary" : button === 0 ? "foreground" : button === 2 ? "background" : null;
if (color_selection_slot) {
if (within_double_click_period && button === double_click_button) {
show_edit_colors_window($b, color_selection_slot);
} else {
selected_colors[color_selection_slot] = $b.data("swatch");
$G.trigger("option-changed");
}
clearTimeout(double_click_tid);
double_click_tid = setTimeout(() => {
within_double_click_period = false;
double_click_button = null;
}, double_click_period_ms);
within_double_click_period = true;
double_click_button = button;
}
});
};
const build_palette = () => {
$palette.empty();
palette.forEach(make_color_button);
// Note: this doesn't work until the colors box is in the DOM
const $some_button = $palette.find(".color-button");
if (vertical) {
const height_per_button =
$some_button.outerHeight() +
parseFloat(getComputedStyle($some_button[0]).getPropertyValue("margin-top")) +
parseFloat(getComputedStyle($some_button[0]).getPropertyValue("margin-bottom"));
$palette.height(Math.ceil(palette.length / 2) * height_per_button);
} else {
const width_per_button =
$some_button.outerWidth() +
parseFloat(getComputedStyle($some_button[0]).getPropertyValue("margin-left")) +
parseFloat(getComputedStyle($some_button[0]).getPropertyValue("margin-right"));
$palette.width(Math.ceil(palette.length / 2) * width_per_button);
}
// the "last foreground color button" starts out as the first in the palette
$c.data("$last_fg_color_button", $palette.find(".color-button:first-child"));
};
let $c;
if (vertical) {
$c = $Component(localize("Colors"), "colors-component", "tall", $cb);
$c.appendTo(get_direction() === "rtl" ? $left : $right); // opposite ToolBox by default
} else {
$c = $Component(localize("Colors"), "colors-component", "wide", $cb);
$c.appendTo($bottom);
}
build_palette();
$(window).on("theme-change", build_palette);
$c.rebuild_palette = build_palette;
return $c;
}
exports.$ColorBox = $ColorBox;
exports.$Swatch = $Swatch;
exports.update_$swatch = update_$swatch;
})(window);

View File

@@ -0,0 +1,414 @@
((exports) => {
// Segments here represent UI components as far as a layout algorithm is concerned,
// line segments in one dimension (regardless of whether that dimension is vertical or horizontal),
// with a reference to the UI component DOM element so it can be updated.
function get_segments(component_area_el, pos_axis, exclude_component_el) {
const $other_components = $(component_area_el).find(".component").not(exclude_component_el);
return $other_components.toArray().map((component_el) => {
const segment = { element: component_el };
if (pos_axis === "top") {
segment.pos = component_el.offsetTop;
segment.length = component_el.clientHeight;
} else if (pos_axis === "left") {
segment.pos = component_el.offsetLeft;
segment.length = component_el.clientWidth;
} else if (pos_axis === "right") {
segment.pos = component_area_el.scrollWidth - component_el.offsetLeft;
segment.length = component_el.clientWidth;
}
return segment;
});
}
function adjust_segments(segments, total_available_length) {
segments.sort((a, b) => a.pos - b.pos);
// Clamp
for (const segment of segments) {
segment.pos = Math.max(segment.pos, 0);
segment.pos = Math.min(segment.pos, total_available_length - segment.length);
}
// Shove things downwards to prevent overlap
for (let i = 1; i < segments.length; i++) {
const segment = segments[i];
const prev_segment = segments[i - 1];
const overlap = prev_segment.pos + prev_segment.length - segment.pos;
if (overlap > 0) {
segment.pos += overlap;
}
}
// Clamp
for (const segment of segments) {
segment.pos = Math.max(segment.pos, 0);
segment.pos = Math.min(segment.pos, total_available_length - segment.length);
}
// Shove things upwards to get things back on screen
for (let i = segments.length - 2; i >= 0; i--) {
const segment = segments[i];
const prev_segment = segments[i + 1];
const overlap = segment.pos + segment.length - prev_segment.pos;
if (overlap > 0) {
segment.pos -= overlap;
}
}
}
function apply_segments(component_area_el, pos_axis, segments) {
// Since things aren't positioned absolutely, calculate space between
let length_before = 0;
for (const segment of segments) {
segment.margin_before = segment.pos - length_before;
length_before = segment.length + segment.pos;
}
// Apply to the DOM
for (const segment of segments) {
component_area_el.appendChild(segment.element);
$(segment.element).css(`margin-${pos_axis}`, segment.margin_before);
}
}
function $Component(title, className, orientation, $el) {
// A draggable widget that can be undocked into a window
const $c = $(E("div")).addClass("component");
$c.addClass(className);
$c.addClass(orientation);
$c.append($el);
$c.css("touch-action", "none");
const $w = new $ToolWindow($c);
$w.title(title);
$w.hide();
$w.$content.addClass({
tall: "vertical",
wide: "horizontal",
}[orientation]);
// Nudge the Colors component over a tiny bit
if (className === "colors-component" && orientation === "wide") {
$c.css("position", "relative");
$c.css(`margin-${get_direction() === "rtl" ? "right" : "left"}`, "3px");
}
let iid;
if ($("body").hasClass("eye-gaze-mode")) {
// @TODO: don't use an interval for this!
iid = setInterval(() => {
const scale = 3;
$c.css({
transform: `scale(${scale})`,
transformOrigin: "0 0",
marginRight: $c[0].scrollWidth * (scale - 1),
marginBottom: $c[0].scrollHeight * (scale - 1),
});
}, 200);
}
let ox, oy;
let ox2, oy2;
let w, h;
let pos = 0;
let pos_axis;
let last_docked_to_pos;
let $last_docked_to;
let $dock_to;
let $ghost;
if (orientation === "tall") {
pos_axis = "top";
} else if (get_direction() === "rtl") {
pos_axis = "right";
} else {
pos_axis = "left";
}
const dock_to = $dock_to => {
$w.hide();
// must get layout state *before* changing it
const segments = get_segments($dock_to[0], pos_axis, $c[0]);
// so we can measure clientWidth/clientHeight
$dock_to.append($c);
segments.push({
element: $c[0],
pos: pos,
length: $c[0][pos_axis === "top" ? "clientHeight" : "clientWidth"],
});
const total_available_length = pos_axis === "top" ? $dock_to.height() : $dock_to.width();
// console.log("before adjustment", JSON.stringify(segments, (_key,val)=> (val instanceof Element) ? val.className : val));
adjust_segments(segments, total_available_length);
// console.log("after adjustment", JSON.stringify(segments, (_key,val)=> (val instanceof Element) ? val.className : val));
apply_segments($dock_to[0], pos_axis, segments);
// Save where it's now docked to
$last_docked_to = $dock_to;
last_docked_to_pos = pos;
};
const undock_to = (x, y) => {
const component_area_el = $c.closest(".component-area")[0];
// must get layout state *before* changing it
const segments = get_segments(component_area_el, pos_axis, $c[0]);
$c.css("position", "relative");
$c.css(`margin-${pos_axis}`, "");
// Put the component in the window
$w.$content.append($c);
// Show and position the window
$w.show();
$w.css({
left: x,
top: y,
});
const total_available_length = pos_axis === "top" ? $(component_area_el).height() : $(component_area_el).width();
// console.log("before adjustment", JSON.stringify(segments, (_key,val)=> (val instanceof Element) ? val.className : val));
adjust_segments(segments, total_available_length);
// console.log("after adjustment", JSON.stringify(segments, (_key,val)=> (val instanceof Element) ? val.className : val));
apply_segments(component_area_el, pos_axis, segments);
};
$w.on("window-drag-start", (e) => {
e.preventDefault();
});
const imagine_window_dimensions = () => {
const prev_window_shown = $w.is(":visible");
$w.show();
let $spacer;
let { offsetLeft, offsetTop } = $c[0];
if ($c.closest(".tool-window").length == 0) {
const styles = getComputedStyle($c[0]);
$spacer = $(E("div")).addClass("component").css({
width: styles.width,
height: styles.height,
// don't copy margin, margin is actually used for positioning the components in the docking areas
// don't copy padding, padding changes based on whether the component is in a window in modern theme
// let padding be influenced by CSS
});
$w.append($spacer);
({ offsetLeft, offsetTop } = $spacer[0]);
}
const rect = $w[0].getBoundingClientRect();
if ($spacer) {
$spacer.remove();
}
if (!prev_window_shown) {
$w.hide();
}
const w_styles = getComputedStyle($w[0]);
offsetLeft += parseFloat(w_styles.borderLeftWidth);
offsetTop += parseFloat(w_styles.borderTopWidth);
return { rect, offsetLeft, offsetTop };
};
const imagine_docked_dimensions = ($dock_to = (pos_axis === "top" ? $left : $bottom)) => {
if ($c.closest(".tool-window").length == 0) {
return { rect: $c[0].getBoundingClientRect() };
}
const styles = getComputedStyle($c[0]);
const $spacer = $(E("div")).addClass("component").css({
width: styles.width,
height: styles.height,
flex: "0 0 auto",
});
$dock_to.prepend($spacer);
const rect = $spacer[0].getBoundingClientRect();
if ($spacer) {
$spacer.remove();
}
return { rect };
};
const render_ghost = (e) => {
const { rect } = $dock_to ? imagine_docked_dimensions($dock_to) : imagine_window_dimensions()
// Make sure these dimensions are odd numbers
// so the alternating pattern of the border is unbroken
w = (~~(rect.width / 2)) * 2 + 1;
h = (~~(rect.height / 2)) * 2 + 1;
if (!$ghost) {
$ghost = $(E("div")).addClass("component-ghost dock");
$ghost.appendTo("body");
}
const inset = $dock_to ? 0 : 3;
$ghost.css({
position: "absolute",
display: "block",
width: w - inset * 2,
height: h - inset * 2,
left: e.clientX + ($dock_to ? ox : ox2) + inset,
top: e.clientY + ($dock_to ? oy : oy2) + inset,
});
if ($dock_to) {
$ghost.addClass("dock");
} else {
$ghost.removeClass("dock");
}
};
$c.add($w.$titlebar).on("pointerdown", e => {
// Only start a drag via a left click directly on the component element or titlebar
if (e.button !== 0) { return; }
const validTarget =
$c.is(e.target) ||
(
$(e.target).closest($w.$titlebar).length > 0 &&
$(e.target).closest("button").length === 0
);
if (!validTarget) { return; }
// Don't allow dragging in eye gaze mode
if ($("body").hasClass("eye-gaze-mode")) { return; }
const docked = imagine_docked_dimensions();
const rect = $c[0].getBoundingClientRect();
ox = rect.left - e.clientX;
oy = rect.top - e.clientY;
ox = -Math.min(Math.max(-ox, 0), docked.rect.width);
oy = -Math.min(Math.max(-oy, 0), docked.rect.height);
const { offsetLeft, offsetTop } = imagine_window_dimensions();
ox2 = rect.left - offsetLeft - e.clientX;
oy2 = rect.top - offsetTop - e.clientY;
$("body").addClass("dragging");
$("body").css({ cursor: "default" }).addClass("cursor-bully");
$G.on("pointermove", drag_update_position);
$G.one("pointerup", e => {
$G.off("pointermove", drag_update_position);
drag_onpointerup(e);
$("body").removeClass("dragging");
$("body").css({ cursor: "" }).removeClass("cursor-bully");
$canvas.trigger("pointerleave"); // prevent magnifier preview showing until you move the mouse
});
render_ghost(e);
drag_update_position(e);
// Prevent text selection anywhere within the component
e.preventDefault();
});
const drag_update_position = e => {
$ghost.css({
left: e.clientX + ox,
top: e.clientY + oy,
});
$dock_to = null;
const { width, height } = imagine_docked_dimensions().rect;
const dock_ghost_left = e.clientX + ox;
const dock_ghost_top = e.clientY + oy;
const dock_ghost_right = dock_ghost_left + width;
const dock_ghost_bottom = dock_ghost_top + height;
const q = 5;
if (orientation === "tall") {
pos_axis = "top";
if (dock_ghost_left - q < $left[0].getBoundingClientRect().right) {
$dock_to = $left;
}
if (dock_ghost_right + q > $right[0].getBoundingClientRect().left) {
$dock_to = $right;
}
} else {
pos_axis = get_direction() === "rtl" ? "right" : "left";
if (dock_ghost_top - q < $top[0].getBoundingClientRect().bottom) {
$dock_to = $top;
}
if (dock_ghost_bottom + q > $bottom[0].getBoundingClientRect().top) {
$dock_to = $bottom;
}
}
if ($dock_to) {
const dock_to_rect = $dock_to[0].getBoundingClientRect();
pos = (
pos_axis === "top" ? dock_ghost_top : pos_axis === "right" ? dock_ghost_right : dock_ghost_left
) - dock_to_rect[pos_axis];
if (pos_axis === "right") {
pos *= -1;
}
}
render_ghost(e);
e.preventDefault();
};
const drag_onpointerup = e => {
$w.hide();
// If the component is docked to a component area (a side)
if ($c.parent().is(".component-area")) {
// Save where it's docked so we can dock back later
$last_docked_to = $c.parent();
if ($dock_to) {
last_docked_to_pos = pos;
}
}
if ($dock_to) {
// Dock component to $dock_to
dock_to($dock_to);
} else {
undock_to(e.clientX + ox2, e.clientY + oy2);
}
$ghost && $ghost.remove();
$ghost = null;
$G.trigger("resize");
};
$c.dock = ($dock_to) => {
pos = last_docked_to_pos ?? 0;
dock_to($dock_to ?? $last_docked_to);
};
$c.undock_to = undock_to;
$c.show = () => {
$($c[0]).show(); // avoid recursion
if ($.contains($w[0], $c[0])) {
$w.show();
}
return $c;
};
$c.hide = () => {
$c.add($w).hide();
return $c;
};
$c.toggle = () => {
if ($c.is(":visible")) {
$c.hide();
} else {
$c.show();
}
return $c;
};
$c.destroy = () => {
$w.close();
$c.remove();
clearInterval(iid);
};
$w.on("close", e => {
e.preventDefault();
$w.hide();
});
return $c;
}
exports.$Component = $Component;
})(window);

View File

@@ -0,0 +1,98 @@
((exports) => {
function $FontBox() {
const $fb = $(E("div")).addClass("font-box");
const $family = $(E("select")).addClass("inset-deep").attr({
"aria-label": "Font Family",
"aria-description": localize("Selects the font used by the text."),
});
const $size = $(E("input")).addClass("inset-deep").attr({
type: "number",
min: 8,
max: 72,
value: text_tool_font.size,
"aria-label": "Font Size",
"aria-description": localize("Selects the point size of the text."),
}).css({
maxWidth: 50,
});
const $button_group = $(E("span")).addClass("text-toolbar-button-group");
// @TODO: localized labels
const $bold = $Toggle(0, "bold", "Bold", localize("Sets or clears the text bold attribute."));
const $italic = $Toggle(1, "italic", "Italic", localize("Sets or clears the text italic attribute."));
const $underline = $Toggle(2, "underline", "Underline", localize("Sets or clears the text underline attribute."));
const $vertical = $Toggle(3, "vertical", "Vertical Writing Mode", localize("Only a Far East font can be used for vertical editing."));
$vertical.attr("disabled", true);
$button_group.append($bold, $italic, $underline, $vertical);
$fb.append($family, $size, $button_group);
const update_font = () => {
text_tool_font.size = Number($size.val());
text_tool_font.family = $family.val();
$G.trigger("option-changed");
};
FontDetective.each(font => {
const $option = $(E("option"));
$option.val(font).text(font.name);
$family.append($option);
if (!text_tool_font.family) {
update_font();
}
});
if (text_tool_font.family) {
$family.val(text_tool_font.family);
}
$family.on("change", update_font);
$size.on("change", update_font);
const $w = $ToolWindow();
$w.title(localize("Fonts"));
$w.$content.append($fb);
$w.center();
return $w;
function $Toggle(xi, thing, label, description) {
const $button = $(E("button")).addClass("toggle").attr({
"aria-pressed": false,
"aria-label": label,
"aria-description": description,
});
const $icon = $(E("span")).addClass("icon").appendTo($button);
$button.css({
width: 23,
height: 22,
padding: 0,
display: "inline-flex",
alignContent: "center",
alignItems: "center",
justifyContent: "center",
});
$icon.css({
flex: "0 0 auto",
display: "block",
width: 16,
height: 16,
"--icon-index": xi,
});
$button.on("click", () => {
$button.toggleClass("selected");
text_tool_font[thing] = $button.hasClass("selected");
$button.attr("aria-pressed", $button.hasClass("selected"));
update_font();
});
if (text_tool_font[thing]) {
$button.addClass("selected").attr("aria-pressed", true);
}
return $button;
}
}
exports.$FontBox = $FontBox;
})(window);

View File

@@ -0,0 +1,128 @@
((exports) => {
let theme_dev_blob_url;
function $ToolBox(tools, is_extras) {
const $tools = $(E("div")).addClass("tools");
const $tool_options = $(E("div")).addClass("tool-options");
let showing_tooltips = false;
$tools.on("pointerleave", () => {
showing_tooltips = false;
$status_text.default();
});
const $buttons = $($.map(tools, (tool, i) => {
const $b = $(E("div")).addClass("tool");
$b.appendTo($tools);
tool.$button = $b;
$b.attr("title", tool.name);
const $icon = $(E("span")).addClass("tool-icon");
$icon.appendTo($b);
const update_css = () => {
const use_svg = !theme_dev_blob_url && (
(
(window.devicePixelRatio >= 3 || (window.devicePixelRatio % 1) !== 0)
) ||
$("body").hasClass("eye-gaze-mode")
);
$icon.css({
display: "block",
position: "absolute",
left: 4,
top: 4,
width: 16,
height: 16,
backgroundImage: theme_dev_blob_url ? `url(${theme_dev_blob_url})` : "",
"--icon-index": i,
});
$icon.toggleClass("use-svg", use_svg);
};
update_css();
$G.on("theme-load resize", update_css);
$b.on("click", e => {
if (e.shiftKey || e.ctrlKey) {
select_tool(tool, true);
return;
}
if (selected_tool === tool && tool.deselect) {
select_tools(return_to_tools);
} else {
select_tool(tool);
}
});
$b.on("pointerenter", () => {
const show_tooltip = () => {
showing_tooltips = true;
$status_text.text(tool.description);
};
if (showing_tooltips) {
show_tooltip();
} else {
const tid = setTimeout(show_tooltip, 300);
$b.on("pointerleave", () => {
clearTimeout(tid);
});
}
});
return $b[0];
}));
const $c = $Component(
is_extras ? "Extra Tools" : localize("Tools"),
is_extras ? "tools-component extra-tools-component" : "tools-component",
"tall",
$tools.add($tool_options)
);
$c.appendTo(get_direction() === "rtl" ? $right : $left); // opposite ColorBox by default
$c.update_selected_tool = () => {
$buttons.removeClass("selected");
selected_tools.forEach((selected_tool) => {
selected_tool.$button.addClass("selected");
});
$tool_options.children().detach();
$tool_options.append(selected_tool.$options);
$tool_options.children().trigger("update");
$canvas.css({
cursor: make_css_cursor(...selected_tool.cursor),
});
};
$c.update_selected_tool();
if (is_extras) {
$c.height(80);
}
return $c;
}
let dev_theme_tool_icons = false;
try {
dev_theme_tool_icons = localStorage.dev_theme_tool_icons === "true";
// eslint-disable-next-line no-empty
} catch (e) { }
if (dev_theme_tool_icons) {
let last_update_id = 0;
$G.on("session-update", () => {
last_update_id += 1;
const this_update_id = last_update_id;
main_canvas.toBlob((blob) => {
// avoid a race condition particularly when loading the document initially when the default canvas size is large, giving a larger PNG
if (this_update_id !== last_update_id) {
return;
}
URL.revokeObjectURL(theme_dev_blob_url);
theme_dev_blob_url = URL.createObjectURL(blob);
$G.triggerHandler("theme-load");
});
});
}
exports.$ToolBox = $ToolBox;
})(window);

View File

@@ -0,0 +1,110 @@
((exports) => {
function make_window_supporting_scale(options) {
const $w = new $Window(options);
const scale_for_eye_gaze_mode_and_center = () => {
if (!$w.is(".edit-colors-window, .storage-manager, .attributes-window, .flip-and-rotate, .stretch-and-skew")) {
return;
}
const c = $w.$content[0];
const t = $w.$titlebar[0];
let scale = 1;
$w.$content.css({
transform: `scale(${scale})`,
transformOrigin: "0 0",
marginRight: "",
marginBottom: "",
});
if (document.body.classList.contains("eye-gaze-mode")) {
scale = Math.min(
(innerWidth) / c.offsetWidth,
(innerHeight - t.offsetHeight) / c.offsetHeight
);
$w.$content.css({
transform: `scale(${scale})`,
transformOrigin: "0 0",
marginRight: c.scrollWidth * (scale - 1),
});
// This is separate to prevent content going off the bottom of the window
// in case the layout changes due to text wrapping.
$w.$content.css({
marginBottom: c.scrollHeight * (scale - 1),
});
$w.center();
}
// for testing (WARNING: can cause rapid flashing, which can cause seizures):
// requestAnimationFrame(scale_for_eye_gaze_mode_and_center);
};
if (!options.$component) {
$w.center();
const scale_for_eye_gaze_mode_and_center_next_frame = () => {
requestAnimationFrame(scale_for_eye_gaze_mode_and_center);
};
const on_close = () => {
$w.off("close", on_close);
$G.off("eye-gaze-mode-toggled resize", scale_for_eye_gaze_mode_and_center_next_frame);
};
$w.on("close", on_close);
$G.on("eye-gaze-mode-toggled resize", scale_for_eye_gaze_mode_and_center_next_frame);
scale_for_eye_gaze_mode_and_center_next_frame();
}
if (options.$component) {
$w.$content.css({
contain: "none",
});
}
return $w;
}
function $ToolWindow($component) {
return make_window_supporting_scale({
$component,
toolWindow: true,
});
}
function $DialogWindow(title) {
const $w = make_window_supporting_scale({
title,
resizable: false,
maximizeButton: false,
minimizeButton: false,
// helpButton: @TODO
});
$w.addClass("dialog-window");
$w.$form = $(E("form")).appendTo($w.$content);
$w.$main = $(E("div")).appendTo($w.$form);
$w.$buttons = $(E("div")).appendTo($w.$form).addClass("button-group");
$w.$Button = (label, action) => {
const $b = $(E("button")).appendTo($w.$buttons).text(label);
$b.on("click", (e) => {
// prevent the form from submitting
// @TODO: instead, prevent the form's submit event
e.preventDefault();
action();
});
$b.on("pointerdown", () => {
$b.focus();
});
return $b;
};
return $w;
}
exports.$ToolWindow = $ToolWindow;
exports.$DialogWindow = $DialogWindow;
exports.make_window_supporting_scale = make_window_supporting_scale;
})(window);

View File

@@ -0,0 +1,221 @@
function Handles(options) {
const { $handles_container, $object_container } = options; // required
const outset = options.outset || 0;
const get_handles_offset_left = options.get_handles_offset_left || (() => 0);
const get_handles_offset_top = options.get_handles_offset_top || (() => 0);
const get_ghost_offset_left = options.get_ghost_offset_left || (() => 0);
const get_ghost_offset_top = options.get_ghost_offset_top || (() => 0);
const size_only = options.size_only || false;
const HANDLE_MIDDLE = 0;
const HANDLE_START = -1;
const HANDLE_END = 1;
const HANDLE_LEFT = HANDLE_START;
const HANDLE_RIGHT = HANDLE_END;
const HANDLE_TOP = HANDLE_START;
const HANDLE_BOTTOM = HANDLE_END;
const $resize_ghost = $(E("div")).addClass("resize-ghost");
if (options.thick) {
$resize_ghost.addClass("thick");
}
const handles = [];
[
[HANDLE_TOP, HANDLE_RIGHT], // ↗
[HANDLE_TOP, HANDLE_MIDDLE], // ↑
[HANDLE_TOP, HANDLE_LEFT], // ↖
[HANDLE_MIDDLE, HANDLE_LEFT], // ←
[HANDLE_BOTTOM, HANDLE_LEFT], // ↙
[HANDLE_BOTTOM, HANDLE_MIDDLE], // ↓
[HANDLE_BOTTOM, HANDLE_RIGHT], // ↘
[HANDLE_MIDDLE, HANDLE_RIGHT], // →
].forEach(([y_axis, x_axis]) => {
const $h = $(E("div")).addClass("handle");
$h.appendTo($handles_container);
const $grab_region = $(E("div")).addClass("grab-region").appendTo($handles_container);
if (y_axis === HANDLE_MIDDLE || x_axis === HANDLE_MIDDLE) {
$grab_region.addClass("is-middle");
}
$h.css("touch-action", "none");
let rect;
let dragged = false;
const resizes_height = y_axis !== HANDLE_MIDDLE;
const resizes_width = x_axis !== HANDLE_MIDDLE;
if (size_only && (y_axis === HANDLE_TOP || x_axis === HANDLE_LEFT)) {
$h.addClass("useless-handle");
$grab_region.remove();
} else {
let cursor_fname;
if ((x_axis === HANDLE_LEFT && y_axis === HANDLE_TOP) || (x_axis === HANDLE_RIGHT && y_axis === HANDLE_BOTTOM)) {
cursor_fname = "nwse-resize";
} else if ((x_axis === HANDLE_RIGHT && y_axis === HANDLE_TOP) || (x_axis === HANDLE_LEFT && y_axis === HANDLE_BOTTOM)) {
cursor_fname = "nesw-resize";
} else if (resizes_width) {
cursor_fname = "ew-resize";
} else if (resizes_height) {
cursor_fname = "ns-resize";
}
let fallback_cursor = "";
if (y_axis === HANDLE_TOP) { fallback_cursor += "n"; }
if (y_axis === HANDLE_BOTTOM) { fallback_cursor += "s"; }
if (x_axis === HANDLE_LEFT) { fallback_cursor += "w"; }
if (x_axis === HANDLE_RIGHT) { fallback_cursor += "e"; }
fallback_cursor += "-resize";
const cursor = make_css_cursor(cursor_fname, [16, 16], fallback_cursor);
$h.add($grab_region).css({ cursor });
const drag = (event) => {
$resize_ghost.appendTo($object_container);
dragged = true;
rect = options.get_rect();
const m = to_canvas_coords(event);
let delta_x = 0;
let delta_y = 0;
let width, height;
// @TODO: decide between Math.floor/Math.ceil/Math.round for these values
if (x_axis === HANDLE_RIGHT) {
delta_x = 0;
width = ~~(m.x - rect.x);
} else if (x_axis === HANDLE_LEFT) {
delta_x = ~~(m.x - rect.x);
width = ~~(rect.x + rect.width - m.x);
} else {
width = ~~(rect.width);
}
if (y_axis === HANDLE_BOTTOM) {
delta_y = 0;
height = ~~(m.y - rect.y);
} else if (y_axis === HANDLE_TOP) {
delta_y = ~~(m.y - rect.y);
height = ~~(rect.y + rect.height - m.y);
} else {
height = ~~(rect.height);
}
let new_rect = {
x: rect.x + delta_x,
y: rect.y + delta_y,
width: width,
height: height,
};
new_rect.width = Math.max(1, new_rect.width);
new_rect.height = Math.max(1, new_rect.height);
if (options.constrain_rect) {
new_rect = options.constrain_rect(new_rect, x_axis, y_axis);
} else {
new_rect.x = Math.min(new_rect.x, rect.x + rect.width);
new_rect.y = Math.min(new_rect.y, rect.y + rect.height);
}
$resize_ghost.css({
position: "absolute",
left: magnification * new_rect.x + get_ghost_offset_left(),
top: magnification * new_rect.y + get_ghost_offset_top(),
width: magnification * new_rect.width - 2,
height: magnification * new_rect.height - 2,
});
rect = new_rect;
};
$h.add($grab_region).on("pointerdown", event => {
dragged = false;
if (event.button === 0) {
$G.on("pointermove", drag);
$("body").css({ cursor }).addClass("cursor-bully");
}
$G.one("pointerup", () => {
$G.off("pointermove", drag);
$("body").css({ cursor: "" }).removeClass("cursor-bully");
$resize_ghost.remove();
if (dragged) {
options.set_rect(rect);
}
$handles_container.trigger("update");
});
});
$h.on("mousedown selectstart", event => {
event.preventDefault();
});
}
const update_handle = () => {
const rect = options.get_rect();
const hs = $h.width();
// const x = rect.x + get_handles_offset_left();
// const y = rect.y + get_handles_offset_top();
const x = get_handles_offset_left();
const y = get_handles_offset_top();
const grab_size = 32;
for ({ len_key, pos_key, region, offset } of [
{ len_key: "width", pos_key: "left", region: x_axis, offset: x },
{ len_key: "height", pos_key: "top", region: y_axis, offset: y },
]) {
let middle_start = Math.max(
rect[len_key] * magnification / 2 - grab_size / 2,
Math.min(
grab_size / 2,
rect[len_key] * magnification / 3
)
);
let middle_end = rect[len_key] * magnification - middle_start;
if (middle_end - middle_start < magnification) {
// give middle region min size of one (1) canvas pixel
middle_start = 0;
middle_end = magnification;
}
const start_start = -grab_size / 2;
const start_end = Math.min(
grab_size / 2,
middle_start
);
const end_start = rect[len_key] * magnification - start_end;
const end_end = rect[len_key] * magnification - start_start;
if (size_only) {
// For main canvas handles, where only the right/bottom handles are interactive,
// extend the middle regions left/up into the unused space of the useless handles.
// (This must be after middle_start is used above.)
middle_start = Math.max(-offset, Math.min(middle_start, middle_end - grab_size));
}
if (region === HANDLE_START) {
$h.css({ [pos_key]: offset - outset });
$grab_region.css({
[pos_key]: offset + start_start,
[len_key]: start_end - start_start,
});
} else if (region === HANDLE_MIDDLE) {
$h.css({ [pos_key]: offset + (rect[len_key] * magnification - hs) / 2 });
$grab_region.css({
[pos_key]: offset + middle_start,
[len_key]: middle_end - middle_start,
});
} else if (region === HANDLE_END) {
$h.css({ [pos_key]: offset + (rect[len_key] * magnification - hs / 2) });
$grab_region.css({
[pos_key]: offset + end_start,
[len_key]: end_end - end_start,
});
}
}
};
$handles_container.on("update resize scroll", update_handle);
$G.on("resize theme-load", update_handle);
setTimeout(update_handle, 50);
handles.push($h[0], $grab_region[0]);
});
this.handles = handles;
// It shouldn't scroll when hiding/showing handles, so don't use jQuery hide/show or CSS display.
this.hide = () => { $(handles).css({ opacity: 0, pointerEvents: "none" }); };
this.show = () => { $(handles).css({ opacity: "", pointerEvents: "" }); };
}

View File

@@ -0,0 +1,13 @@
class OnCanvasHelperLayer extends OnCanvasObject {
constructor(x, y, width, height, hideMainCanvasHandles, pixelRatio = 1) {
super(x, y, width, height, hideMainCanvasHandles);
this.$el.addClass("helper-layer");
this.$el.css({
pointerEvents: "none",
});
this.position();
this.canvas = make_canvas(this.width * pixelRatio, this.height * pixelRatio);
this.$el.append(this.canvas);
}
}

View File

@@ -0,0 +1,44 @@
class OnCanvasObject {
constructor(x, y, width, height, hideMainCanvasHandles) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.hideMainCanvasHandles = hideMainCanvasHandles;
this.$el = $(E("div")).addClass("on-canvas-object").appendTo($canvas_area);
if (this.hideMainCanvasHandles) {
canvas_handles.hide();
}
$G.on("resize theme-load", this._global_resize_handler = () => {
this.position();
});
}
position(updateStatus) {
// Nevermind, canvas, isn't aligned to the right in RTL layout!
// const direction = get_direction();
// const left_for_ltr = direction === "rtl" ? "right" : "left";
// const offset_left = parseFloat($canvas_area.css(`padding-${left_for_ltr}`));
const offset_left = parseFloat($canvas_area.css(`padding-left`));
const offset_top = parseFloat($canvas_area.css("padding-top"));
this.$el.css({
position: "absolute",
// [left_for_ltr]: magnification * (direction === "rtl" ? canvas.width - this.width - this.x : this.x) + offset_left,
left: magnification * this.x + offset_left,
top: magnification * this.y + offset_top,
width: magnification * this.width,
height: magnification * this.height,
});
if (updateStatus) {
$status_position.text(`${this.x},${this.y}`);
$status_size.text(`${this.width},${this.height}`);
}
}
destroy() {
this.$el.remove();
if (this.hideMainCanvasHandles) {
canvas_handles.show();
}
$G.off("resize theme-load", this._global_resize_handler);
}
}

View File

@@ -0,0 +1,294 @@
class OnCanvasSelection extends OnCanvasObject {
constructor(x, y, width, height, img_or_canvas) {
super(x, y, width, height, true);
this.$el.addClass("selection");
let last_tool_transparent_mode = tool_transparent_mode;
let last_background_color = selected_colors.background;
this._on_option_changed = () => {
if (!this.source_canvas) {
return;
}
if (last_tool_transparent_mode !== tool_transparent_mode ||
last_background_color !== selected_colors.background) {
last_tool_transparent_mode = tool_transparent_mode;
last_background_color = selected_colors.background;
this.update_tool_transparent_mode();
}
};
$G.on("option-changed", this._on_option_changed);
this.instantiate(img_or_canvas);
}
position() {
super.position(true);
update_helper_layer(); // @TODO: under-grid specific helper layer?
}
instantiate(img_or_canvas) {
this.$el.css({
cursor: make_css_cursor("move", [8, 8], "move"),
touchAction: "none",
});
this.position();
const instantiate = () => {
if (img_or_canvas) {
// (this applies when pasting a selection)
// NOTE: need to create a Canvas because something about imgs makes dragging not work with magnification
// (width vs naturalWidth?)
// and at least apply_image_transformation needs it to be a canvas now (and the property name says canvas anyways)
this.source_canvas = make_canvas(img_or_canvas);
// @TODO: is this width/height code needed? probably not! wouldn't it clear the canvas anyways?
// but maybe we should assert in some way that the widths are the same, or resize the selection?
if (this.source_canvas.width !== this.width) {
this.source_canvas.width = this.width;
}
if (this.source_canvas.height !== this.height) {
this.source_canvas.height = this.height;
}
this.canvas = make_canvas(this.source_canvas);
}
else {
this.source_canvas = make_canvas(this.width, this.height);
this.source_canvas.ctx.drawImage(main_canvas, this.x, this.y, this.width, this.height, 0, 0, this.width, this.height);
this.canvas = make_canvas(this.source_canvas);
this.cut_out_background();
}
this.$el.append(this.canvas);
this.handles = new Handles({
$handles_container: this.$el,
$object_container: $canvas_area,
outset: 2,
get_rect: () => ({ x: this.x, y: this.y, width: this.width, height: this.height }),
set_rect: ({ x, y, width, height }) => {
undoable({
name: "Resize Selection",
icon: get_icon_for_tool(get_tool_by_id(TOOL_SELECT)),
soft: true,
}, () => {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.position();
this.resize();
});
},
get_ghost_offset_left: () => parseFloat($canvas_area.css("padding-left")) + 1,
get_ghost_offset_top: () => parseFloat($canvas_area.css("padding-top")) + 1,
});
let mox, moy;
const pointermove = e => {
make_or_update_undoable({
match: (history_node) =>
(e.shiftKey && history_node.name.match(/^(Smear|Stamp|Move) Selection$/)) ||
(!e.shiftKey && history_node.name.match(/^Move Selection$/)),
name: e.shiftKey ? "Smear Selection" : "Move Selection",
update_name: true,
icon: get_icon_for_tool(get_tool_by_id(TOOL_SELECT)),
soft: true,
}, () => {
const m = to_canvas_coords(e);
this.x = Math.max(Math.min(m.x - mox, main_canvas.width), -this.width);
this.y = Math.max(Math.min(m.y - moy, main_canvas.height), -this.height);
this.position();
if (e.shiftKey) {
// Smear selection
this.draw();
}
});
};
this.canvas_pointerdown = e => {
e.preventDefault();
const rect = this.canvas.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
mox = ~~(cx / rect.width * this.canvas.width);
moy = ~~(cy / rect.height * this.canvas.height);
$G.on("pointermove", pointermove);
this.dragging = true;
update_helper_layer(); // for thumbnail, which draws textbox outline if it's not being dragged
$G.one("pointerup", () => {
$G.off("pointermove", pointermove);
this.dragging = false;
update_helper_layer(); // for thumbnail, which draws selection outline if it's not being dragged
});
if (e.shiftKey) {
// Stamp or start to smear selection
undoable({
name: "Stamp Selection",
icon: get_icon_for_tool(get_tool_by_id(TOOL_SELECT)),
soft: true,
}, () => {
this.draw();
});
}
// @TODO: how should this work for macOS? where ctrl+click = secondary click?
else if (e.ctrlKey) {
// Stamp selection
undoable({
name: "Stamp Selection",
icon: get_icon_for_tool(get_tool_by_id(TOOL_SELECT)),
soft: true,
}, () => {
this.draw();
});
}
};
$(this.canvas).on("pointerdown", this.canvas_pointerdown);
$canvas_area.trigger("resize");
$status_position.text("");
$status_size.text("");
};
instantiate();
}
cut_out_background() {
const cutout = this.canvas;
// doc/this or canvas/cutout, either of those pairs would result in variable names of equal length which is nice :)
const canvasImageData = main_ctx.getImageData(this.x, this.y, this.width, this.height);
const cutoutImageData = cutout.ctx.getImageData(0, 0, this.width, this.height);
// cutoutImageData is initialized with the shape to be cut out (whether rectangular or polygonal)
// and should end up as the cut out image data for the selection
// canvasImageData is initially the portion of image data on the canvas,
// and should end up as... the portion of image data on the canvas that it should end up as.
// @TODO: could simplify by making the later (shared) condition just if (colored_cutout)
// but might change how it works anyways so whatever
// if (!transparency) { // now if !transparency or if tool_transparent_mode
// this is mainly in order to support patterns as the background color
// NOTE: must come before cutout canvas is modified
const colored_cutout = make_canvas(cutout);
replace_colors_with_swatch(colored_cutout.ctx, selected_colors.background, this.x, this.y);
// const colored_cutout_image_data = colored_cutout.ctx.getImageData(0, 0, this.width, this.height);
// }
for (let i = 0; i < cutoutImageData.data.length; i += 4) {
const in_cutout = cutoutImageData.data[i + 3] > 0;
if (in_cutout) {
cutoutImageData.data[i + 0] = canvasImageData.data[i + 0];
cutoutImageData.data[i + 1] = canvasImageData.data[i + 1];
cutoutImageData.data[i + 2] = canvasImageData.data[i + 2];
cutoutImageData.data[i + 3] = canvasImageData.data[i + 3];
canvasImageData.data[i + 0] = 0;
canvasImageData.data[i + 1] = 0;
canvasImageData.data[i + 2] = 0;
canvasImageData.data[i + 3] = 0;
}
else {
cutoutImageData.data[i + 0] = 0;
cutoutImageData.data[i + 1] = 0;
cutoutImageData.data[i + 2] = 0;
cutoutImageData.data[i + 3] = 0;
}
}
main_ctx.putImageData(canvasImageData, this.x, this.y);
cutout.ctx.putImageData(cutoutImageData, 0, 0);
this.update_tool_transparent_mode();
// NOTE: in case you want to use the tool_transparent_mode
// in a document with transparency (for an operation in an area where there's a local background color)
// (and since currently switching to the opaque document mode makes the image opaque)
// (and it would be complicated to make it update the canvas when switching tool options (as opposed to just the selection))
// I'm having it use the tool_transparent_mode option here, so you could at least choose beforehand
// (and this might actually give you more options, although it could be confusingly inconsistent)
// @FIXME: yeah, this is confusing; if you have both transparency modes on and you try to clear an area to transparency, it doesn't work
// and there's no indication that you should try the other selection transparency mode,
// and even if you do, if you do it after creating a selection, it still won't work,
// because you will have already *not cut out* the selection from the canvas
if (!transparency || tool_transparent_mode) {
main_ctx.drawImage(colored_cutout, this.x, this.y);
}
$G.triggerHandler("session-update"); // autosave
update_helper_layer();
}
update_tool_transparent_mode() {
const sourceImageData = this.source_canvas.ctx.getImageData(0, 0, this.width, this.height);
const cutoutImageData = this.canvas.ctx.createImageData(this.width, this.height);
const background_color_rgba = get_rgba_from_color(selected_colors.background);
// NOTE: In b&w mode, mspaint treats the transparency color as white,
// regardless of the pattern selected, even if the selected background color is pure black.
// We allow any kind of image data while in our "b&w mode".
// Our b&w mode is essentially 'patterns in the palette'.
const match_threshold = 1; // 1 is just enough for a workaround for Brave browser's farbling: https://github.com/1j01/jspaint/issues/184
for (let i = 0; i < cutoutImageData.data.length; i += 4) {
let in_cutout = sourceImageData.data[i + 3] > 1;
if (tool_transparent_mode) {
// @FIXME: work with transparent selected background color
// (support treating partially transparent background colors as transparency)
if (
Math.abs(sourceImageData.data[i + 0] - background_color_rgba[0]) <= match_threshold &&
Math.abs(sourceImageData.data[i + 1] - background_color_rgba[1]) <= match_threshold &&
Math.abs(sourceImageData.data[i + 2] - background_color_rgba[2]) <= match_threshold &&
Math.abs(sourceImageData.data[i + 3] - background_color_rgba[3]) <= match_threshold
) {
in_cutout = false;
}
}
if (in_cutout) {
cutoutImageData.data[i + 0] = sourceImageData.data[i + 0];
cutoutImageData.data[i + 1] = sourceImageData.data[i + 1];
cutoutImageData.data[i + 2] = sourceImageData.data[i + 2];
cutoutImageData.data[i + 3] = sourceImageData.data[i + 3];
}
else {
// cutoutImageData.data[i+0] = 0;
// cutoutImageData.data[i+1] = 0;
// cutoutImageData.data[i+2] = 0;
// cutoutImageData.data[i+3] = 0;
}
}
this.canvas.ctx.putImageData(cutoutImageData, 0, 0);
update_helper_layer();
}
// @TODO: should Image > Invert apply to this.source_canvas or to this.canvas (replacing this.source_canvas with the result)?
replace_source_canvas(new_source_canvas) {
this.source_canvas = new_source_canvas;
const new_canvas = make_canvas(new_source_canvas);
$(this.canvas).replaceWith(new_canvas);
this.canvas = new_canvas;
const center_x = this.x + this.width / 2;
const center_y = this.y + this.height / 2;
const new_width = new_canvas.width;
const new_height = new_canvas.height;
// NOTE: flooring the coordinates to integers avoids blurring
// but it introduces "inching", where the selection can move along by pixels if you rotate it repeatedly
// could introduce an "error offset" just to avoid this but that seems overkill
// and then that would be weird hidden behavior, probably not worth it
// Math.round() might make it do it on fewer occasions(?),
// but then it goes down *and* to the right, 2 directions vs One Direction
// and Math.ceil() is the worst of both worlds
this.x = ~~(center_x - new_width / 2);
this.y = ~~(center_y - new_height / 2);
this.width = new_width;
this.height = new_height;
this.position();
$(this.canvas).on("pointerdown", this.canvas_pointerdown);
this.$el.triggerHandler("resize"); //?
this.update_tool_transparent_mode();
}
resize() {
const new_source_canvas = make_canvas(this.width, this.height);
new_source_canvas.ctx.drawImage(this.source_canvas, 0, 0, this.width, this.height);
this.replace_source_canvas(new_source_canvas);
}
scale(factor) {
const new_width = Math.max(1, this.width * factor);
const new_height = Math.max(1, this.height * factor);
const new_source_canvas = make_canvas(new_width, new_height);
new_source_canvas.ctx.drawImage(this.source_canvas, 0, 0, new_source_canvas.width, new_source_canvas.height);
this.replace_source_canvas(new_source_canvas);
}
draw() {
try {
main_ctx.drawImage(this.canvas, this.x, this.y);
}
// eslint-disable-next-line no-empty
catch (e) { }
}
destroy() {
super.destroy();
$G.off("option-changed", this._on_option_changed);
update_helper_layer(); // @TODO: under-grid specific helper layer?
}
}

View File

@@ -0,0 +1,286 @@
class OnCanvasTextBox extends OnCanvasObject {
constructor(x, y, width, height, starting_text) {
super(x, y, width, height, true);
this.$el.addClass("textbox");
this.$editor = $(E("textarea")).addClass("textbox-editor");
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("version", 1.1);
var foreignObject = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
foreignObject.setAttribute("x", 0);
foreignObject.setAttribute("y", 0);
svg.append(foreignObject);
// inline styles so that they'll be serialized for the SVG
this.$editor.css({
position: "absolute",
left: "0",
top: "0",
right: "0",
bottom: "0",
padding: "0",
margin: "0",
border: "0",
resize: "none",
overflow: "hidden",
minWidth: "3em",
});
var edit_textarea = this.$editor[0];
var render_textarea = edit_textarea.cloneNode(false);
foreignObject.append(render_textarea);
edit_textarea.value = starting_text || "";
this.canvas = make_canvas(width, height);
this.canvas.style.pointerEvents = "none";
this.$el.append(this.canvas);
const update_size = () => {
this.position();
this.$el.triggerHandler("update"); // update handles
this.$editor.add(render_textarea).css({
width: this.width,
height: this.height,
});
};
const auto_size = () => {
// Auto-expand, and apply minimum size.
edit_textarea.style.height = "";
edit_textarea.style.minHeight = "0px";
edit_textarea.style.bottom = ""; // needed for when magnified
edit_textarea.setAttribute("rows", 1);
this.height = Math.max(edit_textarea.scrollHeight, this.height);
edit_textarea.removeAttribute("rows");
this.width = edit_textarea.scrollWidth;
edit_textarea.style.bottom = "0"; // doesn't seem to be needed?
// always needs to update at least this.$editor, since style.height is reset above
update_size();
};
const update = () => {
requestAnimationFrame(() => {
edit_textarea.scrollTop = 0; // prevent scrolling edit textarea to keep in sync
});
const font = text_tool_font;
const get_solid_color = (swatch) => `rgba(${get_rgba_from_color(swatch).join(", ")}`;
font.color = get_solid_color(selected_colors.foreground);
font.background = tool_transparent_mode ? "transparent" : get_solid_color(selected_colors.background);
this.$editor.add(this.canvas).css({
transform: `scale(${magnification})`,
transformOrigin: "left top",
});
this.$editor.add(render_textarea).css({
width: this.width,
height: this.height,
fontFamily: font.family,
fontSize: `${font.size}pt`,
fontWeight: font.bold ? "bold" : "normal",
fontStyle: font.italic ? "italic" : "normal",
textDecoration: font.underline ? "underline" : "none",
writingMode: font.vertical ? "vertical-lr" : "",
MsWritingMode: font.vertical ? "vertical-lr" : "",
WebkitWritingMode: font.vertical ? "vertical-lr" : "",
lineHeight: `${font.size * font.line_scale}px`,
color: font.color,
background: font.background,
});
// Must be after font is updated, since the minimum size depends on the font size.
auto_size();
while (render_textarea.firstChild) {
render_textarea.removeChild(render_textarea.firstChild);
}
render_textarea.appendChild(document.createTextNode(edit_textarea.value));
svg.setAttribute("width", this.width);
svg.setAttribute("height", this.height);
foreignObject.setAttribute("width", this.width);
foreignObject.setAttribute("height", this.height);
var svg_source = new XMLSerializer().serializeToString(svg);
var data_url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg_source)}`;
var img = new Image();
img.onload = () => {
this.canvas.width = this.width;
this.canvas.height = this.height;
this.canvas.ctx.drawImage(img, 0, 0);
update_helper_layer(); // @TODO: under-grid specific helper layer?
};
img.onerror = (event) => {
window.console && console.log("Failed to load image", event);
};
img.src = data_url;
};
$G.on("option-changed", this._on_option_changed = update);
this.$editor.on("input", this._on_input = update);
this.$editor.on("scroll", this._on_scroll = () => {
requestAnimationFrame(() => {
edit_textarea.scrollTop = 0; // prevent scrolling edit textarea to keep in sync
});
});
this.$el.css({
cursor: make_css_cursor("move", [8, 8], "move"),
touchAction: "none",
});
this.position();
this.$el.append(this.$editor);
this.$editor[0].focus();
this.handles = new Handles({
$handles_container: this.$el,
$object_container: $canvas_area,
outset: 2,
thick: true,
get_rect: () => ({ x: this.x, y: this.y, width: this.width, height: this.height }),
set_rect: ({ x, y, width, height }) => {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.position();
update();
// clear canvas to avoid an occasional flash of the old canvas (with old size) in the new position
// (trade it off for a flash of the background behind the textbox)
this.canvas.width = width;
this.canvas.height = height;
},
constrain_rect: ({ x, y, width, height }, x_axis, y_axis) => {
// remember dimensions
const old_x = this.x;
const old_y = this.y;
const old_width = this.width;
const old_height = this.height;
// apply prospective new dimensions
this.x = x;
this.y = y;
this.width = width;
this.height = height;
update_size();
// apply constraints
auto_size();
// prevent free movement via resize
if (x_axis === -1) {
x = Math.min(this.x, old_x + old_width - this.width);
}
if (y_axis === -1) {
y = Math.min(this.y, old_y + old_height - this.height);
}
// remember constrained dimensions
width = this.width;
height = this.height;
// reset
this.x = old_x;
this.y = old_y;
this.width = old_width;
this.height = old_height;
update_size();
return { x, y, width, height };
},
get_ghost_offset_left: () => parseFloat($canvas_area.css("padding-left")) + 1,
get_ghost_offset_top: () => parseFloat($canvas_area.css("padding-top")) + 1,
});
let mox, moy; // mouse offset
const pointermove = e => {
const m = to_canvas_coords(e);
this.x = Math.max(Math.min(m.x - mox, main_canvas.width), -this.width);
this.y = Math.max(Math.min(m.y - moy, main_canvas.height), -this.height);
this.position();
if (e.shiftKey) {
// @TODO: maybe re-enable but handle undoables well
// this.draw();
}
};
this.$el.on("pointerdown", e => {
if (e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target.classList.contains("handle") ||
e.target.classList.contains("grab-region")) {
return;
}
e.preventDefault();
const rect = this.$el[0].getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
mox = ~~(cx / rect.width * this.canvas.width);
moy = ~~(cy / rect.height * this.canvas.height);
this.dragging = true;
update_helper_layer(); // for thumbnail, which draws textbox outline if it's not being dragged
$G.on("pointermove", pointermove);
$G.one("pointerup", () => {
$G.off("pointermove", pointermove);
this.dragging = false;
update_helper_layer(); // for thumbnail, which draws textbox outline if it's not being dragged
});
});
$status_position.text("");
$status_size.text("");
$canvas_area.trigger("resize"); // to update handles, get them to hide?
if (OnCanvasTextBox.$fontbox && OnCanvasTextBox.$fontbox.closed) {
OnCanvasTextBox.$fontbox = null;
}
const $fb = OnCanvasTextBox.$fontbox = OnCanvasTextBox.$fontbox || new $FontBox();
const displace_font_box = () => {
// move the font box out of the way if it's overlapping the OnCanvasTextBox
const fb_rect = $fb[0].getBoundingClientRect();
const tb_rect = this.$el[0].getBoundingClientRect();
if (
// the fontbox overlaps textbox
fb_rect.left <= tb_rect.right &&
tb_rect.left <= fb_rect.right &&
fb_rect.top <= tb_rect.bottom &&
tb_rect.top <= fb_rect.bottom
) {
// move the font box out of the way
$fb.css({
top: this.$el.position().top - $fb.height()
});
}
$fb.applyBounds();
};
// must be after textbox is in the DOM
update();
displace_font_box();
// In case a software keyboard opens, like Optikey for eye gaze / head tracking users,
// or perhaps a handwriting input for pen tablet users, or *partially* for mobile browsers.
// Mobile browsers generally scroll the view for a textbox well enough, but
// don't include the custom behavior of moving the font box out of the way.
$(window).on("resize", this._on_window_resize = () => {
this.$editor[0].scrollIntoView({ block: 'nearest', inline: 'nearest' });
displace_font_box();
});
}
position() {
super.position(true);
update_helper_layer(); // @TODO: under-grid specific helper layer?
}
destroy() {
super.destroy();
if (OnCanvasTextBox.$fontbox && !OnCanvasTextBox.$fontbox.closed) {
OnCanvasTextBox.$fontbox.close();
}
OnCanvasTextBox.$fontbox = null;
$G.off("option-changed", this._on_option_changed);
this.$editor.off("input", this._on_input);
this.$editor.off("scroll", this._on_scroll);
$(window).off("resize", this._on_window_resize);
update_helper_layer(); // @TODO: under-grid specific helper layer?
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,633 @@
((exports) => {
// @TODO:
// - Persist custom colors list across reloads? It's not very persistent in real Windows...
// - OK with Enter, after selecting a focused color if applicable
// - maybe use https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/Grid_Role
// - Question mark button in titlebar that lets you click on parts of UI to ask about them; also context menu "What's this?"
// - For mobile layout, maybe add a way to get back (<<) without adding (potentially overwriting) a custom color
// - Speech recognition
// - Lum as Luminosity, Luminance, Lightness, maybe even Brightness
// - Sat as Saturation
// - Add / Add Color / Add Custom Color for Add To Custom Colors or if not available then Define Custom Colors >>
// - Set green to 50 etc.
// In Windows, the Hue goes from 0 to 239 (240 being equivalent to 0), and Sat and Lum go from 0 to 240
// I think people are more familiar with degrees and percentages, so I don't think I'll be implementing that.
// Development workflow:
// - In the console, set localStorage.dev_edit_colors = "true";
// - Reload the page
// - Load a screenshot of the Edit Colors window into the editor
// - Position it finely using the arrow keys on a selection
// - For measuring positions, look at the Windows source code OR:
// - close the window,
// - point on the canvas, mark down the coordinates shown in status bar,
// - point on the canvas at the origin
// - the top left of the inside of the window, or
// - the top left of (what corresponds to) the nearest parent position:fixed/absolute/relative
// - subtract the origin from the target
let $edit_colors_window;
let dev_edit_colors = false;
try {
dev_edit_colors = localStorage.dev_edit_colors === "true";
// eslint-disable-next-line no-empty
} catch (error) { }
if (dev_edit_colors) {
$(() => {
show_edit_colors_window();
$(".define-custom-colors-button").click();
$edit_colors_window.css({
left: 80,
top: 50,
opacity: 0.5,
});
});
}
// Paint-specific handling of color picking
// Note: It always updates a cell in the palette and one of the color selections.
// When the dialog is opened, it always starts* with one of the color selections,
// which lets you use the color picker and then add a custom color based on that.
// *It may not show the color in the grid, but it will in the custom colors area.
function show_edit_colors_window($swatch_to_edit, color_selection_slot_to_edit) {
// console.log($swatch_to_edit, $colorbox.data("$last_fg_color_button"));
$swatch_to_edit = $swatch_to_edit || $colorbox.data("$last_fg_color_button");
color_selection_slot_to_edit = color_selection_slot_to_edit || "foreground";
const $palette = $swatch_to_edit.closest(".palette, .color-box");
const swatch_index = $palette.find(".swatch").toArray().indexOf($swatch_to_edit[0]);
const initial_color = selected_colors[color_selection_slot_to_edit];
choose_color(initial_color, (color) => {
// The palette may have changed or rerendered due to switching themes,
// toggling vertical color box mode, or monochrome document mode.
$swatch_to_edit = $($palette.find(".swatch")[swatch_index]);
if (!$swatch_to_edit.length) {
show_error_message("Swatch no longer exists.");
return;
}
if (monochrome && (swatch_index === 0 || swatch_index === 14)) {
// when editing first color in first or second row (the solid base colors),
// update whole monochrome patterns palette and image
let old_rgba = get_rgba_from_color(palette[swatch_index]);
const new_rgba = get_rgba_from_color(color);
const other_rgba = get_rgba_from_color(palette[14 - swatch_index]);
const main_monochrome_info = detect_monochrome(main_ctx);
const selection_monochrome_info = (selection && selection.canvas) ? detect_monochrome(selection.canvas.ctx) : main_monochrome_info;
const selection_matches_main_canvas_colors =
selection_monochrome_info.isMonochrome &&
selection_monochrome_info.presentNonTransparentRGBAs.every((rgba) =>
main_monochrome_info.presentNonTransparentRGBAs.map(rgba => rgba.toString()).includes(rgba.toString())
);
if (
main_monochrome_info.isMonochrome &&
selection_monochrome_info.isMonochrome &&
selection_matches_main_canvas_colors
) {
const recolor = (ctx, present_rgbas) => {
// HTML5 Canvas API is unreliable for exact colors.
// 1. The specifications specify unpremultiplied alpha, but in practice browsers use premultiplied alpha for performance.
// 2. Some browsers implement protections against fingerprinting that return slightly random data
// 3. There may be color profiles that affect returned pixel values vs color table values when loading images.
// (BMPs are supposed to be able to embed ICC profiles although I doubt it's prevalent.
// Some global system color profile might apply however, I don't know how all that works.)
if (
present_rgbas.length === 2 &&
present_rgbas.every((present_rgba) => `${present_rgba}` !== `${old_rgba}`)
) {
// Find the nearer color in the image data to replace.
const distances = present_rgbas.map((rgba) =>
Math.abs(rgba[0] - old_rgba[0]) +
Math.abs(rgba[1] - old_rgba[1]) +
Math.abs(rgba[2] - old_rgba[2]) +
Math.abs(rgba[3] - old_rgba[3])
);
if (distances[0] < distances[1]) {
old_rgba = present_rgbas[0];
} else {
old_rgba = present_rgbas[1];
}
}
const image_data = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
replace_color_globally(image_data, old_rgba[0], old_rgba[1], old_rgba[2], old_rgba[3], new_rgba[0], new_rgba[1], new_rgba[2], new_rgba[3]);
ctx.putImageData(image_data, 0, 0);
};
undoable({
name: "Recolor",
icon: get_help_folder_icon("p_color.png"),
}, () => {
recolor(main_ctx, main_monochrome_info.presentNonTransparentRGBAs);
if (selection && selection.canvas) {
recolor(selection.canvas.ctx, selection_monochrome_info.presentNonTransparentRGBAs);
// I feel like this shouldn't be necessary, if I'm not changing the size, but it makes it work:
selection.replace_source_canvas(selection.canvas);
}
});
}
if (swatch_index) {
palette = make_monochrome_palette(other_rgba, new_rgba);
} else {
palette = make_monochrome_palette(new_rgba, other_rgba);
}
$colorbox.rebuild_palette();
selected_colors.foreground = palette[0];
selected_colors.background = palette[14]; // first in second row
selected_colors.ternary = "";
$G.triggerHandler("option-changed");
} else {
palette[swatch_index] = color;
update_$swatch($swatch_to_edit, color);
selected_colors[color_selection_slot_to_edit] = color;
$G.triggerHandler("option-changed");
window.console && console.log(`Updated palette: ${palette.map(() => `%c█`).join("")}`, ...palette.map((color) => `color: ${color};`));
}
});
}
// Repurposable color picker modeled after the Windows system color picker
function choose_color(initial_color, callback) {
if ($edit_colors_window) {
$edit_colors_window.close();
}
const $w = new $DialogWindow(localize("Edit Colors"));
$w.addClass("edit-colors-window");
$edit_colors_window = $w;
let hue_degrees = 0;
let sat_percent = 50;
let lum_percent = 50;
let custom_colors_index = 0;
const get_current_color = () => `hsl(${hue_degrees}deg, ${sat_percent}%, ${lum_percent}%)`;
const set_color_from_rgb = (r, g, b) => {
const [h, s, l] = rgb_to_hsl(r, g, b);
hue_degrees = h * 360;
sat_percent = s * 100;
lum_percent = l * 100;
};
const set_color = (color) => {
const [r, g, b] = get_rgba_from_color(color);
set_color_from_rgb(r, g, b);
};
const select = ($swatch) => {
$w.$content.find(".swatch").removeClass("selected");
$swatch.addClass("selected");
set_color($swatch[0].dataset.color);
if ($swatch.closest("#custom-colors")) {
custom_colors_index = Math.max(0, custom_colors_swatches_list_order.indexOf(
$custom_colors_grid.find(".swatch.selected")[0]
));
}
update_inputs("hslrgb");
};
const make_color_grid = (colors, id) => {
const $color_grid = $(`<div class="color-grid" tabindex="0">`).attr({ id });
for (const color of colors) {
const $swatch = $Swatch(color);
$swatch.appendTo($color_grid).addClass("inset-deep");
$swatch.attr("tabindex", -1); // can be focused by clicking or calling focus() but not by tabbing
}
let $local_last_focus = $color_grid.find(".swatch:first-child");
const num_colors_per_row = 8;
const navigate = (relative_index) => {
const $focused = $color_grid.find(".swatch:focus");
if (!$focused.length) { return; }
const $swatches = $color_grid.find(".swatch");
const from_index = $swatches.toArray().indexOf($focused[0]);
if (relative_index === -1 && (from_index % num_colors_per_row) === 0) { return; }
if (relative_index === +1 && (from_index % num_colors_per_row) === num_colors_per_row - 1) { return; }
const to_index = from_index + relative_index;
const $to_focus = $($swatches.toArray()[to_index]);
// console.log({from_index, to_index, $focused, $to_focus});
if (!$to_focus.length) { return; }
$to_focus.focus();
};
$color_grid.on("keydown", (event) => {
// console.log(event.code);
if (event.code === "ArrowRight") { navigate(+1); }
if (event.code === "ArrowLeft") { navigate(-1); }
if (event.code === "ArrowDown") { navigate(+num_colors_per_row); }
if (event.code === "ArrowUp") { navigate(-num_colors_per_row); }
if (event.code === "Home") { $color_grid.find(".swatch:first-child").focus(); }
if (event.code === "End") { $color_grid.find(".swatch:last-child").focus(); }
if (event.code === "Space" || event.code === "Enter") {
select($color_grid.find(".swatch:focus"));
draw();
}
});
$color_grid.on("pointerdown", (event) => {
const $swatch = $(event.target).closest(".swatch");
if ($swatch.length) {
select($swatch);
draw();
}
});
$color_grid.on("dragstart", (event) => {
event.preventDefault();
});
$color_grid.on("focusin", (event) => {
if (event.target.closest(".swatch")) {
$local_last_focus = $(event.target.closest(".swatch"));
} else {
if (!$local_last_focus.is(":focus")) { // prevent infinite recursion
$local_last_focus.focus();
}
}
// allow shift+tabbing out of the control
// otherwise it keeps setting focus back to the color cell,
// since the parent grid is previous in the tab order
$color_grid.attr("tabindex", -1);
});
$color_grid.on("focusout", (event) => {
$color_grid.attr("tabindex", 0);
});
return $color_grid;
};
const $left_right_split = $(`<div class="left-right-split">`).appendTo($w.$main);
const $left = $(`<div class="left-side">`).appendTo($left_right_split);
const $right = $(`<div class="right-side">`).appendTo($left_right_split).hide();
$left.append(`<label for="basic-colors">${display_hotkey("&Basic colors:")}</label>`);
const $basic_colors_grid = make_color_grid(basic_colors, "basic-colors").appendTo($left);
$left.append(`<label for="custom-colors">${display_hotkey("&Custom colors:")}</label>`);
const custom_colors_dom_order = []; // (wanting) horizontal top to bottom
for (let list_index = 0; list_index < custom_colors.length; list_index++) {
const row = list_index % 2;
const column = Math.floor(list_index / 2);
const dom_index = row * 8 + column;
custom_colors_dom_order[dom_index] = custom_colors[list_index];
}
const $custom_colors_grid = make_color_grid(custom_colors_dom_order, "custom-colors").appendTo($left);
const custom_colors_swatches_dom_order = $custom_colors_grid.find(".swatch").toArray(); // horizontal top to bottom
const custom_colors_swatches_list_order = []; // (wanting) vertical left to right
for (let dom_index = 0; dom_index < custom_colors_swatches_dom_order.length; dom_index++) {
const row = Math.floor(dom_index / 8);
const column = dom_index % 8;
const list_index = column * 2 + row;
custom_colors_swatches_list_order[list_index] = custom_colors_swatches_dom_order[dom_index];
// custom_colors_swatches_list_order[list_index].textContent = list_index; // visualization
}
const $define_custom_colors_button = $(`<button class="define-custom-colors-button">`)
.html(display_hotkey("&Define Custom Colors >>"))
.appendTo($left)
.on("click", (e) => {
// prevent the form from submitting
// @TODO: instead, prevent the form's submit event in $Window.js in os-gui (or don't have a form? idk)
e.preventDefault();
$right.show();
$w.addClass("defining-custom-colors"); // for mobile layout
$define_custom_colors_button.attr("disabled", "disabled");
// assuming small viewport implies mobile implies an onscreen keyboard,
// and that you probably don't want to use the keyboard to choose colors
if ($w.width() >= 300) {
inputs_by_component_letter.h.focus();
}
maybe_reenable_button_for_mobile_navigation();
});
// for mobile layout, re-enable button because it's a navigation button in that case, rather than one-time expand action
const maybe_reenable_button_for_mobile_navigation = () => {
// if ($right.is(":hidden")) {
if ($w.width() < 300 || document.body.classList.contains("eye-gaze-mode")) {
$define_custom_colors_button.removeAttr("disabled");
}
};
$(window).on("resize", maybe_reenable_button_for_mobile_navigation);
const $color_solid_label = $(`<label for="color-solid-canvas">${display_hotkey("Color|S&olid")}</label>`);
$color_solid_label.css({
position: "absolute",
left: 10,
top: 244,
});
const rainbow_canvas = make_canvas(175, 187);
const luminosity_canvas = make_canvas(10, 187);
const result_canvas = make_canvas(58, 40);
const lum_arrow_canvas = make_canvas(5, 9);
$(result_canvas).css({
position: "absolute",
left: 10,
top: 198,
});
let mouse_down_on_rainbow_canvas = false;
let crosshair_shown_on_rainbow_canvas = false;
const draw = () => {
if (!mouse_down_on_rainbow_canvas || crosshair_shown_on_rainbow_canvas) {
// rainbow
for (let y = 0; y < rainbow_canvas.height; y += 6) {
for (let x = -1; x < rainbow_canvas.width; x += 3) {
rainbow_canvas.ctx.fillStyle = `hsl(${x / rainbow_canvas.width * 360}deg, ${(1 - y / rainbow_canvas.height) * 100}%, 50%)`;
rainbow_canvas.ctx.fillRect(x, y, 3, 6);
}
}
// crosshair
if (!mouse_down_on_rainbow_canvas) {
const x = ~~(hue_degrees / 360 * rainbow_canvas.width);
const y = ~~((1 - sat_percent / 100) * rainbow_canvas.height);
rainbow_canvas.ctx.fillStyle = "black";
rainbow_canvas.ctx.fillRect(x - 1, y - 9, 3, 5);
rainbow_canvas.ctx.fillRect(x - 1, y + 5, 3, 5);
rainbow_canvas.ctx.fillRect(x - 9, y - 1, 5, 3);
rainbow_canvas.ctx.fillRect(x + 5, y - 1, 5, 3);
}
crosshair_shown_on_rainbow_canvas = !mouse_down_on_rainbow_canvas;
}
for (let y = -2; y < luminosity_canvas.height; y += 6) {
luminosity_canvas.ctx.fillStyle = `hsl(${hue_degrees}deg, ${sat_percent}%, ${(1 - y / luminosity_canvas.height) * 100}%)`;
luminosity_canvas.ctx.fillRect(0, y, luminosity_canvas.width, 6);
}
lum_arrow_canvas.ctx.fillStyle = getComputedStyle($w.$content[0]).getPropertyValue("--ButtonText");
for (let x = 0; x < lum_arrow_canvas.width; x++) {
lum_arrow_canvas.ctx.fillRect(x, lum_arrow_canvas.width - x - 1, 1, 1 + x * 2);
}
lum_arrow_canvas.style.position = "absolute";
lum_arrow_canvas.style.left = "215px";
lum_arrow_canvas.style.top = `${3 + ~~((1 - lum_percent / 100) * luminosity_canvas.height)}px`;
result_canvas.ctx.fillStyle = get_current_color();
result_canvas.ctx.fillRect(0, 0, result_canvas.width, result_canvas.height);
};
draw();
$(rainbow_canvas).addClass("rainbow-canvas inset-shallow");
$(luminosity_canvas).addClass("luminosity-canvas inset-shallow");
$(result_canvas).addClass("result-color-canvas inset-shallow").attr("id", "color-solid-canvas");
const select_hue_sat = (event) => {
hue_degrees = Math.min(1, Math.max(0, event.offsetX / rainbow_canvas.width)) * 360;
sat_percent = Math.min(1, Math.max(0, (1 - event.offsetY / rainbow_canvas.height))) * 100;
update_inputs("hsrgb");
draw();
event.preventDefault();
};
$(rainbow_canvas).on("pointerdown", (event) => {
mouse_down_on_rainbow_canvas = true;
select_hue_sat(event);
$(rainbow_canvas).on("pointermove", select_hue_sat);
if (event.pointerId !== 1234567890) { // for Eye Gaze Mode simulated clicks
rainbow_canvas.setPointerCapture(event.pointerId);
}
});
$G.on("pointerup pointercancel", (event) => {
$(rainbow_canvas).off("pointermove", select_hue_sat);
// rainbow_canvas.releasePointerCapture(event.pointerId);
mouse_down_on_rainbow_canvas = false;
draw();
});
const select_lum = (event) => {
lum_percent = Math.min(1, Math.max(0, (1 - event.offsetY / luminosity_canvas.height))) * 100;
update_inputs("lrgb");
draw();
event.preventDefault();
};
$(luminosity_canvas).on("pointerdown", (event) => {
select_lum(event);
$(luminosity_canvas).on("pointermove", select_lum);
if (event.pointerId !== 1234567890) { // for Eye Gaze Mode simulated clicks
luminosity_canvas.setPointerCapture(event.pointerId);
}
});
$G.on("pointerup pointercancel", (event) => {
$(luminosity_canvas).off("pointermove", select_lum);
// luminosity_canvas.releasePointerCapture(event.pointerId);
});
const inputs_by_component_letter = {};
["hsl", "rgb"].forEach((color_model, color_model_index) => {
[...color_model].forEach((component_letter, component_index) => {
const text_with_hotkey = {
h: "Hu&e:",
s: "&Sat:",
l: "&Lum:",
r: "&Red:",
g: "&Green:",
b: "Bl&ue:",
}[component_letter];
const input = document.createElement("input");
// not doing type="number" because the inputs have no up/down buttons and they have special behavior with validation
input.type = "text";
input.classList.add("inset-deep");
input.dataset.componentLetter = component_letter;
input.dataset.min = 0;
input.dataset.max = {
h: 360,
s: 100,
l: 100,
r: 255,
g: 255,
b: 255,
}[component_letter];
const label = document.createElement("label");
label.innerHTML = display_hotkey(text_with_hotkey);
const input_y_spacing = 22;
$(label).css({
position: "absolute",
left: 63 + color_model_index * 80,
top: 202 + component_index * input_y_spacing,
textAlign: "right",
display: "inline-block",
width: 40,
height: 20,
lineHeight: "20px",
});
$(input).css({
position: "absolute",
left: 106 + color_model_index * 80,
top: 202 + component_index * input_y_spacing + (component_index > 1), // spacing of rows is uneven by a pixel
width: 21,
height: 14,
});
$right.append(label, input);
inputs_by_component_letter[component_letter] = input;
});
});
// listening for input events on input elements using event delegation (looks a little weird)
$right.on("input", "input", (event) => {
const input = event.target;
const component_letter = input.dataset.componentLetter;
if (component_letter) {
// In Windows, it actually only updates if the numerical value changes, not just the text.
// That is, you can add leading zeros, and they'll stay, then add them in the other color model
// and it won't remove the ones in the fields of the first color model.
// This is not important, so I don't know if I'll do that.
if (input.value.match(/^\d+$/)) {
let n = Number(input.value);
if (n < input.dataset.min) {
n = input.dataset.min;
input.value = n;
} else if (n > input.dataset.max) {
n = input.dataset.max;
input.value = n;
}
if ("hsl".indexOf(component_letter) > -1) {
switch (component_letter) {
case "h":
hue_degrees = n;
break;
case "s":
sat_percent = n;
break;
case "l":
lum_percent = n;
break;
}
update_inputs("rgb");
} else {
let [r, g, b] = get_rgba_from_color(get_current_color());
const rgb = { r, g, b };
rgb[component_letter] = n;
set_color_from_rgb(rgb.r, rgb.g, rgb.b);
update_inputs("hsl");
}
draw();
} else if (input.value.length) {
update_inputs(component_letter);
input.select();
}
}
});
$right.on("focusout", "input", (event) => {
const input = event.target;
const component_letter = input.dataset.componentLetter;
if (component_letter) {
// Handle empty input when focus moves away
if (!input.value.match(/^\d+$/)) {
update_inputs(component_letter);
input.select();
}
}
});
$w.on("keydown", (event) => {
if (event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
switch (event.key) {
case "o":
set_color(get_current_color());
update_inputs("hslrgb");
draw();
break;
case "b":
$basic_colors_grid.find(".swatch.selected, .swatch").focus();
break;
case "c":
$basic_colors_grid.find(".swatch.selected, .swatch").focus();
break;
case "e":
inputs_by_component_letter.h.focus();
break;
case "s":
inputs_by_component_letter.s.focus();
break;
case "l":
inputs_by_component_letter.l.focus();
break;
case "r":
inputs_by_component_letter.r.focus();
break;
case "g":
inputs_by_component_letter.g.focus();
break;
case "u":
inputs_by_component_letter.b.focus();
break;
case "a":
if ($add_to_custom_colors_button.is(":visible")) {
$add_to_custom_colors_button.click();
}
break;
case "d":
$define_custom_colors_button.click();
break;
default:
return; // don't prevent default by default
}
} else {
return; // don't prevent default by default
}
event.preventDefault();
event.stopPropagation();
});
const update_inputs = (components) => {
for (const component_letter of components) {
const input = inputs_by_component_letter[component_letter];
const [r, g, b] = get_rgba_from_color(get_current_color());
input.value = Math.floor({
h: hue_degrees,
s: sat_percent,
l: lum_percent,
r,
g,
b,
}[component_letter]);
}
};
$right.append(rainbow_canvas, luminosity_canvas, result_canvas, $color_solid_label, lum_arrow_canvas);
const $add_to_custom_colors_button = $(`<button class="add-to-custom-colors-button">`)
.html(display_hotkey("&Add To Custom Colors"))
.appendTo($right)
.on("click", (event) => {
// prevent the form from submitting
// @TODO: instead, prevent the form's submit event in $Window.js in os-gui (or don't have a form? idk)
event.preventDefault();
const color = get_current_color();
custom_colors[custom_colors_index] = color;
// console.log(custom_colors_swatches_reordered, custom_colors_index, custom_colors_swatches_reordered[custom_colors_index]));
update_$swatch($(custom_colors_swatches_list_order[custom_colors_index]), color);
custom_colors_index = (custom_colors_index + 1) % custom_colors.length;
$w.removeClass("defining-custom-colors"); // for mobile layout
});
$w.$Button(localize("OK"), () => {
callback(get_current_color());
$w.close();
})[0].focus();
$w.$Button(localize("Cancel"), () => {
$w.close();
});
$left.append($w.$buttons);
// initially select the first color cell that matches the swatch to edit, if any
// (first in the basic colors, then in the custom colors otherwise - implicitly)
for (const swatch_el of $left.find(".swatch").toArray()) {
if (get_rgba_from_color(swatch_el.dataset.color).join(",") === get_rgba_from_color(initial_color).join(",")) {
select($(swatch_el));
swatch_el.focus();
break;
}
}
custom_colors_index = Math.max(0, custom_colors_swatches_list_order.indexOf(
$custom_colors_grid.find(".swatch.selected")[0]
));
set_color(initial_color);
update_inputs("hslrgb");
$w.center();
}
exports.show_edit_colors_window = show_edit_colors_window;
})(window);

View File

@@ -0,0 +1,196 @@
// Electron-specific code injected into the renderer process
// to provide integrations, for the desktop app
// I've enabled sandboxing, so the fs module is not available.
// Operations must be carried out in the main process.
const { /*contextBridge,*/ ipcRenderer } = require('electron');
const { isDev, isMacOS, initialFilePath } = ipcRenderer.sendSync("get-env-info");
// contextBridge.exposeInMainWorld("is_electron_app", true);
// contextBridge.exposeInMainWorld("electron_is_dev", isDev);
// contextBridge.exposeInMainWorld("initial_system_file_handle", initialFilePath);
// contextBridge.exposeInMainWorld("electron_app", {
window.is_electron_app = true;
window.electron_is_dev = isDev;
window.initial_system_file_handle = initialFilePath;
ipcRenderer.on("close-window-prompt", () => {
are_you_sure(() => {
window.close();
});
});
window.setRepresentedFilename = (filePath) => {
ipcRenderer.send("set-represented-filename", filePath);
};
window.setDocumentEdited = (documentEdited) => {
ipcRenderer.send("set-document-edited", documentEdited);
};
function show_save_error_message(responseCode, error) {
if (responseCode === "ACCESS_DENIED") {
return show_error_message(localize("Access denied."));
}
if (responseCode === "INVALID_DATA") {
return show_error_message("Failed to save: Invalid data. This shouldn't happen!");
}
if (responseCode !== "SUCCESS") {
return show_error_message(localize("Failed to save document."), error);
}
// return show_save_error_message(localize("No error occurred."));
}
async function write_blob_to_file_path(filePath, blob) {
const arrayBuffer = await blob.arrayBuffer();
const { responseCode, error } = await ipcRenderer.invoke("write-file", filePath, arrayBuffer);
return { responseCode, error };
}
window.systemHooks = window.systemHooks || {};
window.systemHooks.showSaveFileDialog = async ({ formats, defaultFileName, defaultPath, defaultFileFormatID, getBlob, savedCallbackUnreliable }) => {
// First filter in filters list determines default selected file type.
// @TODO: default to existing extension, except it would be awkward to rearrange the list...
// const suggestedExtension = get_file_extension(defaultFileName);
// We can't get the selected file type, so show only a set of formats
// that can be accessed uniquely by their file extensions
formats = formats_unique_per_file_extension(formats);
const filters = formats.map(({ name, extensions }) => ({ name, extensions }));
// @TODO: should defaultFileName/defaultPath be sanitized in some way?
let filePath, fileName, canceled;
try {
// This is not the Electron API directly, but it's similar
// fileName stuff is added so I don't need to do equivalent to path.basename() in the renderer
({ filePath, fileName, canceled } = await ipcRenderer.invoke("show-save-dialog", {
title: localize("Save As"),
// defaultPath: defaultPath || path.basename(defaultFileName),
defaultFileName,
defaultPath,
filters,
}));
} catch (error) {
show_error_message(localize("Failed to save document."), error);
}
if (canceled) {
return;
}
const extension = (filePath.indexOf(".") > -1) && filePath.split(/\./g).pop().toLowerCase();
if (!extension) {
// @TODO: Linux/Unix?? you're not supposed to need file extensions
// should it use defaultFileFormatID?
return show_error_message("Missing file extension - Try adding .png to the end of the file name");
}
const format = get_format_from_extension(formats, filePath);
if (!format) {
return show_error_message(`Can't save as *.${extension} - Try adding .png to the end of the file name`);
}
const blob = await getBlob(format.mimeType);
const { responseCode, error } = await write_blob_to_file_path(filePath, blob);
if (responseCode !== "SUCCESS") {
return show_save_error_message(responseCode, error);
}
savedCallbackUnreliable && savedCallbackUnreliable({
// newFileName: path.basename(filePath),
newFileName: fileName,
newFileFormatID: format.mimeType,
newFileHandle: filePath,
newBlob: blob,
});
};
window.systemHooks.showOpenFileDialog = async ({ formats, defaultPath }) => {
// @TODO: use categories for filters
// ideally this function should be generic to formats, so shouldn't do it here:
// const filters = image_format_categories(formats).map(({ name, extensions }) => ({ name, extensions }));
const filters = formats.map(({ name, extensions }) => ({ name, extensions }));
const { canceled, filePaths } = await ipcRenderer.invoke("show-open-dialog", {
title: localize("Open"),
filters,
defaultPath,
});
if (canceled) {
throw new Error("user canceled");
}
const filePath = filePaths[0];
const file = await window.systemHooks.readBlobFromHandle(filePath);
return { file, fileHandle: filePath };
};
window.systemHooks.writeBlobToHandle = async (filePath, blob) => {
if (typeof filePath !== "string") {
return show_error_message("writeBlobToHandle in Electron expects a file path");
// should it fall back to default writeBlobToHandle?
}
const { responseCode, error } = await write_blob_to_file_path(filePath, blob);
if (responseCode !== "SUCCESS") {
return show_save_error_message(responseCode, error);
}
};
window.systemHooks.readBlobFromHandle = async (filePath) => {
if (typeof filePath !== "string") {
return show_error_message("readBlobFromHandle in Electron expects a file path");
// should it fall back to default readBlobFromHandle?
}
const { responseCode, error, data, fileName } = await ipcRenderer.invoke("read-file", filePath);
if (responseCode === "ACCESS_DENIED") {
return show_error_message(localize("Access denied."));
}
if (responseCode !== "SUCCESS") {
return show_error_message(localize("Paint cannot open this file."), error);
}
const file = new File([new Uint8Array(data)], fileName);
// can't set file.path directly, but we can do this:
Object.defineProperty(file, 'path', {
value: filePath,
});
return file;
};
window.systemHooks.setWallpaperCentered = (canvas) => {
// @TODO: implement centered option for Windows and Linux in https://www.npmjs.com/package/wallpaper
// currently it's only supported on macOS
let wallpaperCanvas;
if (isMacOS) {
wallpaperCanvas = canvas;
} else {
wallpaperCanvas = make_canvas(screen.width, screen.height);
const x = (screen.width - canvas.width) / 2;
const y = (screen.height - canvas.height) / 2;
wallpaperCanvas.ctx.drawImage(canvas, ~~x, ~~y);
}
wallpaperCanvas.toBlob(blob => {
sanity_check_blob(blob, () => {
blob.arrayBuffer().then((arrayBuffer) => {
ipcRenderer.invoke("set-wallpaper", arrayBuffer).then(({ responseCode, error }) => {
if (responseCode === "WRITE_TEMP_PNG_FAILED") {
return show_error_message("Failed to set wallpaper: Couldn't write temporary image file.", error);
}
if (responseCode === "INVALID_DATA") {
return show_error_message(`Failed to set wallpaper. Invalid data in IPC.`, error);
}
if (responseCode === "INVALID_PNG_DATA") {
return show_error_message(`Failed to set wallpaper.\n\n${localize("Unexpected file format.")}`, error);
}
if (responseCode === "XFCONF_FAILED") {
return show_error_message("Failed to set wallpaper (for Xfce).", error);
}
if (responseCode !== "SUCCESS") {
return show_error_message("Failed to set wallpaper.", error);
}
}).catch(error => {
show_error_message("Failed to set wallpaper.", error);
});
}, (error) => {
show_error_message("Failed to set wallpaper: Couldn't read blob as array buffer.", error);
});
});
});
};

View File

@@ -0,0 +1,287 @@
const { app, shell, session, dialog, ipcMain, BrowserWindow } = require('electron');
const fs = require("fs");
const path = require("path");
app.enableSandbox();
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require('electron-squirrel-startup')) { // eslint-disable-line global-require
app.quit();
}
// Reloading and dev tools shortcuts
const { isPackaged } = app;
const isDev = process.env.ELECTRON_DEBUG === "1" || !isPackaged;
if (isDev) {
require('electron-debug')({ showDevTools: false });
}
// @TODO: let user apply this setting somewhere in the UI (togglable)
// (Note: it would be better to use REG.EXE to apply the change, rather than a .reg file)
// This registry modification changes the right click > Edit option for images in Windows Explorer
const reg_contents = `Windows Registry Editor Version 5.00
[HKEY_CLASSES_ROOT\\SystemFileAssociations\\image\\shell\\edit\\command]
@="\\"${process.argv[0].replace(/\\/g, "\\\\")}\\" ${isPackaged ? "" : '\\".\\" '}\\"%1\\""
`; // oof that's a lot of escaping \\
//// \\\\
// /\ /\ /\ /\ /\ /\ /\ \\
// //\\ //\\ //\\ //\\ //\\ //\\ //\\ \\
// || || || || || || || \\
//\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\
const reg_file_path = path.join(
isPackaged ? path.dirname(process.argv[0]) : ".",
`set-jspaint${isPackaged ? "" : "-DEV-MODE"}-as-default-image-editor.reg`
);
if (process.platform == "win32" && isPackaged) {
fs.writeFile(reg_file_path, reg_contents, (err) => {
if (err) {
return console.error(err);
}
});
}
// In case of XSS holes, don't give the page free reign over the filesystem!
// Only allow allow access to files explicitly opened by the user.
const allowed_file_paths = [];
let initial_file_path;
if (process.argv.length >= 2) {
// in production, "path/to/jspaint.exe" "maybe/a/file.png"
// in development, "path/to/electron.exe" "." "maybe/a/file.png"
const initial_file_path = process.argv[isPackaged ? 1 : 2];
allowed_file_paths.push(initial_file_path);
}
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
// @TODO: It's been several electron versions. I doubt this is still necessary. (It was from a boilerplate.)
let mainWindow;
const createWindow = () => {
// Create the browser window.
mainWindow = new BrowserWindow({
useContentSize: true,
autoHideMenuBar: true, // it adds height for a native menu bar unless we hide it here
// setMenu(null) below is too late; it's already decided on the size by then
width: 800,
height: 600,
minWidth: 260,
minHeight: 360,
icon: path.join(__dirname, "../images/icons",
process.platform === "win32" ?
"jspaint.ico" :
process.platform === "darwin" ?
"jspaint.icns" :
"48x48.png"
),
title: "JS Paint",
webPreferences: {
preload: path.join(__dirname, "/electron-injected.js"),
contextIsolation: false,
},
});
// @TODO: maybe use the native menu for the "Modern" theme, or a "Native" theme
mainWindow.setMenu(null);
// and load the index.html of the app.
mainWindow.loadURL(`file://${__dirname}/../index.html`);
// Emitted when the window is closed.
mainWindow.on('closed', () => {
// Dereference the window object, usually you would store windows
// in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element.
mainWindow = null;
});
// Emitted before the window is closed.
mainWindow.on('close', (event) => {
// Don't need to check mainWindow.isDocumentEdited(),
// because the (un)edited state is handled by the renderer process, in are_you_sure().
// Note: if the web contents are not responding, this will make the app harder to close.
// Similarly, if there's an error, the app will be harder to close (perhaps worse as it's less likely to show a Not Responding dialog).
// And this also prevents it from closing with Ctrl+C in the terminal, which is arguably a feature.
mainWindow.webContents.send('close-window-prompt');
event.preventDefault();
});
// Open links without target=_blank externally.
mainWindow.webContents.on('will-navigate', (e, url) => {
// check that the URL is not part of the app
if (!url.includes("file://")) {
e.preventDefault();
shell.openExternal(url);
}
});
// Open links with target=_blank externally.
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
// check that the URL is not part of the app
if (!url.includes("file://")) {
shell.openExternal(url);
}
return { action: "deny" };
});
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
// connect-src needs data: for loading from localStorage,
// and maybe blob: for loading from IndexedDB in the future.
// (It uses fetch().)
// Note: this should mirror the CSP in index.html, except maybe for firebase stuff.
"Content-Security-Policy": [`
default-src 'self';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: blob: http: https:;
font-src 'self' https://fonts.gstatic.com;
connect-src * data: blob:;
`],
}
})
});
ipcMain.on("get-env-info", (event) => {
event.returnValue = {
isDev,
isMacOS: process.platform === "darwin",
initialFilePath: initial_file_path,
};
});
ipcMain.on("set-represented-filename", (event, filePath) => {
if (allowed_file_paths.includes(filePath)) {
mainWindow.setRepresentedFilename(filePath);
}
});
ipcMain.on("set-document-edited", (event, isEdited) => {
mainWindow.setDocumentEdited(isEdited);
});
ipcMain.handle("show-save-dialog", async (event, options) => {
const { filePath, canceled } = await dialog.showSaveDialog(mainWindow, {
title: options.title,
// defaultPath: options.defaultPath,
defaultPath: options.defaultPath || path.basename(options.defaultFileName),
filters: options.filters,
});
const fileName = path.basename(filePath);
allowed_file_paths.push(filePath);
return { filePath, fileName, canceled };
});
ipcMain.handle("show-open-dialog", async (event, options) => {
const { filePaths, canceled } = await dialog.showOpenDialog(mainWindow, {
title: options.title,
defaultPath: options.defaultPath,
filters: options.filters,
properties: options.properties,
});
allowed_file_paths.push(...filePaths);
return { filePaths, canceled };
});
ipcMain.handle("write-file", async (event, file_path, data) => {
if (!allowed_file_paths.includes(file_path)) {
return { responseCode: "ACCESS_DENIED" };
}
// make sure data is an ArrayBuffer, so you can't use an options object for (unknown) evil reasons
if (data instanceof ArrayBuffer) {
try {
await fs.promises.writeFile(file_path, Buffer.from(data));
} catch (error) {
return { responseCode: "WRITE_FAILED", error };
}
return { responseCode: "SUCCESS" };
} else {
return { responseCode: "INVALID_DATA" };
}
});
ipcMain.handle("read-file", async (event, file_path) => {
if (!allowed_file_paths.includes(file_path)) {
return { responseCode: "ACCESS_DENIED" };
}
try {
const buffer = await fs.promises.readFile(file_path);
return { responseCode: "SUCCESS", data: new Uint8Array(buffer), fileName: path.basename(file_path) };
} catch (error) {
return { responseCode: "READ_FAILED", error };
}
});
ipcMain.handle("set-wallpaper", async (event, data) => {
const image_path = path.join(app.getPath("userData"), "bg.png"); // Note: used without escaping
if (!(data instanceof ArrayBuffer)) {
return { responseCode: "INVALID_DATA" };
}
data = new Uint8Array(data);
const png_magic_bytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
for (let i = 0; i < png_magic_bytes.length; i++) {
if (data[i] !== png_magic_bytes[i]) {
console.log("Found bytes:", data.slice(0, png_magic_bytes.length), "but expected:", png_magic_bytes);
return { responseCode: "INVALID_PNG_DATA" };
}
}
try {
await fs.promises.writeFile(image_path, Buffer.from(data));
} catch (error) {
return { responseCode: "WRITE_TEMP_PNG_FAILED", error };
}
// The wallpaper module actually has support for Xfce, but it's not general enough.
const bash_for_xfce = `xfconf-query -c xfce4-desktop -l | grep last-image | while read path; do xfconf-query -c xfce4-desktop -p $path -s '${image_path}'; done`;
const { lookpath } = require("lookpath");
if (await lookpath("xfconf-query") && await lookpath("grep")) {
const exec = require("util").promisify(require('child_process').exec);
try {
await exec(bash_for_xfce);
} catch (error) {
console.error("Error setting wallpaper for Xfce:", error);
return { responseCode: "XFCONF_FAILED", error };
}
return { responseCode: "SUCCESS" };
} else {
// Note: { scale: "center" } is only supported on macOS.
// I worked around this by providing an image with a transparent margin on other platforms,
// in setWallpaperCentered.
return new Promise((resolve, reject) => {
require("wallpaper").set(image_path, { scale: "center" }, error => {
if (error) {
resolve({ responseCode: "SET_WALLPAPER_FAILED", error });
} else {
resolve({ responseCode: "SUCCESS" });
}
});
});
// Newer promise-based wallpaper API that I can't import:
// try {
// await setWallpaper(image_path, { scale: "center" });
// } catch (error) {
// return { responseCode: "SET_WALLPAPER_FAILED", error };
// }
// return { responseCode: "SUCCESS" };
}
});
};
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', createWindow);
// Quit when all windows are closed.
app.on('window-all-closed', () => {
// On OS X it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (mainWindow === null) {
createWindow();
}
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.

View File

@@ -0,0 +1,23 @@
/* eslint-disable no-useless-concat */
/* eslint-disable no-alert */
// use only ES5 syntax for this script
// set up basic global error handling, which we can override later
window.onerror = function (msg, url, lineNo, columnNo, error) {
var string = msg.toLowerCase();
var substring = "script error";
if (string.indexOf(substring) > -1) {
alert('Script Error: See Browser Console for Detail');
} else {
// try {
// // try-catch in case of circular references or old browsers without JSON.stringify
// error = JSON.stringify(error);
// } catch (e) {}
alert('Internal application error: ' + msg + '\n\n' + 'URL: ' + url + '\n' + 'Line: ' + lineNo + '\n' + 'Column: ' + columnNo);
}
return false;
};
window.onunhandledrejection = function (event) {
alert('Unhandled Rejection: ' + event.reason);
}

View File

@@ -0,0 +1,52 @@
// Use only ES5 syntax for this script, as it's meant to handle old IE.
// Note that this can't simply be merged with the other onerror handler with a try/catch,
// because showMessageBox is async, and could throw an error before dependencies are met (or if there was an error in the error handling),
// and try doesn't catch errors in async code. It would need to be awaited.
// And making show_error_message return a promise might cause subtle problems due to the pattern of `return show_error_message()`.
var old_onerror = window.onerror;
window.onerror = function (message, source, lineno, colno, error) {
try {
// Some errors don't give an error object, like "ResizeObserver loop limit exceeded"
show_error_message(localize("Internal application error."), error || message);
} catch (e) {
old_onerror(message, source, lineno, colno, error);
console.warn("Error in error handler:", e);
}
};
var old_onunhandledrejection = window.onunhandledrejection;
var restore_new_onunhandledrejection_tid;
var new_onunhandledrejection = function (event) {
// Just in case show_error_message triggers a new unhandledrejection event,
// we need to make sure we don't call it again.
// Test by adding to the top of show_error_message:
// Promise.reject(new Error("EMIT EMIT EMIT"))
// Also test:
// throw new Error("EMIT EMIT EMIT");
// I want my error handling to be RESILIENT!
window.onunhandledrejection = old_onunhandledrejection;
clearTimeout(restore_new_onunhandledrejection_tid);
restore_new_onunhandledrejection_tid = setTimeout(function () {
window.onunhandledrejection = new_onunhandledrejection;
}, 0);
try {
show_error_message(localize("Internal application error.") + "\nUnhandled Rejection.", event.reason);
} catch (e) {
old_onunhandledrejection(event);
console.warn("Error in unhandledrejection handler:", e);
}
};
window.onunhandledrejection = new_onunhandledrejection;
// Show a message for old Internet Explorer.
if (/MSIE \d|Trident.*rv:/.test(navigator.userAgent)) {
document.write(
'<style>body { text-align: center; }</style>' +
'<div className="not-supported">' +
' <h1 className="not-supported-header">Internet Explorer is not supported!</h1>' +
' <p className="not-supported-details">Try Chrome, Firefox, or Edge.</p>' +
'</div>'
);
}

View File

@@ -0,0 +1,113 @@
extra_tools = [{
name: "Airbrushbrush",
description: "Draws randomly within a radius based on the selected Airbrush size, using a brush with the selected shape and size.",
cursor: ["precise-dotted", [16, 16], "crosshair"],
continuous: "time",
rendered_color: "",
rendered_size: 0,
rendered_shape: "",
paint(ctx, x, y) {
// @XXX: copy pasted all this brush caching/rendering code!
// @TODO: DRY!
const csz = get_brush_canvas_size(brush_size, brush_shape);
if (
this.rendered_shape !== brush_shape ||
this.rendered_color !== stroke_color ||
this.rendered_size !== brush_size
) {
brush_canvas.width = csz;
brush_canvas.height = csz;
// don't need to do brush_ctx.disable_image_smoothing() currently because images aren't drawn to the brush
brush_ctx.fillStyle = brush_ctx.strokeStyle = stroke_color;
render_brush(brush_ctx, brush_shape, brush_size);
this.rendered_color = stroke_color;
this.rendered_size = brush_size;
this.rendered_shape = brush_shape;
}
const draw_brush = (x, y) => {
ctx.drawImage(brush_canvas, Math.ceil(x - csz / 2), Math.ceil(y - csz / 2));
};
const r = airbrush_size * 2;
for (let i = 0; i < 6 + r / 5; i++) {
const rx = (Math.random() * 2 - 1) * r;
const ry = (Math.random() * 2 - 1) * r;
const d = rx * rx + ry * ry;
if (d <= r * r) {
draw_brush(x + ~~rx, y + ~~ry);
}
}
},
$options: $choose_brush
}, {
name: "Spirobrush",
description: "Spirals chaotically using a brush with the selected shape and size.",
cursor: ["precise-dotted", [16, 16], "crosshair"],
continuous: "time",
rendered_color: "",
rendered_size: 0,
rendered_shape: "",
position: {
x: 0,
y: 0,
},
velocity: {
x: 0,
y: 0,
},
pointerdown(ctx, x, y) {
this.position.x = x;
this.position.y = y;
this.velocity.x = 0;
this.velocity.y = 0;
},
paint(ctx, x, y) {
// @XXX: copy pasted all this brush caching/rendering code!
// @TODO: DRY!
const csz = get_brush_canvas_size(brush_size, brush_shape);
if (
this.rendered_shape !== brush_shape ||
this.rendered_color !== stroke_color ||
this.rendered_size !== brush_size
) {
brush_canvas.width = csz;
brush_canvas.height = csz;
// don't need to do brush_ctx.disable_image_smoothing() currently because images aren't drawn to the brush
brush_ctx.fillStyle = brush_ctx.strokeStyle = stroke_color;
render_brush(brush_ctx, brush_shape, brush_size);
this.rendered_color = stroke_color;
this.rendered_size = brush_size;
this.rendered_shape = brush_shape;
}
const draw_brush = (x, y) => {
ctx.drawImage(brush_canvas, Math.ceil(x - csz / 2), Math.ceil(y - csz / 2));
};
for (let i = 0; i < 60; i++) {
const x_diff = x - this.position.x;
const y_diff = y - this.position.y;
const dist = Math.hypot(x_diff, y_diff);
const divisor = Math.max(1, dist);
const force_x = x_diff / divisor;
const force_y = y_diff / divisor;
this.velocity.x += force_x;
this.velocity.y += force_y;
this.position.x += this.velocity.x;
this.position.y += this.velocity.y;
draw_brush(this.position.x, this.position.y);
}
},
$options: $choose_brush
}, {
name: "Airbrush Options",
description: "Lets you configure the Airbrushbrush. It uses this type of tool option as well.",
cursor: ["airbrush", [7, 22], "crosshair"],
continuous: "time",
paint(ctx, x, y) {
},
$options: $choose_airbrush_size
}];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,490 @@
((exports) => {
let $help_window;
function show_help() {
if ($help_window) {
$help_window.focus();
return;
}
$help_window = open_help_viewer({
title: localize("Paint Help"),
root: "help",
contentsFile: "help/mspaint.hhc",
}).$help_window;
$help_window.on("close", () => {
$help_window = null;
});
}
// shared code with 98.js.org
// (copy-pasted / manually synced for now)
function open_help_viewer(options) {
const $help_window = $Window({
title: options.title || "Help Topics",
icons: {
16: "images/chm-16x16.png",
},
resizable: true,
})
$help_window.addClass("help-window");
let ignore_one_load = true;
let back_length = 0;
let forward_length = 0;
const $main = $(E("div")).addClass("main");
const $toolbar = $(E("div")).addClass("toolbar");
const add_toolbar_button = (name, sprite_n, action_fn, enabled_fn) => {
const $button = $("<button class='lightweight'>")
.append($("<span>").text(name))
.appendTo($toolbar)
.on("click", () => {
action_fn();
});
$("<div class='icon'/>")
.appendTo($button)
.css({
backgroundPosition: `${-sprite_n * 55}px 0px`,
});
const update_enabled = () => {
$button[0].disabled = enabled_fn && !enabled_fn();
};
update_enabled();
$help_window.on("click", "*", update_enabled);
$help_window.on("update-buttons", update_enabled);
return $button;
};
const measure_sidebar_width = () =>
$contents.outerWidth() +
parseFloat(getComputedStyle($contents[0]).getPropertyValue("margin-left")) +
parseFloat(getComputedStyle($contents[0]).getPropertyValue("margin-right")) +
$resizer.outerWidth();
const $hide_button = add_toolbar_button("Hide", 0, () => {
const toggling_width = measure_sidebar_width();
$contents.hide();
$resizer.hide();
$hide_button.hide();
$show_button.show();
$help_window.width($help_window.width() - toggling_width);
$help_window.css("left", $help_window.offset().left + toggling_width);
$help_window.bringTitleBarInBounds();
});
const $show_button = add_toolbar_button("Show", 5, () => {
$contents.show();
$resizer.show();
$show_button.hide();
$hide_button.show();
const toggling_width = measure_sidebar_width();
$help_window.css("max-width", "unset");
$help_window.width($help_window.width() + toggling_width);
$help_window.css("left", $help_window.offset().left - toggling_width);
// $help_window.applyBounds() would push the window to fit (before trimming it only if needed)
// Trim the window to fit (especially for if maximized)
if ($help_window.offset().left < 0) {
$help_window.width($help_window.width() + $help_window.offset().left);
$help_window.css("left", 0);
}
$help_window.css("max-width", "");
}).hide();
add_toolbar_button("Back", 1, () => {
$iframe[0].contentWindow.history.back();
ignore_one_load = true;
back_length -= 1;
forward_length += 1;
}, () => back_length > 0);
add_toolbar_button("Forward", 2, () => {
$iframe[0].contentWindow.history.forward();
ignore_one_load = true;
forward_length -= 1;
back_length += 1;
}, () => forward_length > 0);
add_toolbar_button("Options", 3, () => { }, () => false); // @TODO: hotkey and underline on O
add_toolbar_button("Web Help", 4, () => {
iframe.src = "help/online_support.htm";
});
const $iframe = $Iframe({ src: "help/default.html" }).addClass("inset-deep");
const iframe = $iframe[0];
iframe.$window = $help_window; // for focus handling integration
const $resizer = $(E("div")).addClass("resizer");
const $contents = $(E("ul")).addClass("contents inset-deep");
// @TODO: fix race conditions
$iframe.on("load", () => {
if (!ignore_one_load) {
back_length += 1;
forward_length = 0;
}
// iframe.contentWindow.location.href
ignore_one_load = false;
$help_window.triggerHandler("update-buttons");
});
$main.append($contents, $resizer, $iframe);
$help_window.$content.append($toolbar, $main);
$help_window.css({ width: 800, height: 600 });
$iframe.attr({ name: "help-frame" });
$iframe.css({
backgroundColor: "white",
border: "",
margin: "1px",
});
$contents.css({
margin: "1px",
});
$help_window.center();
$main.css({
position: "relative", // for resizer
});
const resizer_width = 4;
$resizer.css({
cursor: "ew-resize",
width: resizer_width,
boxSizing: "border-box",
background: "var(--ButtonFace)",
borderLeft: "1px solid var(--ButtonShadow)",
boxShadow: "inset 1px 0 0 var(--ButtonHilight)",
top: 0,
bottom: 0,
zIndex: 1,
});
$resizer.on("pointerdown", (e) => {
let pointermove, pointerup;
const getPos = (e) =>
Math.min($help_window.width() - 100, Math.max(20,
e.clientX - $help_window.$content.offset().left
));
$G.on("pointermove", pointermove = (e) => {
$resizer.css({
position: "absolute",
left: getPos(e)
});
$contents.css({
marginRight: resizer_width,
});
});
$G.on("pointerup", pointerup = (e) => {
$G.off("pointermove", pointermove);
$G.off("pointerup", pointerup);
$resizer.css({
position: "",
left: ""
});
$contents.css({
flexBasis: getPos(e) - resizer_width,
marginRight: "",
});
});
});
const parse_object_params = $object => {
// parse an $(<object>) to a plain object of key value pairs
const object = {};
for (const param of $object.children("param").get()) {
object[param.name] = param.value;
}
return object;
};
let $last_expanded;
const $Item = text => {
const $item = $(E("div")).addClass("item").text(text.trim());
$item.on("mousedown", () => {
$contents.find(".item").removeClass("selected");
$item.addClass("selected");
});
$item.on("click", () => {
const $li = $item.parent();
if ($li.is(".folder")) {
if ($last_expanded) {
$last_expanded.not($li).removeClass("expanded");
}
$li.toggleClass("expanded");
$last_expanded = $li;
}
});
return $item;
};
const $default_item_li = $(E("li")).addClass("page");
$default_item_li.append($Item("Welcome to Help").on("click", () => {
$iframe.attr({ src: "help/default.html" });
}));
$contents.append($default_item_li);
function renderItem(source_li, $folder_items_ul) {
const object = parse_object_params($(source_li).children("object"));
if ($(source_li).find("li").length > 0) {
const $folder_li = $(E("li")).addClass("folder");
$folder_li.append($Item(object.Name));
$contents.append($folder_li);
const $folder_items_ul = $(E("ul"));
$folder_li.append($folder_items_ul);
$(source_li).children("ul").children().get().forEach((li) => {
renderItem(li, $folder_items_ul);
});
} else {
const $item_li = $(E("li")).addClass("page");
$item_li.append($Item(object.Name).on("click", () => {
$iframe.attr({ src: `${options.root}/${object.Local}` });
}));
if ($folder_items_ul) {
$folder_items_ul.append($item_li);
} else {
$contents.append($item_li);
}
}
}
fetch(options.contentsFile).then((response) => {
response.text().then((hhc) => {
$($.parseHTML(hhc)).filter("ul").children().get().forEach((li) => {
renderItem(li, null);
});
}, (error) => {
show_error_message(`${localize("Failed to launch help.")} Failed to read ${options.contentsFile}.`, error);
});
}, (/* error */) => {
// access to error message is not allowed either, basically
if (location.protocol === "file:") {
showMessageBox({
// <p>${localize("Failed to launch help.")}</p>
// but it's already launched at this point
// what's a good tutorial for starting a web server?
// https://gist.github.com/willurd/5720255 - impressive list, but not a tutorial
// https://attacomsian.com/blog/local-web-server - OK, good enough
messageHTML: `
<p>Help is not available when running from the <code>file:</code> protocol.</p>
<p>To use this feature, <a href="https://attacomsian.com/blog/local-web-server">start a web server</a>.</p>
`,
iconID: "error",
});
} else {
show_error_message(`${localize("Failed to launch help.")} ${localize("Access to %1 was denied.", options.contentsFile)}`);
}
});
// @TODO: keyboard accessability
// $help_window.on("keydown", (e)=> {
// switch(e.keyCode){
// case 37:
// show_error_message("MOVE IT");
// break;
// }
// });
// var task = new Task($help_window);
var task = {};
task.$help_window = $help_window;
return task;
}
var programs_being_loaded = 0;
function $Iframe(options) {
var $iframe = $("<iframe allowfullscreen sandbox='allow-same-origin allow-scripts allow-forms allow-pointer-lock allow-modals allow-popups allow-downloads'>");
var iframe = $iframe[0];
var disable_delegate_pointerup = false;
$iframe.focus_contents = function () {
if (!iframe.contentWindow) {
return;
}
if (iframe.contentDocument.hasFocus()) {
return;
}
disable_delegate_pointerup = true;
iframe.contentWindow.focus();
setTimeout(function () {
iframe.contentWindow.focus();
disable_delegate_pointerup = false;
});
};
// Let the iframe to handle mouseup events outside itself
var delegate_pointerup = function () {
if (disable_delegate_pointerup) {
return;
}
// This try-catch may only be needed for running Cypress tests.
try {
if (iframe.contentWindow && iframe.contentWindow.jQuery) {
iframe.contentWindow.jQuery("body").trigger("pointerup");
}
if (iframe.contentWindow) {
const event = new iframe.contentWindow.MouseEvent("mouseup", { button: 0 });
iframe.contentWindow.dispatchEvent(event);
const event2 = new iframe.contentWindow.MouseEvent("mouseup", { button: 2 });
iframe.contentWindow.dispatchEvent(event2);
}
} catch (error) {
console.log("Failed to access iframe to delegate pointerup; got", error);
}
};
$G.on("mouseup blur", delegate_pointerup);
$iframe.destroy = () => {
$G.off("mouseup blur", delegate_pointerup);
};
// @TODO: delegate pointermove events too?
$("body").addClass("loading-program");
programs_being_loaded += 1;
$iframe.on("load", function () {
if (--programs_being_loaded <= 0) {
$("body").removeClass("loading-program");
}
// This try-catch may only be needed for running Cypress tests.
try {
if (window.themeCSSProperties) {
applyTheme(themeCSSProperties, iframe.contentDocument.documentElement);
}
// on Wayback Machine, and iframe's url not saved yet
if (iframe.contentDocument.querySelector("#error #livewebInfo.available")) {
var message = document.createElement("div");
message.style.position = "absolute";
message.style.left = "0";
message.style.right = "0";
message.style.top = "0";
message.style.bottom = "0";
message.style.background = "#c0c0c0";
message.style.color = "#000";
message.style.padding = "50px";
iframe.contentDocument.body.appendChild(message);
message.innerHTML = `<a target="_blank">Save this url in the Wayback Machine</a>`;
message.querySelector("a").href =
"https://web.archive.org/save/https://98.js.org/" +
iframe.src.replace(/.*https:\/\/98.js.org\/?/, "");
message.querySelector("a").style.color = "blue";
}
var $contentWindow = $(iframe.contentWindow);
$contentWindow.on("pointerdown click", function (e) {
iframe.$window && iframe.$window.focus();
// from close_menus in $MenuBar
$(".menu-button").trigger("release");
// Close any rogue floating submenus
$(".menu-popup").hide();
});
// We want to disable pointer events for other iframes, but not this one
$contentWindow.on("pointerdown", function (e) {
$iframe.css("pointer-events", "all");
$("body").addClass("dragging");
});
$contentWindow.on("pointerup", function (e) {
$("body").removeClass("dragging");
$iframe.css("pointer-events", "");
});
// $("iframe").css("pointer-events", ""); is called elsewhere.
// Otherwise iframes would get stuck in this interaction mode
iframe.contentWindow.close = function () {
iframe.$window && iframe.$window.close();
};
// @TODO: hook into saveAs (a la FileSaver.js) and another function for opening files
// iframe.contentWindow.saveAs = function(){
// saveAsDialog();
// };
} catch (error) {
console.log("Failed to reach into iframe; got", error);
}
});
if (options.src) {
$iframe.attr({ src: options.src });
}
$iframe.css({
minWidth: 0,
minHeight: 0, // overrides user agent styling apparently, fixes Sound Recorder
flex: 1,
border: 0, // overrides user agent styling
});
return $iframe;
}
// function $IframeWindow(options) {
// var $win = new $Window(options);
// var $iframe = $win.$iframe = $Iframe({ src: options.src });
// $win.$content.append($iframe);
// var iframe = $win.iframe = $iframe[0];
// // @TODO: should I instead of having iframe.$window, have something like get$Window?
// // Where all is $window needed?
// // I know it's used from within the iframe contents as frameElement.$window
// iframe.$window = $win;
// $win.on("close", function () {
// $iframe.destroy();
// });
// $win.onFocus($iframe.focus_contents);
// $iframe.on("load", function () {
// $win.show();
// $win.focus();
// // $iframe.focus_contents();
// });
// $win.setInnerDimensions = ({ width, height }) => {
// const width_from_frame = $win.width() - $win.$content.width();
// const height_from_frame = $win.height() - $win.$content.height();
// $win.css({
// width: width + width_from_frame,
// height: height + height_from_frame + 21,
// });
// };
// $win.setInnerDimensions({
// width: (options.innerWidth || 640),
// height: (options.innerHeight || 380),
// });
// $win.$content.css({
// display: "flex",
// flexDirection: "column",
// });
// // @TODO: cascade windows
// $win.center();
// $win.hide();
// return $win;
// }
// Fix dragging things (i.e. windows) over iframes (i.e. other windows)
// (when combined with a bit of css, .dragging iframe { pointer-events: none; })
// (and a similar thing in $IframeWindow)
$(window).on("pointerdown", function (e) {
//console.log(e.type);
$("body").addClass("dragging");
});
$(window).on("pointerup dragend blur", function (e) {
//console.log(e.type);
if (e.type === "blur") {
if (document.activeElement.tagName.match(/iframe/i)) {
return;
}
}
$("body").removeClass("dragging");
$("iframe").css("pointer-events", "");
});
exports.show_help = show_help;
})(window);

View File

@@ -0,0 +1,285 @@
((exports) => {
const TAU =
// //////|//////
// ///// | /////
// /// tau ///
// /// ...--> | <--... ///
// /// -' one | turn '- ///
// // .' | '. //
// // / | \ //
// // | | <-.. | //
// // | .->| \ | //
// // | / | | | //
- - - - - - - - Math.PI + Math.PI - - - - - 0;
// // // | \ | | | //
// // // | '->| / | //
// // // | | <-'' | //
// // // \ | / //
// // // '. | .' //
// // /// -. | .- ///
// // /// '''----|----''' ///
// // /// | ///
// // ////// | /////
// // //////|////// C/r;
const is_pride_month = new Date().getMonth() === 5; // June (0-based, 0 is January)
const $G = $(window);
function make_css_cursor(name, coords, fallback) {
return `url(images/cursors/${name}.png) ${coords.join(" ")}, ${fallback}`;
}
function E(t) {
return document.createElement(t);
}
/** Returns a function, that, as long as it continues to be invoked, will not
be triggered. The function will be called after it stops being called for
N milliseconds. If `immediate` is passed, trigger the function on the
leading edge, instead of the trailing. */
function debounce(func, wait_ms, immediate) {
let timeout;
const debounced_func = function () {
const context = this;
const args = arguments;
const later = () => {
timeout = null;
if (!immediate) {
func.apply(context, args);
}
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait_ms);
if (callNow) {
func.apply(context, args);
}
};
debounced_func.cancel = () => {
clearTimeout(timeout);
};
return debounced_func;
}
function memoize_synchronous_function(func, max_entries = 50000) {
const cache = {};
const keys = [];
const memoized_func = (...args) => {
if (args.some((arg) => arg instanceof CanvasPattern)) {
return func.apply(null, args);
}
const key = JSON.stringify(args);
if (cache[key]) {
return cache[key];
} else {
const val = func.apply(null, args);
cache[key] = val;
keys.push(key);
if (keys.length > max_entries) {
const oldest_key = keys.shift();
delete cache[oldest_key];
}
return val;
}
}
memoized_func.clear_memo_cache = () => {
for (const key of keys) {
delete cache[key];
}
keys.length = 0;
};
return memoized_func;
}
const get_rgba_from_color = memoize_synchronous_function((color) => {
const single_pixel_canvas = make_canvas(1, 1);
single_pixel_canvas.ctx.fillStyle = color;
single_pixel_canvas.ctx.fillRect(0, 0, 1, 1);
const image_data = single_pixel_canvas.ctx.getImageData(0, 0, 1, 1);
// We could just return image_data.data, but let's return an Array instead
// I'm not totally sure image_data.data wouldn't keep the ImageData object around in memory
return Array.from(image_data.data);
});
/**
* Compare two ImageData.
* Note: putImageData is lossy, due to premultiplied alpha.
* @returns {boolean} whether all pixels match within the specified threshold
*/
function image_data_match(a, b, threshold) {
const a_data = a.data;
const b_data = b.data;
if (a_data.length !== b_data.length) {
return false;
}
for (let len = a_data.length, i = 0; i < len; i++) {
if (a_data[i] !== b_data[i]) {
if (Math.abs(a_data[i] - b_data[i]) > threshold) {
return false;
}
}
}
return true;
}
function make_canvas(width, height) {
const image = width;
const new_canvas = E("canvas");
const new_ctx = new_canvas.getContext("2d");
new_canvas.ctx = new_ctx;
new_ctx.disable_image_smoothing = () => {
new_ctx.imageSmoothingEnabled = false;
// condition is to avoid a deprecation warning in Firefox
if (new_ctx.imageSmoothingEnabled !== false) {
new_ctx.mozImageSmoothingEnabled = false;
new_ctx.webkitImageSmoothingEnabled = false;
new_ctx.msImageSmoothingEnabled = false;
}
};
new_ctx.enable_image_smoothing = () => {
new_ctx.imageSmoothingEnabled = true;
if (new_ctx.imageSmoothingEnabled !== true) {
new_ctx.mozImageSmoothingEnabled = true;
new_ctx.webkitImageSmoothingEnabled = true;
new_ctx.msImageSmoothingEnabled = true;
}
};
// @TODO: simplify the abstraction by defining setters for width/height
// that reset the image smoothing to disabled
// and make image smoothing a parameter to make_canvas
new_ctx.copy = image => {
new_canvas.width = image.naturalWidth || image.width;
new_canvas.height = image.naturalHeight || image.height;
// setting width/height resets image smoothing (along with everything)
new_ctx.disable_image_smoothing();
if (image instanceof ImageData) {
new_ctx.putImageData(image, 0, 0);
} else {
new_ctx.drawImage(image, 0, 0);
}
};
if (width && height) {
// make_canvas(width, height)
new_canvas.width = width;
new_canvas.height = height;
// setting width/height resets image smoothing (along with everything)
new_ctx.disable_image_smoothing();
} else if (image) {
// make_canvas(image)
new_ctx.copy(image);
}
return new_canvas;
}
function get_help_folder_icon(file_name) {
const icon_img = new Image();
icon_img.src = `help/${file_name}`;
return icon_img;
}
function get_icon_for_tool(tool) {
return get_help_folder_icon(tool.help_icon);
}
// not to be confused with load_image_from_uri
function load_image_simple(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => { resolve(img); };
img.onerror = () => { reject(new Error(`failed to load image from ${src}`)); };
img.src = src;
});
}
function get_icon_for_tools(tools) {
if (tools.length === 1) {
return get_icon_for_tool(tools[0]);
}
const icon_canvas = make_canvas(16, 16);
Promise.all(tools.map((tool) => load_image_simple(`help/${tool.help_icon}`)))
.then((icons) => {
icons.forEach((icon, i) => {
const w = icon_canvas.width / icons.length;
const x = i * w;
const h = icon_canvas.height;
const y = 0;
icon_canvas.ctx.drawImage(icon, x, y, w, h, x, y, w, h);
});
})
return icon_canvas;
}
/**
* Converts an RGB color value to HSL. Conversion formula
* adapted from http://en.wikipedia.org/wiki/HSL_color_space.
* Assumes r, g, and b are contained in the set [0, 255] and
* returns h, s, and l in the set [0, 1].
*
* @param Number r The red color value
* @param Number g The green color value
* @param Number b The blue color value
* @return Array The HSL representation
*/
function rgb_to_hsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
var max = Math.max(r, g, b), min = Math.min(r, g, b);
var h, s, l = (max + min) / 2;
if (max == min) {
h = s = 0; // achromatic
} else {
var d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [h, s, l];
}
exports.TAU = TAU;
exports.is_pride_month = is_pride_month;
exports.$G = $G;
exports.E = E;
exports.make_css_cursor = make_css_cursor;
exports.make_canvas = make_canvas;
exports.get_help_folder_icon = get_help_folder_icon;
exports.get_icon_for_tool = get_icon_for_tool;
exports.get_icon_for_tools = get_icon_for_tools;
exports.load_image_simple = load_image_simple;
exports.rgb_to_hsl = rgb_to_hsl;
exports.image_data_match = image_data_match;
exports.get_rgba_from_color = get_rgba_from_color;
exports.memoize_synchronous_function = memoize_synchronous_function;
exports.debounce = debounce;
})(window);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,197 @@
((exports) => {
let $imgur_window;
function show_imgur_uploader(blob) {
if ($imgur_window) {
$imgur_window.close();
}
$imgur_window = $DialogWindow().title("Upload To Imgur").addClass("horizontal-buttons");
const $preview_image_area = $(E("div")).appendTo($imgur_window.$main).addClass("inset-deep");
const $imgur_url_area = $(E("div")).appendTo($imgur_window.$main);
const $imgur_status = $(E("div")).appendTo($imgur_window.$main);
// @TODO: maybe make this preview small but zoomable to full size?
// (starting small (max-width: 100%) and toggling to either scrollable or fullscreen)
// it should be clear that it's not going to upload a downsized version of your image
const $preview_image = $(E("img")).appendTo($preview_image_area).css({
display: "block", // prevent margin below due to inline display (vertical-align can also be used)
});
const blob_url = URL.createObjectURL(blob);
$preview_image.attr({ src: blob_url });
// $preview_image.css({maxWidth: "100%", maxHeight: "400px"});
$preview_image_area.css({
maxWidth: "90vw",
maxHeight: "70vh",
overflow: "auto",
marginBottom: "0.5em",
});
$preview_image.on("load", () => {
$imgur_window.css({ width: "auto" });
$imgur_window.center();
});
$imgur_window.on("close", () => {
URL.revokeObjectURL(blob_url);
});
const $upload_button = $imgur_window.$Button("Upload", () => {
URL.revokeObjectURL(blob_url);
$preview_image_area.remove();
$upload_button.remove();
$cancel_button.remove(); // @TODO: allow canceling upload request
$imgur_window.$content.width(300);
$imgur_window.center();
const $progress = $(E("progress")).appendTo($imgur_window.$main).addClass("inset-deep");
const $progress_percent = $(E("span")).appendTo($imgur_window.$main).css({
width: "2.3em",
display: "inline-block",
textAlign: "center",
});
const parseImgurResponseJSON = responseJSON => {
try {
return JSON.parse(responseJSON);
} catch (error) {
$imgur_status.text("Received an invalid JSON response from Imgur: ");
// .append($(E("pre")).text(responseJSON));
// show_error_message("Received an invalid JSON response from Imgur: ", responseJSON);
// show_error_message("Received an invalid JSON response from Imgur: ", responseJSON, but also error);
// $imgur_window.close();
// @TODO: DRY, including with show_error_message
$(E("pre"))
.appendTo($imgur_status)
.text(responseJSON)
.css({
background: "white",
color: "#333",
fontFamily: "monospace",
width: "500px",
overflow: "auto",
});
$(E("pre"))
.appendTo($imgur_status)
.text(error.toString())
.css({
background: "white",
color: "#333",
fontFamily: "monospace",
width: "500px",
overflow: "auto",
});
$imgur_window.css({ width: "auto" });
$imgur_window.center();
}
};
// make an HTTP request to the Imgur image upload API
const req = new XMLHttpRequest();
if (req.upload) {
req.upload.addEventListener('progress', event => {
if (event.lengthComputable) {
const progress_value = event.loaded / event.total;
const percentage_text = `${Math.floor(progress_value * 100)}%`;
$progress.val(progress_value);
$progress_percent.text(percentage_text);
}
}, false);
}
req.addEventListener("readystatechange", () => {
if (req.readyState == 4 && req.status == 200) {
$progress.add($progress_percent).remove();
const response = parseImgurResponseJSON(req.responseText);
if (!response) return;
if (!response.success) {
$imgur_status.text("Failed to upload image :(");
return;
}
const url = response.data.link;
$imgur_status.text("");
const $imgur_url = $(E("a")).attr({ id: "imgur-url", target: "_blank" });
$imgur_url.text(url);
$imgur_url.attr('href', url);
$imgur_url_area.append(
"<label>URL: </label>"
).append($imgur_url);
// @TODO: a button to copy the URL to the clipboard
// (also maybe put the URL in a readonly input)
let $ok_button;
const $delete_button = $imgur_window.$Button("Delete", () => {
const req = new XMLHttpRequest();
$delete_button[0].disabled = true;
req.addEventListener("readystatechange", () => {
if (req.readyState == 4 && req.status == 200) {
$delete_button.remove();
$ok_button.focus();
const response = parseImgurResponseJSON(req.responseText);
if (!response) return;
if (response.success) {
$imgur_url_area.remove();
$imgur_status.text("Deleted successfully");
} else {
$imgur_status.text("Failed to delete image :(");
}
} else if (req.readyState == 4) {
$imgur_status.text("Error deleting image :(");
$delete_button[0].disabled = false;
$delete_button.focus();
}
});
req.open("DELETE", `https://api.imgur.com/3/image/${response.data.deletehash}`, true);
req.setRequestHeader("Authorization", "Client-ID 203da2f300125a1");
req.setRequestHeader("Accept", "application/json");
req.send(null);
$imgur_status.text("Deleting...");
});
$ok_button = $imgur_window.$Button(localize("OK"), () => {
$imgur_window.close();
}).focus();
} else if (req.readyState == 4) {
$progress.add($progress_percent).remove();
$imgur_status.text("Error uploading image :(");
}
});
req.open("POST", "https://api.imgur.com/3/image", true);
const form_data = new FormData();
form_data.append("image", blob);
req.setRequestHeader("Authorization", "Client-ID 203da2f300125a1");
req.setRequestHeader("Accept", "application/json");
req.send(form_data);
$imgur_status.text("Uploading...");
}).focus();
const $cancel_button = $imgur_window.$Button(localize("Cancel"), () => {
$imgur_window.close();
});
$imgur_window.$content.css({
width: "min(1000px, 80vw)",
});
$imgur_window.center();
}
exports.show_imgur_uploader = show_imgur_uploader;
})(window);

View File

@@ -0,0 +1,122 @@
((exports) => {
let $storage_manager;
let $quota_exceeded_window;
let ignoring_quota_exceeded = false;
async function storage_quota_exceeded() {
return;
if ($quota_exceeded_window) {
$quota_exceeded_window.close();
$quota_exceeded_window = null;
}
if (ignoring_quota_exceeded) {
return;
}
const { promise, $window } = showMessageBox({
title: "Storage Error",
messageHTML: `
<p>JS Paint stores images as you work on them so that if you close your browser or tab or reload the page your images are usually safe.</p>
<p>However, it has run out of space to do so.</p>
<p>You can still save the current image with <b>File > Save</b>. You should save frequently, or free up enough space to keep the image safe.</p>
`,
buttons: [
{ label: "Manage Storage", value: "manage", default: true },
{ label: "Ignore", value: "ignore" },
],
iconID: "warning",
});
$quota_exceeded_window = $window;
const result = await promise;
if (result === "ignore") {
ignoring_quota_exceeded = true;
} else if (result === "manage") {
ignoring_quota_exceeded = false;
manage_storage();
}
}
function manage_storage() {
if ($storage_manager) {
$storage_manager.close();
}
$storage_manager = $DialogWindow().title("Manage Storage").addClass("storage-manager squish");
// @TODO: way to remove all (with confirmation)
const $table = $(E("table")).appendTo($storage_manager.$main);
const $message = $(E("p")).appendTo($storage_manager.$main).html(
"Any images you've saved to your computer with <b>File > Save</b> will not be affected."
);
$storage_manager.$Button("Close", () => {
$storage_manager.close();
});
const addRow = (k, imgSrc) => {
const $tr = $(E("tr")).appendTo($table);
const $img = $(E("img")).attr({ src: imgSrc }).addClass("thumbnail-img");
const $remove = $(E("button")).text("Remove").addClass("remove-button");
const href = `#${k.replace("image#", "local:")}`;
const $open_link = $(E("a")).attr({ href, target: "_blank" }).text(localize("Open"));
const $thumbnail_open_link = $(E("a")).attr({ href, target: "_blank" }).addClass("thumbnail-container");
$thumbnail_open_link.append($img);
$(E("td")).append($thumbnail_open_link).appendTo($tr);
$(E("td")).append($open_link).appendTo($tr);
$(E("td")).append($remove).appendTo($tr);
$remove.on("click", () => {
localStorage.removeItem(k);
$tr.remove();
if ($table.find("tr").length == 0) {
$message.html("<p>All clear!</p>");
}
});
};
let localStorageAvailable = false;
try {
if (localStorage.length > 0) {
// This is needed in case it's COMPLETELY full.
// Test with https://stackoverflow.com/questions/45760110/how-to-fill-javascript-localstorage-to-its-max-capacity-quickly
// Of course, this dialog only manages images, not other data (for now anyway).
localStorageAvailable = true;
} else {
localStorage._available = true;
localStorageAvailable = localStorage._available;
delete localStorage._available;
}
// eslint-disable-next-line no-empty
} catch (e) { }
if (localStorageAvailable) {
for (const k in localStorage) {
if (k.match(/^image#/)) {
let v = localStorage[k];
try {
if (v[0] === '"') {
v = JSON.parse(v);
}
// eslint-disable-next-line no-empty
} catch (e) { }
addRow(k, v);
}
}
}
if (!localStorageAvailable) {
// @TODO: DRY with similar message
// @TODO: instructions for your browser; it's called Cookies in chrome/chromium at least, and "storage" gives NO results
$message.html("<p>Please enable local storage in your browser's settings for local backup. It may be called Cookies, Storage, or Site Data.</p>");
} else if ($table.find("tr").length == 0) {
$message.html("<p>All clear!</p>");
}
$storage_manager.$content.width(450);
$storage_manager.center();
$storage_manager.find(".remove-button").focus();
}
exports.storage_quota_exceeded = storage_quota_exceeded;
exports.manage_storage = manage_storage;
})(window);

View File

@@ -0,0 +1,715 @@
((exports) => {
const looksLikeChrome = !!(window.chrome && (window.chrome.loadTimes || window.chrome.csi));
// NOTE: Microsoft Edge includes window.chrome.app
// (also this browser detection logic could likely use some more nuance)
const menus = {
[localize("&File")]: [
{
item: localize("&New"),
shortcut: window.is_electron_app ? "Ctrl+N" : "Ctrl+Alt+N", // Ctrl+N opens a new browser window
speech_recognition: [
"new", "new file", "new document", "create new document", "create a new document", "start new document", "start a new document",
],
action: () => { open_empty_window(); },
description: localize("Creates a new document."),
},
{
item: localize("&Open"),
shortcut: "Ctrl+O",
speech_recognition: [
"open", "open document", "open file", "open an image file", "open a document", "open a file",
"load document", "load a document", "load an image file", "load an image",
"show file picker", "show file chooser", "show file browser", "show finder",
"browser for file", "browse for a file", "browse for an image", "browse for an image file",
],
action: () => { open_in_new_window(); },
description: localize("Opens an existing document."),
},
{
item: localize("&Save"),
shortcut: "Ctrl+S",
speech_recognition: [
"save", "save document", "save file", "save image", "save picture", "save image file",
// "save a document", "save a file", "save an image", "save an image file", // too "save as"-like
"save the document", "save the file", "save the image", "save the image file",
"download", "download document", "download file", "download image", "download picture", "download image file",
"download the document", "download the file", "download the image", "download the image file",
],
action: () => { file_save(); },
description: localize("Saves the active document."),
},
{
item: localize("Save &As"),
// in mspaint, no shortcut is listed; it supports F12 (but in a browser that opens the dev tools)
// it doesn't support Ctrl+Shift+S but that's a good & common modern shortcut
shortcut: "Ctrl+Shift+S",
speech_recognition: [
// this is ridiculous
// this would be really simple in JSGF format
"save as", "save as a new file", "save as a new picture", "save as a new image", "save a new file", "save new file",
"save a new document", "save a new image file", "save a new image", "save a new picture",
"save as a copy", "save a copy", "save as copy", "save under a new name", "save with a new name",
"save document as a copy", "save document copy", "save document as copy", "save document under a new name", "save document with a new name",
"save image as a copy", "save image copy", "save image as copy", "save image under a new name", "save image with a new name",
"save file as a copy", "save file copy", "save file as copy", "save file under a new name", "save file with a new name",
"save image file as a copy", "save image file copy", "save image file as copy", "save image file under a new name", "save image file with a new name",
],
action: () => { file_save_as(); },
description: localize("Saves the active document with a new name."),
},
MENU_DIVIDER,
{
item: localize("&Load From URL"),
// shortcut: "", // no shortcut: Ctrl+L is taken, and you can paste a URL with Ctrl+V, so it's not really needed
speech_recognition: [
"load from url",
"load from a url",
"load from address",
"load from an address",
"load from a web address",
// this is ridiculous
// this would be really simple in JSGF format
"load an image from a URL",
"load an image from an address",
"load an image from a web address",
"load image from a URL",
"load image from an address",
"load image from a web address",
"load an image from URL",
"load an image from address",
"load an image from web address",
"load image from URL",
"load image from address",
"load image from web address",
"load an picture from a URL",
"load an picture from an address",
"load an picture from a web address",
"load picture from a URL",
"load picture from an address",
"load picture from a web address",
"load an picture from URL",
"load an picture from address",
"load an picture from web address",
"load picture from URL",
"load picture from address",
"load picture from web address",
],
action: () => { file_load_from_url(); },
description: localize("Opens an image from the web."),
},
{
item: localize("&Upload To Imgur"),
speech_recognition: [
"upload to imgur", "upload image to imgur", "upload picture to imgur",
],
action: () => {
// include the selection in the saved image
deselect();
main_canvas.toBlob((blob) => {
sanity_check_blob(blob, () => {
show_imgur_uploader(blob);
});
});
},
description: localize("Uploads the active document to Imgur"),
},
// MENU_DIVIDER,
// {
// item: localize("Manage Storage"),
// speech_recognition: [
// "manage storage", "show storage", "open storage window", "manage sessions", "show sessions", "show local sessions", "local sessions", "storage manager", "show storage manager", "open storage manager",
// "show autosaves", "show saves", "show saved documents", "show saved files", "show saved pictures", "show saved images", "show local storage",
// "autosaves", "autosave", "saved documents", "saved files", "saved pictures", "saved images", "local storage",
// ],
// action: () => { manage_storage(); },
// description: localize("Manages storage of previously created or opened pictures."),
// },
// MENU_DIVIDER,
// {
// item: localize("Print Pre&view"),
// speech_recognition: [
// "preview print", "print preview", "show print preview", "show preview of print",
// ],
// action: () => {
// print();
// },
// description: localize("Prints the active document and sets printing options."),
// //description: localize("Displays full pages."),
// },
// {
// item: localize("Page Se&tup"),
// speech_recognition: [
// "setup page for print", "setup page for printing", "set-up page for print", "set-up page for printing", "set up page for print", "set up page for printing",
// "page setup", "printing setup", "page set-up", "printing set-up", "page set up", "printing set up",
// ],
// action: () => {
// print();
// },
// description: localize("Prints the active document and sets printing options."),
// //description: localize("Changes the page layout."),
// },
// {
// item: localize("&Print"),
// shortcut: "Ctrl+P", // relies on browser's print shortcut being Ctrl+P
// speech_recognition: [
// "print", "send to printer", "show print dialog",
// "print page", "print image", "print picture", "print drawing",
// "print out page", "print out image", "print out picture", "print out drawing",
// "print out the page", "print out the image", "print out the picture", "print out the drawing",
// "send page to printer", "send image to printer", "send picture to printer", "send drawing to printer",
// "send page to the printer", "send image to the printer", "send picture to the printer", "send drawing to the printer",
// "send the page to the printer", "send the image to the printer", "send the picture to the printer", "send the drawing to the printer",
// "send the page to printer", "send the image to printer", "send the picture to printer", "send the drawing to printer",
// ],
// action: () => {
// print();
// },
// description: localize("Prints the active document and sets printing options."),
// },
// MENU_DIVIDER,
// {
// item: localize("Set As &Wallpaper (Tiled)"),
// speech_recognition: [
// "set as wallpaper",
// "set as wallpaper tiled",
// "set image as wallpaper tiled", "set picture as wallpaper tiled", "set drawing as wallpaper tiled",
// "use as wallpaper tiled",
// "use image as wallpaper tiled", "use picture as wallpaper tiled", "use drawing as wallpaper tiled",
// "tile image as wallpaper", "tile picture as wallpaper", "tile drawing as wallpaper",
// ],
// action: () => { systemHooks.setWallpaperTiled(main_canvas); },
// description: localize("Tiles this bitmap as the desktop background."),
// },
// {
// item: localize("Set As Wallpaper (&Centered)"), // in mspaint it's Wa&llpaper
// speech_recognition: [
// "set as wallpaper centered",
// "set image as wallpaper centered", "set picture as wallpaper centered", "set drawing as wallpaper centered",
// "use as wallpaper centered",
// "use image as wallpaper centered", "use picture as wallpaper centered", "use drawing as wallpaper centered",
// "center image as wallpaper", "center picture as wallpaper", "center drawing as wallpaper",
// ],
// action: () => { systemHooks.setWallpaperCentered(main_canvas); },
// description: localize("Centers this bitmap as the desktop background."),
// },
// MENU_DIVIDER,
// {
// item: localize("Recent File"),
// enabled: false, // @TODO for desktop app
// description: localize(""),
// },
// MENU_DIVIDER,
// {
// item: localize("E&xit"),
// shortcut: window.is_electron_app ? "Alt+F4" : "", // Alt+F4 closes the browser window (in most window managers)
// speech_recognition: [
// "exit application", "exit paint", "close paint window",
// ],
// action: () => {
// try {
// // API contract is containing page can override window.close()
// // Note that e.g. (()=>{}).bind().toString() gives "function () { [native code] }"
// // so the window.close() must not use bind() (not that that's common practice anyway)
// if (frameElement && window.close && !/\{\s*\[native code\]\s*\}/.test(window.close.toString())) {
// window.close();
// return;
// }
// } catch (e) {
// // In a cross-origin iframe, most likely
// // @TODO: establish postMessage API
// }
// // In a cross-origin iframe, or same origin but without custom close(), or top level:
// // Not all browsers support close() for closing a tab,
// // so redirect instead. Exit to the official web desktop.
// window.location = "https://98.js.org/";
// },
// description: localize("Quits Paint."),
// }
],
[localize("&Edit")]: [
{
item: localize("&Undo"),
shortcut: "Ctrl+Z",
speech_recognition: [
"undo", "undo that",
],
enabled: () => undos.length >= 1,
action: () => { undo(); },
description: localize("Undoes the last action."),
},
{
item: localize("&Repeat"),
shortcut: "F4", // also supported: Ctrl+Shift+Z, Ctrl+Y
speech_recognition: [
"repeat", "redo",
],
enabled: () => redos.length >= 1,
action: () => { redo(); },
description: localize("Redoes the previously undone action."),
},
{
item: localize("&History"),
shortcut: "Ctrl+Shift+Y",
speech_recognition: [
"show history", "history",
],
action: () => { show_document_history(); },
description: localize("Shows the document history and lets you navigate to states not accessible with Undo or Repeat."),
},
MENU_DIVIDER,
{
item: localize("Cu&t"),
shortcut: "Ctrl+X",
speech_recognition: [
"cut", "cut selection", "cut selection to clipboard", "cut the selection", "cut the selection to clipboard", "cut the selection to the clipboard",
],
enabled: () =>
// @TODO: support cutting text with this menu item as well (e.g. for the text tool)
!!selection,
action: () => {
edit_cut(true);
},
description: localize("Cuts the selection and puts it on the Clipboard."),
},
{
item: localize("&Copy"),
shortcut: "Ctrl+C",
speech_recognition: [
"copy", "copy selection", "copy selection to clipboard", "copy the selection", "copy the selection to clipboard", "copy the selection to the clipboard",
],
enabled: () =>
// @TODO: support copying text with this menu item as well (e.g. for the text tool)
!!selection,
action: () => {
edit_copy(true);
},
description: localize("Copies the selection and puts it on the Clipboard."),
},
{
item: localize("&Paste"),
shortcut: "Ctrl+V",
speech_recognition: [
"paste", "paste from clipboard", "paste from the clipboard", "insert clipboard", "insert clipboard contents", "insert the contents of the clipboard", "paste what's on the clipboard",
],
enabled: () =>
// @TODO: disable if nothing in clipboard or wrong type (if we can access that)
true,
action: () => {
edit_paste(true);
},
description: localize("Inserts the contents of the Clipboard."),
},
{
item: localize("C&lear Selection"),
shortcut: "Del",
speech_recognition: [
"delete", "clear selection", "delete selection", "delete selected", "delete selected area", "clear selected area", "erase selected", "erase selected area",
],
enabled: () => !!selection,
action: () => { delete_selection(); },
description: localize("Deletes the selection."),
},
{
item: localize("Select &All"),
shortcut: "Ctrl+A",
speech_recognition: [
"select all", "select everything",
"select the whole image", "select the whole picture", "select the whole drawing", "select the whole canvas", "select the whole document",
"select the entire image", "select the entire picture", "select the entire drawing", "select the entire canvas", "select the entire document",
],
action: () => { select_all(); },
description: localize("Selects everything."),
},
MENU_DIVIDER,
{
item: `${localize("C&opy To")}...`,
speech_recognition: [
"copy to file", "copy selection to file", "copy selection to a file", "save selection",
"save selection as file", "save selection as image", "save selection as picture", "save selection as image file", "save selection as document",
"save selection as a file", "save selection as a image", "save selection as a picture", "save selection as a image file", "save selection as a document",
"save selection to file", "save selection to image", "save selection to picture", "save selection to image file", "save selection to document",
"save selection to a file", "save selection to a image", "save selection to a picture", "save selection to a image file", "save selection to a document",
],
enabled: () => !!selection,
action: () => { save_selection_to_file(); },
description: localize("Copies the selection to a file."),
},
{
item: `${localize("Paste &From")}...`,
speech_recognition: [
"paste a file", "paste from a file", "insert a file", "insert an image file",
],
action: () => { choose_file_to_paste(); },
description: localize("Pastes a file into the selection."),
}
],
[localize("&View")]: [
{
item: localize("&Tool Box"),
shortcut: window.is_electron_app ? "Ctrl+T" : "", // Ctrl+T opens a new browser tab, Ctrl+Alt+T opens a Terminal in Ubuntu, and Ctrl+Shift+Alt+T feels silly.
speech_recognition: [
"toggle tool box", "toggle tools box", "toggle toolbox", "toggle tool palette", "toggle tools palette",
// @TODO: hide/show
],
checkbox: {
toggle: () => {
$toolbox.toggle();
},
check: () => $toolbox.is(":visible"),
},
description: localize("Shows or hides the tool box."),
},
{
item: localize("&Color Box"),
shortcut: "Ctrl+L", // focuses browser address bar, but Firefox and Chrome both allow overriding the default behavior
speech_recognition: [
"toggle color box", "toggle colors box", "toggle palette", "toggle color palette", "toggle colors palette",
// @TODO: hide/show
],
checkbox: {
toggle: () => {
$colorbox.toggle();
},
check: () => $colorbox.is(":visible"),
},
description: localize("Shows or hides the color box."),
},
{
item: localize("&Status Bar"),
speech_recognition: [
"toggle status bar", "toggle status text", "toggle status area", "toggle status indicator",
// @TODO: hide/show
],
checkbox: {
toggle: () => {
$status_area.toggle();
},
check: () => $status_area.is(":visible"),
},
description: localize("Shows or hides the status bar."),
},
{
item: localize("T&ext Toolbar"),
speech_recognition: [
"toggle text toolbar", "toggle font toolbar", "toggle text tool bar", "toggle font tool bar",
"toggle font box", "toggle fonts box", "toggle text options box", "toggle text tool options box", "toggle font options box",
"toggle font window", "toggle fonts window", "toggle text options window", "toggle text tool options window", "toggle font options window",
// @TODO: hide/show
],
enabled: false, // @TODO: toggle fonts box
checkbox: {},
description: localize("Shows or hides the text toolbar."),
},
MENU_DIVIDER,
{
item: localize("&Zoom"),
submenu: [
{
item: localize("&Normal Size"),
shortcut: window.is_electron_app ? "Ctrl+PgUp" : "", // Ctrl+PageUp cycles thru browser tabs in Chrome & Firefox; can be overridden in Chrome in fullscreen only
speech_recognition: [
"reset zoom", "zoom to normal size",
"zoom to 100%", "set zoom to 100%", "set zoom 100%",
"zoom to 1x", "set zoom to 1x", "set zoom 1x",
"zoom level to 100%", "set zoom level to 100%", "set zoom level 100%",
"zoom level to 1x", "set zoom level to 1x", "set zoom level 1x",
],
description: localize("Zooms the picture to 100%."),
enabled: () => magnification !== 1,
action: () => {
set_magnification(1);
},
},
{
item: localize("&Large Size"),
shortcut: window.is_electron_app ? "Ctrl+PgDn" : "", // Ctrl+PageDown cycles thru browser tabs in Chrome & Firefox; can be overridden in Chrome in fullscreen only
speech_recognition: [
"zoom to large size",
"zoom to 400%", "set zoom to 400%", "set zoom 400%",
"zoom to 4x", "set zoom to 4x", "set zoom 4x",
"zoom level to 400%", "set zoom level to 400%", "set zoom level 400%",
"zoom level to 4x", "set zoom level to 4x", "set zoom level 4x",
],
description: localize("Zooms the picture to 400%."),
enabled: () => magnification !== 4,
action: () => {
set_magnification(4);
},
},
{
item: localize("Zoom To &Window"),
speech_recognition: [
"zoom to window", "zoom to view",
"zoom to fit",
"zoom to fit within window", "zoom to fit within view",
"zoom to fit within the window", "zoom to fit within the view",
"zoom to fit in window", "zoom to fit in view",
"zoom to fit in the window", "zoom to fit in the view",
"auto zoom", "fit zoom",
"zoom to max", "zoom to maximum", "zoom to max size", "zoom to maximum size",
"zoom so canvas fits", "zoom so picture fits", "zoom so image fits", "zoom so document fits",
"zoom so whole canvas is visible", "zoom so whole picture is visible", "zoom so whole image is visible", "zoom so whole document is visible",
"zoom so the whole canvas is visible", "zoom so the whole picture is visible", "zoom so the whole image is visible", "zoom so the whole document is visible",
"fit to window", "fit to view", "fit in window", "fit in view", "fit within window", "fit within view",
"fit picture to window", "fit picture to view", "fit picture in window", "fit picture in view", "fit picture within window", "fit picture within view",
"fit image to window", "fit image to view", "fit image in window", "fit image in view", "fit image within window", "fit image within view",
"fit canvas to window", "fit canvas to view", "fit canvas in window", "fit canvas in view", "fit canvas within window", "fit canvas within view",
"fit document to window", "fit document to view", "fit document in window", "fit document in view", "fit document within window", "fit document within view",
],
description: localize("Zooms the picture to fit within the view."),
action: () => {
const rect = $canvas_area[0].getBoundingClientRect();
const margin = 30; // leave a margin so scrollbars won't appear
let mag = Math.min(
(rect.width - margin) / main_canvas.width,
(rect.height - margin) / main_canvas.height,
);
// round to an integer percent for the View > Zoom > Custom... dialog, which shows non-integers as invalid
mag = Math.floor(100 * mag) / 100;
set_magnification(mag);
},
},
{
item: `${localize("C&ustom")}...`,
description: localize("Zooms the picture."),
speech_recognition: [
"zoom custom", "custom zoom", "set custom zoom", "set custom zoom level", "zoom to custom level", "zoom to custom", "zoom level", "set zoom level",
],
action: () => { show_custom_zoom_window(); },
},
MENU_DIVIDER,
{
item: localize("Show &Grid"),
shortcut: "Ctrl+G",
speech_recognition: [
"toggle show grid",
"toggle grid", "toggle gridlines", "toggle grid lines", "toggle grid cells",
// @TODO: hide/show
],
enabled: () => magnification >= 4,
checkbox: {
toggle: () => { toggle_grid(); },
check: () => show_grid,
},
description: localize("Shows or hides the grid."),
},
{
item: localize("Show T&humbnail"),
speech_recognition: [
"toggle show thumbnail",
"toggle thumbnail", "toggle thumbnail view", "toggle thumbnail box", "toggle thumbnail window",
"toggle preview", "toggle image preview", "toggle picture preview",
"toggle picture in picture", "toggle picture in picture view", "toggle picture in picture box", "toggle picture in picture window",
// @TODO: hide/show
],
checkbox: {
toggle: () => { toggle_thumbnail(); },
check: () => show_thumbnail,
},
description: localize("Shows or hides the thumbnail view of the picture."),
}
]
},
{
item: localize("&View Bitmap"),
shortcut: "Ctrl+F",
speech_recognition: [
"view bitmap", "show bitmap",
"fullscreen", "full-screen", "full screen",
"show picture fullscreen", "show picture full-screen", "show picture full screen",
"show image fullscreen", "show image full-screen", "show image full screen",
// @TODO: exit fullscreen
],
action: () => { view_bitmap(); },
description: localize("Displays the entire picture."),
},
MENU_DIVIDER,
{
item: localize("&Fullscreen"),
shortcut: "F11", // relies on browser's shortcut
speech_recognition: [
// won't work with speech recognition, needs a user gesture
],
enabled: () => document.fullscreenEnabled || document.webkitFullscreenEnabled,
checkbox: {
check: () => document.fullscreenElement || document.webkitFullscreenElement,
toggle: () => {
if (document.fullscreenElement || document.webkitFullscreenElement) {
if (document.exitFullscreen) { document.exitFullscreen(); }
else if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); }
} else {
if (document.documentElement.requestFullscreen) { document.documentElement.requestFullscreen(); }
else if (document.documentElement.webkitRequestFullscreen) { document.documentElement.webkitRequestFullscreen(); }
}
// check() would need to be async or faked with a timeout,
// if the menus stayed open. @TODO: make all checkboxes close menus
menu_bar.closeMenus();
},
},
description: localize("Makes the application take up the entire screen."),
},
],
[localize("&Image")]: [
// @TODO: speech recognition: terms that apply to selection
{
item: localize("&Flip/Rotate"),
shortcut: (window.is_electron_app && !window.electron_is_dev) ? "Ctrl+R" : "Ctrl+Alt+R", // Ctrl+R reloads the browser tab (or Electron window in dev mode via electron-debug)
speech_recognition: [
"flip",
"rotate",
"flip/rotate", "flip slash rotate", "flip and rotate", "flip or rotate", "flip rotate",
// @TODO: parameters to command
],
action: () => { image_flip_and_rotate(); },
description: localize("Flips or rotates the picture or a selection."),
},
{
item: localize("&Stretch/Skew"),
shortcut: window.is_electron_app ? "Ctrl+W" : "Ctrl+Alt+W", // Ctrl+W closes the browser tab
speech_recognition: [
"stretch", "scale", "resize image",
"skew",
"stretch/skew", "stretch slash skew", "stretch and skew", "stretch or skew", "stretch skew",
// @TODO: parameters to command
],
action: () => { image_stretch_and_skew(); },
description: localize("Stretches or skews the picture or a selection."),
},
{
item: localize("&Invert Colors"),
shortcut: "Ctrl+I",
speech_recognition: [
"invert",
"invert colors",
"invert image", "invert picture", "invert drawing",
"invert image colors", "invert picture colors", "invert drawing colors",
"invert colors of image", "invert colors of picture", "invert colors of drawing",
],
action: () => { image_invert_colors(); },
description: localize("Inverts the colors of the picture or a selection."),
},
{
item: `${localize("&Attributes")}...`,
shortcut: "Ctrl+E",
speech_recognition: [
"attributes", "image attributes", "picture attributes", "image options", "picture options",
"dimensions", "image dimensions", "picture dimensions",
"resize canvas", "resize document", "resize page", // not resize image/picture because that implies scaling, handled by Stretch/Skew
"set image size", "set picture size", "set canvas size", "set document size", "set page size",
"image size", "picture size", "canvas size", "document size", "page size",
"configure image size", "configure picture size", "configure canvas size", "configure document size", "configure page size",
],
action: () => { image_attributes(); },
description: localize("Changes the attributes of the picture."),
},
{
item: localize("&Clear Image"),
shortcut: (window.is_electron_app || !looksLikeChrome) ? "Ctrl+Shift+N" : "", // Ctrl+Shift+N opens incognito window in chrome
speech_recognition: [
"clear image", "clear canvas", "clear picture", "clear page", "clear drawing",
// @TODO: erase?
],
// (mspaint says "Ctrl+Shft+N")
action: () => { !selection && clear(); },
enabled: () => !selection,
description: localize("Clears the picture."),
// action: ()=> {
// if (selection) {
// delete_selection();
// } else {
// clear();
// }
// },
// mspaint says localize("Clears the picture or selection."), but grays out the option when there's a selection
},
{
item: localize("&Draw Opaque"),
speech_recognition: [
"toggle draw opaque",
"toggle transparent selection", "toggle transparent selections",
"toggle transparent selection mode", "toggle transparent selections mode",
"toggle opaque selection", "toggle opaque selections",
"toggle opaque selection mode", "toggle opaque selections mode",
// toggle opaque? toggle opacity?
// @TODO: hide/show / "draw opaque" / "draw transparent"/translucent?
],
checkbox: {
toggle: () => {
tool_transparent_mode = !tool_transparent_mode;
$G.trigger("option-changed");
},
check: () => !tool_transparent_mode,
},
description: localize("Makes the current selection either opaque or transparent."),
}
],
[localize("&Help")]: [
{
item: localize("&Help Topics"),
speech_recognition: [
"help topics", "help me", "show help", "help", "show help window", "show help topics", "open help",
"help viewer", "show help viewer", "open help viewer",
],
action: () => { show_help(); },
description: localize("Displays Help for the current task or command."),
},
MENU_DIVIDER,
{
item: localize("&About Paint"),
speech_recognition: [
"about paint", "about js paint", "about jspaint", "show about window", "open about window", "about window",
"app info", "about the app", "app information", "information about the app",
"application info", "about the application", "application information", "information about the application",
"who made this", "who did this", "who did this xd",
],
action: () => { show_about_paint(); },
description: localize("Displays information about this application."),
//description: localize("Displays program information, version number, and copyright."),
}
],
};
for (const [top_level_menu_key, menu] of Object.entries(menus)) {
const top_level_menu_name = top_level_menu_key.replace(/&/, "");
const add_literal_navigation_speech_recognition = (menu, ancestor_names) => {
for (const menu_item of menu) {
if (menu_item !== MENU_DIVIDER) {
const menu_item_name = menu_item.item.replace(/&|\.\.\.|\(|\)/g, "");
// console.log(menu_item_name);
let menu_item_matchers = [menu_item_name];
if (menu_item_name.match(/\//)) {
menu_item_matchers = [
menu_item_name,
menu_item_name.replace(/\//, " "),
menu_item_name.replace(/\//, " and "),
menu_item_name.replace(/\//, " or "),
menu_item_name.replace(/\//, " slash "),
];
}
menu_item_matchers = menu_item_matchers.map((menu_item_matcher) => {
return `${ancestor_names} ${menu_item_matcher}`;
});
menu_item.speech_recognition = (menu_item.speech_recognition || []).concat(menu_item_matchers);
// console.log(menu_item_matchers, menu_item.speech_recognition);
if (menu_item.submenu) {
add_literal_navigation_speech_recognition(menu_item.submenu, `${ancestor_names} ${menu_item_name}`);
}
}
}
};
add_literal_navigation_speech_recognition(menu, top_level_menu_name);
}
exports.menus = menus;
})(window);

View File

@@ -0,0 +1,137 @@
((exports) => {
// Note that this API must be kept in sync with the version in 98.js.org.
try {
// <audio> element is simpler for sound effects,
// but in iOS/iPad it shows up in the Control Center, as if it's music you'd want to play/pause/etc.
// It's very silly. Also, on subsequent plays, it only plays part of the sound.
// And Web Audio API is better for playing SFX anyway because it can play a sound overlapping with itself.
window.audioContext = window.audioContext || new AudioContext();
const audio_buffer_promise =
fetch("audio/chord.wav")
.then(response => response.arrayBuffer())
.then(array_buffer => audioContext.decodeAudioData(array_buffer))
var play_chord = async function () {
audioContext.resume(); // in case it was not allowed to start until a user interaction
// Note that this should be before waiting for the audio buffer,
// so that it works the first time.
// (This only works if the message box is opened during a user gesture.)
const audio_buffer = await audio_buffer_promise;
const source = audioContext.createBufferSource();
source.buffer = audio_buffer;
source.connect(audioContext.destination);
source.start();
};
} catch (error) {
console.log("AudioContext not supported", error);
}
function showMessageBox({
title = window.defaultMessageBoxTitle ?? "Alert",
message,
messageHTML,
buttons = [{ label: "OK", value: "ok", default: true }],
iconID = "warning", // "error", "warning", "info", or "nuke" for deleting files/folders
windowOptions = {}, // for controlling width, etc.
}) {
let $window, $message;
const promise = new Promise((resolve, reject) => {
$window = make_window_supporting_scale(Object.assign({
title,
resizable: false,
innerWidth: 400,
maximizeButton: false,
minimizeButton: false,
}, windowOptions));
// $window.addClass("dialog-window horizontal-buttons");
$message =
$("<div>").css({
textAlign: "left",
fontFamily: "MS Sans Serif, Arial, sans-serif",
fontSize: "14px",
marginTop: "22px",
flex: 1,
minWidth: 0, // Fixes hidden overflow, see https://css-tricks.com/flexbox-truncated-text/
whiteSpace: "normal", // overriding .window:not(.squish)
});
if (messageHTML) {
$message.html(messageHTML);
} else if (message) { // both are optional because you may populate later with dynamic content
$message.text(message).css({
whiteSpace: "pre-wrap",
wordWrap: "break-word",
});
}
$("<div>").append(
$("<img width='32' height='32'>").attr("src", `images/${iconID}-32x32-8bpp.png`).css({
margin: "16px",
display: "block",
}),
$message
).css({
display: "flex",
flexDirection: "row",
}).appendTo($window.$content);
$window.$content.css({
textAlign: "center",
});
for (const button of buttons) {
const $button = $window.$Button(button.label, () => {
button.action?.(); // API may be required for using user gesture requiring APIs
resolve(button.value);
$window.close(); // actually happens automatically
});
if (button.default) {
$button.addClass("default");
$button.focus();
setTimeout(() => $button.focus(), 0); // @TODO: why is this needed? does it have to do with the iframe window handling?
}
$button.css({
minWidth: 75,
height: 23,
margin: "16px 2px",
});
}
$window.on("focusin", "button", (event) => {
$(event.currentTarget).addClass("default");
});
$window.on("focusout", "button", (event) => {
$(event.currentTarget).removeClass("default");
});
$window.on("closed", () => {
resolve("closed"); // or "cancel"? do you need to distinguish?
});
$window.center();
});
promise.$window = $window;
promise.$message = $message;
promise.promise = promise; // for easy destructuring
try {
play_chord();
} catch (error) {
console.log(`Failed to play ${chord_audio.src}: `, error);
}
return promise;
}
// Prefer a function injected from outside an iframe,
// which will make dialogs that can go outside the iframe,
// for 98.js.org integration.
// exports.showMessageBox = window.showMessageBox;
exports.showMessageBox = exports.showMessageBox || showMessageBox;
})(window);
// Note `defaultMessageBoxTitle` handling in make_iframe_window
// Any other default parameters need to be handled there (as it works now)
window.defaultMessageBoxTitle = localize("Paint");
// Don't override alert, because I only use it as a fallback for global error handling.
// If make_window_supporting_scale is not defined, then alert is used instead,
// so it must not also end up calling make_window_supporting_scale.
// More generally, if there's an error in showMessageBox, it must fall back to something that does not use showMessageBox.
// window.alert = (message) => {
// showMessageBox({ message });
// };

View File

@@ -0,0 +1,588 @@
(() => {
const log = (...args) => {
window.console && console.log(...args);
};
let localStorageAvailable = false;
try {
localStorage._available = true;
localStorageAvailable = localStorage._available;
delete localStorage._available;
// eslint-disable-next-line no-empty
} catch (e) { }
// @TODO: keep other data in addition to the image data
// such as the file_name and other state
// (maybe even whether it's considered saved? idk about that)
// I could have the image in one storage slot and the state in another
const match_threshold = 1; // 1 is just enough for a workaround for Brave browser's farbling: https://github.com/1j01/jspaint/issues/184
const canvas_has_any_apparent_image_data = () =>
main_canvas.ctx.getImageData(0, 0, main_canvas.width, main_canvas.height).data.some((v) => v > match_threshold);
let $recovery_window;
function show_recovery_window(no_longer_blank) {
$recovery_window && $recovery_window.close();
const $w = $recovery_window = $DialogWindow();
$w.on("close", () => {
$recovery_window = null;
});
$w.title("Recover Document");
let backup_impossible = false;
try { window.localStorage } catch (e) { backup_impossible = true; }
$w.$main.append($(`
<h1>Woah!</h1>
<p>Your browser may have cleared the canvas due to memory usage.</p>
<p>Undo to recover the document, and remember to save with <b>File > Save</b>!</p>
${backup_impossible ?
"<p><b>Note:</b> No automatic backup is possible unless you enable Cookies in your browser.</p>"
: (
no_longer_blank ?
`<p>
<b>Note:</b> normally a backup is saved automatically,<br>
but autosave is paused while this dialog is open<br>
to avoid overwriting the (singular) backup.
</p>
<p>
(See <b>File &gt; Manage Storage</b> to view backups.)
</p>`
: ""
)
}
`));
const $undo = $w.$Button("Undo", () => {
undo();
});
const $redo = $w.$Button("Redo", () => {
redo();
});
const update_buttons_disabled = () => {
$undo.attr("disabled", undos.length < 1);
$redo.attr("disabled", redos.length < 1);
};
$G.on("session-update.session-hook", update_buttons_disabled);
update_buttons_disabled();
$w.$Button(localize("Close"), () => {
$w.close();
});
$w.center();
$w.find("button:enabled").focus();
}
let last_undos_length = undos.length;
function handle_data_loss() {
const window_is_open = $recovery_window && !$recovery_window.closed;
let save_paused = false;
if (!canvas_has_any_apparent_image_data()) {
if (!window_is_open) {
show_recovery_window();
}
save_paused = true;
} else if (window_is_open) {
if (undos.length > last_undos_length) {
show_recovery_window(true);
}
save_paused = true;
}
last_undos_length = undos.length;
return save_paused;
}
class LocalSession {
constructor(session_id) {
this.id = session_id;
const lsid = `image#${session_id}`;
log(`Local storage ID: ${lsid}`);
// save image to storage
this.save_image_to_storage_immediately = () => {
const save_paused = handle_data_loss();
if (save_paused) {
return;
}
log(`Saving image to storage: ${lsid}`);
storage.set(lsid, main_canvas.toDataURL("image/png"), err => {
if (err) {
if (err.quotaExceeded) {
storage_quota_exceeded();
}
else {
// e.g. localStorage is disabled
// (or there's some other error?)
// @TODO: show warning with "Don't tell me again" type option
}
}
});
};
this.save_image_to_storage_soon = debounce(this.save_image_to_storage_immediately, 100);
storage.get(lsid, (err, uri) => {
if (err) {
if (localStorageAvailable) {
show_error_message("Failed to retrieve image from local storage.", err);
}
else {
// @TODO: DRY with storage manager message
showMessageBox({
message: "Please enable local storage in your browser's settings for local backup. It may be called Cookies, Storage, or Site Data.",
});
}
}
else if (uri) {
load_image_from_uri(uri).then((info) => {
open_from_image_info(info, null, null, true, true);
}, (error) => {
show_error_message("Failed to open image from local storage.", error);
});
}
else {
// no uri so lets save the blank canvas
this.save_image_to_storage_soon();
}
});
$G.on("session-update.session-hook", this.save_image_to_storage_soon);
}
end() {
// Skip debounce and save immediately
this.save_image_to_storage_soon.cancel();
this.save_image_to_storage_immediately();
// Remove session-related hooks
$G.off(".session-hook");
}
}
// The user ID is not persistent
// A person can enter a session multiple times,
// and is always given a new user ID
let user_id;
// @TODO: I could make the color persistent, though.
// You could still have multiple cursors and they would just be the same color.
// There could also be an option to change your color
// The data in this object is stored in the server when you enter a session
// It is (supposed to be) removed when you leave
const user = {
// Cursor status
cursor: {
// cursor position in canvas coordinates
x: 0, y: 0,
// whether the user is elsewhere, such as in another tab
away: true,
},
// Currently selected tool (@TODO)
tool: localize("Pencil"),
// Color components
hue: ~~(Math.random() * 360),
saturation: ~~(Math.random() * 50) + 50,
lightness: ~~(Math.random() * 40) + 50,
};
// The main cursor color
user.color = `hsla(${user.hue}, ${user.saturation}%, ${user.lightness}%, 1)`;
// Unused
user.color_transparent = `hsla(${user.hue}, ${user.saturation}%, ${user.lightness}%, 0.5)`;
// (@TODO) The color (that may be) used in the toolbar indicating to other users it is selected by this user
user.color_desaturated = `hsla(${user.hue}, ${~~(user.saturation * 0.4)}%, ${user.lightness}%, 0.8)`;
// The image used for other people's cursors
const cursor_image = new Image();
cursor_image.src = "images/cursors/default.png";
class MultiUserSession {
constructor(session_id) {
this.id = session_id;
this._fb_listeners = [];
file_name = `[Loading ${this.id}]`;
update_title();
const on_firebase_loaded = () => {
file_name = `[${this.id}]`;
update_title();
this.start();
};
if (!MultiUserSession.fb_root) {
var script = document.createElement("script");
script.addEventListener("load", () => {
const config = {
apiKey: "AIzaSyBgau8Vu9ZE8u_j0rp-Lc044gYTX5O3X9k",
authDomain: "jspaint.firebaseapp.com",
databaseURL: "https://jspaint.firebaseio.com",
projectId: "firebase-jspaint",
storageBucket: "",
messagingSenderId: "63395010995"
};
firebase.initializeApp(config);
MultiUserSession.fb_root = firebase.database().ref("/");
on_firebase_loaded();
});
script.addEventListener("error", () => {
show_error_message("Failed to load Firebase; the document will not load, and changes will not be saved.");
file_name = `[Failed to load ${this.id}]`;
update_title();
});
script.src = "lib/firebase.js";
document.head.appendChild(script);
}
else {
on_firebase_loaded();
}
}
start() {
// @TODO: how do you actually detect if it's failing???
showMessageBox({
messageHTML: `
<p>The document may not load. Changes may not save.</p>
<p>Multiuser sessions are public. There is no security.</p>
`
});
// "<p>The document may not load. Changes may not save. If it does save, it's public. There is no security.</p>"// +
// "<p>I haven't found a way to detect Firebase quota limits being exceeded, " +
// "so for now I'm showing this message regardless of whether it's working.</p>" +
// "<p>If you're interested in using multiuser mode, please thumbs-up " +
// "<a href='https://github.com/1j01/jspaint/issues/68'>this issue</a> to show interest, and/or subscribe for updates.</p>"
// Wrap the Firebase API because they don't
// provide a great way to clean up event listeners
const _fb_on = (fb, event_type, callback, error_callback) => {
this._fb_listeners.push({ fb, event_type, callback, error_callback });
fb.on(event_type, callback, error_callback);
};
// Get Firebase references
this.fb = MultiUserSession.fb_root.child(this.id);
this.fb_data = this.fb.child("data");
this.fb_users = this.fb.child("users");
if (user_id) {
this.fb_user = this.fb_users.child(user_id);
}
else {
this.fb_user = this.fb_users.push();
user_id = this.fb_user.key;
}
// Remove the user from the session when they disconnect
this.fb_user.onDisconnect().remove();
// Make the user present in the session
this.fb_user.set(user);
// @TODO: Execute the above two lines when .info/connected
// For each existing and new user
_fb_on(this.fb_users, "child_added", snap => {
// Is this you?
if (snap.key === user_id) {
// You already have a cursor.
return;
}
// Get the Firebase reference for this user
const fb_other_user = snap.ref;
// Get the user object stored on the server
let other_user = snap.val();
// @TODO: display other cursor types?
// @TODO: display pointer button state?
// @TODO: display selections
const cursor_canvas = make_canvas(32, 32);
// Make the cursor element
const $cursor = $(cursor_canvas).addClass("user-cursor").appendTo($app);
$cursor.css({
display: "none",
position: "absolute",
left: 0,
top: 0,
opacity: 0,
zIndex: 5, // @#: z-index
pointerEvents: "none",
transition: "opacity 0.5s",
});
// When the cursor data changes
_fb_on(fb_other_user, "value", snap => {
other_user = snap.val();
// If the user has left
if (other_user == null) {
// Remove the cursor element
$cursor.remove();
}
else {
// Draw the cursor
const draw_cursor = () => {
cursor_canvas.width = cursor_image.width;
cursor_canvas.height = cursor_image.height;
const cursor_ctx = cursor_canvas.ctx;
cursor_ctx.fillStyle = other_user.color;
cursor_ctx.fillRect(0, 0, cursor_canvas.width, cursor_canvas.height);
cursor_ctx.globalCompositeOperation = "multiply";
cursor_ctx.drawImage(cursor_image, 0, 0);
cursor_ctx.globalCompositeOperation = "destination-atop";
cursor_ctx.drawImage(cursor_image, 0, 0);
};
if (cursor_image.complete) {
draw_cursor();
}
else {
$(cursor_image).one("load", draw_cursor);
}
// Update the cursor element
const canvas_rect = canvas_bounding_client_rect;
$cursor.css({
display: "block",
position: "absolute",
left: canvas_rect.left + magnification * other_user.cursor.x,
top: canvas_rect.top + magnification * other_user.cursor.y,
opacity: 1 - other_user.cursor.away,
});
}
});
});
let previous_uri;
// let pointer_operations = []; // the multiplayer syncing stuff is a can of worms, so this is disabled
this.write_canvas_to_database_immediately = () => {
const save_paused = handle_data_loss();
if (save_paused) {
return;
}
// Sync the data from this client to the server (one-way)
const uri = main_canvas.toDataURL();
if (previous_uri !== uri) {
// log("clear pointer operations to set data", pointer_operations);
// pointer_operations = [];
log("Write canvas data to Firebase");
this.fb_data.set(uri);
previous_uri = uri;
}
else {
log("(Don't write canvas data to Firebase; it hasn't changed)");
}
};
this.write_canvas_to_database_soon = debounce(this.write_canvas_to_database_immediately, 100);
let ignore_session_update = false;
$G.on("session-update.session-hook", () => {
if (ignore_session_update) {
log("(Ignore session-update from Sync Session undoable)");
return;
}
this.write_canvas_to_database_soon();
});
// Any time we change or receive the image data
_fb_on(this.fb_data, "value", snap => {
log("Firebase data update");
const uri = snap.val();
if (uri == null) {
// If there's no value at the data location, this is a new session
// Sync the current data to it
this.write_canvas_to_database_soon();
}
else {
previous_uri = uri;
// Load the new image data
const img = new Image();
img.onload = () => {
// Cancel any in-progress pointer operations
// if (pointer_operations.length) {
// $G.triggerHandler("pointerup", "cancel");
// }
const test_canvas = make_canvas(img);
const image_data_remote = test_canvas.ctx.getImageData(0, 0, test_canvas.width, test_canvas.height);
const image_data_local = main_ctx.getImageData(0, 0, main_canvas.width, main_canvas.height);
if (!image_data_match(image_data_remote, image_data_local, 5)) {
ignore_session_update = true;
undoable({
name: "Sync Session",
icon: get_help_folder_icon("p_database.png"),
}, () => {
// Write the image data to the canvas
main_ctx.copy(img);
$canvas_area.trigger("resize");
});
ignore_session_update = false;
}
// (transparency = has_any_transparency(main_ctx); here would not be ideal
// Perhaps a better way of syncing transparency
// and other options will be established)
/*
// Playback recorded in-progress pointer operations
log("Playback", pointer_operations);
for (const e of pointer_operations) {
// Trigger the event at each place it is listened for
$canvas.triggerHandler(e, ["synthetic"]);
$G.triggerHandler(e, ["synthetic"]);
}
*/
};
img.src = uri;
}
}, error => {
show_error_message("Failed to retrieve data from Firebase. The document will not load, and changes will not be saved.", error);
file_name = `[Failed to load ${this.id}]`;
update_title();
});
// Update the cursor status
$G.on("pointermove.session-hook", e => {
const m = to_canvas_coords(e);
this.fb_user.child("cursor").update({
x: m.x,
y: m.y,
away: false,
});
});
$G.on("blur.session-hook", () => {
this.fb_user.child("cursor").update({
away: true,
});
});
// @FIXME: the cursor can come back from "away" via a pointer event
// while the window is blurred and stay there when the user goes away
// maybe replace "away" with a timestamp of activity and then
// clients can decide whether a given cursor should be visible
/*
const debug_event = (e, synthetic) => {
// const label = synthetic ? "(synthetic)" : "(normal)";
// window.console && console.debug && console.debug(e.type, label);
};
$canvas_area.on("pointerdown.session-hook", "*", (e, synthetic) => {
debug_event(e, synthetic);
if (synthetic) { return; }
pointer_operations = [e];
const pointermove = (e, synthetic) => {
debug_event(e, synthetic);
if (synthetic) { return; }
pointer_operations.push(e);
};
$G.on("pointermove.session-hook", pointermove);
$G.one("pointerup.session-hook", (e, synthetic) => {
debug_event(e, synthetic);
if (synthetic) { return; }
$G.off("pointermove.session-hook", pointermove);
});
});
*/
}
end() {
// Skip debounce and save immediately
this.write_canvas_to_database_soon.cancel();
this.write_canvas_to_database_immediately();
// Remove session-related hooks
$G.off(".session-hook");
// $canvas_area.off("pointerdown.session-hook");
// Remove collected Firebase event listeners
this._fb_listeners.forEach(({ fb, event_type, callback/*, error_callback*/ }) => {
log(`Remove listener for ${fb.path.toString()} .on ${event_type}`);
fb.off(event_type, callback);
});
this._fb_listeners.length = 0;
// Remove the user from the session
this.fb_user.remove();
// Remove any cursor elements
$app.find(".user-cursor").remove();
// Reset to "untitled"
reset_file();
}
}
// Handle the starting, switching, and ending of sessions from the location.hash
let current_session;
const end_current_session = () => {
if (current_session) {
log("Ending current session");
current_session.end();
current_session = null;
}
};
const generate_session_id = () => (Math.random() * (2 ** 32)).toString(16).replace(".", "");
const update_session_from_location_hash = () => {
const session_match = location.hash.match(/^#?(?:.*,)?(session|local):(.*)$/i);
const load_from_url_match = location.hash.match(/^#?(?:.*,)?(load):(.*)$/i);
if (session_match) {
const local = session_match[1].toLowerCase() === "local";
const session_id = session_match[2];
if (session_id === "") {
log("Invalid session ID; session ID cannot be empty");
end_current_session();
} else if (!local && session_id.match(/[./[\]#$]/)) {
log("Session ID is not a valid Firebase location; it cannot contain any of ./[]#$");
end_current_session();
} else if (!session_id.match(/[-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]+/)) {
log("Invalid session ID; it must consist of 'alphanumeric-esque' characters");
end_current_session();
} else if (
current_session && current_session.id === session_id &&
local === (current_session instanceof LocalSession)
) {
log("Hash changed but the session ID and session type are the same");
} else {
// @TODO: Ask if you want to save before starting a new session
end_current_session();
if (local) {
log(`Starting a new LocalSession, ID: ${session_id}`);
current_session = new LocalSession(session_id);
} else {
log(`Starting a new MultiUserSession, ID: ${session_id}`);
current_session = new MultiUserSession(session_id);
}
}
} else if (load_from_url_match) {
const url = decodeURIComponent(load_from_url_match[2]);
const uris = get_uris(url);
if (uris.length === 0) {
show_error_message("Invalid URL to load (after #load: in the address bar). It must include a protocol (https:// or http://)");
return;
}
log("Switching to new session from #load: URL (to #local: URL with session ID)");
// Note: could use into_existing_session=false on open_from_image_info instead of creating the new session beforehand
end_current_session();
change_url_param("local", generate_session_id());
load_image_from_uri(url).then((info) => {
open_from_image_info(info, null, null, true, true);
}, show_resource_load_error_message);
} else {
log("No session ID in hash");
const old_hash = location.hash;
end_current_session();
change_url_param("local", generate_session_id(), { replace_history_state: true });
log("After replaceState:", location.hash);
if (old_hash === location.hash) {
// e.g. on Wayback Machine
show_error_message("Autosave is disabled. Failed to update URL to start session.");
} else {
update_session_from_location_hash();
}
}
};
$G.on("hashchange popstate change-url-params", e => {
log(e.type, location.hash);
update_session_from_location_hash();
});
log("Initializing with location hash:", location.hash);
update_session_from_location_hash();
window.new_local_session = () => {
end_current_session();
log("Changing URL to start new session...");
change_url_param("local", generate_session_id());
};
// @TODO: Session GUI
// @TODO: Indicate when the session ID is invalid
// @TODO: Indicate when the session switches
// @TODO: Indicate when there is no session!
// Probably in app.js so as to handle the possibility of sessions.js failing to load.
})();

View File

@@ -0,0 +1,269 @@
((exports) => {
let seed = 4; // chosen later
const seededRandom = (max = 1, min = 0) => {
seed = (seed * 9301 + 49297) % 233280;
const rnd = seed / 233280;
return min + rnd * (max - min);
};
exports.stopSimulatingGestures && exports.stopSimulatingGestures();
exports.simulatingGestures = false;
let gestureTimeoutID;
let periodicGesturesTimeoutID;
let choose = (array) => array[~~(seededRandom() * array.length)];
let isAnyMenuOpen = () => $(".menu-button.active").length > 0;
let cursor_image = new Image();
cursor_image.src = "images/cursors/default.png";
const $cursor = $(cursor_image).addClass("user-cursor");
$cursor.css({
position: "absolute",
left: 0,
top: 0,
opacity: 0,
zIndex: 5, // @#: z-index
pointerEvents: "none",
transition: "opacity 0.5s",
});
exports.simulateRandomGesture = (callback, { shift, shiftToggleChance = 0.01, secondary, secondaryToggleChance, target = main_canvas }) => {
let startWithinRect = target.getBoundingClientRect();
let canvasAreaRect = $canvas_area[0].getBoundingClientRect();
let startMinX = Math.max(startWithinRect.left, canvasAreaRect.left);
let startMaxX = Math.min(startWithinRect.right, canvasAreaRect.right);
let startMinY = Math.max(startWithinRect.top, canvasAreaRect.top);
let startMaxY = Math.min(startWithinRect.bottom, canvasAreaRect.bottom);
let startPointX = startMinX + seededRandom() * (startMaxX - startMinX);
let startPointY = startMinY + seededRandom() * (startMaxY - startMinY);
$cursor.appendTo($app);
let triggerMouseEvent = (type, point) => {
if (isAnyMenuOpen()) {
return;
}
const clientX = point.x;
const clientY = point.y;
const el_over = document.elementFromPoint(clientX, clientY);
const do_nothing = !type.match(/move/) && (!el_over || !el_over.closest(".canvas-area"));
$cursor.css({
display: "block",
position: "absolute",
left: clientX,
top: clientY,
opacity: do_nothing ? 0.5 : 1,
});
if (do_nothing) {
return;
}
let event = new $.Event(type, {
view: window,
bubbles: true,
cancelable: true,
clientX,
clientY,
screenX: clientX,
screenY: clientY,
offsetX: point.x,
offsetY: point.y,
button: secondary ? 2 : 0,
buttons: secondary ? 2 : 1,
shiftKey: shift,
});
$(target).trigger(event);
};
let t = 0;
let gestureComponents = [];
let numberOfComponents = 5;
for (let i = 0; i < numberOfComponents; i += 1) {
gestureComponents.push({
rx:
(seededRandom() * Math.min(canvasAreaRect.width, canvasAreaRect.height)) /
2 /
numberOfComponents,
ry:
(seededRandom() * Math.min(canvasAreaRect.width, canvasAreaRect.height)) /
2 /
numberOfComponents,
angularFactor: seededRandom() * 5 - seededRandom(),
angularOffset: seededRandom() * 5 - seededRandom(),
});
}
const stepsInGesture = 50;
let pointForTimeWithArbitraryStart = (t) => {
let point = { x: 0, y: 0 };
for (let i = 0; i < gestureComponents.length; i += 1) {
let { rx, ry, angularFactor, angularOffset } = gestureComponents[i];
point.x +=
Math.sin(Math.PI * 2 * ((t / 2) * angularFactor + angularOffset)) *
rx;
point.y +=
Math.cos(Math.PI * 2 * ((t / 2) * angularFactor + angularOffset)) *
ry;
}
return point;
};
let pointForTime = (t) => {
let point = pointForTimeWithArbitraryStart(t);
let zeroPoint = pointForTimeWithArbitraryStart(0);
point.x -= zeroPoint.x;
point.y -= zeroPoint.y;
point.x += startPointX;
point.y += startPointY;
return point;
};
triggerMouseEvent("pointerenter", pointForTime(t)); // so dynamic cursors follow the simulation cursor
triggerMouseEvent("pointerdown", pointForTime(t));
let move = () => {
t += 1 / stepsInGesture;
if (seededRandom() < shiftToggleChance) {
shift = !shift;
}
if (seededRandom() < secondaryToggleChance) {
secondary = !secondary;
}
if (t > 1) {
triggerMouseEvent("pointerup", pointForTime(t));
$cursor.remove();
if (callback) {
callback();
}
} else {
triggerMouseEvent("pointermove", pointForTime(t));
gestureTimeoutID = setTimeout(move, 10);
}
};
triggerMouseEvent("pointerleave", pointForTime(t));
move();
};
exports.simulateRandomGesturesPeriodically = () => {
exports.simulatingGestures = true;
if (window.drawRandomlySeed != null) {
seed = window.drawRandomlySeed;
} else {
seed = ~~(Math.random() * 5000000);
}
window.console && console.log("Using seed:", seed);
window.console && console.log("Note: Seeds are not guaranteed to work with different versions of the app, but within the same version it should produce the same results given the same starting document & other state & NO interference... except for airbrush randomness");
window.console && console.log(`To use this seed:
window.drawRandomlySeed = ${seed};
document.body.style.width = "${getComputedStyle(document.body).width}";
document.body.style.height = "${getComputedStyle(document.body).height}";
simulateRandomGesturesPeriodically();
delete window.drawRandomlySeed;
`);
let delayBetweenGestures = 500;
let shiftStart = false;
let shiftStartToggleChance = 0.1;
let shiftToggleChance = 0.001;
let secondaryStart = false;
let secondaryStartToggleChance = 0.1;
let secondaryToggleChance = 0.001;
let switchToolsChance = 0.5;
let multiToolsChance = 0.8;
let pickColorChance = 0.5;
let pickToolOptionsChance = 0.8;
let scrollChance = 0.2;
let dragSelectionChance = 0.8;
// scroll randomly absolutely initially so the starting scroll doesn't play into whether a seed reproduces
$canvas_area.scrollTop($canvas_area.width() * seededRandom());
$canvas_area.scrollLeft($canvas_area.height() * seededRandom());
let _simulateRandomGesture = (callback) => {
exports.simulateRandomGesture(callback, {
shift: shiftStart,
shiftToggleChance,
secondary: secondaryStart,
secondaryToggleChance
});
};
let waitThenGo = () => {
// @TODO: a button to stop it as well (maybe make "stop drawing randomly" a link button?)
$status_text.text("Press Esc to stop drawing randomly.");
if (isAnyMenuOpen()) {
periodicGesturesTimeoutID = setTimeout(waitThenGo, 50);
return;
}
if (seededRandom() < shiftStartToggleChance) {
shiftStart = !shiftStart;
}
if (seededRandom() < secondaryStartToggleChance) {
secondaryStart = !secondaryStart;
}
if (seededRandom() < switchToolsChance) {
let multiToolsPlz = seededRandom() < multiToolsChance;
$(choose($(".tool, tool-button"))).trigger($.Event("click", { shiftKey: multiToolsPlz }));
}
if (seededRandom() < pickToolOptionsChance) {
$(choose($(".tool-options *"))).trigger("click");
}
if (seededRandom() < pickColorChance) {
// @TODO: maybe these should respond to a normal click?
let secondary = seededRandom() < 0.5;
const colorButton = choose($(".swatch, .color-button"));
$(colorButton)
.trigger($.Event("pointerdown", { button: secondary ? 2 : 0 }))
.trigger($.Event("click", { button: secondary ? 2 : 0 }))
.trigger($.Event("pointerup", { button: secondary ? 2 : 0 }));
}
if (seededRandom() < scrollChance) {
let scrollAmount = (seededRandom() * 2 - 1) * 700;
if (seededRandom() < 0.5) {
$canvas_area.scrollTop($canvas_area.scrollTop() + scrollAmount);
} else {
$canvas_area.scrollLeft($canvas_area.scrollLeft() + scrollAmount);
}
}
periodicGesturesTimeoutID = setTimeout(() => {
_simulateRandomGesture(() => {
if (selection && seededRandom() < dragSelectionChance) {
exports.simulateRandomGesture(waitThenGo, {
shift: shiftStart,
shiftToggleChance,
secondary: secondaryStart,
secondaryToggleChance,
target: selection.canvas
});
} else {
waitThenGo();
}
});
}, delayBetweenGestures);
};
_simulateRandomGesture(waitThenGo);
};
exports.stopSimulatingGestures = () => {
if (exports.simulatingGestures) {
clearTimeout(gestureTimeoutID);
clearTimeout(periodicGesturesTimeoutID);
exports.simulatingGestures = false;
$status_text.default();
$cursor.remove();
cancel();
}
document.body.style.width = "";
document.body.style.height = "";
};
})(window);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
// @TODO: remove remaining cruft from being compiled from CoffeeScript
// or maybe replace this module with localforage actually
// (but need to address asynchronous concerns if doing that)
((exports) => {
let localStore = {
get(key, callback) {
let i, item, len, obj, keys, keys_obj;
try {
if (typeof key === "string") {
item = localStorage.getItem(key);
if (item) {
obj = JSON.parse(item);
}
} else {
obj = {};
if (Array.isArray(key)) {
keys = key;
for (i = 0, len = keys.length; i < len; i++) {
key = keys[i];
item = localStorage.getItem(key);
if (item) {
obj[key] = JSON.parse(item);
}
}
} else {
keys_obj = key;
for (key in keys_obj) {
let defaultValue = keys_obj[key];
item = localStorage.getItem(key);
if (item) {
obj[key] = JSON.parse(item);
} else {
obj[key] = defaultValue;
}
}
}
}
} catch (error) {
callback(error);
return;
}
callback(null, obj);
},
set(key, value, callback) {
let to_set = {};
if (typeof key === "string") {
to_set = {
[key]: value
};
} else if (Array.isArray(key)) {
throw new TypeError("Cannot set an array of keys (to what?)");
} else {
to_set = key;
callback = value;
}
for (key in to_set) {
value = to_set[key];
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
error.quotaExceeded = error.code === 22 || error.name === "NS_ERROR_DOM_QUOTA_REACHED" || error.number === -2147024882;
callback(error);
return;
}
}
return callback(null);
}
};
exports.storage = localStore;
})(window);

View File

@@ -0,0 +1,80 @@
/*
Automated checks to catch errors before publishing a news update:
- The <time> element's datetime attribute is set to the date of the update.
- The <time> element's text content is set to the date of the update.
- The id of the <article> is unique and follows the format 'news-YYYY-some-topic'.
- All <a> elements have a target="_blank" attribute.
- All <a> elements have an href attribute.
HTML validity checking is not performed.
*/
const newsEl = document.querySelector('#news');
const articles = newsEl.querySelectorAll('article');
const articleIDs = [];
for (const article of articles) {
// Check id
if (articleIDs.includes(article.id)) {
console.error(`Duplicate article id: #${article.id}`, article);
}
articleIDs.push(article.id);
if (!article.id.startsWith('news-')) {
console.error(`Article id does not start with 'news-': #${article.id}`, article);
}
// Check date
const time = article.querySelector('time');
if (!time) {
console.error(`Missing <time> element in article #${article.id}`, article);
} else {
const datetime = time.getAttribute('datetime');
const dateText = time.textContent;
if (!datetime) {
console.error(`Missing datetime attribute in <time> element in article #${article.id}`, time);
}
if (!dateText) {
console.error(`Missing text content in <time> element in article #${article.id}`, time);
}
// This doesn't handle time zones:
// if (new Date(datetime).toUTCString() !== new Date(dateText).toUTCString()) {
// console.error(
// `Mismatch between datetime attribute and text content in <time> element in article #${article.id}`,
// time,
// `\ndatetime: ${datetime}`,
// `\ntext: ${dateText}`,
// `\n${new Date(datetime).toUTCString()} !== ${new Date(dateText).toUTCString()}`
// );
// }
// I'm just using ISO 8601 date format for now.
if (datetime && dateText && datetime !== dateText) {
console.error(
`Mismatch between datetime attribute and text content in <time> element in article #${article.id}`,
time,
`\n${JSON.stringify(datetime)} !== ${JSON.stringify(dateText)}`
);
}
if (datetime) {
// Check id matches date
const expectedYYYY = new Date(datetime).getFullYear().toString();
if (!article.id.includes(`-${expectedYYYY}-`)) {
console.error(`Article id does not contain expected year: #${article.id}`,
`\nexpected: "-${expectedYYYY}-"`,
`\nactual: ${JSON.stringify(article.id.substring(4, 10))}`
);
}
}
}
// Check links
const links = article.querySelectorAll('a');
for (const link of links) {
const target = link.getAttribute('target');
const href = link.getAttribute('href');
if (target !== '_blank') {
console.error(`target is not "_blank"`, link);
}
if (!href) {
console.error(`href is not set`, link);
}
}
}

View File

@@ -0,0 +1,179 @@
((exports) => {
const default_theme = "classic.css";
const theme_storage_key = "jspaint theme";
const disable_seasonal_theme_key = "jspaint disable seasonal theme";
const href_for = theme => `styles/themes/${theme}`;
let iid;
function wait_for_theme_loaded(theme, callback) {
clearInterval(iid);
iid = setInterval(() => {
const theme_loaded =
getComputedStyle(document.documentElement)
.getPropertyValue("--theme-loaded")
.replace(/['"]+/g, "").trim();
if (theme_loaded === theme) {
clearInterval(iid);
callback();
}
}, 15);
}
let grinch_button;
let current_theme;
try {
const grinch = localStorage[disable_seasonal_theme_key] === "true";
const is_december = new Date().getMonth() === 11;
if (is_december && !grinch) {
current_theme = "winter.css"; // overriding theme preference until you disable the seasonal theme
wait_for_theme_loaded(current_theme, () => { // could just wait for DOM to load, but theme is needed for the button styling
make_grinch_button();
});
} else {
current_theme = localStorage[theme_storage_key] || default_theme;
}
} catch (error) {
console.error(error);
current_theme = default_theme;
}
const theme_link = document.createElement("link");
theme_link.rel = "stylesheet";
theme_link.type = "text/css";
theme_link.href = href_for(current_theme);
theme_link.id = "theme-link";
document.head.appendChild(theme_link);
update_not_for_modern_theme();
exports.get_theme = () => current_theme;
exports.set_theme = theme => {
current_theme = theme;
try {
localStorage[theme_storage_key] = theme;
localStorage[disable_seasonal_theme_key] = "true"; // any theme change disables seasonal theme (unless of course you select the seasonal theme)
grinch_button?.remove();
// eslint-disable-next-line no-empty
} catch (error) { }
const signal_theme_load = () => {
$(window).triggerHandler("theme-load");
$(window).trigger("resize"); // not exactly, but get dynamic cursor to update its offset
};
wait_for_theme_loaded(theme, signal_theme_load);
theme_link.href = href_for(theme);
update_not_for_modern_theme();
signal_theme_load();
};
function update_not_for_modern_theme() {
const not_for_modern = document.querySelectorAll("link.not-for-modern");
for (const link of not_for_modern) {
link.disabled = current_theme === "modern.css";
}
}
function make_grinch_button() {
const button = document.createElement("button");
button.ariaLabel = "Disable seasonal theme";
button.className = "grinch-button";
let clicked = false;
let smile = 0;
let momentum = 0;
let smile_target = 0;
let anim_id;
const num_frames = 38;
const frame_width = 100;
button.onclick = () => {
if (smile === smile_target) {
steal_christmas();
}
clicked = true;
};
button.onmouseleave = () => {
smile_target = clicked ? 1 : 0;
animate();
document.removeEventListener('touchmove', document_touchmove);
};
button.onmouseenter = () => {
smile_target = 1;
momentum = Math.max(momentum, 0.02); // for the immediacy of the hover effect
animate();
};
button.onpointerdown = (event) => {
if (event.pointerType === "touch") {
button.onmouseenter();
document.addEventListener('touchmove', document_touchmove);
}
};
// Not using pointerleave because it includes when the finger is lifted off the screen
// Maybe it would be easier to detect that case with event.button(s) though.
function document_touchmove(event) {
var touch = event.touches[0];
if (button !== document.elementFromPoint(touch.pageX, touch.pageY)) {
// finger left the button
clicked = false;
button.onmouseleave();
}
}
function animate() {
cancelAnimationFrame(anim_id);
smile += momentum * 0.5;
momentum *= 0.9; // set to 0.99 to test smile getting stuck (should be fixed)
if (smile_target) {
momentum += 0.001;
} else {
if (smile < 0.4) {
momentum -= 0.0005; // slowing down the last bit of un-smiling (feels more natural; I wish there were more frames though)
} else {
momentum -= 0.001;
}
}
if (smile > 1) {
smile = 1;
momentum = 0;
if (clicked) {
steal_christmas();
}
} else if (smile < 0) {
smile = 0;
momentum = 0;
}
if (smile !== smile_target) {
anim_id = requestAnimationFrame(animate);
}
button.style.backgroundPosition = `${-Math.floor(smile * (num_frames - 1)) * frame_width}px 0px`;
}
function on_zoom_etc() {
// scale to nearest pixel-perfect size
button.style.transform = `scale(${Math.max(1, Math.floor(devicePixelRatio)) / devicePixelRatio})`;
button.style.transformOrigin = "bottom right";
button.style.imageRendering = "pixelated";
}
window.addEventListener("resize", on_zoom_etc);
on_zoom_etc();
function steal_christmas() {
let new_theme;
try {
localStorage[disable_seasonal_theme_key] = "true";
new_theme = localStorage[theme_storage_key] || default_theme;
// eslint-disable-next-line no-empty
} catch (error) { }
if (new_theme === "winter.css") {
new_theme = default_theme;
}
set_theme(new_theme);
button.remove();
window.removeEventListener("resize", on_zoom_etc);
document.removeEventListener('touchmove', document_touchmove);
}
document.body.appendChild(button);
grinch_button = button;
}
})(window);

View File

@@ -0,0 +1,383 @@
const ChooserCanvas = (
url,
invert,
width,
height,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
destX,
destY,
destWidth,
destHeight,
reuse_canvas,
) => {
const c = reuse_canvas(width, height);
let img = ChooserCanvas.cache[url];
if (!img) {
img = ChooserCanvas.cache[url] = E("img");
img.onerror = () => {
delete ChooserCanvas.cache[url];
};
img.src = url;
}
const render = () => {
try {
c.ctx.drawImage(
img,
sourceX, sourceY, sourceWidth, sourceHeight,
destX, destY, destWidth, destHeight
);
// eslint-disable-next-line no-empty
} catch (error) { }
// if (invert) {
// invert_rgb(c.ctx); // can fail due to tainted canvas if running from file: protocol
// }
c.style.filter = invert ? "invert()" : "";
};
$(img).on("load", render);
render();
return c;
};
ChooserCanvas.cache = {};
// @TODO: convert all options to use this themeable version (or more options? some are dynamically rendered...)
const ChooserDiv = (
class_name,
invert,
width,
height,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
destX,
destY,
destWidth,
destHeight,
reuse_div,
) => {
const div = reuse_div(width, height);
div.classList.add(class_name);
div.style.width = sourceWidth + "px";
div.style.height = sourceHeight + "px";
// @TODO: single listener for all divs
const on_zoom_etc = () => {
const use_svg = (window.devicePixelRatio >= 3 || (window.devicePixelRatio % 1) !== 0);
div.classList.toggle("use-svg", use_svg);
};
if (div._on_zoom_etc) { // condition is needed, otherwise it will remove all listeners! (leading to only the last graphic being updated when zooming)
$G.off("theme-load resize", div._on_zoom_etc);
}
$G.on("theme-load resize", on_zoom_etc);
div._on_zoom_etc = on_zoom_etc;
on_zoom_etc();
div.style.backgroundPosition = `${-sourceX}px ${-sourceY}px`;
div.style.borderColor = "transparent";
div.style.borderStyle = "solid";
div.style.borderLeftWidth = destX + "px";
div.style.borderTopWidth = destY + "px";
div.style.borderRightWidth = (width - destX - destWidth) + "px";
div.style.borderBottomWidth = (height - destY - destHeight) + "px";
div.style.backgroundClip = "content-box";
div.style.filter = invert ? "invert()" : "";
return div;
};
const $Choose = (things, display, choose, is_chosen, gray_background_for_unselected) => {
const $chooser = $(E("div")).addClass("chooser").css("touch-action", "none");
const choose_thing = (thing) => {
if (is_chosen(thing)) {
return;
}
choose(thing);
$G.trigger("option-changed");
};
$chooser.on("update", () => {
if (!$chooser.is(":visible")) {
return;
}
$chooser.empty();
for (let i = 0; i < things.length; i++) {
(thing => {
const $option_container = $(E("div")).addClass("chooser-option").appendTo($chooser);
$option_container.data("thing", thing);
const reuse_canvas = (width, height) => {
let option_canvas = $option_container.find("canvas")[0];
if (option_canvas) {
if (option_canvas.width !== width) { option_canvas.width = width; }
if (option_canvas.height !== height) { option_canvas.height = height; }
} else {
option_canvas = make_canvas(width, height);
$option_container.append(option_canvas);
}
return option_canvas;
};
const reuse_div = (width, height) => {
let option_div = $option_container.find("div")[0];
if (option_div) {
if (option_div.style.width !== width + "px") { option_div.style.width = width + "px"; }
if (option_div.style.height !== height + "px") { option_div.style.height = height + "px"; }
} else {
option_div = E("div");
option_div.style.width = width + "px";
option_div.style.height = height + "px";
$option_container.append(option_div);
}
return option_div;
};
const update = () => {
const selected_color = getComputedStyle($chooser[0]).getPropertyValue("--Hilight");
const unselected_color = gray_background_for_unselected ? "rgb(192, 192, 192)" : "";
$option_container.css({
backgroundColor: is_chosen(thing) ? selected_color : unselected_color,
});
display(thing, is_chosen(thing), reuse_canvas, reuse_div);
};
update();
$G.on("option-changed theme-load redraw-tool-options-because-webglcontextrestored", update);
})(things[i]);
}
const onpointerover_while_pointer_down = (event) => {
const option_container = event.target.closest(".chooser-option");
if (option_container) {
choose_thing($(option_container).data("thing"));
}
};
const ontouchmove_while_pointer_down = (event) => {
const touch = event.originalEvent.changedTouches[0];
const target = document.elementFromPoint(touch.clientX, touch.clientY);
const option_container = target.closest(".chooser-option");
if (option_container) {
choose_thing($(option_container).data("thing"));
}
event.preventDefault();
};
$chooser.on("pointerdown click", (event) => {
const option_container = event.target.closest(".chooser-option");
if (option_container) {
choose_thing($(option_container).data("thing"));
}
if (event.type === "pointerdown") {
// glide thru tool options
$chooser.on("pointerover", onpointerover_while_pointer_down);
$chooser.on("touchmove", ontouchmove_while_pointer_down);
}
});
$G.on("pointerup pointercancel", () => {
$chooser.off("pointerover", onpointerover_while_pointer_down);
$chooser.off("touchmove", ontouchmove_while_pointer_down);
});
});
return $chooser;
};
const $ChooseShapeStyle = () => {
const $chooser = $Choose(
[
{ stroke: true, fill: false },
{ stroke: true, fill: true },
{ stroke: false, fill: true }
],
({ stroke, fill }, is_chosen, reuse_canvas) => {
const ss_canvas = reuse_canvas(39, 21);
const ss_ctx = ss_canvas.ctx;
// border px inwards amount
let b = 5;
const style = getComputedStyle(ss_canvas);
ss_ctx.fillStyle = is_chosen ? style.getPropertyValue("--HilightText") : style.getPropertyValue("--WindowText");
if (stroke) {
// just using a solid rectangle for the stroke
// so as not to have to deal with the pixel grid with strokes
ss_ctx.fillRect(b, b, ss_canvas.width - b * 2, ss_canvas.height - b * 2);
}
// go inward a pixel for the fill
b += 1;
ss_ctx.fillStyle = style.getPropertyValue("--ButtonShadow");
if (fill) {
ss_ctx.fillRect(b, b, ss_canvas.width - b * 2, ss_canvas.height - b * 2);
} else {
ss_ctx.clearRect(b, b, ss_canvas.width - b * 2, ss_canvas.height - b * 2);
}
return ss_canvas;
},
({ stroke, fill }) => {
$chooser.stroke = stroke;
$chooser.fill = fill;
},
({ stroke, fill }) => $chooser.stroke === stroke && $chooser.fill === fill
).addClass("choose-shape-style");
$chooser.fill = false;
$chooser.stroke = true;
return $chooser;
};
const $choose_brush = $Choose(
(() => {
const brush_shapes = ["circle", "square", "reverse_diagonal", "diagonal"];
const circular_brush_sizes = [7, 4, 1];
const brush_sizes = [8, 5, 2];
const things = [];
brush_shapes.forEach((brush_shape) => {
const sizes = brush_shape === "circle" ? circular_brush_sizes : brush_sizes;
sizes.forEach((brush_size) => {
things.push({
shape: brush_shape,
size: brush_size,
});
});
});
return things;
})(),
(o, is_chosen, reuse_canvas) => {
const cb_canvas = reuse_canvas(10, 10);
const style = getComputedStyle(cb_canvas);
const shape = o.shape;
const size = o.size;
const color = is_chosen ? style.getPropertyValue("--HilightText") : style.getPropertyValue("--WindowText");
stamp_brush_canvas(cb_canvas.ctx, 5, 5, shape, size);
replace_colors_with_swatch(cb_canvas.ctx, color);
return cb_canvas;
}, ({ shape, size }) => {
brush_shape = shape;
brush_size = size;
}, ({ shape, size }) => brush_shape === shape && brush_size === size
).addClass("choose-brush");
const $choose_eraser_size = $Choose(
[4, 6, 8, 10],
(size, is_chosen, reuse_canvas) => {
const ce_canvas = reuse_canvas(39, 16);
const style = getComputedStyle(ce_canvas);
ce_canvas.ctx.fillStyle = is_chosen ? style.getPropertyValue("--HilightText") : style.getPropertyValue("--WindowText");
render_brush(ce_canvas.ctx, "square", size);
return ce_canvas;
},
size => {
eraser_size = size;
},
size => eraser_size === size
).addClass("choose-eraser");
const $choose_stroke_size = $Choose(
[1, 2, 3, 4, 5],
(size, is_chosen, reuse_canvas) => {
const w = 39, h = 12, b = 5;
const cs_canvas = reuse_canvas(w, h);
const center_y = (h - size) / 2;
const style = getComputedStyle(cs_canvas);
cs_canvas.ctx.fillStyle = is_chosen ? style.getPropertyValue("--HilightText") : style.getPropertyValue("--WindowText");
cs_canvas.ctx.fillRect(b, ~~center_y, w - b * 2, size);
return cs_canvas;
},
size => {
stroke_size = size;
},
size => stroke_size === size
).addClass("choose-stroke-size");
const magnifications = [1, 2, 6, 8, 10];
const $choose_magnification = $Choose(
magnifications,
(scale, is_chosen, reuse_canvas, reuse_div) => {
const i = magnifications.indexOf(scale);
const secret = scale === 10; // 10x is secret
const chooser_el = ChooserDiv(
"magnification-option",
is_chosen, // invert if chosen
39, (secret ? 2 : 13), // width, height of destination canvas
i * 23, 0, 23, 9, // x, y, width, height from source image
8, 2, 23, 9, // x, y, width, height on destination
reuse_div,
);
if (secret) {
$(chooser_el).addClass("secret-option");
}
return chooser_el;
},
scale => {
set_magnification(scale);
},
scale => scale === magnification,
true,
).addClass("choose-magnification")
.css({ position: "relative" }); // positioning context for .secret-option `position: "absolute"` canvas
$choose_magnification.on("update", () => {
$choose_magnification
.find(".secret-option")
.parent()
.css({ position: "absolute", bottom: "-2px", left: 0, opacity: 0, height: 2, overflow: "hidden" });
});
const airbrush_sizes = [9, 16, 24];
const $choose_airbrush_size = $Choose(
airbrush_sizes,
(size, is_chosen, reuse_canvas) => {
const image_width = 72; // width of source image
const i = airbrush_sizes.indexOf(size); // 0 or 1 or 2
const l = airbrush_sizes.length; // 3
const is_bottom = (i === 2);
const shrink = 4 * !is_bottom;
const w = image_width / l - shrink * 2;
const h = 23;
const source_x = image_width / l * i + shrink;
return ChooserCanvas(
"images/options-airbrush-size.png",
is_chosen, // invert if chosen
w, h, // width, height of created destination canvas
source_x, 0, w, h, // x, y, width, height from source image
0, 0, w, h, // x, y, width, height on created destination canvas
reuse_canvas,
);
},
size => {
airbrush_size = size;
},
size => size === airbrush_size,
true,
).addClass("choose-airbrush-size");
const $choose_transparent_mode = $Choose(
[false, true],
(option, _is_chosen, reuse_canvas, reuse_div) => {
const sw = 35, sh = 23; // width, height from source image
const b = 2; // margin by which the source image is inset on the destination
const theme_folder = `images/${get_theme().replace(/\.css/i, "")}`;
return ChooserDiv(
"transparent-mode-option",
false, // never invert it
b + sw + b, b + sh + b, // width, height of created destination canvas
0, option ? 22 : 0, sw, sh, // x, y, width, height from source image
b, b, sw, sh, // x, y, width, height on created destination canvas
reuse_div,
);
},
option => {
tool_transparent_mode = option;
},
option => option === tool_transparent_mode,
true,
).addClass("choose-transparent-mode");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,207 @@
(() => {
let rAF_ID, rotologo, $window, space_phase_key_handler, player, player_placeholder;
let vaporwave_active = false;
if (parent && frameElement && parent.$) {
$window = parent.$(frameElement).closest(".window");
} else {
$window = $();
}
const wait_for_youtube_api = callback => {
if (typeof YT !== "undefined") {
callback();
} else {
const tag = document.createElement('script');
tag.src = "https://www.youtube.com/player_api";
const firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
// The YouTube API will call this global function when loaded and ready.
window.onYouTubeIframeAPIReady = () => {
callback();
};
}
};
const stop_vaporwave = () => {
vaporwave_active = false;
cancelAnimationFrame(rAF_ID);
$(rotologo).remove();
$window.css({ transform: "" });
removeEventListener("keydown", space_phase_key_handler);
if (player) {
player.destroy();
player = null;
}
$(player_placeholder).remove();
// vaporwave is dead. long live vaporwave.
// bepis pepsi isded pepsi isded
};
const start_vaporwave = () => {
vaporwave_active = true;
rotologo = document.createElement("img");
rotologo.classList.add("rotologo");
if (frameElement) {
frameElement.parentElement.appendChild(rotologo);
rotologo.src = "images/logo/98.js.org.svg";
} else {
document.body.appendChild(rotologo);
rotologo.src = "images/98.js.org.svg";
}
$(rotologo).css({
position: "absolute",
left: "50%",
top: "50%",
pointerEvents: "none",
transformOrigin: "0% 0%",
transition: "opacity 1s ease",
opacity: "0",
});
const animate = () => {
rAF_ID = requestAnimationFrame(animate);
// @TODO: slow down and stop when you pause?
const turns = Math.sin(Date.now() / 5000);
const hueTurns = Math.sin(Date.now() / 4000);
$(rotologo).css({
transform: `perspective(4000px) rotateY(${turns}turn) translate(-50%, -50%) translateZ(500px)`,
filter: `hue-rotate(${hueTurns}turn)`,
});
if ($window.length) {
let el = $window[0];
let offsetLeft = 0;
let offsetTop = 0;
do {
offsetLeft += el.offsetLeft;
offsetTop += el.offsetTop;
el = el.offsetParent;
} while (el);
const rotateY = -(offsetLeft + ($window.outerWidth() - parent.innerWidth) / 2) / parent.innerWidth / 3;
const rotateX = (offsetTop + ($window.outerHeight() - parent.innerHeight) / 2) / parent.innerHeight / 3;
$window.css({
transform: `perspective(4000px) rotateY(${rotateY}turn) rotateX(${rotateX}turn)`,
transformOrigin: "50% 50%",
transformStyle: "preserve-3d",
// @FIXME: interactivity problems (with order elements are considered to have), I think related to preserve-3d
});
}
};
animate();
player_placeholder = document.createElement("div");
document.querySelector(".canvas-area").appendChild(player_placeholder);
$(player_placeholder).css({
position: "absolute",
top: "3px", // @TODO: dynamic
left: "3px",
mixBlendMode: "multiply",
pointerEvents: "none",
transition: "opacity 0.4s ease",
width: "100vw",
height: "100vh",
});
// NOTE: placeholder not a container; the YT API replaces the element passed in the DOM
// but keeps inline styles apparently, and maybe other things, I don't know; it's weird
wait_for_youtube_api(() => {
player = new YT.Player(player_placeholder, {
height: "390",
width: "640",
videoId: "8TvcyPCgKSU",
playerVars: {
autoplay: 1,
controls: 0,
},
events: {
onReady: onPlayerReady,
onStateChange: onPlayerStateChange,
},
});
// @TODO: attribution for this video!
// I mean, you can see the title if you hit spacebar, but
// I could make it wave across the screen behind Paint on the desktop
// I could add a "Song Name?" button that responds "Darude Sandstorm"
// I could add a "Song At 420?" button that actually links to the video
// some number of those things or something like that
});
// The API will call this function when the video player is ready.
function onPlayerReady(/*event*/) {
player.playVideo();
player.unMute();
}
// The API calls this function when the player's state changes.
function onPlayerStateChange(event) {
if (event.data == YT.PlayerState.PLAYING) {
// @TODO: pause and resume this timer with the video
setTimeout(() => {
$(rotologo).css({ opacity: 1 });
}, 14150);
}
if (event.data == YT.PlayerState.ENDED) {
player.destroy();
player = null;
// @TODO: fade to white instead of black, to work with the multiply effect
// or fade out opacity alternatively
// setTimeout/setInterval and check player.getCurrentTime() for when near the end?
// or we might switch to using soundcloud for the audio and so trigger it with that, with a separate video of just clouds
// also fade out the rotologo earlier
$(rotologo).css({ opacity: 0 });
// destroy rotologo once faded out
setTimeout(stop_vaporwave, 1200);
}
}
let is_theoretically_playing = true;
space_phase_key_handler = e => {
// press space to phase in and out of space phase スペース相 - windows 98 マイクロソフト 『WINTRAP』 X 将来のオペレーティングシステムサウンド 1998 VAPORWAVE
if (e.which === 32) {
// @TODO: record player SFX
if (is_theoretically_playing) {
player.pauseVideo();
is_theoretically_playing = false;
$(player.getIframe())
.add(rotologo)
.css({ opacity: "0" });
} else {
player.playVideo();
is_theoretically_playing = true;
$(player.getIframe())
.add(rotologo)
.css({ opacity: "" });
}
e.preventDefault();
// player.getIframe().focus();
}
};
addEventListener("keydown", space_phase_key_handler);
};
const toggle_vaporwave = () => {
if (vaporwave_active) {
stop_vaporwave();
} else {
start_vaporwave();
}
};
addEventListener("keydown", Konami.code(toggle_vaporwave));
addEventListener("keydown", (event) => {
if (event.key === "Escape") {
stop_vaporwave();
}
});
})();