DesignGym / web /js /canva.js
AdithyaSK's picture
AdithyaSK HF Staff
Serve Crello corpus aafdb0e86816 from the mounted bucket
df84e45 verified
Raw History Blame Contribute Delete
11.5 kB
// Canva's own side panels and popovers, as its editor draws them: colour and font open in the
// side panel (not a popover), the colour panel has a search ("#00c4cc" or "blue"), a rainbow "+"
// for a custom colour, the design's colours and Canva's default palette as round swatches; the
// Spacing popover reads letter spacing in thousandths of an em, as Canva does.
import { clamp, fmt, h } from "./util.js";
import { icon } from "./icons.js";
import { ensureFonts, fontCatalog } from "./fonts.js";
import { hexToRgb, hsvToRgb, numberField, popover, rgbToHex, rgbToHsv } from "./widgets.js";
export const CANVA_PALETTE = [
"#000000", "#545454", "#737373", "#a6a6a6", "#bfbfbf", "#d9d9d9", "#ffffff",
"#ff3131", "#ff5757", "#ff66c4", "#e2a9f1", "#cb6ce6", "#8c52ff", "#5e17eb",
"#0097b2", "#0cc0df", "#5ce1e6", "#38b6ff", "#5170ff", "#004aad", "#1800ad",
"#00bf63", "#7ed957", "#c1ff72", "#ffde59", "#ffbd59", "#ff914d", "#ff5c00",
];
/** "#00c4cc", "00c4cc", "#0cc" or a colour name ("blue") -> "#rrggbb", or null. */
export function parseColor(text) {
const t = (text || "").trim();
if (!t) return null;
const hex = t.replace(/^#?/, "#");
if (/^#[0-9a-f]{6}$/i.test(hex)) return hex.toLowerCase();
if (/^#[0-9a-f]{3}$/i.test(hex)) return `#${[...hex.slice(1)].map((c) => c + c).join("")}`.toLowerCase();
if (!/^[a-z ]+$/i.test(t)) return null;
const probe = h("span", { style: { color: "rgb(1, 2, 3)" } });
probe.style.color = t.replace(/\s+/g, "");
if (probe.style.color === "rgb(1, 2, 3)") return null; // not a CSS colour name
document.body.append(probe);
const m = /rgba?\((\d+), (\d+), (\d+)/.exec(getComputedStyle(probe).color);
probe.remove();
return m ? rgbToHex(+m[1], +m[2], +m[3]) : null;
}
const swatch = (c, onPick, { on = false, cls = "" } = {}) => h("button", {
class: `cv-swatch ${on ? "on" : ""} ${cls}`, type: "button", title: c.toUpperCase(), "aria-label": `Colour ${c}`,
style: { background: c }, dataset: { color: c }, onclick: () => onPick(c) });
/** The mini "Solid color" picker the rainbow "+" opens: square, hue bar, hex field. */
function solidPicker(anchor, initial, onPick) {
let [hue, sat, val] = rgbToHsv(...hexToRgb(initial || "#000000"));
const current = () => rgbToHex(...hsvToRgb(hue, sat, val));
const sv = h("div", { class: "cp-sv cvp-sv" }, h("div", { class: "cp-sv-knob" }));
const hueBar = h("div", { class: "cp-hue cvp-hue" }, h("div", { class: "cp-hue-knob" }));
const dot = h("span", { class: "cvp-dot" });
const hex = h("input", { class: "cp-hex num-input cvp-hex", spellcheck: "false", "aria-label": "Hex colour" });
const paint = () => {
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}%`;
dot.style.background = current();
if (document.activeElement !== hex) hex.value = current();
};
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); onPick(current()); };
el.addEventListener("pointermove", move);
el.addEventListener("pointerup", up);
});
drag(sv, (x, y) => { sat = x; val = 1 - y; });
drag(hueBar, (x) => { hue = x * 359.9; });
hex.addEventListener("keydown", (e) => {
if (e.key !== "Escape") e.stopPropagation();
if (e.key === "Enter") {
const c = parseColor(hex.value);
if (c) { [hue, sat, val] = rgbToHsv(...hexToRgb(c)); paint(); onPick(c); }
hex.select();
}
});
paint();
// Canva's popover has Solid color / Gradient tabs; solid colours are what this editor has.
popover(anchor, h("div", { class: "cvp" }, h("div", { class: "cvp-tabs" }, h("span", { class: "cvp-tab on" }, "Solid color")),
sv, hueBar, h("div", { class: "cvp-row" }, h("div", { class: "cvp-hexbox" }, dot, hex))), { cls: "cv-solid-pop" });
hex.focus();
hex.select();
}
/** The side panel colour picker. `kind` names the section ("Text colors", "Colors"). */
export function colorPanel(app, { get, set, kind = "Text colors", docColors = () => [] }) {
const search = h("input", { class: "cv-colsearch", placeholder: "Try “blue” or “#00c4cc”", spellcheck: "false",
"aria-label": "Search colours" });
const body = h("div", { class: "cv-colors" });
const pick = async (c) => { await set(c); paint(); };
const paint = () => {
const cur = (get() || "").toLowerCase();
body.textContent = "";
const add = h("button", { class: "cv-swatch cv-addcolor", type: "button", title: "Add a new color", "aria-label": "Add a new color",
html: icon("plus", 14) });
add.addEventListener("click", (e) => { e.stopPropagation(); solidPicker(add, cur || "#000000", pick); });
const docs = [...new Set(docColors().map((c) => c.toLowerCase()))].filter((c) => c !== cur);
body.append(
h("div", { class: "cv-colhead" }, h("span", { html: icon("palette", 16) }), typeof kind === "function" ? kind() : kind),
h("div", { class: "cv-swatches" }, add, ...(cur ? [swatch(cur, pick, { on: true })] : [])),
...(docs.length ? [h("div", { class: "cv-colsub" }, "Colors in this design"), h("div", { class: "cv-swatches" }, ...docs.map((c) => swatch(c, pick)))] : []),
h("div", { class: "cv-colhead" }, h("span", { html: icon("palette", 16) }), "Default solid colors"),
h("div", { class: "cv-swatches cv-palette" }, ...CANVA_PALETTE.map((c) => swatch(c, pick, { on: c === cur }))));
};
search.addEventListener("keydown", (e) => {
if (e.key !== "Escape") e.stopPropagation();
if (e.key === "Enter") {
const c = parseColor(search.value);
search.classList.toggle("bad", !c);
if (c) pick(c);
}
});
search.addEventListener("focus", () => search.select());
const el = h("div", { class: "cv-colorpanel" }, h("div", { class: "cv-searchbox" }, h("span", { html: icon("search", 16) }), search), body);
return { el, refresh: paint, focus: () => {} };
}
const recent = [];
/** The side panel font list: search, the design's fonts, recently used, then every font in its own face. */
export function fontPanel(app, { get, set, docFonts = () => [] }) {
const search = h("input", { class: "font-search cv-fontsearch", placeholder: "Try “Calligraphy” or “Open Sans”", spellcheck: "false",
"aria-label": "Search fonts" });
const list = h("div", { class: "font-list cv-fontlist", role: "listbox" });
let families = [];
const seen = new IntersectionObserver((entries) => {
const fams = entries.filter((e) => e.isIntersecting).map((e) => e.target.dataset.family);
if (fams.length) ensureFonts(fams);
}, { root: list, rootMargin: "160px" });
const row = (fam, cur) => {
const r = h("button", { class: `font-row ${fam === cur ? "on" : ""}`, type: "button", role: "option", dataset: { family: fam },
onclick: async () => {
await set(fam);
ensureFonts([fam]);
const i = recent.indexOf(fam);
if (i >= 0) recent.splice(i, 1);
recent.unshift(fam);
recent.length = Math.min(recent.length, 5);
paint();
} },
h("span", { class: "cv-fontname", style: { fontFamily: `'${fam}'` } }, fam), h("span", { class: "cv-fontcheck", html: fam === cur ? icon("check", 16) : "" }));
seen.observe(r);
return r;
};
const group = (title, ic) => h("div", { class: "font-group cv-fontgroup" }, h("span", { html: icon(ic, 16) }), title);
const paint = () => {
const cur = get() || "";
const q = search.value.trim().toLowerCase();
seen.disconnect();
list.textContent = "";
if (q) {
const hits = families.filter((f) => f.toLowerCase().includes(q));
hits.forEach((f) => list.append(row(f, cur)));
if (!hits.length) list.append(h("div", { class: "cv-fontempty" }, `No results for “${search.value.trim()}”`));
return;
}
const docs = [...new Set(docFonts().filter(Boolean))];
if (docs.length) { list.append(group("Document fonts", "file-text")); docs.forEach((f) => list.append(row(f, cur))); }
const rec = recent.filter((f) => !docs.includes(f));
if (rec.length) { list.append(group("Recently used", "clock")); rec.forEach((f) => list.append(row(f, cur))); }
list.append(group("All fonts", "type"));
families.forEach((f) => list.append(row(f, cur)));
};
search.addEventListener("input", paint);
search.addEventListener("focus", () => search.select());
search.addEventListener("keydown", (ev) => {
if (ev.key !== "Escape") ev.stopPropagation();
if (ev.key === "Enter") list.querySelector(".font-row")?.click();
});
const el = h("div", { class: "font-pop cv-fontpanel" }, h("div", { class: "cv-searchbox" }, h("span", { html: icon("search", 16) }), search), list);
return {
el,
refresh: async () => {
const first = !families.length;
if (first) families = (await fontCatalog()).map((f) => f.family);
// Repaint for new state, but not under someone typing a search.
if (first || !list.children.length || document.activeElement !== search || !search.value) {
paint();
if (first) list.querySelector(".font-row.on")?.scrollIntoView({ block: "nearest" });
}
},
// Opening the panel puts the cursor in the search with any old query selected, so typing replaces it.
focus: () => { search.focus(); search.select(); list.querySelector(".font-row.on")?.scrollIntoView({ block: "nearest" }); },
};
}
/** Canva's Spacing popover: letter spacing (thousandths of an em) and line spacing, slider + number each. */
export function spacingPopover(anchor, m, tget) {
const size = () => m.common("font_size", ["text"]) || 16;
const pair = (label, name, { min, max, step, get, set, digits }) => {
const range = h("input", { type: "range", class: "range cv-range", min, max, step, "aria-label": label });
const field = numberField({ label: "", title: label, name, digits, min, max, step, get, set: async (v) => { await set(v); sync(); } });
const sync = () => { const v = get(); range.value = v ?? min; field.set(); };
range.addEventListener("input", () => { field.input.value = fmt(+range.value, digits); });
range.addEventListener("change", async () => { await set(+range.value); sync(); });
sync();
return h("div", { class: "cv-spacing-item" }, h("div", { class: "cv-spacing-label" }, label), h("div", { class: "cv-spacing-row" }, range, field));
};
const ls = pair("Letter spacing", "letter_spacing", { min: -200, max: 800, step: 1, digits: 0,
get: () => { const v = tget("letter_spacing")(); return v === undefined || v === null ? v : (v / size()) * 1000; },
set: (v) => m.set((e) => ({ letter_spacing: (v / 1000) * e.font_size }), ["text"]) });
const lh = pair("Line spacing", "line_height", { min: 0.5, max: 2.5, step: 0.01, digits: 2,
get: tget("line_height"), set: (v) => m.set({ line_height: v }, ["text"]) });
popover(anchor, h("div", { class: "cv-pop-body cv-spacing" }, ls, lh), { cls: "cv-spacing-pop" });
}