The four mode buttons become one switch, Slide | A | B | Erase, whose marker slides to the choice. In Erase the brush's two jobs and the picture on top are switches of the same kind, and PSD is a plain off | on. Save moves to the bottom right of the node, larger, with a disk icon, in a row of its own with the name of the files and the PSD choice. The files can be named. A Name field on the node takes letters, digits, spaces, dots, dashes and underscores; the server checks the same rule and refuses anything else, so a folder is never part of a name. A new optional filename input takes a wired name, such as the source picture's file name: its folder and extension are dropped. The save route still names no file to read. Size and Soft scrub from their label and from the unit inside the box as well as from the number (the scrub control gains handle() for this). Workflows saved before load as they were: 60 that use the node were opened (the pack's examples, published workflows, the oldest saved shapes), 72 nodes kept their size, mode and links. The node is one input socket taller.
108 lines
5.1 KiB
JavaScript
108 lines
5.1 KiB
JavaScript
// A switch with its choices side by side and a marker that slides to the
|
|
// chosen one: the mode row of Image Compare A/B, and its off | on choices.
|
|
// The choices share one width, so the marker only has to move. With `fit`
|
|
// each choice is as wide as its own name, and the marker is measured.
|
|
import { BRAND } from "./index.mjs";
|
|
|
|
const CSS_ID = "ausboss-slide-switch-v1";
|
|
|
|
function ensureCss() {
|
|
if (document.getElementById(CSS_ID)) return;
|
|
const style = document.createElement("style");
|
|
style.id = CSS_ID;
|
|
style.textContent = `
|
|
.ausboss-slide{position:relative;box-sizing:border-box;flex:none;display:grid;grid-auto-flow:column;grid-auto-columns:1fr;height:24px;padding:2px;border:1px solid #2a3437;border-radius:6px;background:#0f1516;}
|
|
.ausboss-slide.fit{grid-auto-columns:max-content;}
|
|
.ausboss-slide.fit button{padding:0 7px;}
|
|
.ausboss-slide-mark{position:absolute;top:2px;bottom:2px;left:2px;border-radius:4px;background:${BRAND};box-shadow:0 0 7px rgba(0,180,170,.3);transition:transform .16s ease,width .16s ease;pointer-events:none;}
|
|
.ausboss-slide.none .ausboss-slide-mark{display:none;}
|
|
.ausboss-slide button{position:relative;box-sizing:border-box;min-width:0;height:100%;margin:0;padding:0 9px;border:none;border-radius:4px;background:transparent;color:#8ba3a1;font:700 10px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.02em;white-space:nowrap;cursor:pointer;transition:color .16s ease;}
|
|
.ausboss-slide button:hover{color:#fff;}
|
|
.ausboss-slide button.on,.ausboss-slide button.on:hover{color:#04201d;}
|
|
.ausboss-slide.off-left button.on:first-of-type,.ausboss-slide.off-left button.on:first-of-type:hover{color:#d8eeee;}
|
|
.ausboss-slide.off-left.is-off .ausboss-slide-mark{background:#33423f;box-shadow:none;}
|
|
.ausboss-slide button:focus-visible{outline:1px solid ${BRAND};outline-offset:1px;}
|
|
@media (prefers-reduced-motion: reduce){.ausboss-slide-mark,.ausboss-slide button{transition:none;}}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
// options: [{ value, label, title }]. `label` names the whole switch for
|
|
// screen readers. `offLeft` is for an off | on pair: while it is off the
|
|
// marker is grey, so "on" is the only teal state. onChange(value) runs for
|
|
// a click on a choice, also on the one that is already chosen.
|
|
export function makeSlideSwitch({ options, value, label = "", offLeft = false, fit = false, onChange, signal } = {}) {
|
|
ensureCss();
|
|
const root = document.createElement("div");
|
|
root.className = `ausboss-slide${offLeft ? " off-left" : ""}${fit ? " fit" : ""}`;
|
|
root.setAttribute("role", "radiogroup");
|
|
if (label) root.setAttribute("aria-label", label);
|
|
const mark = document.createElement("div");
|
|
mark.className = "ausboss-slide-mark";
|
|
if (!fit) mark.style.width = `calc((100% - 4px) / ${options.length})`;
|
|
root.append(mark);
|
|
const buttons = new Map();
|
|
let current;
|
|
// Choices of their own widths: the marker takes the chosen one's place
|
|
// and size, read from the page.
|
|
const measure = () => {
|
|
const button = buttons.get(current);
|
|
if (!fit || !button || !button.offsetWidth) return;
|
|
mark.style.width = `${button.offsetWidth}px`;
|
|
mark.style.transform = `translateX(${button.offsetLeft - 2}px)`;
|
|
};
|
|
const show = (next) => {
|
|
current = next;
|
|
const index = options.findIndex((option) => option.value === next);
|
|
root.classList.toggle("none", index < 0);
|
|
root.classList.toggle("is-off", index === 0);
|
|
if (fit) measure();
|
|
else mark.style.transform = `translateX(${Math.max(0, index) * 100}%)`;
|
|
for (const [key, button] of buttons) {
|
|
const on = key === next;
|
|
button.classList.toggle("on", on);
|
|
button.setAttribute("aria-checked", String(on));
|
|
}
|
|
};
|
|
for (const option of options) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.setAttribute("role", "radio");
|
|
button.textContent = option.label;
|
|
if (option.title) button.title = option.title;
|
|
// Stopped here so a press on the switch never starts a node drag.
|
|
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
|
|
button.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onChange?.(option.value);
|
|
}, { signal });
|
|
buttons.set(option.value, button);
|
|
root.append(button);
|
|
}
|
|
show(value);
|
|
if (fit && typeof ResizeObserver !== "undefined") {
|
|
// Nothing can be measured before the switch is on the page.
|
|
const seen = new ResizeObserver(measure);
|
|
seen.observe(root);
|
|
signal?.addEventListener("abort", () => seen.disconnect());
|
|
}
|
|
return {
|
|
root,
|
|
get: () => current,
|
|
// Move the marker. It does not call onChange: the caller decides what a
|
|
// click means and then says where the switch stands.
|
|
set: show,
|
|
// A choice's words can follow the node's state (which picture is on top).
|
|
describe(key, { label: text, title } = {}) {
|
|
const button = buttons.get(key);
|
|
if (!button) return;
|
|
if (text !== undefined) button.textContent = text;
|
|
if (title !== undefined) button.title = title;
|
|
},
|
|
disable(disabled) {
|
|
for (const button of buttons.values()) button.disabled = Boolean(disabled);
|
|
},
|
|
};
|
|
}
|