// 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 ? `` : '', ).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 = "") => ``; const act = (a, name, title) => ``; const group = (label, html) => `
${html}
`; 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), ``, "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 = `
${parts.join("")}
`; 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 = `

This drawing could not load

${esc(message)}

` + '

'; $("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; } }