DesignGym / web /js /ppt.js
AdithyaSK's picture
AdithyaSK HF Staff
Serve Crello corpus aafdb0e86816 from the mounted bucket
df84e45 verified
Raw History Blame Contribute Delete
29.5 kB
// PowerPoint for the web's own controls, as it draws them: the colour gallery (Theme Colors with their
// tints and shades, Standard Colors, Recent Colors, More Colors...) and the Colors dialog; the font and
// size comboboxes (type a name, or pick from the list); Align and Line Spacing dropdowns; the Font dialog
// (Font / Character Spacing) and the Paragraph dialog; the Format pane (Shape Options / Text Options,
// Fill & Line, Size & Properties, Picture) and Format Background; the header's Search box.
import { clamp, h } from "./util.js";
import { icon } from "./icons.js";
import { ensureFonts, fontCatalog } from "./fonts.js";
import { closePopover, hexToRgb, hsvToRgb, iconButton, menu, modal, numberField, popover, rgbToHex, rgbToHsv, slider } from "./widgets.js";
// ---------------------------------------------------------------- colours
// The Office Theme (2023) colours, in gallery order, and the ten Standard Colors.
const THEME = [["#FFFFFF", "White, Background 1"], ["#000000", "Black, Text 1"], ["#E8E8E8", "Light Gray, Background 2"],
["#0E2841", "Dark Blue, Text 2"], ["#156082", "Dark Teal, Accent 1"], ["#E97132", "Orange, Accent 2"], ["#196B24", "Green, Accent 3"],
["#0F9ED5", "Blue, Accent 4"], ["#A02B93", "Purple, Accent 5"], ["#4EA72E", "Green, Accent 6"]];
const STANDARD = [["#C00000", "Dark Red"], ["#FF0000", "Red"], ["#FFC000", "Orange"], ["#FFFF00", "Yellow"], ["#92D050", "Light Green"],
["#00B050", "Green"], ["#00B0F0", "Light Blue"], ["#0070C0", "Blue"], ["#002060", "Dark Blue"], ["#7030A0", "Purple"]];
const recent = [];
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2;
if (mx === mn) return [0, 0, l];
const d = mx - mn, s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
const hh = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
return [hh * 60, s, l];
}
function hslToHex(hh, s, l) {
const k = (n) => (n + hh / 30) % 12, a = s * Math.min(l, 1 - l);
const f = (n) => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))));
return rgbToHex(f(0), f(8), f(4));
}
// Office's tint / shade rows: which five variants a theme colour gets depends on how light it is.
function variants(hex) {
const [hh, s, l] = rgbToHsl(...hexToRgb(hex));
const lighter = (p) => [hslToHex(hh, s, l + (1 - l) * p), `Lighter ${Math.round(p * 100)}%`];
const darker = (p) => [hslToHex(hh, s, l * (1 - p)), `Darker ${Math.round(p * 100)}%`];
if (l >= 0.999) return [0.05, 0.15, 0.25, 0.35, 0.5].map(darker);
if (l <= 0.001) return [0.5, 0.35, 0.25, 0.15, 0.05].map(lighter);
if (l > 0.8) return [0.1, 0.25, 0.5, 0.75, 0.9].map(darker);
if (l < 0.2) return [...[0.9, 0.75, 0.5, 0.25, 0.1].map(lighter)];
return [lighter(0.8), lighter(0.6), lighter(0.4), darker(0.25), darker(0.5)];
}
/** The Colors dialog (More Colors...): colour field, hue slider, Hex / Red / Green / Blue, New / Current, OK / Cancel. */
export function moreColors(initial, onOK) {
const init = (initial || "#000000").slice(0, 7);
let [hue, sat, val] = rgbToHsv(...hexToRgb(init));
const current = () => rgbToHex(...hsvToRgb(hue, sat, val));
const sv = h("div", { class: "cp-sv ppc-sv" }, h("div", { class: "cp-sv-knob" }));
const hueBar = h("div", { class: "cp-hue ppc-hue" }, h("div", { class: "cp-hue-knob" }));
const hex = h("input", { class: "cp-hex num-input ppc-hex", spellcheck: "false", "aria-label": "Hex" });
const rgb = ["Red", "Green", "Blue"].map((n) => h("input", { class: "num-input ppc-rgb", "aria-label": n }));
const now = h("div", { class: "ppc-new" }), was = h("div", { class: "ppc-cur", style: { background: init } });
const paint = (skip) => {
sv.style.background = `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${hue}, 100%, 50%))`;
sv.firstChild.style.left = `${sat * 100}%`;
sv.firstChild.style.top = `${(1 - val) * 100}%`;
hueBar.firstChild.style.left = `${(hue / 360) * 100}%`;
const c = current();
now.style.background = c;
if (skip !== hex && document.activeElement !== hex) hex.value = c.slice(1).toUpperCase();
hexToRgb(c).forEach((v, i) => { if (skip !== rgb[i] && document.activeElement !== rgb[i]) rgb[i].value = String(v); });
};
const fromHex = () => {
let t = hex.value.trim().replace(/^#/, "");
if (/^[0-9a-f]{3}$/i.test(t)) t = [...t].map((c) => c + c).join("");
if (/^[0-9a-f]{6}$/i.test(t)) { [hue, sat, val] = rgbToHsv(...hexToRgb(`#${t}`)); paint(hex); }
};
let dlg = null;
const ok = () => { dlg.close(); const c = current(); if (!recent.includes(c)) recent.unshift(c); recent.length = Math.min(recent.length, 10); onOK(c); };
hex.addEventListener("input", fromHex);
hex.addEventListener("focus", () => hex.select());
const keys = (e) => { if (e.key === "Escape") return; e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); ok(); } };
hex.addEventListener("keydown", keys);
rgb.forEach((inp) => {
inp.addEventListener("input", () => { [hue, sat, val] = rgbToHsv(...rgb.map((x) => clamp(parseInt(x.value, 10) || 0, 0, 255))); paint(inp); });
inp.addEventListener("keydown", keys);
});
const drag = (el, fn) => el.addEventListener("pointerdown", (e) => {
el.setPointerCapture(e.pointerId);
const at = (ev) => { const r = el.getBoundingClientRect(); fn(clamp((ev.clientX - r.left) / r.width, 0, 1), clamp((ev.clientY - r.top) / r.height, 0, 1)); paint(); };
at(e);
const move = (ev) => at(ev);
const up = () => { el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", up); };
el.addEventListener("pointermove", move);
el.addEventListener("pointerup", up);
});
drag(sv, (x, y) => { sat = x; val = 1 - y; });
drag(hueBar, (x) => { hue = x * 359.9; });
const body = h("div", { class: "ppc-dialog" },
h("div", { class: "ppc-main" }, sv, h("div", { class: "ppc-side" },
h("label", { class: "ppc-f" }, h("span", {}, "Hex"), h("span", { class: "ppc-hash" }, "#"), hex),
...rgb.map((inp, i) => h("label", { class: "ppc-f" }, h("span", {}, ["Red", "Green", "Blue"][i]), inp)),
h("div", { class: "ppc-cmp" }, h("div", { class: "ppc-cap" }, "New"), now, was, h("div", { class: "ppc-cap" }, "Current")))),
hueBar);
paint();
dlg = modal("Colors", body, { cls: "pp-dialog pp-colors", actions: [{ label: "OK", primary: true, run: () => { ok(); return true; } }, { label: "Cancel" }] });
hex.focus();
hex.select();
return dlg;
}
/** The colour gallery a Font Color / Shape Fill / Shape Outline dropdown opens. */
export function colorGallery(anchor, { get, set, automatic = null, none = null }) {
const cur = (get() || "").toLowerCase();
const pick = (c) => { closePopover(); if (c && !recent.includes(c) && !THEME.concat(STANDARD).some(([x]) => x.toLowerCase() === c)) recent.unshift(c); set(c); };
const sw = (c, name) => h("button", { class: `ppc-sw ${c.toLowerCase() === cur ? "on" : ""}`, type: "button", title: name, "aria-label": name,
dataset: { color: c.toLowerCase() }, style: { background: c }, onclick: () => pick(c.toLowerCase()) });
const theme = h("div", { class: "ppc-grid ppc-theme" }, ...THEME.map(([c, n]) => sw(c, n)));
const tints = h("div", { class: "ppc-grid ppc-tints" });
for (let row = 0; row < 5; row++) THEME.forEach(([c, n]) => { const [v, lab] = variants(c)[row]; tints.append(sw(v, `${n}, ${lab}`)); });
const item = (label, ic, run, cls = "") => h("button", { class: `ppc-item ${cls}`, type: "button", onclick: run },
h("span", { class: "ppc-ic", html: ic ? icon(ic, 14) : "" }), label);
const body = h("div", { class: "ppc-menu" },
automatic ? item(automatic, null, () => pick("#000000"), "ppc-auto") : null,
none ? item(none, "slash", () => pick(null), "ppc-none") : null,
h("div", { class: "ppc-head" }, "Theme Colors"), theme, tints,
h("div", { class: "ppc-head" }, "Standard Colors"), h("div", { class: "ppc-grid" }, ...STANDARD.map(([c, n]) => sw(c, n))),
recent.length ? h("div", { class: "ppc-head" }, "Recent Colors") : null,
recent.length ? h("div", { class: "ppc-grid" }, ...recent.map((c) => sw(c, c.toUpperCase()))) : null,
h("div", { class: "ppc-sep" }),
item("More Colors...", "palette", () => { closePopover(); moreColors(cur || "#000000", (c) => set(c)); }, "pp-morecolors"));
popover(anchor, body, { cls: "pp-pop" });
}
// ---------------------------------------------------------------- fonts
const recentFonts = [];
/** The Font combobox: type a font name (Enter applies it) or pick from Theme / Recently Used / All Fonts. */
export function fontCombo({ get, set, docFonts = () => [] }) {
const input = h("input", { class: "pp-fontinput font-search", spellcheck: "false", autocomplete: "off", "aria-label": "Font" });
const caret = h("button", { class: "pp-combo-caret", type: "button", tabindex: "-1", "aria-label": "Font list", html: icon("chevron-down", 12) });
const el = h("div", { class: "font-btn pp-font pp-combo", role: "combobox" }, input, caret);
let families = null, list = null;
const choose = (f) => {
const i = recentFonts.indexOf(f);
if (i >= 0) recentFonts.splice(i, 1);
recentFonts.unshift(f);
recentFonts.length = Math.min(recentFonts.length, 6);
ensureFonts([f]);
closePopover();
input.value = f;
input.blur();
set(f);
};
const row = (f) => h("button", { class: `font-row pp-fontrow ${f === get() ? "on" : ""}`, type: "button", role: "option", dataset: { family: f },
style: { fontFamily: `'${f}'` }, onpointerdown: (e) => e.preventDefault(), onclick: () => choose(f) }, f);
const paint = (filter) => {
const q = filter ? input.value.trim().toLowerCase() : "";
list.textContent = "";
const add = (title, fams) => { if (!fams.length) return; list.append(h("div", { class: "font-group" }, title)); fams.forEach((f) => list.append(row(f))); };
const match = (f) => !q || f.toLowerCase().includes(q);
if (!q) {
add("Theme Fonts", [...new Set(docFonts())].filter((f) => families.includes(f)).slice(0, 2));
add("Recently Used Fonts", recentFonts.filter(match));
}
add("All Fonts", families.filter(match));
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 });
list.querySelectorAll(".font-row").forEach((r) => seen.observe(r));
};
const openList = async (filter = false) => {
families = families || (await fontCatalog()).map((f) => f.family);
if (!list || !list.isConnected) {
list = h("div", { class: "font-list pp-fontlist", role: "listbox" });
popover(el, h("div", { class: "font-pop" }, list), { cls: "pop-font pp-pop" });
}
paint(filter);
};
input.addEventListener("focus", () => { input.select(); openList(); });
input.addEventListener("input", () => openList(true));
input.addEventListener("keydown", (e) => {
if (e.key === "Escape") { input.value = get() || ""; closePopover(); input.blur(); return; }
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
const t = input.value.trim().toLowerCase();
const exact = (families || []).find((f) => f.toLowerCase() === t);
const first = list?.querySelector(".font-row")?.dataset.family;
if (exact || first) choose(exact || first);
}
});
caret.addEventListener("click", () => { if (list?.isConnected) closePopover(); else { input.focus(); } });
el.set = () => { if (document.activeElement !== input) input.value = get() || ""; const off = get() === undefined; input.disabled = off; caret.disabled = off; };
return el;
}
const SIZES = [8, 9, 10, 10.5, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 44, 48, 54, 60, 66, 72, 80, 88, 96];
/** The Font Size combobox, with its list of sizes; and Increase / Decrease Font Size. */
export function sizeCombo({ get, set }) {
const f = numberField({ label: "", title: "Font size", digits: 1, min: 1, max: 4000, cls: "pp-size", get, set });
const caret = h("button", { class: "pp-combo-caret", type: "button", "aria-label": "Font size list", html: icon("chevron-down", 12) });
caret.addEventListener("click", () => menu(caret, SIZES.map((v) => ({ label: String(v), icon: get() === v ? "check" : null, run: () => set(v) })), { cls: "pp-sizes" }));
f.append(caret);
const base = f.set;
f.set = () => { base?.(); const off = get() === undefined; f.input.disabled = off; caret.disabled = off; f.classList.toggle("disabled", off); };
const step = (d) => { const v = get(); if (v === undefined) return;
const next = d > 0 ? SIZES.find((s) => s > v + 1e-6) ?? Math.round(v + 10) : [...SIZES].reverse().find((s) => s < v - 1e-6) ?? Math.max(1, v - 1); set(next); };
return { field: f, grow: () => step(1), shrink: () => step(-1) };
}
// ---------------------------------------------------------------- paragraph + font dialogs
/** Line Spacing: 1.0 / 1.5 / 2.0 / 2.5 / 3.0, and Line Spacing Options... (the Paragraph dialog). */
export function lineSpacingMenu(anchor, app, m) {
const cur = m.common("line_height", ["text"]);
menu(anchor, [...[1, 1.5, 2, 2.5, 3].map((v) => ({ label: v.toFixed(1), icon: cur !== undefined && Math.abs((cur ?? 1) - v) < 1e-6 ? "check" : null,
run: () => m.set({ line_height: v }, ["text"]) })), "-", { label: "Line Spacing Options...", run: () => paragraphDialog(app, m) }], { cls: "pp-linemenu" });
}
// A dropdown that looks like Office's: a box with the value and a chevron, a list of choices.
function dropdown(options, value, onChange, cls = "") {
let v = value;
const b = h("button", { class: `pp-dd ${cls}`, type: "button" });
const paint = () => { b.innerHTML = `<span>${options.find((o) => o[0] === v)?.[1] ?? ""}</span>${icon("chevron-down", 12)}`; };
b.addEventListener("click", () => menu(b, options.map(([k, label]) => ({ label, icon: k === v ? "check" : null, run: () => { v = k; paint(); onChange(k); } })), { cls: "pp-ddmenu" }));
paint();
b.value = () => v;
return b;
}
/** Paragraph: Alignment; Line Spacing (Single / 1.5 lines / Double / Multiple) At. */
export function paragraphDialog(app, m) {
const lh = m.common("line_height", ["text"]) ?? 1;
let align = m.common("align", ["text"]) || "left";
let mode = { 1: "single", 1.5: "one5", 2: "double" }[lh] || "multiple";
const at = numberField({ label: "At", name: "line_height", digits: 2, step: 0.1, min: 0, max: 9.99, get: () => at.v, set: (x) => { at.v = x; } });
at.v = lh;
at.set();
const alignDd = dropdown([["left", "Left"], ["center", "Centered"], ["right", "Right"]], align, (k) => { align = k; }, "pp-para-align");
const modeDd = dropdown([["single", "Single"], ["one5", "1.5 lines"], ["double", "Double"], ["multiple", "Multiple"]], mode, (k) => {
mode = k; at.v = { single: 1, one5: 1.5, double: 2 }[k] ?? at.v; at.set(); }, "pp-para-spacing");
const body = h("div", { class: "pp-dlgbody" },
h("div", { class: "pp-dlgtabs" }, h("span", { class: "pp-dlgtab on" }, "Indents and Spacing")),
h("div", { class: "pp-dlgsec" }, "General"), h("div", { class: "pp-dlgrow" }, h("span", {}, "Alignment:"), alignDd),
h("div", { class: "pp-dlgsec" }, "Spacing"), h("div", { class: "pp-dlgrow" }, h("span", {}, "Line Spacing:"), modeDd, at));
modal("Paragraph", body, { cls: "pp-dialog pp-paragraph", actions: [{ label: "OK", primary: true, run: () => {
at.input.blur();
m.set({ align, line_height: at.v }, ["text"]);
} }, { label: "Cancel" }] });
}
/** Font: the Font tab (font, style, size, colour, underline, All Caps) and Character Spacing (Normal / Expanded / Condensed, By). */
export function fontDialog(app, m, tab = "font") {
const g = (p) => m.common(p, ["text"]);
const st = { font_family: g("font_family"), bold: !!g("bold"), italic: !!g("italic"), font_size: g("font_size"), color: g("color") || "#000000",
underline: !!g("underline"), uppercase: !!g("uppercase"), letter_spacing: g("letter_spacing") || 0 };
const tabs = h("div", { class: "pp-dlgtabs" });
const host = h("div", { class: "pp-dlgpage" });
const fontBox = fontCombo({ get: () => st.font_family, set: (f) => { st.font_family = f; }, docFonts: () => [] });
fontBox.set();
const style = dropdown([["r", "Regular"], ["i", "Italic"], ["b", "Bold"], ["bi", "Bold Italic"]], `${st.bold ? "b" : ""}${st.italic ? "i" : ""}` || "r",
(k) => { st.bold = k.includes("b"); st.italic = k.includes("i"); }, "pp-fd-style");
const size = numberField({ label: "Size:", name: "font_size", digits: 1, min: 1, max: 4000, get: () => st.font_size, set: (v) => { st.font_size = v; } });
size.set();
const chip = h("button", { class: "pp-dd pp-fd-color", type: "button", "aria-label": "Font color" });
const paintChip = () => { chip.innerHTML = `<span class="pp-fd-a">A</span><span class="pp-fd-bar" style="background:${st.color}"></span>${icon("chevron-down", 12)}`; };
paintChip();
chip.addEventListener("click", () => colorGallery(chip, { get: () => st.color, set: (c) => { st.color = c || "#000000"; paintChip(); } }));
const under = dropdown([[false, "(none)"], [true, "Single"]], st.underline, (k) => { st.underline = k; }, "pp-fd-under");
const caps = h("label", { class: "pp-check" }, h("input", { type: "checkbox", checked: st.uppercase, dataset: { field: "uppercase" },
onchange: (e) => { st.uppercase = e.target.checked; } }), "All Caps");
let kind = st.letter_spacing > 0 ? "expanded" : st.letter_spacing < 0 ? "condensed" : "normal";
const by = numberField({ label: "By:", name: "letter_spacing", digits: 2, min: 0, max: 1584, get: () => Math.abs(st.letter_spacing), set: (v) => {
st.letter_spacing = kind === "condensed" ? -v : kind === "normal" ? (v ? v : 0) : v; if (kind === "normal" && v) { kind = "expanded"; spacing.repaint(); } } });
by.set();
const spacing = dropdown([["normal", "Normal"], ["expanded", "Expanded"], ["condensed", "Condensed"]], kind, (k) => {
kind = k; const v = Math.abs(st.letter_spacing); st.letter_spacing = k === "normal" ? 0 : k === "condensed" ? -v : v; by.set(); }, "pp-fd-spacing");
spacing.repaint = () => { spacing.querySelector("span").textContent = { normal: "Normal", expanded: "Expanded", condensed: "Condensed" }[kind]; };
const pages = {
font: h("div", { class: "pp-fd-font" },
h("div", { class: "pp-fd-grid" }, h("label", {}, "Latin text font:"), h("label", {}, "Font style:"), h("label", {}, "Size:"),
fontBox, style, size),
h("div", { class: "pp-dlgrow" }, h("span", {}, "Font color"), chip, h("span", {}, "Underline style"), under),
h("div", { class: "pp-dlgsec" }, "Effects"), caps),
spacing: h("div", { class: "pp-fd-spacingpage" }, h("div", { class: "pp-dlgrow" }, h("span", {}, "Spacing:"), spacing, by)),
};
const show = (id) => {
tab = id;
tabs.querySelectorAll(".pp-dlgtab").forEach((b) => b.classList.toggle("on", b.dataset.tab === id));
host.textContent = "";
host.append(pages[id]);
};
for (const [id, label] of [["font", "Font"], ["spacing", "Character Spacing"]])
tabs.append(h("button", { class: "pp-dlgtab", type: "button", dataset: { tab: id }, onclick: () => show(id) }, label));
show(tab);
modal("Font", h("div", { class: "pp-dlgbody" }, tabs, host), { cls: "pp-dialog pp-fontdlg", actions: [{ label: "OK", primary: true, run: () => {
document.activeElement?.blur?.();
const patch = { bold: st.bold, italic: st.italic, underline: st.underline, uppercase: st.uppercase, color: st.color, letter_spacing: st.letter_spacing };
if (st.font_family) { patch.font_family = st.font_family; ensureFonts([st.font_family]); }
if (st.font_size) patch.font_size = st.font_size;
m.set(patch, ["text"]);
} }, { label: "Cancel" }] });
}
// ---------------------------------------------------------------- Format pane
/** The Format pane: Format Shape / Format Picture / Format Background, as PowerPoint lays them out. */
export function formatPane(app, m) {
const s = app.store;
const root = h("div", { class: "panel-body inspector pp-format" });
const state = { top: "shape", sub: "size", open: { fill: true, line: false, size: true, position: true, textfill: true, transparency: true, bg: true } };
let base = null, lock = false, fromCenter = false;
const num = (name, label, get, set, opts = {}) => {
const f = numberField({ label, title: label, name, digits: 2, min: opts.min ?? -1e5, max: opts.max ?? 1e5, unit: opts.unit ?? "px", get, set, cls: "pp-num" });
f.set();
return h("div", { class: "pp-frow" }, h("span", { class: "pp-flabel" }, label), f);
};
const sec = (id, title, ...rows) => {
const head = h("button", { class: "pp-fsec-head", type: "button", "aria-expanded": String(state.open[id]) },
h("span", { html: icon(state.open[id] ? "chevron-down" : "chevron-right", 14) }), title);
head.addEventListener("click", () => { state.open[id] = !state.open[id]; paint(); });
return h("section", { class: `pp-fsec ${state.open[id] ? "open" : ""}`, dataset: { sec: id } }, head, state.open[id] ? h("div", { class: "pp-fsec-body" }, ...rows) : null);
};
const radio = (label, on, run) => h("label", { class: "pp-radio" }, h("input", { type: "radio", checked: on, onchange: run }), label);
const colorRow = (label, get, set, opts = {}) => {
const b = h("button", { class: "pp-colorbtn", type: "button", "aria-label": label, dataset: { field: opts.name || "" } });
const c = get();
b.innerHTML = `<span class="pp-colorbar" style="background:${c || "transparent"}"></span>${icon("chevron-down", 12)}`;
b.addEventListener("click", () => colorGallery(b, { get, set, automatic: opts.automatic || null }));
return h("div", { class: "pp-frow" }, h("span", { class: "pp-flabel" }, label), b);
};
const transparency = () => {
const tr = slider({ label: "", name: "opacity", unit: "%", min: 0, max: 100,
get: () => { const v = m.common("opacity"); return v === null || v === undefined ? v : Math.round((1 - v) * 100); }, set: (v) => m.set({ opacity: 1 - v / 100 }) });
tr.set();
return h("div", { class: "pp-frow pp-trow" }, h("span", { class: "pp-flabel" }, "Transparency"), tr);
};
const iconTabs = (tabs) => h("div", { class: "pp-ftabs" }, ...tabs.map(([id, ic, label]) => {
const b = iconButton(ic, label, () => { state.sub = id; paint(); }, { cls: `pp-ftab ${state.sub === id ? "on" : ""}`, size: 18 });
b.dataset.sub = id;
return b;
}));
const sizeSections = (els) => {
const e0 = els[0], sid = els.map((e) => e.id).join(), isText = els.every((e) => e.kind === "text");
if (!base || base.id !== sid) base = { id: sid, w: e0.width, h: e0.height };
const setSize = (prop) => (v) => m.set((e) => (lock && e.kind !== "text" ? (prop === "width" ? { width: v, height: v * e.height / e.width } : { height: v, width: v * e.width / e.height }) : { [prop]: v }));
const cx = (e) => e.x + (fromCenter ? e.width / 2 : 0), cy = (e) => e.y + (fromCenter ? e.height / 2 : 0);
const from = () => { const b = dropdown([[false, "Top Left Corner"], [true, "Center"]], fromCenter, (k) => { fromCenter = k; paint(); }, "pp-from"); return h("div", { class: "pp-frow" }, h("span", { class: "pp-flabel" }, "From"), b); };
return [
sec("size", "Size",
num("height", "Height", () => (isText ? undefined : m.common("height")), setSize("height"), { min: 1 }),
num("width", "Width", () => m.common("width"), setSize("width"), { min: 1 }),
num("angle", "Rotation", () => m.common("angle"), (v) => m.set({ angle: v }), { unit: "°", min: -3600, max: 3600 }),
num("height_scale", "Scale Height", () => (isText ? undefined : (m.common("height") ?? 0) / base.h * 100), (v) => m.set({ height: base.h * v / 100 }), { unit: "%", min: 1 }),
num("width_scale", "Scale Width", () => (m.common("width") ?? 0) / base.w * 100, (v) => m.set((e) => ({ width: base.w * v / 100, ...(lock && e.kind !== "text" ? { height: base.h * v / 100 } : {}) })), { unit: "%", min: 1 }),
h("label", { class: "pp-check" }, h("input", { type: "checkbox", checked: lock, onchange: (ev) => { lock = ev.target.checked; } }), "Lock aspect ratio")),
sec("position", "Position",
num("x", "Horizontal position", () => (els.length === 1 ? cx(e0) : m.common("x")), (v) => m.set((e) => ({ x: fromCenter ? v - e.width / 2 : v }))), from(),
num("y", "Vertical position", () => (els.length === 1 ? cy(e0) : m.common("y")), (v) => m.set((e) => ({ y: fromCenter ? v - e.height / 2 : v }))), from()),
];
};
const paint = () => {
root.textContent = "";
const els = m.els();
if (!els.length) { // Format Background
root.append(iconTabs([["fill", "paint-bucket", "Fill"]]),
sec("bg", "Fill", radio("Solid fill", true, () => {}),
colorRow("Color", () => s.doc.background, (c) => s.op("set_background", { color: c || "#ffffff" }), { name: "background" })),
h("div", { class: "pp-fbtns" }, h("button", { class: "btn pp-reset", type: "button", onclick: () => s.op("set_background", { color: "#ffffff" }) }, "Reset Background")));
return;
}
const kinds = new Set(els.map((e) => e.kind));
const text = kinds.size === 1 && kinds.has("text"), image = kinds.size === 1 && kinds.has("image"), shape = kinds.size === 1 && kinds.has("shape");
if (text) {
const tops = h("div", { class: "pp-ftop" }, ...[["shape", "Shape Options"], ["text", "Text Options"]].map(([id, label]) =>
h("button", { class: `pp-ftop-tab ${state.top === id ? "on" : ""}`, type: "button", dataset: { top: id }, onclick: () => { state.top = id; paint(); } }, label)));
root.append(tops);
if (state.top === "text") {
root.append(iconTabs([["textfill", "baseline", "Text Fill & Outline"]]),
sec("textfill", "Text Fill", radio("Solid fill", true, () => {}),
colorRow("Color", () => m.common("color", ["text"]), (c) => m.set({ color: c || "#000000" }, ["text"]), { name: "color" }), transparency()));
return;
}
root.append(iconTabs([["size", "maximize-2", "Size & Properties"]]), ...sizeSections(els));
return;
}
const tabs = [...(shape ? [["fill", "paint-bucket", "Fill & Line"]] : []), ["size", "maximize-2", "Size & Properties"], ...(image ? [["picture", "image", "Picture"]] : [])];
if (!tabs.some(([id]) => id === state.sub)) state.sub = tabs[0][0];
root.append(iconTabs(tabs));
if (state.sub === "fill") {
const fill = m.common("fill", ["shape"]), stroke = m.common("stroke", ["shape"]);
root.append(
sec("fill", "Fill", radio("No fill", !fill, () => m.set({ fill: null }, ["shape"])), radio("Solid fill", !!fill, () => m.set((e) => ({ fill: e.fill || "#156082" }), ["shape"])),
colorRow("Color", () => fill, (c) => m.set({ fill: c }, ["shape"]), { name: "fill" }), transparency()),
sec("line", "Line", radio("No line", !stroke, () => m.set({ stroke: null }, ["shape"])),
radio("Solid line", !!stroke, () => m.set((e) => ({ stroke: e.stroke || "#000000", stroke_width: e.stroke_width || 1 }), ["shape"])),
colorRow("Color", () => stroke, (c) => m.set((e) => ({ stroke: c, stroke_width: e.stroke_width || 1 }), ["shape"]), { name: "stroke" }),
num("stroke_width", "Width", () => m.common("stroke_width", ["shape"]), (v) => m.set({ stroke_width: v }, ["shape"]), { min: 0, max: 1584 })));
} else if (state.sub === "picture") {
root.append(sec("transparency", "Picture Transparency", transparency()));
} else {
root.append(...sizeSections(els));
}
};
const title = () => {
const els = m.els();
if (!els.length) return "Format Background";
const k = new Set(els.map((e) => e.kind));
return k.size === 1 && k.has("image") ? "Format Picture" : "Format Shape";
};
return { el: root, title, refresh: () => { if (!root.contains(document.activeElement)) paint(); } };
}
// ---------------------------------------------------------------- header search
/** The header's Search box: find a command by name; Enter runs the first match. */
export function commandSearch(app, commands) {
const input = h("input", { class: "pp-search", placeholder: "Search", spellcheck: "false", "aria-label": "Search (Alt + Q)" });
const box = h("label", { class: "pp-searchbox" }, h("span", { html: icon("search", 14) }), input);
const matches = () => {
const q = input.value.trim().toLowerCase();
return q ? commands().filter((c) => c.label.toLowerCase().replace(/…/g, "").includes(q) && (!c.enabled || c.enabled())).slice(0, 8) : [];
};
input.addEventListener("input", () => {
const found = matches();
if (!found.length) { closePopover(); return; }
const pop = menu(box, [{ header: "Actions" }, ...found.map((c) => ({ label: c.label, icon: c.icon, run: () => { input.value = ""; c.run(); } }))], { cls: "pp-search-pop" });
pop.querySelectorAll(".menu-item").forEach((b) => b.setAttribute("tabindex", "-1"));
input.focus();
});
input.addEventListener("keydown", (e) => {
e.stopPropagation();
if (e.key === "Enter") { const f = matches()[0]; closePopover(); input.value = ""; input.blur(); f?.run(); }
if (e.key === "Escape") { closePopover(); input.value = ""; input.blur(); }
});
return box;
}