mirror of
https://github.com/ducbao414/win32.run.git
synced 2025-12-18 18:22:50 +09:00
init the awkward code
This commit is contained in:
149
static/html/jspaint/src/$ColorBox.js
Normal file
149
static/html/jspaint/src/$ColorBox.js
Normal 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);
|
||||
414
static/html/jspaint/src/$Component.js
Normal file
414
static/html/jspaint/src/$Component.js
Normal 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);
|
||||
98
static/html/jspaint/src/$FontBox.js
Normal file
98
static/html/jspaint/src/$FontBox.js
Normal 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);
|
||||
128
static/html/jspaint/src/$ToolBox.js
Normal file
128
static/html/jspaint/src/$ToolBox.js
Normal 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);
|
||||
110
static/html/jspaint/src/$ToolWindow.js
Normal file
110
static/html/jspaint/src/$ToolWindow.js
Normal 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);
|
||||
221
static/html/jspaint/src/Handles.js
Normal file
221
static/html/jspaint/src/Handles.js
Normal 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: "" }); };
|
||||
}
|
||||
13
static/html/jspaint/src/OnCanvasHelperLayer.js
Normal file
13
static/html/jspaint/src/OnCanvasHelperLayer.js
Normal 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);
|
||||
}
|
||||
}
|
||||
44
static/html/jspaint/src/OnCanvasObject.js
Normal file
44
static/html/jspaint/src/OnCanvasObject.js
Normal 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);
|
||||
}
|
||||
}
|
||||
294
static/html/jspaint/src/OnCanvasSelection.js
Normal file
294
static/html/jspaint/src/OnCanvasSelection.js
Normal 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?
|
||||
}
|
||||
}
|
||||
286
static/html/jspaint/src/OnCanvasTextBox.js
Normal file
286
static/html/jspaint/src/OnCanvasTextBox.js
Normal 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?
|
||||
}
|
||||
}
|
||||
1133
static/html/jspaint/src/app-localization.js
Normal file
1133
static/html/jspaint/src/app-localization.js
Normal file
File diff suppressed because it is too large
Load Diff
2409
static/html/jspaint/src/app.js
Normal file
2409
static/html/jspaint/src/app.js
Normal file
File diff suppressed because it is too large
Load Diff
633
static/html/jspaint/src/edit-colors.js
Normal file
633
static/html/jspaint/src/edit-colors.js
Normal 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);
|
||||
196
static/html/jspaint/src/electron-injected.js
Normal file
196
static/html/jspaint/src/electron-injected.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
287
static/html/jspaint/src/electron-main.js
Normal file
287
static/html/jspaint/src/electron-main.js
Normal 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.
|
||||
23
static/html/jspaint/src/error-handling-basic.js
Normal file
23
static/html/jspaint/src/error-handling-basic.js
Normal 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);
|
||||
}
|
||||
52
static/html/jspaint/src/error-handling-enhanced.js
Normal file
52
static/html/jspaint/src/error-handling-enhanced.js
Normal 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>'
|
||||
);
|
||||
}
|
||||
113
static/html/jspaint/src/extra-tools.js
Normal file
113
static/html/jspaint/src/extra-tools.js
Normal 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
|
||||
}];
|
||||
3706
static/html/jspaint/src/functions.js
Normal file
3706
static/html/jspaint/src/functions.js
Normal file
File diff suppressed because it is too large
Load Diff
490
static/html/jspaint/src/help.js
Normal file
490
static/html/jspaint/src/help.js
Normal 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);
|
||||
285
static/html/jspaint/src/helpers.js
Normal file
285
static/html/jspaint/src/helpers.js
Normal 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);
|
||||
1350
static/html/jspaint/src/image-manipulation.js
Normal file
1350
static/html/jspaint/src/image-manipulation.js
Normal file
File diff suppressed because it is too large
Load Diff
197
static/html/jspaint/src/imgur.js
Normal file
197
static/html/jspaint/src/imgur.js
Normal 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);
|
||||
122
static/html/jspaint/src/manage-storage.js
Normal file
122
static/html/jspaint/src/manage-storage.js
Normal 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);
|
||||
715
static/html/jspaint/src/menus.js
Normal file
715
static/html/jspaint/src/menus.js
Normal 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);
|
||||
137
static/html/jspaint/src/msgbox.js
Normal file
137
static/html/jspaint/src/msgbox.js
Normal 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 });
|
||||
// };
|
||||
588
static/html/jspaint/src/sessions.js
Normal file
588
static/html/jspaint/src/sessions.js
Normal 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 > 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.
|
||||
})();
|
||||
269
static/html/jspaint/src/simulate-random-gestures.js
Normal file
269
static/html/jspaint/src/simulate-random-gestures.js
Normal 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);
|
||||
2259
static/html/jspaint/src/speech-recognition.js
Normal file
2259
static/html/jspaint/src/speech-recognition.js
Normal file
File diff suppressed because it is too large
Load Diff
73
static/html/jspaint/src/storage.js
Normal file
73
static/html/jspaint/src/storage.js
Normal 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);
|
||||
80
static/html/jspaint/src/test-news.js
Normal file
80
static/html/jspaint/src/test-news.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
179
static/html/jspaint/src/theme.js
Normal file
179
static/html/jspaint/src/theme.js
Normal 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);
|
||||
383
static/html/jspaint/src/tool-options.js
Normal file
383
static/html/jspaint/src/tool-options.js
Normal 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");
|
||||
|
||||
1416
static/html/jspaint/src/tools.js
Normal file
1416
static/html/jspaint/src/tools.js
Normal file
File diff suppressed because it is too large
Load Diff
207
static/html/jspaint/src/vaporwave-fun.js
Normal file
207
static/html/jspaint/src/vaporwave-fun.js
Normal 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();
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user