PageSourceSearch

https://trmnl.com/assets/controllers/theme_builder_controller-5cb40235.js

js trmnl.com collected 2026-09-25 19:52:10 UTC 22,757 bytes, 558 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2import { COLOR_PALETTE, COLOR_HEX_MAP } from "lib/visual_editor_inspector_constants"
3import { createInspectorDropdown, createInspectorPopover } from "lib/visual_editor_inspector_dropdown"
4
5const humanize = (value) => value.replace(/[-_]/g, " ").replace(/^./, (letter) => letter.toUpperCase())
6
7// The settings sidebar is built from the Visual Editor's inspector builders, so both panels stay one design.
8export default class extends Controller {
9  static targets = ["iframe", "form", "inspector", "font", "sample", "singleCanvas", "previewStrip", "palettePreview", "previewsToggle",
10    "shareModal", "importModal", "shareLink", "shareSnippet", "importInput", "importError"]
11  static values = { previewUrl: String, defaults: Object, presets: Object, labels: Object, version: Number }
12
13  connect() {
14    this.hues = Object.keys(COLOR_PALETTE.chromatic)
15    this.picker = this.labelsValue.picker
16    this.watchedDocuments = new WeakSet()
17    this.buildInspector()
18    // The iframe can finish loading before Stimulus boots (cold dev JS); its load
19    // event is gone by then, so catch up here or the demo chrome stays visible.
20    if (this.iframeTarget.contentDocument?.readyState === "complete") this.frameLoaded({ target: this.iframeTarget })
21  }
22
23  // Not the editor's builder mixin: it expects responsive-override state and
24  // stacks label over control.
25  buildInspector() {
26    const sections = new Map()
27    this.formTarget.querySelectorAll("[data-control]").forEach((input) => {
28      const rows = sections.get(input.dataset.section) || []
29      rows.push(input)
30      sections.set(input.dataset.section, rows)
31    })
32
33    this.inspectorTarget.replaceChildren()
34    for (const [title, inputs] of sections) {
35      const section = this.el("section", "ve-inspector-section")
36      const header = this.el("div", "ve-section-header")
37      const heading = this.el("span", "ve-section-title")
38      heading.textContent = title
39      header.appendChild(heading)
40      const body = this.el("div", "ve-section-body")
41      // A section that opens collapsed still opens when one of its fields carries an error.
42      const collapsed = inputs.some((input) => input.dataset.collapsed === "true") && !inputs.some((input) => input.dataset.error)
43      body.classList.toggle("collapsed", collapsed)
44      header.addEventListener("click", () => body.classList.toggle("collapsed"))
45      section.append(header, body)
46      inputs.forEach((input) => this.buildControl(body, input))
47      this.inspectorTarget.append(section, this.el("div", "ve-section-divider"))
48    }
49  }
50
51  el(tag, className) {
52    const element = document.createElement(tag)
53    if (className) element.className = className
54    return element
55  }
56
57  addRow(body, labelText) {
58    const row = this.el("div", "flex items-center justify-between gap-2 min-w-0")
59    const label = this.el("span", "ve-control-label truncate")
60    label.textContent = labelText
61    const control = this.el("div", "shrink-0 w-[9.25rem] flex items-center")
62    row.append(label, control)
63    body.appendChild(row)
64    return control
65  }
66
67  buildControl(body, input) {
68    // A choice may carry its own label after a pipe, for values whose names do
69    // not read well on their own (the font ids).
70    const options = (input.dataset.choices || "").split(",").filter(Boolean).map((choice) => {
71      const [value, customLabel, previewFamily] = choice.split("|")
72      const fontFamily = previewFamily ? `"${previewFamily}", sans-serif` : null
73      return { value, label: customLabel || humanize(value), fontFamily }
74    })
75    const writeValue = (value) => {
76      input.value = value
77      input.dispatchEvent(new Event("change", { bubbles: true }))
78    }
79
80    if (input.dataset.control === "slider") {
81      this.buildSlider(this.addRow(body, input.dataset.label), input, options, writeValue)
82    } else if (input.dataset.control === "textarea") {
83      this.buildTextarea(body, input, writeValue)
84    } else if (input.dataset.control === "select") {
85      const dropdown = createInspectorDropdown({
86        options,
87        value: input.value,
88        ariaLabel: input.dataset.label,
89        onChange: writeValue
90      })
91      this.addRow(body, input.dataset.label).appendChild(dropdown.wrapper)
92    } else {
93      this.buildTokenControl(this.addRow(body, input.dataset.label), input, input.dataset.label, writeValue)
94    }
95  }
96
97  buildSlider(control, input, options, writeValue) {
98    const values = options.map((option) => option.value)
99    const track = this.el("div", "relative flex-1 h-[26px] flex items-center")
100    const rail = this.el("div", "absolute inset-x-1.5 h-1 rounded-full bg-gray-200 dark:bg-gray-700")
101    track.appendChild(rail)
102    options.forEach((_option, index) => {
103      const tick = this.el("span", "absolute w-px h-2 -translate-x-1/2 bg-gray-300 dark:bg-gray-600 pointer-events-none")
104      const position = values.length < 2 ? 0 : index / (values.length - 1)
105      tick.style.left = `calc(0.375rem + (100% - 0.75rem) * ${position})`
106      track.appendChild(tick)
107    })
108
109    const range = document.createElement("input")
110    range.type = "range"
111    range.min = 0
112    range.max = values.length - 1
113    range.step = 1
114    range.value = Math.max(0, values.indexOf(input.value))
115    range.className = "absolute inset-x-0 w-full appearance-none bg-transparent cursor-pointer"
116    range.setAttribute("aria-label", input.dataset.label)
117    track.appendChild(range)
118
119    const valueLabel = this.el("span", "ve-control-label !text-gray-700 dark:!text-gray-300 w-[3.5rem] shrink-0 text-right truncate")
120    const show = (index) => {
121      valueLabel.textContent = options[index].label
122      range.setAttribute("aria-valuetext", options[index].label)
123    }
124    show(Number(range.value))
125    range.addEventListener("input", () => {
126      show(Number(range.value))
127      writeValue(values[Number(range.value)])
128    })
129
130    control.append(track, valueLabel)
131  }
132
133  // Stacked, since YAML needs the sidebar's width; it writes back on blur so half-typed lines never hit the preview.
134  buildTextarea(body, input, writeValue) {
135    const label = this.el("span", "ve-control-label")
136    label.textContent = input.dataset.label
137    const textarea = document.createElement("textarea")
138    textarea.rows = 8
139    textarea.spellcheck = false
140    textarea.className = "w-full rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-2 font-mono text-[11px] text-gray-800 dark:text-gray-200"
141    textarea.setAttribute("aria-label", input.dataset.label)
142    textarea.value = input.value
143    textarea.addEventListener("change", () => writeValue(textarea.value))
144    body.append(label, textarea)
145    if (!input.dataset.error) return
146    const error = this.el("p", "text-xs text-red-600 dark:text-red-400")
147    error.textContent = input.dataset.error
148    body.appendChild(error)
149  }
150
151  changed() {
152    this.refreshPreview()
153  }
154
155  sampleChanged() {
156    this.loadFrames()
157  }
158
159  togglePalettePreviews() {
160    this.comparingPalettes = this.previewsToggleTarget.checked
161    this.singleCanvasTarget.hidden = this.comparingPalettes
162    this.previewStripTarget.hidden = !this.comparingPalettes
163    this.loadFrames()
164  }
165
166  get frames() {
167    return this.comparingPalettes ? this.palettePreviewTargets : [this.iframeTarget]
168  }
169
170  // Only the frames on show load, so the palette previews cost nothing until compared.
171  loadFrames() {
172    this.frames.forEach((frame) => {
173      const url = new URL(this.sampleTarget.value, window.location.origin)
174      if (frame.dataset.previewModel) {
175        url.searchParams.set("model", frame.dataset.previewModel)
176        url.searchParams.set("palette", frame.dataset.previewPalette)
177      }
178      const src = `${url.pathname}${url.search}`
179      if (frame.getAttribute("src") !== src) frame.src = src
180    })
181  }
182
183  frameLoaded(event) {
184    this.hideDemoChrome(event.target.contentDocument)
185    this.watchScreenClassChanges(event.target.contentDocument)
186    this.refreshPreview()
187  }
188
189  hideDemoChrome(doc) {
190    if (!doc || doc.getElementById("theme-builder-hidden-chrome")) return
191    const style = doc.createElement("style")
192    style.id = "theme-builder-hidden-chrome"
193    style.textContent = `
194      #screen-picker-component { display: none !important; }
195      html, body { background: transparent !important; }
196      body { display: flex; flex-direction: column; align-items: center; margin: 0; padding: 0; }
197      body > div[style] { margin: 0 !important; }
198      .screen { float: none !important; margin: 0 !important; box-shadow: 0 6px 28px rgba(0, 0, 0, 0.2); }
199    `
200    doc.head.appendChild(style)
201  }
202
203  // The picker's BroadcastChannel class rewrites fire no change event, and rAF stalls in a hidden pane.
204  watchScreenClassChanges(doc) {
205    if (!doc || this.watchedDocuments.has(doc)) return
206    this.watchedDocuments.add(doc)
207    const observer = new MutationObserver(() => {
208      clearTimeout(this.previewRefreshTimer)
209      this.previewRefreshTimer = setTimeout(() => this.refreshPreview(), 50)
210    })
211    observer.observe(doc.body, { attributes: true, attributeFilter: ["class"], subtree: true })
212  }
213
214  // The iframe fits the rendered screen so the canvas shows the device, not a frame; a palette preview shows it at half size.
215  sizeFrame(frame) {
216    const screen = frame.contentDocument?.querySelector(".screen")
217    if (!screen) return
218    const width = screen.offsetWidth + 4
219    const height = screen.offsetHeight + 40
220    frame.style.width = `${width}px`
221    frame.style.height = `${height}px`
222    if (frame.dataset.previewModel) Object.assign(frame.parentElement.style, { width: `${width / 2}px`, height: `${height / 2}px` })
223  }
224
225  refreshPreview() {
226    const params = new URLSearchParams(new FormData(this.formTarget))
227    const font = this.fontTarget.value
228    if (font) params.set("font", font)
229    this.frames.forEach((frame) => this.applyPreview(frame, `${this.previewUrlValue}?${params}`, font))
230  }
231
232  applyPreview(frame, previewHref, font) {
233    const doc = frame.contentDocument
234    if (!doc || !doc.head) return
235
236    let link = doc.getElementById("user-theme-preview")
237    if (!link) {
238      link = doc.createElement("link")
239      link.id = "user-theme-preview"
240      link.rel = "stylesheet"
241      doc.head.appendChild(link)
242      // The embedded screen picker rewrites screen classes; re-tag after it settles.
243      doc.addEventListener("change", () => setTimeout(() => this.refreshPreview(), 150))
244    }
245    if (link.getAttribute("href") !== previewHref) link.setAttribute("href", previewHref)
246
247    const fontClass = font && `screen--fonts-${font}`
248    doc.querySelectorAll(".screen").forEach((screen) => {
249      for (const cssClass of [...screen.classList]) {
250        if (cssClass === "screen--theme-preview" || cssClass === fontClass) continue
251        // Without a pack the picker's own Font Family choice keeps its class.
252        if (cssClass.startsWith("screen--theme-") || (font && cssClass.startsWith("screen--fonts-"))) {
253          screen.classList.remove(cssClass)
254        }
255      }
256      if (!screen.classList.contains("screen--theme-preview")) screen.classList.add("screen--theme-preview")
257      if (fontClass && !screen.classList.contains(fontClass)) screen.classList.add(fontClass)
258      this.pinPreviewPalette(frame, screen)
259    })
260
261    this.sizeFrame(frame)
262  }
263
264  // The demo pickers sync over one BroadcastChannel, so a palette preview keeps its palette class by hand.
265  pinPreviewPalette(frame, screen) {
266    const previewClass = frame.dataset.previewClass
267    if (!previewClass || screen.classList.contains(previewClass)) return
268    for (const cssClass of [...screen.classList]) {
269      if (/^screen--(\dbit|color-)/.test(cssClass)) screen.classList.remove(cssClass)
270    }
271    screen.classList.add(previewClass)
272    frame.contentWindow.executeTerminalize?.()
273  }
274
275  presetChanged(event) {
276    const preset = this.presetsValue[event.target.value]
277    if (!preset) return
278    this.fontTarget.value = ""
279    this.applySettings(preset)
280  }
281
282  openShare() {
283    const token = this.buildToken()
284    this.shareLinkTarget.value = `${window.location.origin}/t/${token}`
285    this.shareSnippetTarget.value = token
286    this.shareModalTarget.classList.remove("hidden")
287  }
288
289  openImport() {
290    this.importInputTarget.value = ""
291    this.importErrorTarget.classList.add("hidden")
292    this.importModalTarget.classList.remove("hidden")
293    this.importInputTarget.focus()
294  }
295
296  applyImport() {
297    let payload
298    try {
299      payload = this.decodeToken(this.importInputTarget.value)
300    } catch (error) {
301      this.importErrorTarget.textContent = error.message
302      this.importErrorTarget.classList.remove("hidden")
303      return
304    }
305    // Older snippets carried the font beside the settings as `f`.
306    const settings = payload.s || {}
307    if (payload.f && !settings.font) settings.font = payload.f
308    this.fontTarget.value = ""
309    this.applySettings(settings)
310    this.closeModals()
311  }
312
313  copyShareLink() {
314    navigator.clipboard?.writeText(this.shareLinkTarget.value)
315  }
316
317  copyShareSnippet() {
318    navigator.clipboard?.writeText(this.shareSnippetTarget.value)
319  }
320
321  closeModals() {
322    this.shareModalTarget.classList.add("hidden")
323    this.importModalTarget.classList.add("hidden")
324  }
325
326  closeModalBackdrop(event) {
327    if (!event.target.closest("[data-modal-panel]")) this.closeModals()
328  }
329
330  applySettings(settings) {
331    const mergedSettings = this.deepMerge(structuredClone(this.defaultsValue), settings)
332    this.formTarget.querySelectorAll("[data-control]").forEach((input) => {
333      const value = this.dig(mergedSettings, this.settingsPath(input.name))
334      if (value != null) input.value = typeof value === "object" ? this.toYaml(value) : value
335    })
336    // The controls read their state from the inputs at build time, so the
337    // cheapest way to show a preset is to build them again.
338    this.buildInspector()
339    this.refreshPreview()
340  }
341
342  collectSettings() {
343    const settings = {}
344    for (const [name, value] of new FormData(this.formTarget)) {
345      const path = this.settingsPath(name)
346      if (!path) continue
347      let node = settings
348      path.slice(0, -1).forEach((key) => { node = node[key] ||= {} })
349      node[path.at(-1)] = value
350    }
351    return settings
352  }
353
354  // Presets and Ruby tokens carry the advanced block as an object; the textarea shows it the way to_yaml writes it.
355  toYaml(value, indent = "") {
356    if (Array.isArray(value)) return value.map((item) => `${indent}- ${item}`).join("\n")
357    return Object.entries(value).map(([key, nested]) => (
358      nested && typeof nested === "object" ? `${indent}${key}:\n${this.toYaml(nested, indent + "  ")}` : `${indent}${key}: ${nested}`
359    )).join("\n")
360  }
361
362  settingsPath(name) {
363    const match = name.match(/^user_theme\[settings\]((?:\[[^\]]+\])+)$/)
364    if (!match) return null
365    return [...match[1].matchAll(/\[([^\]]+)\]/g)].map((part) => part[1])
366  }
367
368  dig(object, path) {
369    return path?.reduce((node, key) => (node && typeof node === "object" ? node[key] : undefined), object)
370  }
371
372  deepMerge(base, overlay) {
373    for (const [key, value] of Object.entries(overlay || {})) {
374      base[key] = value && typeof value === "object" && !Array.isArray(value)
375        ? this.deepMerge(base[key] && typeof base[key] === "object" ? base[key] : {}, value)
376        : value
377    }
378    return base
379  }
380
381  // Must match ThemeSnippet.encode, except the advanced block travels as the textarea's YAML, which the import action parses.
382  buildToken() {
383    const payload = { s: this.sortKeys(this.pruneDefaults(this.collectSettings(), this.defaultsValue)) }
384    return `v${this.versionValue}.${this.base64UrlEncode(JSON.stringify(payload))}`
385  }
386
387  decodeToken(text) {
388    const raw = text.trim().split("/t/").pop()
389    const prefix = `v${this.versionValue}.`
390    if (!raw.startsWith(prefix)) throw new Error(this.labelsValue.snippet_wrong_version.replace("%{version}", this.versionValue))
391    try {
392      const payload = JSON.parse(this.base64UrlDecode(raw.slice(prefix.length)))
393      if (!payload || typeof payload.s !== "object") throw new Error()
394      return payload
395    } catch {
396      throw new Error(this.labelsValue.snippet_unreadable)
397    }
398  }
399
400  pruneDefaults(chosen, defaults) {
401    const kept = {}
402    for (const [key, value] of Object.entries(chosen)) {
403      const fallback = defaults && typeof defaults === "object" ? defaults[key] : undefined
404      if (value && typeof value === "object") {
405        const pruned = this.pruneDefaults(value, fallback)
406        if (Object.keys(pruned).length) kept[key] = pruned
407      } else if (value !== fallback && value !== "") {
408        kept[key] = value
409      }
410    }
411    return kept
412  }
413
414  sortKeys(value) {
415    if (!value || typeof value !== "object" || Array.isArray(value)) return value
416    return Object.fromEntries(Object.keys(value).sort().map((key) => [key, this.sortKeys(value[key])]))
417  }
418
419  base64UrlEncode(text) {
420    const bytes = new TextEncoder().encode(text)
421    return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "")
422  }
423
424  base64UrlDecode(encoded) {
425    const base64 = encoded.replaceAll("-", "+").replaceAll("_", "/")
426    const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0))
427    return new TextDecoder().decode(bytes)
428  }
429
430  buildTokenControl(control, input, labelText, writeValue) {
431    const keywordChoices = (input.dataset.keywordChoices || "").split(",").filter(Boolean)
432    const sides = (input.dataset.sides || "").split(",").filter(Boolean)
433    // A row may restrict the grid to an explicit token list.
434    const tokenOptions = (input.dataset.tokenOptions || "").split(",").filter(Boolean)
435    // Sided rows keep the flat token grid too when they opt in: the emitter
436    // accepts both, so the picker must reach both.
437    const showTokens = !tokenOptions.length && (!sides.length || input.dataset.tokens === "true")
438
439    const trigger = document.createElement("button")
440    trigger.type = "button"
441    trigger.className = "ve-color-trigger w-full"
442
443    const swatch = document.createElement("span")
444    swatch.className = "ve-color-trigger-swatch"
445    trigger.appendChild(swatch)
446
447    const label = document.createElement("span")
448    label.className = "ve-color-trigger-label"
449    trigger.appendChild(label)
450
451    const chevron = document.createElement("span")
452    chevron.className = "ve-color-trigger-chevron"
453    chevron.innerHTML = `<svg width="10" height="6" viewBox="0 0 10 6" fill="currentColor"><path d="M1 1l4 4 4-4"/></svg>`
454    trigger.appendChild(chevron)
455
456    const keywordSwatchBackground = (token) => {
457      if (token === "keep" || token === "none") return "repeating-conic-gradient(#ccc 0% 25%, transparent 0% 50%) 0 0 / 8px 8px"
458      if (token === "mute") return "linear-gradient(90deg, #222, #ddd)"
459      if (token === "auto") return "linear-gradient(135deg, #999 50%, #eee 50%)"
460      return "linear-gradient(135deg, #000 50%, #fff 50%)"
461    }
462
463    const reflect = (value) => {
464      const sided = value.match(/^([a-z]+)-(dark|bright|linear)$/)
465      const hue = sided ? sided[1] : value
466      if (keywordChoices.includes(value)) {
467        swatch.style.backgroundColor = ""
468        swatch.style.background = keywordSwatchBackground(value)
469        label.textContent = value
470      } else {
471        swatch.style.background = ""
472        swatch.style.backgroundColor = COLOR_HEX_MAP[hue] || "#ffffff"
473        label.textContent = sided ? `${hue} · ${sided[2] === "linear" ? this.picker.ramp : sided[2]}` : value
474      }
475    }
476    reflect(input.value)
477
478    const popover = document.createElement("div")
479    popover.className = "ve-color-popover"
480    const popoverCtl = createInspectorPopover({ triggerEl: trigger, menuEl: popover, ariaLabel: labelText, minWidth: 0, maxHeight: 280 })
481
482    const setValue = (value, { notify = true } = {}) => {
483      reflect(value)
484      popover.querySelectorAll(".ve-color-cell").forEach((cell) => cell.classList.toggle("active", cell.dataset.token === value))
485      if (notify) writeValue(value)
486    }
487
488    const pick = (value) => {
489      setValue(value)
490      popoverCtl.close()
491    }
492
493    const addSection = (title, cells) => {
494      const section = document.createElement("div")
495      section.className = "ve-color-section"
496      if (title) {
497        const header = document.createElement("div")
498        header.className = "ve-color-section-label"
499        header.textContent = title
500        section.appendChild(header)
501      }
502      const grid = document.createElement("div")
503      grid.className = "ve-color-grid"
504      for (const { value, background, title: cellTitle } of cells) {
505        const cell = document.createElement("button")
506        cell.type = "button"
507        cell.className = "ve-color-cell" + (value === input.value ? " active" : "")
508        cell.style.background = background
509        cell.title = cellTitle || value
510        cell.dataset.token = value
511        cell.addEventListener("click", (event) => { event.preventDefault(); pick(value) })
512        grid.appendChild(cell)
513      }
514      section.appendChild(grid)
515      popover.appendChild(section)
516    }
517
518    if (keywordChoices.length) {
519      addSection(null, keywordChoices.map((keyword) => ({
520        value: keyword,
521        background: keywordSwatchBackground(keyword),
522        title: { none: this.picker.no_scale_remap, keep: this.picker.keep_as_painted, mute: this.picker.mute_to_gray, invert: this.picker.invert_the_scale, auto: this.picker.follow_the_bar }[keyword]
523      })))
524    }
525
526    // Sided rows: one section per side, each hue picks that hue on that side.
527    for (const side of sides) {
528      addSection(this.sideLabel(side), this.hues.map((hue) => ({
529        value: `${hue}-${side}`,
530        background: COLOR_HEX_MAP[hue],
531        title: `${hue} · ${this.sideLabel(side)}`
532      })))
533    }
534
535    if (tokenOptions.length) {
536      addSection(this.picker.panel_inks, tokenOptions.map((token) => ({ value: token, background: COLOR_HEX_MAP[token] || "#ffffff" })))
537    }
538
539    if (showTokens) {
540      const sections = [[this.picker.grayscale, COLOR_PALETTE.grayscale], ...Object.entries(COLOR_PALETTE.chromatic).map(([hue, colors]) => [this.labelsValue.hues[hue] || humanize(hue), colors])]
541      for (const [title, colors] of sections) {
542        addSection(title, colors.map((color) => ({ value: color.token, background: color.hex })))
543      }
544    }
545
546    trigger.addEventListener("click", (event) => {
547      event.preventDefault()
548      event.stopPropagation()
549      popoverCtl.toggle()
550    })
551
552    control.appendChild(trigger)
553  }
554
555  sideLabel(side) {
556    return { dark: this.picker.dark_side, bright: this.picker.bright_side, linear: this.picker.full_ramp }[side]
557  }
558}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.