Files
awesome-copilot/extensions/draw/public/ui.js
T

420 lines
16 KiB
JavaScript

// Tool rail, style bar, zoom controls, save status, toasts and popovers.
import { COLORS, SHAPE_TYPES, SIZE_KEYS, PEN_WIDTHS } from "/lib/model.mjs";
import { icon } from "./icons.js";
import { categoryOf, STYLE_KEYS } from "./editor.js";
import { esc, capitalize } from "./util.js";
import { makeAnnouncer, TYPE_NAMES } from "./announce.js";
const $ = (id) => document.getElementById(id);
const TOOLS = [
["select", "V", "Select"], ["hand", "H", "Pan"], null,
["rect", "R", "Rectangle"], ["ellipse", "O", "Ellipse"], ["diamond", "D", "Diamond (decision)"], ["cylinder", "C", "Database"], null,
["arrow", "A", "Arrow"], ["text", "T", "Text"], ["pen", "P", "Pen"],
];
// What each tool does, read out when it is picked from the keyboard. Shape tools share one.
const TOOL_TIPS = {
select: "Select tool. N selects the next element.",
hand: "Pan tool. Drag to pan, or use the arrow keys when nothing is selected.",
arrow: "Arrow tool. Select a shape and press Enter, then select the shape it goes to and press Enter.",
text: "Text tool. Press Enter to add text in the middle, or click the canvas.",
pen: "Pen tool. Draw with a mouse, pen or touch.",
};
const SIZE_LABELS = { s: "Small", m: "Medium", l: "Large", xl: "Extra large" };
const FILL_OPTIONS = [["none", "No fill"], ["soft", "Light fill"], ["solid", "Solid fill"]];
const HEAD_OPTIONS = [["end", "Arrow at the end"], ["both", "Arrows at both ends"], ["none", "No arrowheads"]];
const ROUTE_OPTIONS = [["straight", "Straight"], ["curve", "Curved"], ["elbow", "Elbow"]];
const penSizeKey = (w) => SIZE_KEYS.reduce((best, k) => (Math.abs(PEN_WIDTHS[k] - w) < Math.abs(PEN_WIDTHS[best] - w) ? k : best), "m");
export class UI {
constructor(ed) {
this.ed = ed;
this.sync = null;
this.doc = null;
this.drawings = [];
this.folder = "";
this.popover = null;
this.statusTimer = 0;
this.connected = true;
this.sbHtml = "";
this.sbRaf = 0;
this.announce = makeAnnouncer($("announce"));
this.buildTools();
this.buildButtons();
this.bindEditor();
}
// ---------- tool rail and buttons ----------
buildTools() {
const nav = $("tools");
nav.innerHTML = TOOLS.map((t) =>
t
? `<button type="button" class="icon-btn tool" data-tool="${t[0]}" aria-pressed="false" title="${t[2]} (${t[1]})" aria-label="${t[2]}">${icon(t[0])}<span class="key" aria-hidden="true">${t[1]}</span></button>`
: '<div class="rule" role="separator"></div>',
).join("");
nav.addEventListener("mousedown", (e) => e.preventDefault());
nav.addEventListener("click", (e) => {
const b = e.target.closest("[data-tool]");
if (!b) return;
this.ed.setTool(b.dataset.tool);
// Picked with the keyboard (a click without a pointer), so go on to the canvas, where Enter
// adds a shape.
if (e.detail === 0) {
this.ed.focusCanvas();
this.announceTool(b.dataset.tool);
}
});
this.syncTools();
}
announceTool(tool) {
const name = TOOLS.find((t) => t && t[0] === tool)?.[2] || "Shape";
this.announce(TOOL_TIPS[tool] || `${name} tool. Press Enter to add one in the middle, or click the canvas.`);
}
syncTools() {
for (const b of $("tools").querySelectorAll("[data-tool]")) b.setAttribute("aria-pressed", String(b.dataset.tool === this.ed.tool));
}
buildButtons() {
const ed = this.ed;
const icons = { undo: "undo", redo: "redo", "zoom-out": "minus", "zoom-in": "plus", "zoom-fit": "fit", "help-button": "help" };
for (const [id, name] of Object.entries(icons)) $(id).innerHTML = icon(name);
// These act on the canvas, so they should not take keyboard focus away from it.
const run = (fn) => () => {
if (ed.editing) ed.labels.commit();
fn();
};
const actions = {
undo: () => ed.undo(),
redo: () => ed.redo(),
"zoom-out": () => ed.zoomBy(0.8),
"zoom-in": () => ed.zoomBy(1.25),
"zoom-level": () => ed.zoomTo(1),
"zoom-fit": () => ed.fit({ maxZoom: 2 }),
};
for (const [id, fn] of Object.entries(actions)) {
$(id).addEventListener("mousedown", (e) => e.preventDefault());
$(id).addEventListener("click", run(fn));
}
const sb = $("stylebar");
sb.addEventListener("mousedown", (e) => e.preventDefault());
sb.addEventListener("click", (e) => this.onStyleClick(e));
}
bindEditor() {
const ed = this.ed;
ed.on("tool", () => {
this.syncTools();
this.scheduleStylebar();
});
ed.on("selection", () => this.scheduleStylebar());
ed.on("style", () => this.scheduleStylebar());
ed.on("change", () => {
this.scheduleStylebar();
this.syncEmpty();
});
ed.on("editing", () => this.syncEmpty());
ed.on("history", () => this.syncHistory());
ed.on("view", () => this.syncZoom());
ed.on("hint", (msg) => this.toast(msg));
ed.on("announce", (msg) => this.announce(msg));
}
setup(state) {
this.folder = state.folder || "";
this.setDoc(state.drawing);
this.setDrawings(state.drawings || []);
this.syncHistory();
this.syncZoom();
this.syncEmpty();
this.renderStylebar();
}
setDoc(doc) {
this.doc = { id: doc.id, name: doc.name };
$("doc-name").textContent = doc.name;
$("svg").setAttribute("aria-label", doc.name);
document.title = `${doc.name} - Draw`;
}
setDrawings(list) {
this.drawings = list;
const cur = this.doc && list.find((d) => d.id === this.doc.id);
if (cur && cur.name !== this.doc.name) this.setDoc(cur);
}
refresh() {
this.sbHtml = "";
this.renderStylebar();
}
syncEmpty() {
$("empty").hidden = this.ed.elements.length > 0 || !!this.ed.editing;
}
syncHistory() {
$("undo").disabled = !this.ed.history.length;
$("redo").disabled = !this.ed.future.length;
}
syncZoom() {
$("zoom-level").textContent = `${Math.round(this.ed.view.zoom * 100)}%`;
}
// ---------- style bar ----------
scheduleStylebar() {
if (this.sbRaf) return;
this.sbRaf = requestAnimationFrame(() => {
this.sbRaf = 0;
if (!this.ed.interaction) this.renderStylebar();
});
}
renderStylebar() {
const ed = this.ed;
const bar = $("stylebar");
const sel = ed.selected();
let items;
if (sel.length) {
items = sel.map((el) => ({ cat: categoryOf(el), v: el }));
} else {
const cat = ed.toolCategory();
items = cat ? [{ cat, v: ed.styles[cat] }] : [];
}
if (!items.length) {
// A hidden bar cannot keep focus, so the canvas gets it, as when Escape closes a menu.
if (bar.contains(document.activeElement)) ed.focusCanvas();
bar.hidden = true;
return;
}
const cats = new Set(items.map((i) => i.cat));
const common = (key) => {
const vals = items.filter((i) => STYLE_KEYS[i.cat].includes(key)).map((i) => i.v[key]);
return vals.length && vals.every((x) => x === vals[0]) ? vals[0] : undefined;
};
const sizes = items.map((i) => (i.cat === "pen" ? penSizeKey(i.v.width) : i.v.size));
const size = sizes.every((x) => x === sizes[0]) ? sizes[0] : undefined;
const btn = (k, v, pressed, title, inner, cls = "") =>
`<button type="button" class="icon-btn ${cls}" data-k="${k}" data-v="${v}" aria-pressed="${pressed}" title="${title}" aria-label="${title}">${inner}</button>`;
const act = (a, name, title) => `<button type="button" class="icon-btn" data-act="${a}" title="${title}" aria-label="${title}">${icon(name)}</button>`;
const group = (label, html) => `<div class="style-group" role="group" aria-label="${label}">${html}</div>`;
const options = (k, list, cur, iconPrefix) => list.map(([v, title]) => btn(k, v, v === cur, title, icon(iconPrefix + v))).join("");
const color = common("color");
const parts = [
group("Color", COLORS.map((c) =>
btn("color", c, c === color, capitalize(c), `<span class="dot" style="background:${ed.paint(c, "soft")};border-color:${ed.paint(c, "stroke")}"></span>`, "swatch"),
).join("")),
];
if (cats.has("shape") && sel.length) {
const type = common("type");
parts.push(group("Shape", SHAPE_TYPES.map((t) => btn("type", t, t === type, TYPE_NAMES[t], icon(t))).join("")));
}
if (cats.has("shape")) parts.push(group("Fill", options("fill", FILL_OPTIONS, common("fill"), "fill-")));
if (cats.has("shape") || cats.has("arrow")) {
const dash = common("dash");
parts.push(group("Line", btn("dash", "false", dash === false, "Solid line", icon("line-solid")) + btn("dash", "true", dash === true, "Dashed line", icon("line-dashed"))));
}
if (cats.has("arrow")) {
parts.push(group("Arrowheads", options("head", HEAD_OPTIONS, common("head"), "head-")));
parts.push(group("Route", options("route", ROUTE_OPTIONS, common("route"), "route-")));
}
const sizeTitle = cats.size === 1 && cats.has("pen") ? "Stroke" : "Text size";
parts.push(group(sizeTitle, SIZE_KEYS.map((k) => btn("size", k, k === size, `${SIZE_LABELS[k]} ${sizeTitle.toLowerCase()}`, k.toUpperCase(), "size-btn")).join("")));
if (sel.length) {
// Arrows are always drawn on top, so only other elements can move between layers.
const layered = sel.some((el) => el.type !== "arrow");
parts.push(group("Arrange", [
...(layered ? [act("front", "front", "Bring to front (Ctrl+])"), act("back", "back", "Send to back (Ctrl+[)")] : []),
act("duplicate", "duplicate", "Duplicate (Ctrl+D)"),
act("delete", "trash", "Delete (Del)"),
].join("")));
}
const html = `<div class="stylebar-inner">${parts.join("")}</div>`;
if (html !== this.sbHtml) {
// Rebuilding the bar removes the focused button, so the same button in the new bar gets
// focus, and keyboard users stay where they were.
const focused = bar.contains(document.activeElement) ? { ...document.activeElement.dataset } : null;
bar.innerHTML = html;
this.sbHtml = html;
if (focused) {
const same = [...bar.querySelectorAll("button")].find((b) =>
b.dataset.k === focused.k && b.dataset.v === focused.v && b.dataset.act === focused.act);
if (same) same.focus({ preventScroll: true });
else ed.focusCanvas();
}
}
bar.hidden = false;
}
onStyleClick(e) {
const b = e.target.closest("button");
if (!b) return;
const ed = this.ed;
if (ed.editing) ed.labels.commit();
const a = b.dataset.act;
if (a === "front") return ed.reorder(true);
if (a === "back") return ed.reorder(false);
if (a === "duplicate") return ed.duplicate();
if (a === "delete") return ed.deleteSelection();
const k = b.dataset.k;
if (!k) return;
const v = b.dataset.v;
if (k === "dash") ed.applyStyle({ dash: v === "true" });
else if (k === "size") ed.applyStyle({ size: v, width: PEN_WIDTHS[v] });
else ed.applyStyle({ [k]: v });
}
// ---------- status and toasts ----------
// Quiet by default: "Saving" only shows when a save is slow, and errors stay until it recovers.
setStatus(kind, msg) {
clearTimeout(this.statusTimer);
const el = $("save-status");
if (kind === "saving") {
if (!el.classList.contains("is-error")) this.statusTimer = setTimeout(() => this.showStatus("Saving"), 600);
return;
}
if (kind === "saved") {
if (el.textContent) {
this.showStatus("Saved");
this.statusTimer = setTimeout(() => this.showStatus(""), 1500);
}
return;
}
this.showStatus(msg || "Not saved", true);
}
showStatus(text, error = false) {
const el = $("save-status");
el.textContent = text;
// A long message gets cut off, so hovering shows all of it.
el.title = text;
el.classList.toggle("is-error", error);
}
setConnection(ok) {
if (ok === this.connected) return;
this.connected = ok;
if (!ok) this.showStatus("Reconnecting", true);
else if ($("save-status").textContent === "Reconnecting") this.showStatus("");
}
toast(msg, { error = false, action = null, timeout = 3200 } = {}) {
const box = $("toasts");
const el = document.createElement("div");
el.className = `toast${error ? " is-error" : ""}`;
el.setAttribute("role", error ? "alert" : "status");
const text = document.createElement("span");
text.textContent = msg;
el.append(text);
const remove = () => el.remove();
if (action) {
const b = document.createElement("button");
b.type = "button";
b.textContent = action.label;
b.addEventListener("click", () => {
remove();
action.run();
});
el.append(b);
}
box.append(el);
while (box.children.length > 3) box.firstChild.remove();
setTimeout(remove, action ? Math.max(timeout, 6000) : error ? Math.max(timeout, 5000) : timeout);
}
// Draw stayed on this drawing because the edits on screen are not saved, and switching would
// lose them. The status line says why. `discard` goes ahead anyway.
warnUnsaved(discard) {
this.toast("Your latest changes are not saved yet, so Draw stayed on this drawing.", {
error: true,
timeout: 12000,
action: { label: "Discard changes", run: discard },
});
}
fatal(message) {
$("stage").innerHTML =
`<div class="empty" style="pointer-events:auto"><p class="empty-title">This drawing could not load</p><p>${esc(message)}</p>` +
'<p><button type="button" class="btn" id="reload">Try again</button></p></div>';
$("reload").addEventListener("click", () => location.reload());
}
// ---------- popovers ----------
openPopover(anchor, build, { className = "", placement = "below", align = "start", role = "dialog", label = "" } = {}) {
this.closePopover();
const pop = document.createElement("div");
pop.className = `popover ${className}`;
pop.setAttribute("role", role);
// Screen readers announce a dialog or menu by its name, so each one gets one.
pop.setAttribute("aria-label", label || anchor.getAttribute("aria-label") || anchor.title || anchor.textContent.trim());
$("popovers").append(pop);
const close = () => this.closePopover();
build(pop, close);
const r = anchor.getBoundingClientRect();
const w = pop.offsetWidth;
const h = pop.offsetHeight;
const left = Math.max(8, Math.min(align === "end" ? r.right - w : r.left, innerWidth - w - 8));
let top = r.bottom + 6;
if (placement === "above" || top + h > innerHeight - 8) top = Math.max(8, r.top - h - 6);
pop.style.left = `${left}px`;
pop.style.top = `${top}px`;
anchor.setAttribute("aria-expanded", "true");
const onDown = (e) => {
if (!pop.contains(e.target) && !anchor.contains(e.target)) close();
};
const onKey = (e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
close();
anchor.focus({ preventScroll: true });
return;
}
if (pop.getAttribute("role") === "menu" && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
const list = [...pop.querySelectorAll(".menu-item")];
const i = list.indexOf(document.activeElement);
const next = list[(i + (e.key === "ArrowDown" ? 1 : -1) + list.length) % list.length];
next?.focus();
e.preventDefault();
}
};
document.addEventListener("pointerdown", onDown, true);
pop.addEventListener("keydown", onKey);
this.popover = {
pop,
anchor,
cleanup: () => {
document.removeEventListener("pointerdown", onDown, true);
anchor.setAttribute("aria-expanded", "false");
pop.remove();
},
};
return pop;
}
togglePopover(anchor, build, opts) {
if (this.popover && this.popover.anchor === anchor) {
this.closePopover();
return null;
}
return this.openPopover(anchor, build, opts);
}
closePopover() {
if (!this.popover) return false;
const p = this.popover;
this.popover = null;
const hadFocus = p.pop.contains(document.activeElement);
p.cleanup();
if (hadFocus) this.ed.focusCanvas();
return true;
}
}