// Inkscape's own dialogs and controls, as Inkscape 1.3 / 1.4 draw them (checked against the // Inkscape Beginners' Guide screenshots): docked dialogs are closable tabs; Fill and Stroke has // Fill / Stroke paint / Stroke style, a paint-type row, RGB / HSL / HSV / CMYK sliders and the // RGBA entry; Text and Font applies with its Apply button; Transform moves, scales and rotates // by Apply; Document Properties holds the page background; Export has the Export button. // Number entries are spin buttons with - / +. Only what this editor can honour is offered. import { clamp, fmt, h } from "./util.js"; import { icon } from "./icons.js"; import { ensureFonts, fontCatalog, hasStyle } from "./fonts.js"; import { hexToRgb, hsvToRgb, iconButton, menu, numberField, popover, rgbToHex, rgbToHsv } from "./widgets.js"; /** An Inkscape spin button: a number entry with - and + beside it. */ export function spin(opts) { const f = numberField({ digits: 3, ...opts }); const step = opts.step || 1; const bump = (d) => { const v = opts.get(); if (v === null || v === undefined) return; opts.set(clamp(v + d * step, opts.min ?? -1e6, opts.max ?? 1e6)); }; f.classList.add("ik-spin"); const minus = iconButton("minus", `Decrease ${opts.title || opts.label || ""}`.trim(), () => bump(-1), { cls: "ik-spin-btn", size: 12 }); const plus = iconButton("plus", `Increase ${opts.title || opts.label || ""}`.trim(), () => bump(1), { cls: "ik-spin-btn", size: 12 }); f.append(minus, plus); const set0 = f.set; f.set = (...a) => { set0(...a); const off = opts.get() === undefined; minus.disabled = plus.disabled = off; }; // greyed with the entry return f; } // ---------------------------------------------------------------- colour: the Flat color editor function hsvToHsl(hh, s, v) { const l = v * (1 - s / 2); return [hh, l === 0 || l === 1 ? 0 : (v - l) / Math.min(l, 1 - l), l]; } function hslToHsv(hh, s, l) { const v = l + s * Math.min(l, 1 - l); return [hh, v === 0 ? 0 : 2 * (1 - l / v), v]; } const MODES = ["RGB", "HSL", "HSV", "CMYK"]; let mode = "HSL"; // Inkscape's Flat color opens in HSL /** RGB / HSL / HSV / CMYK channel sliders with spin values, and the RGBA entry. */ export function flatColor({ get, set, onChange = null }) { const root = h("div", { class: "ik-flat" }); // The colour model is a dropdown beside "Flat color", as in Inkscape 1.4. const modeBar = h("button", { class: "ik-mode-dd ik-drop", type: "button", "aria-label": "Color mode" }); const rows = h("div", { class: "ik-chans" }); const rgba = h("input", { class: "num-input ik-rgba", spellcheck: "false", "aria-label": "RGBA", dataset: { field: "rgba" } }); let cur = get() || "#000000"; const apply = async (hex) => { cur = hex; await set(hex); paint(); onChange?.(hex); }; const channels = () => { const [r, g, b] = hexToRgb(cur); const [hh, s, v] = rgbToHsv(r, g, b); if (mode === "RGB") return [["R", r, 255], ["G", g, 255], ["B", b, 255]]; if (mode === "HSV") return [["H", hh, 360], ["S", s * 100, 100], ["V", v * 100, 100]]; if (mode === "HSL") { const [, s2, l] = hsvToHsl(hh, s, v); return [["H", hh, 360], ["S", s2 * 100, 100], ["L", l * 100, 100]]; } const k = 1 - Math.max(r, g, b) / 255; const c = k >= 1 ? 0 : (1 - r / 255 - k) / (1 - k), m = k >= 1 ? 0 : (1 - g / 255 - k) / (1 - k), y = k >= 1 ? 0 : (1 - b / 255 - k) / (1 - k); return [["C", c * 100, 100], ["M", m * 100, 100], ["Y", y * 100, 100], ["K", k * 100, 100]]; }; const fromChannels = (vals) => { if (mode === "RGB") return rgbToHex(...vals); if (mode === "HSV") return rgbToHex(...hsvToRgb(vals[0] % 360, vals[1] / 100, vals[2] / 100)); if (mode === "HSL") { const [hh, s, v] = hslToHsv(vals[0] % 360, vals[1] / 100, vals[2] / 100); return rgbToHex(...hsvToRgb(hh, s, v)); } const [c, m, y, k] = vals.map((x) => x / 100); return rgbToHex(255 * (1 - c) * (1 - k), 255 * (1 - m) * (1 - k), 255 * (1 - y) * (1 - k)); }; const paint = () => { cur = get() || cur; modeBar.innerHTML = `${icon("palette", 13)}${mode}${icon("chevron-down", 10)}`; const chans = channels(); rows.textContent = ""; chans.forEach(([name, val, max], i) => { // Each slider's track shows the colours that channel runs through, the others held. const vals0 = chans.map((c) => c[1]); // (Hue always shows the full spectrum, as Inkscape draws it.) const stops = name === "H" && mode !== "RGB" && mode !== "CMYK" ? ["#ff0000", "#ffff00", "#00ff00", "#00ffff", "#0000ff", "#ff00ff", "#ff0000"] : Array.from({ length: 9 }, (_, k) => { const v = [...vals0]; v[i] = (max * k) / 8; return fromChannels(v); }); const range = h("input", { type: "range", class: "ik-chan-range", min: 0, max, step: 1, value: String(Math.round(val)), "aria-label": name, style: { background: `linear-gradient(to right, ${stops.join(", ")})` } }); const num = h("input", { class: "num-input ik-chan-num", value: String(Math.round(val)), "aria-label": `${name} value`, dataset: { chan: name } }); const commit = (v) => { const vals = channels().map((c) => c[1]); vals[i] = clamp(v, 0, max); apply(fromChannels(vals)); }; range.addEventListener("change", () => commit(+range.value)); range.addEventListener("input", () => { num.value = range.value; }); num.addEventListener("keydown", (e) => { if (e.key === "Escape") { num.blur(); return; } e.stopPropagation(); if (e.key === "Enter" || e.key === "Tab") commit(parseFloat(num.value) || 0); }); const step = (d) => commit(Math.round(val) + d); rows.append(h("label", { class: "ik-chan" }, h("span", { class: "ik-chan-name" }, `${name}:`), range, num, iconButton("minus", `Decrease ${name}`, () => step(-1), { cls: "ik-spin-btn", size: 12 }), iconButton("plus", `Increase ${name}`, () => step(1), { cls: "ik-spin-btn", size: 12 }))); }); if (document.activeElement !== rgba) rgba.value = `${cur.slice(1, 7)}ff`; }; modeBar.addEventListener("click", () => menu(modeBar, MODES.map((md) => ({ label: md, icon: md === mode ? "check" : null, run: () => { mode = md; paint(); } })), { align: "end", cls: "ik-mode-menu" })); // The RGBA entry reads eight hex digits (the last two are alpha); six are accepted too. rgba.addEventListener("keydown", (e) => { if (e.key === "Escape") { rgba.blur(); return; } // Escape leaves the entry and goes back to the canvas e.stopPropagation(); if (e.key === "Enter" || e.key === "Tab") { const t = rgba.value.trim().replace(/^#/, ""); if (/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(t)) apply(`#${t.slice(0, 6).toLowerCase()}`); else rgba.value = `${cur.slice(1, 7)}ff`; if (e.key === "Enter") rgba.select(); } }); rgba.addEventListener("focus", () => rgba.select()); root.append(h("div", { class: "ik-flat-head" }, h("span", { class: "ik-flat-title" }, "Flat color"), h("span", { class: "grow" }), modeBar), rows, h("div", { class: "ik-rgbarow" }, h("span", { class: "grow" }), h("label", { class: "ik-rgbalabel" }, "RGBA:", rgba))); paint(); root.refresh = paint; return root; } // ---------------------------------------------------------------- the dock: closable dialog tabs export function dock(defs, { onChange = null } = {}) { const open = []; let active = null; const bar = h("div", { class: "ik-docktabs", role: "tablist" }); const host = h("div", { class: "ik-dock-body" }); const el = h("aside", { class: "ik-dock" }, bar, host); const paint = () => { bar.textContent = ""; for (const id of open) { const d = defs[id]; const t = h("div", { class: `ik-docktab ${id === active ? "on" : ""}`, role: "tab", dataset: { tab: id } }, h("span", { class: "ik-docktab-ic", html: icon(d.icon, 14) }), h("span", { class: "ik-docktab-label" }, d.title), iconButton("x", `Close ${d.title}`, () => close(id), { cls: "ik-docktab-x", size: 11 })); t.addEventListener("click", (e) => { if (!e.target.closest(".ik-docktab-x")) show(id); }); bar.append(t); } el.classList.toggle("empty", !open.length); }; const show = (id) => { if (!defs[id]) return; if (!open.includes(id)) open.push(id); active = id; host.textContent = ""; host.append(defs[id].panel.el); defs[id].panel.refresh(); paint(); onChange?.(); }; const close = (id) => { const i = open.indexOf(id); if (i < 0) return; open.splice(i, 1); if (active === id) { active = open[Math.min(i, open.length - 1)] || null; host.textContent = ""; if (active) { host.append(defs[active].panel.el); defs[active].panel.refresh(); } } paint(); onChange?.(); }; return { el, show, close, current: () => active, isOpen: (id) => open.includes(id), refresh: () => { if (active) defs[active].panel.refresh(); } }; } // ---------------------------------------------------------------- Fill and Stroke export function fillAndStroke(app, m) { const tabs = [["fill", "Fill", "square"], ["stroke", "Stroke paint", "square-dashed"], ["style", "Stroke style", "minus"]]; let tab = "fill"; const bar = h("div", { class: "ik-subtabs" }); const body = h("div", { class: "ik-fs-body" }); const opacity = spin({ label: "", title: "Opacity (%)", name: "opacity", digits: 1, min: 0, max: 100, cls: "ik-opacity", get: () => { const v = m.common("opacity"); return v === null || v === undefined ? v : v * 100; }, set: (v) => m.set({ opacity: v / 100 }) }); const foot = h("div", { class: "ik-fs-foot" }, h("span", { class: "ik-fs-label" }, "Opacity (%)"), opacity); const el = h("div", { class: "panel-body ik-fs" }, bar, body, foot); const target = () => { const els = m.els(); if (!els.length) return null; if (els.every((e) => e.kind === "text")) return tab === "fill" ? { prop: "color", kinds: ["text"], none: false } : null; if (els.every((e) => e.kind === "shape")) return tab === "fill" ? { prop: "fill", kinds: ["shape"], none: true } : { prop: "stroke", kinds: ["shape"], none: true }; return null; }; let flat = null; const paint = () => { bar.textContent = ""; for (const [id, label, ic] of tabs) { const b = h("button", { class: `ik-subtab ${id === tab ? "on" : ""}`, type: "button", dataset: { tab: id } }, h("span", { html: icon(ic, 13) }), label); b.addEventListener("click", () => { tab = id; paint(); }); bar.append(b); } body.textContent = ""; flat = null; const els = m.els(); foot.hidden = !els.length; if (!els.length) { body.append(h("div", { class: "ik-fs-empty" }, "No objects selected")); return; } if (tab === "style") { if (!els.every((e) => e.kind === "shape")) { body.append(h("div", { class: "ik-fs-empty" }, "No stroke on this object")); return; } const w = spin({ label: "Width:", title: "Stroke width", name: "stroke_width", digits: 3, min: 0, max: 1000, get: () => m.common("stroke_width", ["shape"]), set: (v) => m.set({ stroke_width: v }, ["shape"]) }); w.set(); body.append(h("div", { class: "ik-fs-row" }, w, h("span", { class: "ik-unit" }, "px"))); return; } const t = target(); if (!t) { body.append(h("div", { class: "ik-fs-empty" }, "No paint")); return; } const value = m.common(t.prop, t.kinds); const isNone = !value || value === "none"; const paintRow = h("div", { class: "ik-paints" }, ...(t.none ? [iconButton("x", "No paint", () => m.set(t.prop === "stroke" ? { stroke: null, stroke_width: 0 } : { [t.prop]: "none" }, t.kinds), { cls: `ik-paint ${isNone ? "on" : ""}` })] : []), iconButton("square", "Flat color", () => { if (isNone) m.set(t.prop === "stroke" ? { stroke: "#000000", stroke_width: 4 } : { [t.prop]: "#000000" }, t.kinds); }, { cls: `ik-paint ${!isNone ? "on" : ""}` })); body.append(paintRow); if (isNone) { body.append(h("div", { class: "ik-fs-empty" }, "No paint")); return; } flat = flatColor({ get: () => { const v = m.common(t.prop, t.kinds); return v && v !== "none" ? v : "#000000"; }, set: (c) => m.set(t.prop === "stroke" ? (e) => ({ stroke: c, stroke_width: e.stroke_width || 4 }) : { [t.prop]: c }, t.kinds) }); body.append(flat); }; const opBar = () => { opacity.set(); const v = m.common("opacity"); opacity.style.setProperty("--pct", `${Math.round((v ?? 1) * 100)}%`); }; return { el, refresh: () => { if (flat && el.contains(document.activeElement)) { flat.refresh(); opBar(); return; } paint(); opBar(); }, setTab: (id) => { tab = id; paint(); } }; } // ---------------------------------------------------------------- Text and Font export function textAndFont(app, m) { let tab = "font"; let families = []; const pending = {}; const textOnly = () => { const e = m.els(); return e.length && e.every((x) => x.kind === "text"); }; const bar = h("div", { class: "ik-subtabs" }); const body = h("div", { class: "ik-tf-body" }); const applyBtn = h("button", { class: "btn btn-primary ik-apply", type: "button" }, "Apply"); const el = h("div", { class: "panel-body ik-tf" }, bar, body, h("div", { class: "ik-tf-foot" }, h("span", { class: "grow" }), applyBtn)); const val = (k) => (k in pending ? pending[k] : m.common(k, ["text"])); applyBtn.addEventListener("click", async () => { if (!textOnly() || !Object.keys(pending).length) return; const p = { ...pending }; for (const k of Object.keys(pending)) delete pending[k]; if (p.font_family) ensureFonts([p.font_family]); await m.set(p, ["text"]); paint(); }); const paintFont = () => { const fam = val("font_family") || ""; const search = h("input", { class: "font-search ik-tf-search", placeholder: "Font family", spellcheck: "false", "aria-label": "Font family" }); const list = h("div", { class: "ik-tf-list font-list", role: "listbox" }); const seen = new IntersectionObserver((es) => { const f = es.filter((x) => x.isIntersecting).map((x) => x.target.dataset.family); if (f.length) ensureFonts(f); }, { root: list }); const fill = () => { seen.disconnect(); list.textContent = ""; const q = search.value.trim().toLowerCase(); families.filter((f) => f.toLowerCase().includes(q)).forEach((f) => { const r = h("button", { class: `font-row ik-tf-row ${f === fam ? "on" : ""}`, type: "button", role: "option", dataset: { family: f }, onclick: () => { pending.font_family = f; paint(); } }, h("span", { class: "ik-tf-name" }, f), h("span", { class: "ik-tf-sample", style: { fontFamily: `'${f}'` } }, "AaBbCcIiPpQq12369")); seen.observe(r); list.append(r); }); }; search.addEventListener("input", fill); search.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") list.querySelector(".font-row")?.click(); }); fill(); const styles = [["Normal", false, false, "regular"], ["Italic", false, true, "italic"], ["Bold", true, false, "bold"], ["Bold Italic", true, true, "bolditalic"]] .filter(([, , , face]) => face === "regular" || !fam || hasStyle(fam, face)); const b = !!val("bold"), it = !!val("italic"); const styleList = h("div", { class: "ik-tf-styles" }, h("div", { class: "ik-tf-head" }, h("span", {}, "CSS"), h("span", {}, "Face")), ...styles.map(([label, bb, ii]) => h("button", { class: `ik-tf-style ${bb === b && ii === it ? "on" : ""}`, type: "button", dataset: { style: label }, onclick: () => { pending.bold = bb; pending.italic = ii; paint(); } }, h("span", {}, label), h("span", { class: "ik-tf-face" }, label === "Normal" ? "Regular" : label)))); const size = numberField({ label: "", title: "Font size", name: "font_size", digits: 2, min: 1, max: 2000, cls: "ik-tf-size", get: () => (textOnly() ? val("font_size") : undefined), set: (v) => { pending.font_size = v; paint(); } }); size.set(); const preview = h("div", { class: "ik-tf-preview", style: { fontFamily: `'${fam}'`, fontWeight: b ? 700 : 400, fontStyle: it ? "italic" : "normal" } }, (m.els()[0]?.text || "AaBbCc").split("\n")[0].slice(0, 40)); ensureFonts([fam]); body.append(h("div", { class: "ik-tf-cols" }, h("fieldset", { class: "ik-tf-set" }, h("legend", {}, "Font family"), search, list), h("div", { class: "ik-tf-right" }, h("fieldset", { class: "ik-tf-set" }, h("legend", {}, "Style"), styleList), h("label", { class: "ik-tf-sizerow" }, "Font size", size))), preview); list.querySelector(".font-row.on")?.scrollIntoView({ block: "nearest" }); }; const paintFeatures = () => { const row = (label, key, opts) => h("div", { class: "ik-tf-feat" }, h("span", { class: "ik-tf-featlabel" }, label), ...opts.map(([v, text]) => { const id = `ik-${key}-${v}`; const r = h("input", { type: "radio", name: `ik-${key}`, id, checked: !!val(key) === v }); r.addEventListener("change", () => { pending[key] = v; }); return h("label", { for: id, class: "ik-tf-radio", dataset: { field: key, value: String(v) } }, r, text); })); body.append(row("Capitals:", "uppercase", [[false, "Normal"], [true, "All caps"]]), row("Decoration:", "underline", [[false, "None"], [true, "Underline"]])); }; const paintText = () => { const area = h("textarea", { class: "ik-tf-text", spellcheck: "false", "aria-label": "Text" }); area.value = val("text") ?? ""; area.addEventListener("input", () => { pending.text = area.value; }); area.addEventListener("keydown", (e) => e.stopPropagation()); body.append(area); }; const paint = () => { bar.textContent = ""; for (const [id, label] of [["font", "Font"], ["features", "Features"], ["text", "Text"]]) { const b = h("button", { class: `ik-subtab ${id === tab ? "on" : ""}`, type: "button", dataset: { tab: id } }, label); b.addEventListener("click", () => { tab = id; paint(); }); bar.append(b); } body.textContent = ""; applyBtn.disabled = !textOnly(); if (!textOnly()) { body.append(h("div", { class: "ik-fs-empty" }, "Select a text object")); return; } ({ font: paintFont, features: paintFeatures, text: paintText })[tab](); }; let lastSel = ""; return { el, refresh: async () => { if (!families.length) families = (await fontCatalog()).map((f) => f.family); const sel = m.els().map((e) => e.id).join(); if (sel !== lastSel) { for (const k of Object.keys(pending)) delete pending[k]; lastSel = sel; } if (!el.contains(document.activeElement)) paint(); }, setTab: (id) => { tab = id; paint(); }, }; } // ---------------------------------------------------------------- Transform export function transformDialog(app, m) { let tab = "move"; const state = { dx: 0, dy: 0, relative: true, w: null, h: null, prop: true, angle: 0, dir: "cw" }; const bar = h("div", { class: "ik-subtabs" }); const body = h("div", { class: "ik-tr-body" }); const applyBtn = h("button", { class: "btn btn-primary ik-apply", type: "button" }, "Apply"); const clearBtn = h("button", { class: "btn ik-clear", type: "button" }, "Clear"); const el = h("div", { class: "panel-body ik-tr" }, bar, body, h("div", { class: "ik-tf-foot" }, clearBtn, h("span", { class: "grow" }), applyBtn)); const one = () => { const e = m.els(); return e.length ? e : null; }; applyBtn.addEventListener("click", async () => { const els = one(); if (!els) return; if (tab === "move") { await m.set((e) => (state.relative ? { x: e.x + state.dx, y: e.y + state.dy } : { x: state.dx, y: state.dy })); } else if (tab === "scale") { await m.set((e) => { let w = state.w ?? e.width, hh = state.h ?? e.height; if (state.prop) { if (state.w !== null) hh = e.height * (w / e.width); else if (state.h !== null) w = e.width * (hh / e.height); } return e.kind === "text" ? { width: w } : { width: w, height: hh }; }); } else { const d = state.dir === "cw" ? state.angle : -state.angle; await m.set((e) => ({ angle: (e.angle || 0) + d })); // Inkscape rotates relative to the current angle } paint(); }); clearBtn.addEventListener("click", () => { Object.assign(state, { dx: 0, dy: 0, w: null, h: null, angle: 0 }); paint(); }); const field = (label, name, key, opts = {}) => { const f = spin({ label, title: label.replace(":", ""), name, digits: 3, min: -1e5, max: 1e5, ...opts, get: () => (one() ? (state[key] ?? opts.fallback?.() ?? 0) : undefined), set: (v) => { state[key] = v; f.set(); } }); f.set(); return f; }; const check = (label, key) => { const c = h("input", { type: "checkbox", checked: state[key] }); c.addEventListener("change", () => { state[key] = c.checked; if (key === "relative") { const e = m.els()[0]; Object.assign(state, c.checked ? { dx: 0, dy: 0 } : { dx: e?.x ?? 0, dy: e?.y ?? 0 }); paint(); } }); return h("label", { class: "ik-tr-check", dataset: { field: key } }, c, label); }; const paint = () => { bar.textContent = ""; for (const [id, label] of [["move", "Move"], ["scale", "Scale"], ["rotate", "Rotate"]]) { const b = h("button", { class: `ik-subtab ${id === tab ? "on" : ""}`, type: "button", dataset: { tab: id } }, label); b.addEventListener("click", () => { tab = id; paint(); }); bar.append(b); } body.textContent = ""; applyBtn.disabled = !one(); if (!one()) { body.append(h("div", { class: "ik-fs-empty" }, "No objects selected")); return; } const e0 = m.els()[0]; if (tab === "move") { body.append(h("div", { class: "ik-tr-grid" }, field("Horizontal:", "move_x", "dx"), h("span", { class: "ik-unit" }, "px"), field("Vertical:", "move_y", "dy"), h("span", { class: "ik-unit" }, "px")), check("Relative move", "relative")); } else if (tab === "scale") { body.append(h("div", { class: "ik-tr-grid" }, field("Width:", "scale_w", "w", { min: 1, fallback: () => e0.width }), h("span", { class: "ik-unit" }, "px"), field("Height:", "scale_h", "h", { min: 1, fallback: () => e0.height }), h("span", { class: "ik-unit" }, "px")), check("Scale proportionally", "prop")); } else { const dir = h("div", { class: "segmented ik-tr-dir" }, ...[["ccw", "rotate-ccw", "Counterclockwise"], ["cw", "rotate-cw", "Clockwise"]].map(([v, ic, t]) => { const b = iconButton(ic, t, () => { state.dir = v; paint(); }, { cls: state.dir === v ? "on" : "" }); b.dataset.value = v; return b; })); body.append(h("div", { class: "ik-tr-grid" }, field("Angle:", "rotate_angle", "angle", { min: -360, max: 360 }), h("span", { class: "ik-unit" }, "°")), dir); } }; let lastSel = ""; return { el, refresh: () => { const sel = m.els().map((e) => e.id).join(); if (sel !== lastSel) { Object.assign(state, { dx: 0, dy: 0, w: null, h: null, angle: 0, relative: true }); lastSel = sel; } if (!el.contains(document.activeElement)) paint(); }, setTab: (id) => { tab = id; paint(); }, }; } // ---------------------------------------------------------------- Document Properties, Export export function documentProperties(app) { const s = app.store; const chip = h("button", { class: "color-chip ik-bgchip", type: "button", "aria-label": "Background color", dataset: { field: "page_background" } }); chip.addEventListener("click", (e) => { e.stopPropagation(); const ed = flatColor({ get: () => s.doc.background, set: (c) => s.op("set_background", { color: c }) }); popover(chip, h("div", { class: "ik-colorpop" }, h("div", { class: "ik-colorpop-title" }, "Background color"), ed), { cls: "ik-pop" }); ed.querySelector(".ik-rgba").focus(); }); const size = h("div", { class: "ik-dp-size" }); const el = h("div", { class: "panel-body ik-dp" }, h("div", { class: "ik-subtabs" }, h("span", { class: "ik-subtab on" }, "Page")), h("div", { class: "ik-dp-grid" }, h("span", { class: "ik-dp-label" }, "Format"), size, h("span", { class: "ik-dp-label" }, "Background color"), chip)); return { el, refresh: () => { chip.style.background = s.doc?.background || "#ffffff"; size.textContent = `${s.doc.width} × ${s.doc.height} px`; } }; } export function exportDialog(app) { const s = app.store; const name = h("span", { class: "ik-ex-name" }); const btn = h("button", { class: "btn btn-primary ik-ex-btn", type: "button", dataset: { command: "submit" } }, "Export"); btn.addEventListener("click", () => app.commands.submit.run()); const el = h("div", { class: "panel-body ik-exportdlg" }, h("div", { class: "ik-subtabs" }, h("span", { class: "ik-subtab on" }, "Single Image")), h("div", { class: "ik-dp-grid" }, h("span", { class: "ik-dp-label" }, "Export area"), h("span", {}, "Page"), h("span", { class: "ik-dp-label" }, "Image size"), h("span", { class: "ik-ex-size" }), h("span", { class: "ik-dp-label" }, "Filename"), name, h("span", { class: "ik-dp-label" }, "Format"), h("span", {}, "PNG")), h("div", { class: "ik-tf-foot" }, h("span", { class: "grow" }), btn)); return { el, refresh: () => { const t = s.task || {}; name.textContent = `${(t.title || "drawing").replace(/[^\w\- ]+/g, "").trim().slice(0, 32) || "drawing"}.png`; el.querySelector(".ik-ex-size").textContent = `${s.doc.width} × ${s.doc.height} px at 96 dpi`; btn.disabled = s.done; } }; } /** The status bar's zoom entry: "Z: 52%" with - / +. */ export function zoomSpin(app) { const f = spin({ label: "Z:", title: "Zoom", name: "zoom", digits: 0, min: 1, max: 6400, step: 10, cls: "ik-zoom", get: () => Math.round(app.stage.zoom * 100), set: (v) => app.stage.setZoom(v / 100), format: (v) => `${fmt(v, 0)}%`, parse: (t) => parseFloat(t) }); app.on("zoom", () => f.set()); return f; } /** Inkscape's alignment dropdown in the Text tool's controls. */ export function alignDropdown(m) { const b = h("button", { class: "ik-align ik-drop", type: "button", "aria-label": "Alignment", title: "Alignment" }); const opts = [["left", "align-left", "Align left"], ["center", "align-center", "Align center"], ["right", "align-right", "Align right"]]; b.addEventListener("click", () => menu(b, opts.map(([v, ic, label]) => ({ label, icon: ic, run: () => m.set({ align: v }, ["text"]) })), { cls: "ik-align-menu" })); b.set = () => { const v = m.common("align", ["text"]) || "left"; b.innerHTML = `${icon(`align-${v}`, 15)}${icon("chevron-down", 10)}`; b.disabled = !m.els().length; }; return b; } // ---------------------------------------------------------------- the palette (Inkscape default, as share/palettes/inkscape.gpl) function hsl(hh, s, l) { const c = (1 - Math.abs(2 * l - 1)) * s, x = c * (1 - Math.abs(((hh / 60) % 2) - 1)), m0 = l - c / 2; const [r, g, b] = hh < 60 ? [c, x, 0] : hh < 120 ? [x, c, 0] : hh < 180 ? [0, c, x] : hh < 240 ? [0, x, c] : hh < 300 ? [x, 0, c] : [c, 0, x]; return rgbToHex((r + m0) * 255, (g + m0) * 255, (b + m0) * 255); } const GREYS = [["#000000", "Black"], ...[90, 80, 70, 60, 50, 40, 30, 20, 10, 7.5, 5, 2.5].map((p) => [rgbToHex(...[0, 0, 0].map(() => 255 * (1 - p / 100))), `${p}% Gray`]), ["#ffffff", "White"]]; const BASIC = [["#800000", "Maroon"], ["#ff0000", "Red"], ["#808000", "Olive"], ["#ffff00", "Yellow"], ["#008000", "Green"], ["#00ff00", "Lime"], ["#008080", "Teal"], ["#00ffff", "Aqua"], ["#000080", "Navy"], ["#0000ff", "Blue"], ["#800080", "Purple"], ["#ff00ff", "Fuchsia"]]; function inkscapeDefault() { const out = [...GREYS, ...BASIC.map(([c, n]) => [c, `${n} (${c.toUpperCase()})`])]; for (let hh = 0; hh < 360; hh += 24) { for (let k = 1; k <= 11; k++) out.push([hsl(hh, 1, k / 12), null]); // full saturation, dark to light for (let k = 1; k <= 9; k++) out.push([hsl(hh, 0.569, k / 10), null]); // muted for (let k = 1; k <= 7; k++) out.push([hsl(hh, 0.13, k / 8), null]); // greyish } return out; } const TANGO = [["#fce94f", "Butter 1"], ["#edd400", "Butter 2"], ["#c4a000", "Butter 3"], ["#fcaf3e", "Orange 1"], ["#f57900", "Orange 2"], ["#ce5c00", "Orange 3"], ["#e9b96e", "Chocolate 1"], ["#c17d11", "Chocolate 2"], ["#8f5902", "Chocolate 3"], ["#8ae234", "Chameleon 1"], ["#73d216", "Chameleon 2"], ["#4e9a06", "Chameleon 3"], ["#729fcf", "Sky Blue 1"], ["#3465a4", "Sky Blue 2"], ["#204a87", "Sky Blue 3"], ["#ad7fa8", "Plum 1"], ["#75507b", "Plum 2"], ["#5c3566", "Plum 3"], ["#ef2929", "Scarlet Red 1"], ["#cc0000", "Scarlet Red 2"], ["#a40000", "Scarlet Red 3"], ["#eeeeec", "Aluminium 1"], ["#d3d7cf", "Aluminium 2"], ["#babdb6", "Aluminium 3"], ["#888a85", "Aluminium 4"], ["#555753", "Aluminium 5"], ["#2e3436", "Aluminium 6"]]; const GRAY = Array.from({ length: 21 }, (_, i) => { const v = Math.round(255 * i / 20); return [rgbToHex(v, v, v), `${100 - i * 5}% Gray`]; }); export const PALETTES = { "Inkscape default": inkscapeDefault(), "Tango icons": TANGO, Gray: GRAY }; /** The palette bar: X (no paint), pinned black / grey / white, two scrolling rows, ^ v and the palette menu. */ export function paletteBar({ onPick, onNone }) { let name = "Inkscape default"; let row = 0; const grid = h("div", { class: "ik-pal-grid" }); const view = h("div", { class: "ik-pal-view" }, grid); const tip = (c, n) => (n ? n : c.toUpperCase()); const sw = (c, n, cls = "") => h("button", { class: `ik-swatch ${cls}`, type: "button", title: `${tip(c, n)} — click: fill, shift+click: stroke`, "aria-label": `Swatch ${tip(c, n)}`, style: { background: c }, dataset: { color: c }, onclick: (e) => onPick(c, e.shiftKey) }); const none = h("button", { class: "ik-swatch ik-none", type: "button", title: "No paint — click: fill, shift+click: stroke", "aria-label": "No paint", onclick: (e) => onNone(e.shiftKey) }); const pinned = h("div", { class: "ik-pal-pinned" }, none, sw("#000000", "Black", "big"), sw("#808080", "50% Gray", "big"), sw("#ffffff", "White", "big")); const scroll = (d) => { row = clamp(row + d, 0, Math.max(0, rows() - 2)); grid.style.transform = `translateY(${-row * 13}px)`; }; const rows = () => Math.ceil(grid.children.length / Math.max(1, Math.floor(view.clientWidth / 13))); const paint = () => { grid.textContent = ""; grid.append(...PALETTES[name].map(([c, n]) => sw(c, n))); row = 0; scroll(0); }; const up = iconButton("chevron-up", "Scroll palette up", () => scroll(-1), { cls: "ik-pal-btn", size: 12 }); const down = iconButton("chevron-down", "Scroll palette down", () => scroll(1), { cls: "ik-pal-btn", size: 12 }); const more = iconButton("menu", "Palette", () => menu(more, Object.keys(PALETTES).map((p) => ({ label: p, icon: p === name ? "check" : null, run: () => { name = p; paint(); } })), { align: "end", cls: "ik-pal-menu" }), { cls: "ik-pal-btn", size: 14 }); const el = h("div", { class: "ik-palette", "aria-label": "Palette" }, pinned, view, h("div", { class: "ik-pal-arrows" }, up, down), more); paint(); return el; }