File size: 11,465 Bytes
df84e45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
// 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" });
}