jspaint/src/$Component.js

415 lines
12 KiB
JavaScript

((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);