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.