View layer for the Brazen user scripts framework
Verzia zo dňa
Tento skript by nemal byť nainštalovaný priamo. Je to knižnica pre ďalšie skripty, ktorú by mali používať cez meta príkaz // @require https://update.sleazyfork.org/scripts/416104/1882644/Brazen%20Framework%20-%20View%20Layer.js
// ==UserScript==
// @name Brazen Framework - View Layer
// @namespace brazenvoid
// @version 4.0.0
// @author brazenvoid
// @license GPL-3.0-only
// @description View layer for the Brazen user scripts framework
// @grant GM_addStyle
// ==/UserScript==
/** Build id for Tampermonkey Resource-override checks (must match local `base-scripts` file). */
const BRAZEN_VIEW_LAYER_BUILD = 'local-4.0.0'
/**
* @function GM_addStyle
* @param {string} style
*/
GM_addStyle(
`@keyframes brazen-fade { from { opacity: 0 } to { opacity: 1 } } #bv-resizer { align-items: center; background-color: white; border-radius: 3px; box-shadow: 3px 3px 10px rgba(0, 0, 0, 0.5); cursor: ew-resize; display: flex; height: 18px; justify-content: center; position: absolute; right: 10px; transition: background 0.3s ease; width: 25px; z-index: 5; } #bv-resizer:hover { background-color: lightgrey; } #bv-resizer-icon { color: black; font-size: 100%; pointer-events: none; } #bv-ui { backdrop-filter: blur(10px); background: rgba(255, 255, 255, 0.1); border-bottom-right-radius: 8px; border-left: 0; border-top-right-radius: 8px; bottom: 5vh; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); box-sizing: border-box; opacity: 0.3; overflow: auto; top: 5vh; z-index: 1001; } #bv-ui:hover { opacity: 1; } #restore-settings.bv-input { margin-bottom: 1rem } .bv-actions { display: inline-flex; justify-content: center; padding: 0 0.25rem; text-align: center } .bv-actions .bv-button { width: auto } .bv-bg-colour { background-color: #4f535b } .bv-border-primary { border: 1px solid black } .bv-break { margin: 0.5rem 0 } .bv-button { background-color: revert; padding: 0.5rem 1rem; width: 100% } .bv-flex-column { flex-direction: column } .bv-font-primary { color: white } .bv-font-secondary { color: black } .bv-group { align-items: center; display: flex; min-height: 20px } .bv-group + .bv-group { margin-top: 1rem } .bv-group.bv-range-group { align-items: center } .bv-group.bv-range-group > input { width: 75px } .bv-group.bv-range-group > input + input { margin-left: 5px } .bv-group.bv-textarea-group { align-items: start; flex-direction: column; overflow: hidden } .bv-group.bv-textarea-group > textarea.bv-input { margin-top: 0.5rem; resize: vertical; width: 100% } .bv-group.bv-textarea-group > textarea.bv-input[readonly] { cursor: default; opacity: 0.9 } .bv-substitution-composer { align-items: center; display: flex; gap: 0.35rem; margin-top: 0.5rem; min-width: 0; width: 100% } .bv-substitution-composer > .bv-input.bv-text { flex: 1 1 0; min-width: 0; width: auto } .bv-substitution-arrow { flex: 0 0 auto; font-size: 0.95rem; line-height: 1; opacity: 0.85; user-select: none } .bv-substitution-add { align-items: center; box-sizing: border-box; display: inline-flex; flex: 0 0 auto; height: 2rem; justify-content: center; margin: 0; min-width: 2rem; padding: 0.25rem; width: 2rem } .bv-substitution-add-icon { display: block; height: 1.1rem; pointer-events: none; width: 1.1rem } input.bv-input, select.bv-input, textarea.bv-input { box-sizing: border-box; margin: 0; padding: 0.5rem } .bv-input.bv-checkbox-radio { margin-right: 5px; scale: 2 } .bv-input.bv-text { width: 100% } .bv-donate-intro { font-size: 0.8125rem; line-height: 1.35; margin: 0 0 0.55rem; } .bv-donate-actions { display: flex; flex-direction: column; gap: 0.3rem; } a.bv-button.bv-donate-patreon { align-items: center; box-sizing: border-box; color: #fff; display: inline-flex; gap: 0.45rem; justify-content: center; text-decoration: none; width: 100%; } a.bv-button.bv-donate-patreon:hover { filter: brightness(1.08); } .bv-donate-patreon-icon { color: #ff424d; display: block; flex: 0 0 auto; height: 1.15rem; width: 1.15rem; } .bv-donate-patreon-label { line-height: 1.2; } .bv-label { flex-grow: 1; text-align: start } .bv-label.bv-text + .bv-input.bv-text { width: 40% } .bv-section, #bv-ui, .bv-modal-dialog { --bv-font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: var(--bv-font-family); } .bv-section :is(button, input, select, textarea, .bv-button, .bv-tab-button, .bv-label, .bv-title, .bv-token-btn, .bv-bookmark-link), #bv-ui :is(button, input, select, textarea, .bv-button, .bv-tab-button, .bv-label, .bv-title, .bv-token-btn, .bv-bookmark-link), .bv-modal-dialog :is(button, input, select, textarea, .bv-button, .bv-modal-title, h3, p, span) { font-family: var(--bv-font-family); } .bv-section { display: flex; flex-direction: column; font-size: 1rem; font-weight: normal; left: 0; padding: 1rem; position: fixed; z-index: 1000 } .bv-section > .bv-bottom-section { display: flex; flex-direction: column; margin-top: auto; } .bv-section > div + div { margin-top: 1rem } .bv-section hr { border: 1px solid white; margin: 1rem 0 } .bv-section button + button { margin-left: 0.25rem } .bv-section .bv-title { display: block; height: 20px; margin-bottom: 1rem; text-align: center; width: 100% } .bv-tab-button { background-color: inherit; border-bottom: 0; border-top-left-radius: 3px; border-top-right-radius: 3px; cursor: pointer; outline: none; padding: 0.5rem 0.75rem; transition: 0.3s } .bv-tab-button.bv-active, .bv-tab-button:hover { background-color: white; color: black; } .bv-tab-panel { animation: brazen-fade 1s; display: none; flex-direction: column; padding: 1rem } .bv-tab-panel.bv-active { display: flex } .bv-tabs-nav { display: flex; flex-wrap: wrap; overflow: hidden } .bv-bookmarks-group { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; width: 100%; } .bv-bookmark-columns { column-gap: 0.25rem; display: grid; grid-template-columns: minmax(0, 1fr) var(--bv-bookmark-action-size, 1.5rem); min-width: 0; row-gap: 0.25rem; scrollbar-gutter: stable; width: 100%; } .bv-bookmark-toolbar { align-items: center; display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; } .bv-bookmark-scroll { display: grid; grid-auto-rows: min-content; grid-column: 1 / -1; grid-template-columns: subgrid; max-height: 14rem; overflow-y: auto; row-gap: 0.25rem; } .bv-bookmark-list { display: contents; } .bv-bookmark-row { align-items: center; display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; } .bv-bookmark-search { box-sizing: border-box; grid-column: 1; height: var(--bv-bookmark-action-size, 1.5rem); margin-top: 0; min-height: var(--bv-bookmark-action-size, 1.5rem); min-width: 0; width: auto; } .bv-bookmark-search.bv-input.bv-text { width: auto; } .bv-bookmark-sort { box-sizing: border-box; grid-column: 2; height: var(--bv-bookmark-action-size, 1.5rem); margin: 0; min-height: var(--bv-bookmark-action-size, 1.5rem); min-width: 0; padding: 0.25rem 0.5rem; width: var(--bv-bookmark-action-size, 1.5rem); } .bv-bookmark-main { align-items: center; display: flex; gap: 0.2rem; grid-column: 1; min-width: 0; } .bv-bookmark-main > .bv-bookmark-link { flex: 1 1 auto; } .bv-bookmark-action { align-items: center; box-sizing: border-box; display: inline-flex; flex: 0 0 auto; height: var(--bv-bookmark-action-size, 1.5rem); justify-content: center; margin: 0; min-height: var(--bv-bookmark-action-size, 1.5rem); min-width: 0; padding: 0; width: var(--bv-bookmark-action-size, 1.5rem); } .bv-bookmark-link { box-sizing: border-box; display: block; height: var(--bv-bookmark-action-size, 1.5rem); line-height: var(--bv-bookmark-action-size, 1.5rem); margin: 0; min-height: var(--bv-bookmark-action-size, 1.5rem); min-width: 0; overflow: hidden; padding: 0 0.38rem; text-align: left; text-overflow: ellipsis; white-space: nowrap; width: auto; } .bv-bookmark-remove { align-items: center; box-sizing: border-box; display: inline-flex; grid-column: 2; height: var(--bv-bookmark-action-size, 1.5rem); justify-content: center; line-height: 1; margin: 0; min-height: var(--bv-bookmark-action-size, 1.5rem); min-width: 0; padding: 0.25rem 0.5rem; width: var(--bv-bookmark-action-size, 1.5rem); } .bv-bookmark-empty { font-size: 0.85rem; grid-column: 1 / -1; margin-top: 0.15rem; } .bv-bookmark-toolbar button + button, .bv-bookmark-row button + button { margin-left: 0; } @supports not (grid-template-columns: subgrid) { .bv-bookmark-columns { scrollbar-gutter: auto; } .bv-bookmark-toolbar, .bv-bookmark-row { column-gap: 0.25rem; display: grid; grid-column: 1 / -1; grid-template-columns: minmax(0, 1fr) var(--bv-bookmark-action-size, 1.5rem); } .bv-bookmark-scroll { display: block; } .bv-bookmark-list { display: flex; flex-direction: column; gap: 0.25rem; } .bv-bookmark-toolbar { box-sizing: border-box; padding-inline-end: var(--bv-bookmark-scroll-inset, 0px); } } .bv-modal-overlay { align-items: center; background: rgba(0, 0, 0, 0.55); display: flex; inset: 0; justify-content: center; position: fixed; z-index: 10002; } .bv-modal-dialog { background: #4f535b; border: 1px solid black; border-radius: 4px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); color: white; max-height: 80vh; max-width: 90vw; min-width: 280px; overflow: auto; padding: 1rem; } .bv-modal-header { align-items: center; display: flex; gap: 0.5rem; justify-content: space-between; margin-bottom: 0.75rem; } .bv-modal-title { font-size: 1rem; font-weight: 500; margin: 0; } .bv-modal-close { padding: 0.25rem 0.5rem; width: auto; } .bv-modal-dialog .bv-button { background-color: #3a3f47; border: 1px solid #222; color: #fff; cursor: pointer; width: auto; } .bv-modal-dialog .bv-button:hover { background-color: #4a505a; } .bv-modal-body ul:not(.bv-dock-list) { margin: 0 0 1rem; padding-left: 1.25rem; } .bv-modal-body h3 { font-size: 0.9rem; margin: 0 0 0.35rem; } .bv-dock-slide-panel { box-sizing: border-box; display: flex; flex-direction: column; position: fixed; z-index: 1001; } .bv-dock-slide-panel-head { align-items: center; display: flex; flex-shrink: 0; gap: 0.5rem; justify-content: space-between; margin-bottom: 0.35rem; } .bv-dock-slide-panel-title { font-size: 0.85rem; font-weight: 500; margin: 0; } .bv-dock-slide-panel-close { padding: 0.2rem 0.45rem; width: auto; } .bv-dock-slide-panel-body { flex: 1 1 auto; min-height: 0; overflow: auto; } .bv-dock-slide-panel.bv-dock-panel-left { border-bottom-left-radius: 0; border-top-left-radius: 0; } .bv-dock-slide-panel.bv-dock-panel-right { border-bottom-right-radius: 0; border-top-right-radius: 0; } .bv-dock-slide-panel.bv-dock-panel-bottom { border-bottom-left-radius: 0; border-bottom-right-radius: 0; box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); } .bv-dock-slide-panel.bv-dock-panel-animate { transition: transform 0.25s ease, opacity 0.25s ease; } .bv-dock-slide-panel.bv-dock-panel-offscreen { pointer-events: none; } .bv-dock-slide-panel.bv-dock-panel-hidden { display: none !important; } #bv-idb-blocked-banner { background: #6b2d2d; color: #fff; font: 13px/1.35 sans-serif; left: 0; padding: 8px 12px; position: fixed; right: 0; text-align: center; top: 0; z-index: 10001; } .bv-tag-actions-slot { display: inline-flex; position: relative; } .bv-tag-actions-slot .tag-count[hidden] { display: none; } .bv-tag-actions { align-items: center; display: inline-flex; gap: 0.15rem; } .bv-tag-actions[hidden] { display: none; } .bv-dock-list-intro { font-size: 0.78rem; line-height: 1.35; margin: 0 0 0.5rem; } .bv-dock-list-groups { display: flex; flex-direction: column; gap: 0.35rem; } .bv-dock-list-group h3 { color: #e8efe8; font-size: 0.72rem; font-weight: 500; letter-spacing: 0.04em; margin: 0.35rem 0 0.2rem; text-transform: uppercase; } .bv-dock-list-group:first-child h3 { margin-top: 0; } .bv-dock-list { list-style: none; margin: 0; padding: 0; } .bv-dock-list-row { align-items: center; display: flex; gap: 0.35rem; justify-content: space-between; margin-bottom: 0.22rem; min-height: 1.35em; } .bv-dock-list-row.bv-tag-muted .bv-dock-list-name, .bv-dock-list-row.r34-tag-muted .bv-dock-list-name { color: #8a8a8a; } .bv-dock-list-row.bv-tag-hidden .bv-dock-list-name, .bv-dock-list-row.r34-tag-hidden .bv-dock-list-name { color: #865050; } .bv-dock-list-name { flex: 1 1 auto; font-size: 0.8125rem; line-height: 1.3; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bv-dock-list-name.bv-dock-list-name-link { cursor: pointer; text-decoration: none; } .bv-dock-list-name.bv-dock-list-name-link:hover, .bv-dock-list-name.bv-dock-list-name-link:focus { text-decoration: none; } .bv-dock-list-similar { margin-top: 0.55rem; } .bv-dock-list-similar > summary { cursor: pointer; list-style: none; margin: 0; user-select: none; } .bv-dock-list-similar > summary::-webkit-details-marker { display: none; } .bv-dock-list-similar > summary::before { content: '▸ '; display: inline-block; width: 0.85em; } .bv-dock-list-similar[open] > summary::before { content: '▾ '; } .bv-dock-list-similar > summary > .bv-dock-list-section-title { display: inline; margin: 0; } .bv-dock-list-similar-body { margin-top: 0.35rem; } .bv-dock-list-similar-status { color: #9eb09e; font-size: 0.72rem; line-height: 1.35; margin: 0.15rem 0 0; } .bv-dock-list-meta { color: #9eb09e; flex: 0 0 auto; font-size: 0.72rem; line-height: 1.3; white-space: nowrap; } .bv-dock-list-section-title { color: #e8efe8; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.03em; margin: 0.55rem 0 0.25rem; } .bv-dock-list-section-title:first-child { margin-top: 0; } .bv-dock-list-row .bv-tag-actions { flex: 0 0 auto; gap: 0.1rem; } .bv-dock-list-footer { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.55rem; } .bv-dock-list-footer.bv-dock-list-footer-row { flex-direction: row; flex-wrap: wrap; } .bv-dock-list-footer.bv-dock-list-footer-row .bv-button { flex: 1 1 0; min-width: 0; width: auto; } .bv-dock-list-footer .bv-button { width: 100%; } .bv-dock-list-footer .bv-button.bv-dock-list-footer-secondary { background-color: transparent; border: 1px solid rgba(255, 255, 255, 0.25); opacity: 0.9; } .bv-dm-item-progress { align-items: center; box-sizing: border-box; display: flex; inset: 5px; justify-content: center; pointer-events: none; position: absolute; z-index: 4; } .bv-dm-item-progress-inner { align-items: center; box-sizing: border-box; display: flex; flex-direction: column; gap: 2px; justify-content: center; max-width: 100%; width: 100%; } .bv-dm-item-progress-label { color: #fff; font-size: 0.58rem; font-weight: 600; letter-spacing: 0.03em; line-height: 1.1; margin: 0; overflow: hidden; text-align: center; text-overflow: ellipsis; text-shadow: 0 0 3px rgba(0, 0, 0, 0.95), 0 1px 2px rgba(0, 0, 0, 0.85); text-transform: uppercase; white-space: nowrap; width: 100%; } .bv-dm-item-progress-steps { display: flex; gap: 1px; height: 4px; width: 100%; } .bv-dm-item-progress-step { background: rgba(255, 255, 255, 0.28); border-radius: 1px; flex: 1 1 0; min-width: 0; transition: background 0.2s ease, box-shadow 0.2s ease; } .bv-dm-item-progress-step.bv-dm-step-done { background: #39d353; } .bv-dm-item-progress-step.bv-dm-step-current { background: #9ae6a9; box-shadow: 0 0 4px rgba(57, 211, 83, 0.85); } .bv-dm-item-progress-step.bv-dm-step-failed { background: #e55353; box-shadow: 0 0 4px rgba(229, 83, 83, 0.85); } .bv-dm-item-active { position: relative; } .bv-dm-item-active.bv-dm-item-failed { box-shadow: inset 0 0 0 2px #e55353; } .bv-dm-item-active > a, .bv-dm-item-active > img, .bv-dm-item-active > video { filter: blur(2px); opacity: 0.55; transition: filter 0.1s ease, opacity 0.1s ease; } .bv-dock { --bv-dock-bg: var(--bv-theme-color, #4f535b); --bv-dock-btn-bg: #3a3f47; --bv-dock-btn-hover-bg: #4a505a; --bv-dock-btn-active-bg: #5a6f5a; --bv-dock-btn-size: 2.75rem; --bv-dock-child-btn-size: 2.15rem; --bv-dock-gap: 0.2rem; --bv-dock-slideout-bg: rgba(0, 0, 0, 0.22); --bv-dock-slideout-gap: 0.12rem; align-items: stretch; background: var(--bv-dock-bg); border: 1px solid rgba(0, 0, 0, 0.35); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45); box-sizing: border-box; color: #e8efe8; display: flex; font-family: var(--bv-font-family); gap: var(--bv-dock-gap); padding: 0.35rem; position: fixed; z-index: 1002; } .bv-dock-left { align-items: flex-start; flex-direction: row; left: 0; top: 5vh; } .bv-dock-right { align-items: flex-start; flex-direction: row-reverse; right: 0; top: 5vh; } .bv-dock-bottom { bottom: 0; flex-direction: column-reverse; left: 50%; max-width: 96vw; overflow: visible; transform: translateX(-50%); } .bv-dock-name { align-items: center; display: flex; font-size: 0.68rem; font-weight: 500; justify-content: center; letter-spacing: 0.02em; line-height: 1.2; min-width: 1.1rem; opacity: 0.9; padding: 0.15rem 0.1rem; text-align: center; white-space: nowrap; } .bv-dock-left .bv-dock-name, .bv-dock-right .bv-dock-name, .bv-dock-left .bv-dock-slideout .bv-dock-progress-label, .bv-dock-right .bv-dock-slideout .bv-dock-progress-label { writing-mode: sideways-rl; } .bv-dock-rail { align-items: stretch; display: flex; flex: 1 1 auto; gap: var(--bv-dock-gap); min-height: 0; min-width: 0; } .bv-dock-rail-head, .bv-dock-rail-body, .bv-dock-rail-foot { display: contents; } .bv-dock-separator { background: rgba(255, 255, 255, 0.12); flex: 0 0 auto; } .bv-dock-separator + .bv-dock-separator { display: none; } .bv-dock-rail-head:has(> .bv-dock-separator:last-child) + .bv-dock-rail-body > .bv-dock-separator:first-child, .bv-dock-rail-body:has(> .bv-dock-separator:last-child) + .bv-dock-rail-foot > .bv-dock-separator:first-child { display: none; } .bv-dock-left .bv-dock-separator, .bv-dock-right .bv-dock-separator { height: 1px; width: 100%; } .bv-dock-bottom .bv-dock-separator { align-self: stretch; width: 1px; } .bv-dock-status { align-items: center; background: var(--bv-dock-btn-hover-bg, #4a505a); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 4px; box-sizing: border-box; color: #e8efe8; display: inline-flex; font-size: 0.62rem; font-weight: 500; gap: 0.3rem; line-height: 1.2; max-width: calc(var(--bv-dock-btn-size) * 2.5); opacity: 0.95; padding: 0.3rem 0.35rem; pointer-events: none; text-align: center; user-select: none; white-space: normal; } .bv-dock-status-spinner { animation: bv-dock-spin 0.9s linear infinite; border: 2px solid rgba(255, 255, 255, 0.25); border-radius: 50%; border-top-color: rgba(255, 255, 255, 0.9); flex: 0 0 auto; height: 0.75rem; width: 0.75rem; } @keyframes bv-dock-spin { to { transform: rotate(360deg); } } .bv-dock-left .bv-dock-rail { align-self: flex-start; flex-direction: column; } .bv-dock-left .bv-dock-slot { align-items: stretch; display: flex; flex-direction: column; flex: 0 0 auto; gap: 0; width: 100%; } .bv-dock-left .bv-dock-slot:hover, .bv-dock-left .bv-dock-slot:focus-within, .bv-dock-left .bv-dock-slot.bv-dock-slideout-pinned { gap: var(--bv-dock-slideout-gap); } .bv-dock-left .bv-dock-slot > .bv-dock-btn { width: 100%; } .bv-dock-left .bv-dock-slideout { flex-direction: column; max-height: 0; transition: max-height 0.2s ease, opacity 0.2s ease, padding 0.2s ease; } .bv-dock-left .bv-dock-slot:hover .bv-dock-slideout, .bv-dock-left .bv-dock-slot:focus-within .bv-dock-slideout, .bv-dock-left .bv-dock-slot.bv-dock-slideout-pinned .bv-dock-slideout { max-height: calc(var(--bv-dock-child-btn-size) * 6 + 0.75rem); opacity: 1; padding: 0.12rem; pointer-events: auto; } .bv-dock-right .bv-dock-rail { align-self: flex-start; flex-direction: column; } .bv-dock-right .bv-dock-slot { align-items: stretch; display: flex; flex-direction: column; flex: 0 0 auto; gap: 0; width: 100%; } .bv-dock-right .bv-dock-slot:hover, .bv-dock-right .bv-dock-slot:focus-within, .bv-dock-right .bv-dock-slot.bv-dock-slideout-pinned { gap: var(--bv-dock-slideout-gap); } .bv-dock-right .bv-dock-slot > .bv-dock-btn { width: 100%; } .bv-dock-right .bv-dock-slideout { flex-direction: column; max-height: 0; transition: max-height 0.2s ease, opacity 0.2s ease, padding 0.2s ease; } .bv-dock-right .bv-dock-slot:hover .bv-dock-slideout, .bv-dock-right .bv-dock-slot:focus-within .bv-dock-slideout, .bv-dock-right .bv-dock-slot.bv-dock-slideout-pinned .bv-dock-slideout { max-height: calc(var(--bv-dock-child-btn-size) * 6 + 0.75rem); opacity: 1; padding: 0.12rem; pointer-events: auto; } .bv-dock-bottom .bv-dock-rail { flex: 0 0 auto; flex-direction: row; flex-wrap: nowrap; overflow: visible; } .bv-dock-bottom .bv-dock-slot { align-items: center; display: flex; flex-direction: row; flex: 0 0 auto; gap: 0; } .bv-dock-bottom .bv-dock-slot:hover, .bv-dock-bottom .bv-dock-slot:focus-within, .bv-dock-bottom .bv-dock-slot.bv-dock-slideout-pinned { gap: var(--bv-dock-slideout-gap); } .bv-dock-bottom .bv-dock-slideout { flex-direction: row; max-height: none; max-width: 0; transition: max-width 0.2s ease, opacity 0.2s ease, padding 0.2s ease; } .bv-dock-bottom .bv-dock-slot:hover .bv-dock-slideout, .bv-dock-bottom .bv-dock-slot:focus-within .bv-dock-slideout, .bv-dock-bottom .bv-dock-slot.bv-dock-slideout-pinned .bv-dock-slideout { max-width: calc(var(--bv-dock-child-btn-size) * 10 + 0.75rem); opacity: 1; padding: 0.12rem; pointer-events: auto; } .bv-dock-btn { align-items: center; background: var(--bv-dock-btn-bg); border: 1px solid rgba(0, 0, 0, 0.35); border-radius: 4px; box-sizing: border-box; color: inherit; cursor: pointer; display: inline-flex; flex: 0 0 auto; height: var(--bv-dock-btn-size); justify-content: center; margin: 0; padding: 0.35rem; width: var(--bv-dock-btn-size); } .bv-dock-btn:hover { background: var(--bv-dock-btn-hover-bg); } .bv-dock-btn.bv-dock-btn-active { background: var(--bv-dock-btn-active-bg); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15); } .bv-dock-btn:disabled, .bv-dock-btn.bv-dock-btn-disabled { cursor: not-allowed; opacity: 0.45; pointer-events: none; } .bv-dock-btn:disabled:hover, .bv-dock-btn.bv-dock-btn-disabled:hover { background: var(--bv-dock-btn-bg); } .bv-dock-btn-icon { display: block; height: 1.35rem; pointer-events: none; width: 1.35rem; } .bv-dock-slideout { align-items: center; background: var(--bv-dock-slideout-bg); border-radius: 4px; box-sizing: border-box; display: flex; flex-wrap: nowrap; gap: var(--bv-dock-slideout-gap); opacity: 0; overflow: hidden; padding: 0; pointer-events: none; } .bv-dock-slideout > .bv-dock-btn { height: var(--bv-dock-child-btn-size); padding: 0.2rem; width: var(--bv-dock-child-btn-size); } .bv-dock-slideout > .bv-dock-btn .bv-dock-btn-icon { height: 1rem; width: 1rem; } .bv-dock-inset-group { align-items: center; background: var(--bv-dock-inset-bg, rgba(0, 0, 0, 0.38)); border-radius: 4px; box-sizing: border-box; display: flex; flex: 0 0 auto; flex-wrap: nowrap; gap: var(--bv-dock-slideout-gap); padding: 0.12rem; } .bv-dock-left .bv-dock-inset-group, .bv-dock-right .bv-dock-inset-group { align-self: center; flex-direction: column; width: var(--bv-dock-btn-size); } .bv-dock-bottom .bv-dock-inset-group { flex-direction: row; width: auto; } .bv-dock-inset-group > .bv-dock-btn { align-self: center; flex: 0 0 auto; height: var(--bv-dock-child-btn-size); padding: 0.2rem; width: var(--bv-dock-child-btn-size); } .bv-dock-inset-group > .bv-dock-btn .bv-dock-btn-icon { height: 1rem; width: 1rem; } .bv-dock-slideout > .bv-dock-progress-panel { flex: 0 0 auto; } .bv-dock-progress-panel { align-items: stretch; background: transparent; border: 1px solid var(--bv-dock-btn-bg, #3a3f47); border-radius: 4px; box-sizing: border-box; display: flex; flex: 0 0 auto; flex-direction: column; gap: 0.12rem; justify-content: center; min-height: var(--bv-dock-child-btn-size); min-width: calc(var(--bv-dock-btn-size) * 1.35); padding: 0.2rem 0.28rem; width: max-content; } .bv-dock-progress-panel.bv-dock-progress-hidden { display: none; } .bv-dock-progress-slot { align-items: stretch; display: flex; flex: 1 1 auto; flex-direction: column; gap: 0.12rem; justify-content: flex-start; min-width: calc(var(--bv-dock-btn-size) * 1.35); padding: 0; width: max-content; } .bv-dock-progress-slot.bv-dock-progress-hidden { display: none; } .bv-dock-progress-bar { align-self: stretch; background: transparent; border: 1px solid var(--bv-dock-btn-bg, #3a3f47); border-radius: 3px; box-sizing: border-box; flex: 0 0 auto; height: 0.35rem; order: 1; overflow: hidden; position: relative; width: auto; } .bv-dock-progress-resolution { align-self: stretch; order: 3; width: auto; } .bv-dock-progress-bar > span { background: var(--bv-dock-btn-active-bg, #5a6f5a); display: block; height: 100%; left: 0; position: absolute; top: 0; transform: scaleX(0); transform-origin: left center; transition: transform 0.2s ease; width: 100%; } .bv-dock-progress-label-wrap { display: block; flex: 0 0 auto; order: 2; width: max-content; } .bv-dock-progress-label { color: #e8efe8; display: block; font-size: 0.62rem; font-weight: 500; line-height: 1.1; opacity: 1; text-align: center; white-space: nowrap; width: max-content; } .bv-dock-bottom .bv-dock-slideout .bv-dock-progress-panel { min-width: calc(var(--bv-dock-btn-size) * 1.35); } .bv-dock-bottom .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label-wrap { align-self: stretch; text-align: right; width: auto; } .bv-dock-bottom .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label { text-align: right; width: 100%; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-panel, .bv-dock-right .bv-dock-slideout .bv-dock-progress-panel { align-self: stretch; box-sizing: border-box; max-width: 100%; min-height: calc(var(--bv-dock-child-btn-size) * 1.45 + 0.24rem); min-width: 0; padding: 0.12rem; width: auto; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot, .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot { align-items: center; flex: 1 1 auto; flex-direction: row; gap: 0.12rem; justify-content: center; min-width: 0; width: 100%; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-bar, .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-bar { flex: 0 0 auto; height: calc(var(--bv-dock-child-btn-size) * 1.45); width: 0.28rem; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-download { order: 2; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-resolution { order: 3; } .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-download { order: 1; } .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-resolution { order: 3; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-bar > span, .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-bar > span { bottom: 0; height: 100%; top: auto; transform: scaleY(0); transform-origin: bottom center; width: 100%; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label-wrap { align-self: center; flex: 0 1 auto; min-width: 0; order: 1; text-align: center; width: max-content; } .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label-wrap { align-self: center; flex: 0 1 auto; min-width: 0; order: 2; text-align: center; width: max-content; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label, .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label { display: block; font-size: 0.62rem; opacity: 0.9; text-align: center; white-space: nowrap; width: max-content; } .bv-dock-position-btn { flex: 0 0 auto; } #bv-ui.bv-dock-panel-left #bv-resizer, #bv-ui.bv-dock-panel-bottom #bv-resizer { left: auto; right: 10px; } #bv-ui.bv-dock-panel-right #bv-resizer { left: 10px; right: auto; } #bv-ui.bv-dock-panel-bottom { box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); } .bv-hidden { display: none !important; } .bv-dock-slide-panel.bv-dock-panel-bottom-half-left, .bv-dock-slide-panel.bv-dock-panel-bottom-half-right, #bv-ui.bv-dock-panel-bottom-half-left { box-sizing: border-box; overflow: auto; } #bv-ui.bv-dock-panel-bottom-half-left { opacity: 1; }`
)
class BrazenViewLayer
{
/**
* @typedef {{id: string, label: string, tags: string, url: string}} BrazenBookmarkEntry
*/
/**
* @typedef {{
* getCurrentUrl?: function(): string,
* normalizeUrl?: function(string): string,
* onMatchChange: function(boolean): void,
* watchSelectors?: string[],
* isActive?: function(): boolean,
* }} BrazenBookmarksPageMatchOptions
*/
/**
* @typedef {{
* showAddButton?: boolean,
* addCaption?: string,
* addHelpText?: string,
* searchPlaceholder?: string,
* searchHelpText?: string,
* sortHelpText?: string,
* emptyMessage?: string,
* noMatchMessage?: string,
* pageMatch?: BrazenBookmarksPageMatchOptions,
* onAdd?: Function,
* onSort?: Function,
* onRemove?: function(string): void,
* onNavigate?: function(string): void,
* getRowActions?: function(BrazenBookmarkEntry): HTMLElement[],
* onBeforeRender?: function(BrazenBookmarkEntry[]): void|Promise<void>,
* }} BrazenBookmarksPanelOptions
*/
/**
* @typedef {{
* element: HTMLElement,
* render: function(BrazenBookmarkEntry[]): void,
* getFilterQuery: function(): string,
* isCurrentPageBookmarked: function(string=): boolean,
* checkPageMatch: function(): void,
* }} BrazenBookmarksPanel
*/
/**
* @typedef {{
* sidebarSelector?: string,
* rowSelector?: string,
* countSelector?: string,
* hideDelayMs?: number,
* extractTag?: function(Element): *,
* renderActions?: function(Element, *, Element, *): void,
* isBookmarked?: function(string): boolean,
* }} TagSidebarCountEnhancementOptions
*/
// -------------------------------------------------------------------------
// Static public methods
// -------------------------------------------------------------------------
/**
* @param {Node|Node[]} nodes
*/
static appendToBody(nodes)
{
Utilities.appendChildren(document.body, nodes)
}
/**
* @param {string} name
* @return {SVGSVGElement}
*/
static getDockIcon(name)
{
let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('class', 'bv-dock-btn-icon')
svg.setAttribute('aria-hidden', 'true')
let path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('fill', 'none')
path.setAttribute('stroke', 'currentColor')
path.setAttribute('stroke-width', '1.75')
path.setAttribute('stroke-linecap', 'round')
path.setAttribute('stroke-linejoin', 'round')
switch (name) {
case 'menu':
path.setAttribute('d', 'M4 7h16M4 12h16M4 17h16')
break
case 'bookmark':
path.setAttribute('d', 'M6 4h12v16l-6-4-6 4V4z')
break
case 'download':
path.setAttribute('d', 'M12 4v10m0 0l4-4m-4 4l-4-4M5 20h14')
break
case 'next':
path.setAttribute('d', 'M6 7l5 5-5 5M13 7l5 5-5 5')
break
case 'explore':
svg.innerHTML = ''
let ring = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
ring.setAttribute('cx', '12')
ring.setAttribute('cy', '12')
ring.setAttribute('r', '8')
ring.setAttribute('fill', 'none')
ring.setAttribute('stroke', 'currentColor')
ring.setAttribute('stroke-width', '1.5')
let needle = document.createElementNS('http://www.w3.org/2000/svg', 'path')
needle.setAttribute('d', 'M12 6.5L14.5 12L12 17.5L9.5 12Z')
needle.setAttribute('fill', 'none')
needle.setAttribute('stroke', 'currentColor')
needle.setAttribute('stroke-width', '1.5')
needle.setAttribute('stroke-linejoin', 'round')
svg.append(ring, needle)
return svg
case 'clear':
path.setAttribute('d', 'M4 7h16M6 7l1 14h10l1-14M9 7V5h6v2')
break
case 'filter':
path.setAttribute('d', 'M4 6h16M7 12h10M10 18h4')
break
case 'tags':
path.setAttribute('d', 'M7 7h.01M4 6l8.5 8.5a2 2 0 0 0 2.8 0l5.2-5.2a2 2 0 0 0 0-2.8L12 2 4 10a2 2 0 0 0 0 2.8z')
break
case 'inject':
path.setAttribute('d', 'M11 6H4M11 10H4M11 14H7M18 9v6m-3-3h6')
break
case 'block':
path.setAttribute('d', 'M6 6l12 12M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18')
break
case 'resolution':
path.setAttribute('d', 'M5 6h14v12H5z M8 10h8M8 14h5')
break
case 'rules':
path.setAttribute('d', 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01')
break
case 'history':
path.setAttribute('d', 'M12 8v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z')
break
case 'eye':
path.setAttribute('d', 'M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12z')
svg.append(path)
let pupil = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
pupil.setAttribute('cx', '12')
pupil.setAttribute('cy', '12')
pupil.setAttribute('r', '2.25')
pupil.setAttribute('fill', 'none')
pupil.setAttribute('stroke', 'currentColor')
pupil.setAttribute('stroke-width', '1.75')
svg.append(pupil)
return svg
case 'eye-off':
svg.innerHTML = ''
let eyeOffOutline = document.createElementNS('http://www.w3.org/2000/svg', 'path')
eyeOffOutline.setAttribute('d', 'M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12z')
eyeOffOutline.setAttribute('fill', 'none')
eyeOffOutline.setAttribute('stroke', 'currentColor')
eyeOffOutline.setAttribute('stroke-width', '1.75')
eyeOffOutline.setAttribute('stroke-linecap', 'round')
eyeOffOutline.setAttribute('stroke-linejoin', 'round')
let eyeOffPupil = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
eyeOffPupil.setAttribute('cx', '12')
eyeOffPupil.setAttribute('cy', '12')
eyeOffPupil.setAttribute('r', '2.25')
eyeOffPupil.setAttribute('fill', 'none')
eyeOffPupil.setAttribute('stroke', 'currentColor')
eyeOffPupil.setAttribute('stroke-width', '1.75')
let eyeOffSlash = document.createElementNS('http://www.w3.org/2000/svg', 'path')
eyeOffSlash.setAttribute('d', 'M4 4l16 16')
eyeOffSlash.setAttribute('fill', 'none')
eyeOffSlash.setAttribute('stroke', 'currentColor')
eyeOffSlash.setAttribute('stroke-width', '1.75')
eyeOffSlash.setAttribute('stroke-linecap', 'round')
svg.append(eyeOffOutline, eyeOffPupil, eyeOffSlash)
return svg
case 'skip-duplicate':
// Overlapping sheets (duplicate media) — distinct from tag-blacklist `block`.
path.setAttribute('d', 'M9 5h8v11H9zM7 8h8v11H7z')
break
case 'target':
path.setAttribute('d', 'M12 3v3m0 12v3m9-9h-3M6 12H3m14.3-5.3l-2.1 2.1M8.8 15.2l-2.1 2.1m12.6 0l-2.1-2.1M8.8 8.8L6.7 6.7')
break
case 'play':
path.setAttribute('d', 'M8 6v12l11-6-11-6z')
path.setAttribute('fill', 'currentColor')
path.setAttribute('stroke', 'none')
svg.append(path)
return svg
case 'pause':
path.setAttribute('d', 'M9 7v10M15 7v10')
break
case 'queue-add': {
// Circle + plus — unambiguous “add to queue”.
svg.innerHTML = ''
let addRing = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
addRing.setAttribute('cx', '12')
addRing.setAttribute('cy', '12')
addRing.setAttribute('r', '8.25')
addRing.setAttribute('fill', 'none')
addRing.setAttribute('stroke', 'currentColor')
addRing.setAttribute('stroke-width', '1.75')
let addPlus = document.createElementNS('http://www.w3.org/2000/svg', 'path')
addPlus.setAttribute('d', 'M12 8v8M8 12h8')
addPlus.setAttribute('fill', 'none')
addPlus.setAttribute('stroke', 'currentColor')
addPlus.setAttribute('stroke-width', '2.25')
addPlus.setAttribute('stroke-linecap', 'round')
svg.append(addRing, addPlus)
return svg
}
case 'queue-remove': {
// Circle + X — unambiguous “remove from queue”.
svg.innerHTML = ''
let remRing = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
remRing.setAttribute('cx', '12')
remRing.setAttribute('cy', '12')
remRing.setAttribute('r', '8.25')
remRing.setAttribute('fill', 'none')
remRing.setAttribute('stroke', 'currentColor')
remRing.setAttribute('stroke-width', '1.75')
let remX = document.createElementNS('http://www.w3.org/2000/svg', 'path')
remX.setAttribute('d', 'M9 9l6 6M15 9l-6 6')
remX.setAttribute('fill', 'none')
remX.setAttribute('stroke', 'currentColor')
remX.setAttribute('stroke-width', '2.25')
remX.setAttribute('stroke-linecap', 'round')
svg.append(remRing, remX)
return svg
}
case 'select':
path.setAttribute('d', 'M5 9V5h4M15 5h4v4M19 15v4h-4M9 19H5v-4')
break
case 'discovery':
path.setAttribute('d', 'M10 5a5 5 0 1 1 0 10 5 5 0 0 1 0-10m6 11l4 4')
break
case 'crown':
// Three-point crown — download-manager leader tab control.
path.setAttribute('d', 'M5 16h14l-1.5-8-3.5 3L12 6l-2 5-3.5-3L5 16zM5 16v2h14v-2')
break
case 'dock-left':
path.setAttribute('d', 'M5 5v14M9 7h10M9 12h10M9 17h10')
break
case 'dock-right':
path.setAttribute('d', 'M19 5v14M15 7H5M15 12H5M15 17H5')
break
case 'dock-bottom':
path.setAttribute('d', 'M5 19h14M7 15V5M12 15V5M17 15V5')
break
default:
path.setAttribute('d', 'M12 12h.01')
break
}
svg.append(path)
return svg
}
/**
* @return {HTMLElement|null}
*/
static ensureIdbBlockedBanner()
{
let existing = document.getElementById('bv-idb-blocked-banner')
if (existing) {
return existing
}
let banner = document.createElement('div')
banner.id = 'bv-idb-blocked-banner'
banner.textContent = 'IndexedDB is unavailable — persistence is disabled for this session.'
document.body.prepend(banner)
return banner
}
/**
* @param {HTMLElement} panel
* @return {boolean}
*/
static isDockSlidePanelVisible(panel)
{
if (!panel) {
return false
}
return !panel.classList.contains('bv-dock-panel-hidden') &&
!panel.classList.contains('bv-hidden') &&
!panel.hidden &&
panel.style.display !== 'none' &&
!panel.classList.contains('bv-dock-panel-offscreen')
}
/**
* @param {string} orientation
*/
static setDockSlidePanelOrientations(orientation)
{
for (let panel of document.querySelectorAll('.bv-dock-slide-panel')) {
panel.classList.remove('bv-dock-panel-left', 'bv-dock-panel-right', 'bv-dock-panel-bottom')
panel.classList.add('bv-dock-panel-' + orientation)
}
}
/**
* Stack visible dock slide panels from the rail outward.
* Callers pass panels in near-dock → outward order (e.g. discovery before `#bv-ui`).
* Bottom dock: when settings (`#bv-ui`) and a review panel are both open, place them
* side-by-side in screen halves hugging the centerline instead of stacking vertically.
*
* @param {HTMLElement} dockElement
* @param {string} orientation
* @param {HTMLElement[]} panels
*/
static syncDockSlidePanelStack(dockElement, orientation, panels)
{
if (!dockElement?.getBoundingClientRect) {
return
}
let dockRect = dockElement.getBoundingClientRect()
let list = (panels ?? []).filter(Boolean)
if (orientation === 'bottom') {
BrazenViewLayer._syncBottomDockSlidePanelStack(dockRect, list)
return
}
for (let panel of list) {
BrazenViewLayer._clearBottomHalfPanelLayout(panel)
}
let anchorRect = dockRect
for (let panel of list) {
BrazenViewLayer._syncDockSlidePanelPosition(panel, dockRect, orientation, anchorRect)
if (BrazenViewLayer.isDockSlidePanelVisible(panel)) {
anchorRect = panel.getBoundingClientRect()
}
}
}
/**
* @param {HTMLElement} panel
* @private
*/
static _clearBottomHalfPanelLayout(panel)
{
if (!panel) {
return
}
panel.classList.remove('bv-dock-panel-bottom-half-left', 'bv-dock-panel-bottom-half-right')
panel.style.maxWidth = ''
panel.style.width = ''
panel.style.minWidth = ''
panel.style.maxHeight = ''
}
/**
* Bottom dock: dual-open settings + review → left/right halves; otherwise vertical stack.
* @param {DOMRect} dockRect
* @param {HTMLElement[]} panels
* @private
*/
static _syncBottomDockSlidePanelStack(dockRect, panels)
{
let visible = panels.filter((panel) => BrazenViewLayer.isDockSlidePanelVisible(panel))
let settings = visible.find((panel) => panel.id === 'bv-ui') ?? null
let reviews = visible.filter((panel) => panel.id !== 'bv-ui')
let dual = !!(settings && reviews.length)
if (!dual) {
for (let panel of panels) {
BrazenViewLayer._clearBottomHalfPanelLayout(panel)
}
let anchorRect = dockRect
for (let panel of panels) {
BrazenViewLayer._syncDockSlidePanelPosition(panel, dockRect, 'bottom', anchorRect)
if (BrazenViewLayer.isDockSlidePanelVisible(panel)) {
anchorRect = panel.getBoundingClientRect()
}
}
return
}
let gap = 8
let halfMax = Math.max(160, (window.innerWidth / 2) - gap)
let maxHeight = Math.max(160, dockRect.top - gap)
let dockBottom = window.innerHeight - dockRect.top
let reviewAnchorTop = dockRect.top
for (let panel of reviews) {
panel.classList.add('bv-dock-panel-bottom-half-right')
panel.classList.remove('bv-dock-panel-bottom-half-left')
panel.style.left = `calc(50% + ${gap / 2}px)`
panel.style.right = 'auto'
panel.style.top = 'auto'
panel.style.bottom = (window.innerHeight - reviewAnchorTop) + 'px'
panel.style.minWidth = '0'
panel.style.maxWidth = halfMax + 'px'
panel.style.width = halfMax + 'px'
panel.style.maxHeight = maxHeight + 'px'
BrazenViewLayer._applyDockSlidePanelTransform(
panel,
'bottom-half-right',
panel.classList.contains('bv-dock-panel-offscreen'),
)
reviewAnchorTop = panel.getBoundingClientRect().top
}
settings.classList.add('bv-dock-panel-bottom-half-left')
settings.classList.remove('bv-dock-panel-bottom-half-right')
settings.style.left = 'auto'
settings.style.right = `calc(50% + ${gap / 2}px)`
settings.style.top = 'auto'
settings.style.bottom = dockBottom + 'px'
settings.style.minWidth = '0'
settings.style.maxWidth = halfMax + 'px'
settings.style.width = halfMax + 'px'
settings.style.maxHeight = maxHeight + 'px'
BrazenViewLayer._applyDockSlidePanelTransform(
settings,
'bottom-half-left',
settings.classList.contains('bv-dock-panel-offscreen'),
)
for (let panel of panels) {
if (BrazenViewLayer.isDockSlidePanelVisible(panel)) {
continue
}
BrazenViewLayer._clearBottomHalfPanelLayout(panel)
BrazenViewLayer._syncDockSlidePanelPosition(panel, dockRect, 'bottom', dockRect)
}
}
/**
* @param {HTMLElement} panel
* @param {string} orientation
* @param {Function|null} [beforeAnimate] Called after the panel is in layout (off-screen) and before the slide-in.
*/
static showDockSlidePanel(panel, orientation, beforeAnimate = null)
{
BrazenViewLayer._clearDockSlidePanelHideTimer(panel)
Utilities.setHidden(panel, false)
let transformOrientation = BrazenViewLayer._resolveDockSlideTransformOrientation(panel, orientation)
if (BrazenViewLayer.isDockSlidePanelVisible(panel)) {
BrazenViewLayer._applyDockSlidePanelTransform(panel, transformOrientation, false)
beforeAnimate?.()
return
}
// Already in the open layout (e.g. mid hide/show) — finish on-screen without restarting
// the off→on pulse (avoids stacked open animations / delayed jerks).
let prepared = panel.style.display === 'flex' && !panel.classList.contains('bv-dock-panel-hidden')
panel.classList.remove('bv-dock-panel-hidden')
panel.style.display = 'flex'
if (prepared) {
panel.classList.remove('bv-dock-panel-offscreen')
BrazenViewLayer._applyDockSlidePanelTransform(panel, transformOrientation, false)
beforeAnimate?.()
return
}
panel.classList.add('bv-dock-panel-offscreen')
BrazenViewLayer._applyDockSlidePanelTransform(panel, transformOrientation, true)
beforeAnimate?.()
panel.offsetHeight
panel.classList.remove('bv-dock-panel-offscreen')
BrazenViewLayer._applyDockSlidePanelTransform(panel, transformOrientation, false)
}
/**
* @param {HTMLElement} panel
* @param {string} orientation
* @param {boolean} [animate]
* @param {Function|null} [onHidden]
*/
static hideDockSlidePanel(panel, orientation, animate = true, onHidden = null)
{
BrazenViewLayer._clearDockSlidePanelHideTimer(panel)
if (!BrazenViewLayer.isDockSlidePanelVisible(panel) && panel.classList.contains('bv-dock-panel-hidden')) {
onHidden?.()
return
}
let transformOrientation = BrazenViewLayer._resolveDockSlideTransformOrientation(panel, orientation)
panel.classList.add('bv-dock-panel-offscreen')
BrazenViewLayer._applyDockSlidePanelTransform(panel, transformOrientation, true)
let finish = () => {
panel._bvDockSlideHideTimer = null
if (!panel.classList.contains('bv-dock-panel-offscreen')) {
return
}
panel.classList.add('bv-dock-panel-hidden')
panel.style.display = 'none'
onHidden?.()
}
if (animate) {
panel._bvDockSlideHideTimer = window.setTimeout(finish, 250)
} else {
finish()
}
}
/**
* @param {HTMLElement} panel
* @private
*/
static _clearDockSlidePanelHideTimer(panel)
{
if (panel?._bvDockSlideHideTimer != null) {
window.clearTimeout(panel._bvDockSlideHideTimer)
panel._bvDockSlideHideTimer = null
}
}
/**
* @param {HTMLElement} panel
* @param {boolean} hidden
*/
static setDockSlidePanelHidden(panel, hidden)
{
if (!panel) {
return
}
panel.classList.toggle('bv-dock-panel-hidden', hidden)
}
/**
* @param {HTMLElement} panel
* @param {{onClose?: function(): void, onMouseLeave?: function(HTMLElement): void}} handlers
*/
static wireDockSlidePanel(panel, handlers = {})
{
// Abort prior wiring so repeat calls do not stack listeners.
panel._bvDockWireAbort?.abort()
let controller = new AbortController()
panel._bvDockWireAbort = controller
let signal = controller.signal
let closeButton = panel.querySelector('.bv-dock-slide-panel-close')
if (closeButton && handlers.onClose) {
closeButton.addEventListener('click', handlers.onClose, {signal})
}
if (handlers.onMouseLeave) {
panel.addEventListener('mouseleave', (event) => {
handlers.onMouseLeave(event.currentTarget)
}, {signal})
}
}
/**
* @param {{mainPanel?: HTMLElement|null, compliancePanel?: HTMLElement|null}} panels
*/
static updateDockSlidePanelToggleButtons(panels = {})
{
let mainPanel = panels.mainPanel ?? document.getElementById('bv-ui')
let compliancePanel = panels.compliancePanel ?? document.getElementById('bv-compliance-rules')
let mainActive = !!(mainPanel && BrazenViewLayer.isDockSlidePanelVisible(mainPanel))
let complianceActive = !!(compliancePanel && BrazenViewLayer.isDockSlidePanelVisible(compliancePanel))
for (let button of document.querySelectorAll('.bv-dock-main-ui-btn')) {
button.classList.toggle('bv-dock-btn-active', mainActive)
}
for (let button of document.querySelectorAll('.bv-dock-compliance-rules-btn')) {
button.classList.toggle('bv-dock-btn-active', complianceActive)
}
}
/**
* @param {HTMLElement} slot
* @param {boolean} visible
*/
static setDownloadManagerProgressSlotVisible(slot, visible)
{
slot.classList.toggle('bv-dock-progress-hidden', !visible)
}
/**
* @param {HTMLElement|null|undefined} element
* @param {{label: string, stepIndex: number, stepCount: number, failed?: boolean, complete?: boolean}|null} progress
*/
static updateDownloadManagerItemProgress(element, progress)
{
if (!element) {
return
}
if (!progress?.label || progress.stepCount == null || progress.stepIndex == null) {
BrazenViewLayer.clearDownloadManagerItemProgress(element)
return
}
let signature = `${progress.label}|${progress.stepIndex}|${progress.stepCount}|${progress.complete ? 1 : 0}|${progress.failed ? 1 : 0}`
if (element.dataset.bvDmProgress === signature) {
return
}
element.dataset.bvDmProgress = signature
element.classList.add('bv-dm-item-active')
element.classList.toggle('bv-dm-item-failed', !!progress.failed)
let overlay = element.querySelector('.bv-dm-item-progress')
if (!overlay) {
overlay = document.createElement('div')
overlay.className = 'bv-dm-item-progress'
overlay.setAttribute('aria-hidden', 'true')
let inner = document.createElement('div')
inner.className = 'bv-dm-item-progress-inner'
let labelElement = document.createElement('div')
labelElement.className = 'bv-dm-item-progress-label'
let stepsElement = document.createElement('div')
stepsElement.className = 'bv-dm-item-progress-steps'
inner.append(labelElement, stepsElement)
overlay.append(inner)
element.prepend(overlay)
}
overlay.querySelector('.bv-dm-item-progress-label').textContent = progress.label
let stepsElement = overlay.querySelector('.bv-dm-item-progress-steps')
if (stepsElement.childElementCount !== progress.stepCount) {
stepsElement.replaceChildren()
for (let index = 0; index < progress.stepCount; index++) {
let step = document.createElement('span')
step.className = 'bv-dm-item-progress-step'
stepsElement.append(step)
}
}
let complete = !!progress.complete
let failed = !!progress.failed
stepsElement.querySelectorAll('.bv-dm-item-progress-step').forEach((step, index) => {
step.classList.toggle('bv-dm-step-done', complete || index < progress.stepIndex)
step.classList.toggle('bv-dm-step-current', !complete && !failed && index === progress.stepIndex)
step.classList.toggle('bv-dm-step-failed', failed && index === progress.stepIndex)
})
}
/**
* @param {HTMLElement|null|undefined} element
*/
static clearDownloadManagerItemProgress(element)
{
if (!element) {
return
}
delete element.dataset.bvDmProgress
element.classList.remove('bv-dm-item-active', 'bv-dm-item-failed')
element.querySelector('.bv-dm-item-progress')?.remove()
}
/**
* @return {HTMLElement}
*/
static createDownloadManagerProgressSlot()
{
return Utilities.makeEl('div', {
class: 'bv-dock-progress-panel',
attrs: {
title: 'Download queue: primary bar and completed/total. Resolution queue: secondary bar (below on bottom dock; beside counters on left/right).',
},
children: Utilities.makeEl('div', {
class: 'bv-dock-progress-slot',
children: [
Utilities.makeEl('div', {class: 'bv-dock-progress-bar bv-dock-progress-download', children: Utilities.makeEl('span')}),
Utilities.makeEl('div', {class: 'bv-dock-progress-label-wrap', children: Utilities.makeEl('span', {class: 'bv-dock-progress-label'})}),
Utilities.makeEl('div', {class: 'bv-dock-progress-bar bv-dock-progress-resolution', children: Utilities.makeEl('span')}),
],
}),
})
}
/**
* @param {HTMLElement} slot
* @param {{resolution: {current: number, total: number}, download: {current: number, total: number}}} progress
*/
static updateDownloadManagerProgressSlot(slot, progress)
{
let download = progress.download ?? {current: 0, total: 0}
let resolution = progress.resolution ?? {current: 0, total: 0}
let vertical = !!slot.closest('.bv-dock-left, .bv-dock-right')
// scaleX/scaleY so fill works before the panel has a definite used width (first
// resolution-only batch used to paint 0% until download counters widened the slot).
let setFill = (fill, current, total, fillVertical) => {
if (!fill) {
return
}
let ratio = total > 0 ? Math.min(1, Math.max(0, current / total)) : 0
fill.style.width = '100%'
fill.style.height = '100%'
if (fillVertical) {
fill.style.transform = `scaleY(${ratio})`
fill.style.transformOrigin = 'bottom center'
} else {
fill.style.transform = `scaleX(${ratio})`
fill.style.transformOrigin = 'left center'
}
}
setFill(slot.querySelector('.bv-dock-progress-download > span'), download.current, download.total, vertical)
setFill(slot.querySelector('.bv-dock-progress-resolution > span'), resolution.current, resolution.total, vertical)
let label = slot.querySelector('.bv-dock-progress-label')
if (label) {
label.textContent = `${download.current}/${download.total}`
}
}
/**
* @param {HTMLElement} panel
* @param {DOMRect} dockRect
* @param {string} orientation
* @param {DOMRect} anchorRect
* @private
*/
static _syncDockSlidePanelPosition(panel, dockRect, orientation, anchorRect)
{
if (orientation === 'left') {
panel.style.left = anchorRect.right + 'px'
panel.style.right = 'auto'
panel.style.top = dockRect.top + 'px'
panel.style.bottom = 'auto'
BrazenViewLayer._applyDockSlidePanelTransform(panel, orientation, panel.classList.contains('bv-dock-panel-offscreen'))
return
}
if (orientation === 'right') {
panel.style.left = 'auto'
panel.style.right = (window.innerWidth - anchorRect.left) + 'px'
panel.style.top = dockRect.top + 'px'
panel.style.bottom = 'auto'
BrazenViewLayer._applyDockSlidePanelTransform(panel, orientation, panel.classList.contains('bv-dock-panel-offscreen'))
return
}
panel.style.left = (dockRect.left + dockRect.width / 2) + 'px'
panel.style.right = 'auto'
panel.style.top = 'auto'
panel.style.bottom = (window.innerHeight - anchorRect.top) + 'px'
BrazenViewLayer._applyDockSlidePanelTransform(panel, orientation, panel.classList.contains('bv-dock-panel-offscreen'))
}
/**
* Prefer bottom half-panel transform when dual-open classes are set.
* @param {HTMLElement} panel
* @param {string} orientation
* @return {string}
* @private
*/
static _resolveDockSlideTransformOrientation(panel, orientation)
{
if (orientation !== 'bottom') {
return orientation
}
if (panel?.classList.contains('bv-dock-panel-bottom-half-left')) {
return 'bottom-half-left'
}
if (panel?.classList.contains('bv-dock-panel-bottom-half-right')) {
return 'bottom-half-right'
}
return orientation
}
static _applyDockSlidePanelTransform(panel, orientation, offscreen)
{
panel.classList.add('bv-dock-panel-animate')
if (orientation === 'bottom-half-left' || orientation === 'bottom-half-right') {
panel.style.transform = offscreen ? 'translateY(100%)' : 'translateY(0)'
return
}
if (orientation === 'bottom') {
panel.style.transform = offscreen ? 'translate(-50%, 100%)' : 'translateX(-50%)'
return
}
if (orientation === 'right') {
panel.style.transform = offscreen ? 'translateX(100%)' : 'translateX(0)'
return
}
panel.style.transform = offscreen ? 'translateX(-100%)' : 'translateX(0)'
}
// -------------------------------------------------------------------------
// Private class variables
// -------------------------------------------------------------------------
/**
* @type {boolean}
* @private
*/
_resizing = false
/**
* @type {HTMLElement}
* @private
*/
_section = null
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
/**
* @param {string} selectorPrefix
*/
constructor(selectorPrefix)
{
this._selectorGenerator = new SelectorGenerator(selectorPrefix)
this._selectorPrefix = selectorPrefix
}
// -------------------------------------------------------------------------
// Public class methods
// -------------------------------------------------------------------------
/**
* @param {(HTMLElement|Node)[]} children
* @return {HTMLElement}
*/
createBottomSection(children)
{
return Utilities.makeEl('div', {class: 'bv-bottom-section', children})
}
/**
* @return {HTMLElement}
*/
createBreakSeparator()
{
return Utilities.makeEl('br', {class: 'bv-break'})
}
/**
* @return {HTMLElement}
*/
createContainer()
{
this._section = Utilities.makeEl('section', {class: 'bv-section bv-font-primary'})
return this._section
}
/**
* @param {HTMLElement|HTMLElement[]} children
* @param {string} wrapperClasses
* @return {HTMLElement}
*/
createFormActions(children, wrapperClasses = '')
{
let el = Utilities.makeEl('div', {class: 'bv-actions'})
if (wrapperClasses) {
el.className = ('bv-actions ' + wrapperClasses).trim()
}
Utilities.appendChildren(el, children)
return el
}
/**
* @param {string} caption
* @param {EventListener} onClick
* @param {string} hoverHelp
* @return {HTMLElement}
*/
createFormButton(caption, hoverHelp, onClick)
{
return Utilities.makeEl('button', {
class: 'bv-button',
text: caption,
attrs: {title: hoverHelp},
on: {click: onClick},
})
}
createFormCheckBoxesGroupSection(label, keyValuePairs, hoverHelp)
{
return this._createFormMultiInputSection(label, keyValuePairs, hoverHelp, 'checkbox', 'bv-checkboxes-group')
}
/**
* @return {HTMLElement}
*/
createFormGroup()
{
return Utilities.makeEl('div', {class: 'bv-group'})
}
/**
* @param {string} id
* @param {Array} keyValuePairs
*
* @return {HTMLElement}
*/
createFormGroupDropdown(id, keyValuePairs)
{
let dropdown = Utilities.makeEl('select', {class: 'bv-input', attrs: {id}})
for (let i = 0; i < keyValuePairs.length; i++) {
let option = Utilities.makeEl('option', {text: keyValuePairs[i][1], attrs: {value: keyValuePairs[i][0]}})
option.selected = (i === 0)
dropdown.append(option)
}
return dropdown
}
/**
* @param {string} type
*
* @return {HTMLElement}
*/
createFormGroupInput(type)
{
let input = Utilities.makeEl('input', {class: 'bv-input', attrs: {type}})
switch (type) {
case 'number':
case 'text':
input.classList.add('bv-text')
break
case 'radio':
case 'checkbox':
input.classList.add('bv-checkbox-radio')
break
}
return input
}
/**
* @param {string} label
* @param {string} inputType
* @return {HTMLElement}
*/
createFormGroupLabel(label, inputType = '')
{
let labelFormGroup = Utilities.makeEl('label', {class: 'bv-label', text: label})
if (inputType !== '') {
switch (inputType) {
case 'number':
case 'text':
labelFormGroup.classList.add('bv-text')
labelFormGroup.textContent = labelFormGroup.textContent + ': '
break
case 'radio':
case 'checkbox':
labelFormGroup.classList.add('bv-checkbox-radio')
break
}
}
return labelFormGroup
}
/**
* @param {string} statisticType
* @return {HTMLElement}
*/
createFormGroupStatLabel(statisticType)
{
return Utilities.makeEl('label', {
class: 'bv-stat-label',
text: '0',
attrs: {id: this._selectorGenerator.getStatLabelSelector(statisticType)},
})
}
/**
* @param {string} label
* @param {string} inputType
* @param {string} hoverHelp
* @return {HTMLElement}
*/
createFormInputGroup(label, inputType, hoverHelp = '')
{
let group = this.createFormGroup()
group.setAttribute('title', hoverHelp)
Utilities.appendChildren(group, [
this.createFormGroupLabel(label, inputType),
this.createFormGroupInput(inputType),
])
return group
}
createFormRadiosGroupSection(label, keyValuePairs, hoverHelp)
{
return this._createFormMultiInputSection(label, keyValuePairs, hoverHelp, 'radio', 'bv-radios-group')
}
/**
* @param {string} label
* @param {Array} keyValuePairs
* @param {string} hoverHelp
* @param {'checkbox'|'radio'} inputType
* @param {string} sectionClass
* @return {HTMLElement}
* @private
*/
_createFormMultiInputSection(label, keyValuePairs, hoverHelp, inputType, sectionClass)
{
let section = this.createFormSection(label)
section.classList.add(sectionClass)
section.setAttribute('title', hoverHelp)
for (let i = 0; i < keyValuePairs.length; i++) {
let pair = keyValuePairs[i]
let input = this.createFormGroupInput(inputType)
input.setAttribute('data-value', pair[1])
if (inputType === 'radio') {
input.checked = (i === 0)
input.addEventListener('change', (event) => {
let currentTarget = event.currentTarget
let sectionElement = currentTarget.closest('.' + sectionClass)
if (sectionElement) {
for (let element of sectionElement.querySelectorAll('input')) {
if (!element.isSameNode(currentTarget)) {
element.checked = false
}
}
}
})
}
section.append(
Utilities.makeEl('div', {
class: 'bv-group',
children: [
this.createFormGroupLabel(pair[0], inputType),
input,
],
}),
)
}
return section
}
/**
* @param {string} label
* @param {string} inputsType
* @param {number} minimum
* @param {number} maximum
* @param {string} hoverHelp
* @return {HTMLElement}
*/
createFormRangeInputGroup(label, inputsType, minimum, maximum, hoverHelp)
{
let group = this.createFormGroup()
group.classList.add('bv-range-group')
group.setAttribute('title', hoverHelp)
let first = this.createFormGroupInput(inputsType)
first.setAttribute('min', String(minimum))
first.setAttribute('max', String(maximum))
let second = this.createFormGroupInput(inputsType)
second.setAttribute('min', String(minimum))
second.setAttribute('max', String(maximum))
Utilities.appendChildren(group, [
this.createFormGroupLabel(label, inputsType),
first,
second,
])
return group
}
/**
* @param {string} title
* @return {HTMLElement}
*/
createFormSection(title = '')
{
return Utilities.makeEl('div', {
children: Utilities.makeEl('label', {class: 'bv-title', text: title}),
})
}
/**
* @param {string} label
* @param {int} rows
* @param {string} hoverHelp
* @return {HTMLElement}
*/
createFormTextAreaGroup(label, rows, hoverHelp = '')
{
let group = this.createFormGroup()
group.setAttribute('title', hoverHelp)
group.classList.add('bv-textarea-group')
Utilities.appendChildren(group, [
this.createFormGroupLabel(label),
Utilities.makeEl('textarea', {
class: 'bv-input',
attrs: {rows: String(rows), spellcheck: 'false'},
}),
])
return group
}
/**
* Subject → Alias row used above a read-only substitution ruleset textarea.
*
* @param {{
* subjectPlaceholder?: string,
* aliasPlaceholder?: string,
* addHelpText?: string,
* onAdd?: function(string, string): void,
* }} [options]
* @return {HTMLElement}
*/
createFormSubstitutionComposer(options = {})
{
let settings = {
subjectPlaceholder: 'Subject',
aliasPlaceholder: 'Alias',
addHelpText: 'Add substitution',
onAdd: () => {},
...options,
}
let subjectInput = this.createFormGroupInput('text')
subjectInput.setAttribute('placeholder', settings.subjectPlaceholder)
subjectInput.setAttribute('autocomplete', 'off')
subjectInput.setAttribute('spellcheck', 'false')
let aliasInput = this.createFormGroupInput('text')
aliasInput.setAttribute('placeholder', settings.aliasPlaceholder)
aliasInput.setAttribute('autocomplete', 'off')
aliasInput.setAttribute('spellcheck', 'false')
let submit = () => {
let subject = String(subjectInput.value ?? '').trim()
let alias = String(aliasInput.value ?? '').trim()
if (!subject || !alias) {
return
}
Utilities.callEventHandler(settings.onAdd, [subject, alias])
subjectInput.value = ''
aliasInput.value = ''
subjectInput.focus()
}
subjectInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault()
aliasInput.focus()
}
})
aliasInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault()
submit()
}
})
let addButton = Utilities.makeEl('button', {
class: 'bv-button bv-substitution-add',
attrs: {type: 'button', title: settings.addHelpText},
children: BrazenViewLayer.createPlusIcon('bv-substitution-add-icon'),
on: {
click: (event) => {
event.preventDefault()
submit()
},
},
})
return Utilities.makeEl('div', {
class: 'bv-substitution-composer',
children: [
subjectInput,
Utilities.makeEl('span', {class: 'bv-substitution-arrow', attrs: {'aria-hidden': 'true'}, text: '→'}),
aliasInput,
addButton,
],
})
}
/**
* Searchable bookmark list with add, sort, navigate, and remove controls.
*
* @param {BrazenBookmarksPanelOptions} options
* @return {BrazenBookmarksPanel}
*/
createBookmarksPanel(options = {})
{
let settings = {
showAddButton: true,
addCaption: 'Add Bookmark',
addHelpText: '',
searchPlaceholder: 'Search bookmarks…',
searchHelpText: 'Filter bookmarks by label or tags',
sortHelpText: 'Sort bookmarks A–Z',
emptyMessage: 'No bookmarks yet.',
noMatchMessage: 'No matching bookmarks.',
onAdd: () => {},
onSort: () => {},
onRemove: () => {},
onNavigate: (url) => {
location.href = url
},
getRowActions: () => [],
onBeforeRender: () => {},
...options,
}
let filterQuery = ''
let lastBookmarks = []
let listEl = Utilities.makeEl('div', {class: 'bv-bookmark-list'})
let emptyEl = Utilities.makeEl('div', {class: 'bv-bookmark-empty', text: settings.emptyMessage})
let searchEl = Utilities.makeEl('input', {
class: 'bv-input bv-text bv-bookmark-search',
attrs: {
type: 'search',
placeholder: settings.searchPlaceholder,
title: settings.searchHelpText,
},
})
searchEl.addEventListener('input', () => {
filterQuery = String(searchEl.value ?? '')
widget.render(lastBookmarks)
})
let sortButton = Utilities.makeEl('button', {
class: 'bv-bookmark-sort bv-button',
text: '⇅',
attrs: {type: 'button', title: settings.sortHelpText},
on: {click: () => settings.onSort()},
})
let toolbar = Utilities.makeEl('div', {class: 'bv-bookmark-toolbar', children: [searchEl, sortButton]})
let scrollEl = Utilities.makeEl('div', {class: 'bv-bookmark-scroll', children: listEl})
let columnsEl = Utilities.makeEl('div', {class: 'bv-bookmark-columns', children: [toolbar, emptyEl, scrollEl]})
let syncBookmarkColumnInset = () => {
columnsEl.style.setProperty('--bv-bookmark-scroll-inset', `${scrollEl.offsetWidth - scrollEl.clientWidth}px`)
}
/** @type {ResizeObserver|null} */
let bookmarkResizeObserver = null
if (typeof ResizeObserver !== 'undefined') {
bookmarkResizeObserver = new ResizeObserver(() => syncBookmarkColumnInset())
bookmarkResizeObserver.observe(scrollEl)
}
/** @type {{element: Element, refresh: Function}[]} */
let pageMatchListeners = []
let elementChildren = []
if (settings.showAddButton) {
elementChildren.push(this.createFormButton(settings.addCaption, settings.addHelpText, () => settings.onAdd()))
}
elementChildren.push(columnsEl)
let element = Utilities.makeEl('div', {class: 'bv-bookmarks-group', children: elementChildren})
let widget = {
element: element,
getFilterQuery: () => filterQuery,
isCurrentPageBookmarked: (url) => {
let match = settings.pageMatch
if (!match) {
return false
}
let normalize = match.normalizeUrl ?? ((value) => String(value ?? '').trim())
let currentUrl = normalize(url ?? (match.getCurrentUrl?.() ?? location.href))
if (!currentUrl) {
return false
}
return lastBookmarks.some((bookmark) => normalize(bookmark.url) === currentUrl)
},
checkPageMatch: () => {
let match = settings.pageMatch
if (!match) {
return
}
if (match.isActive && !match.isActive()) {
match.onMatchChange(false)
return
}
match.onMatchChange(widget.isCurrentPageBookmarked())
},
dispose: () => {
bookmarkResizeObserver?.disconnect()
bookmarkResizeObserver = null
for (let entry of pageMatchListeners) {
entry.element.removeEventListener('input', entry.refresh)
}
pageMatchListeners = []
},
render: (bookmarks) => {
lastBookmarks = Array.isArray(bookmarks) ? bookmarks : []
let query = filterQuery.trim().toLowerCase()
let visible = query ? lastBookmarks.filter((bookmark) =>
bookmark.label.toLowerCase().includes(query) || bookmark.tags.toLowerCase().includes(query)) : lastBookmarks
// Fire-and-forget: consumers may warm async caches then re-render once.
try {
let before = settings.onBeforeRender(visible)
if (before && typeof before.then === 'function') {
void before.catch(() => {})
}
} catch (_) { /* ignore */ }
listEl.replaceChildren()
if (lastBookmarks.length === 0) {
emptyEl.textContent = settings.emptyMessage
Utilities.setHidden(emptyEl, false)
} else if (visible.length === 0) {
emptyEl.textContent = settings.noMatchMessage
Utilities.setHidden(emptyEl, false)
} else {
Utilities.setHidden(emptyEl, true)
}
for (let bookmark of visible) {
let link = Utilities.makeEl('button', {
class: 'bv-bookmark-link',
text: bookmark.label,
attrs: {type: 'button', title: bookmark.tags},
on: {click: () => settings.onNavigate(bookmark.url)},
})
let main = Utilities.makeEl('div', {class: 'bv-bookmark-main', children: link})
for (let action of settings.getRowActions(bookmark) ?? []) {
main.append(action)
}
let remove = Utilities.makeEl('button', {
class: 'bv-bookmark-remove',
text: '×',
attrs: {type: 'button', title: 'Remove bookmark'},
on: {
click: (event) => {
event.stopPropagation()
settings.onRemove(bookmark.entryId ?? bookmark.id)
},
},
})
listEl.append(Utilities.makeEl('div', {class: 'bv-bookmark-row', children: [main, remove]}))
}
syncBookmarkColumnInset()
widget.checkPageMatch()
},
}
syncBookmarkColumnInset()
requestAnimationFrame(() => requestAnimationFrame(syncBookmarkColumnInset))
if (settings.pageMatch) {
let refresh = () => widget.checkPageMatch()
for (let selector of settings.pageMatch.watchSelectors ?? []) {
for (let matchElement of document.querySelectorAll(selector)) {
matchElement.addEventListener('input', refresh)
pageMatchListeners.push({element: matchElement, refresh})
}
}
refresh()
}
return widget
}
createResizer()
{
let handle = Utilities.makeEl('div', {
attrs: {id: 'bv-resizer', title: 'Resize'},
children: Utilities.makeEl('span', {attrs: {id: 'bv-resizer-icon'}, text: '↔'}),
})
handle.addEventListener('mousedown', (event) => {
event.preventDefault()
this._resizing = true
let resizeHorizontal = (moveEvent) => {
if (!this._section) {
return
}
this._section.style.width = Math.max(150, moveEvent.clientX - this._section.getBoundingClientRect().left) + 'px'
}
let stopResize = () => {
this._resizing = false
removeEventListener('mousemove', resizeHorizontal)
removeEventListener('mouseup', stopResize)
}
addEventListener('mousemove', resizeHorizontal)
addEventListener('mouseup', stopResize)
})
return handle
}
createModal(title, bodyNodes)
{
let body = Utilities.makeEl('div', {class: 'bv-modal-body'})
let nodes = Array.isArray(bodyNodes) ? bodyNodes : [bodyNodes]
Utilities.appendChildren(body, nodes)
let dialog = Utilities.makeEl('div', {
class: 'bv-modal-dialog',
children: [
Utilities.makeEl('div', {
class: 'bv-modal-header',
children: [
Utilities.makeEl('h2', {class: 'bv-modal-title', text: title}),
Utilities.makeEl('button', {
class: 'bv-modal-close bv-button',
text: '×',
attrs: {type: 'button', 'aria-label': 'Close'},
}),
],
}),
body,
],
})
return Utilities.makeEl('div', {class: 'bv-modal-overlay', children: dialog})
}
/**
* @param {HTMLElement} modal
*/
showModal(modal)
{
let hide = () => this.hideModal(modal)
modal.addEventListener('click', (event) => {
if (event.target === modal) {
hide()
}
})
modal.querySelector('.bv-modal-close')?.addEventListener('click', hide)
modal._bvModalKeydown = (event) => {
if (event.key === 'Escape') {
hide()
}
}
document.addEventListener('keydown', modal._bvModalKeydown)
BrazenViewLayer.appendToBody(modal)
}
/**
* @param {HTMLElement} modal
*/
hideModal(modal)
{
if (modal?._bvModalKeydown) {
document.removeEventListener('keydown', modal._bvModalKeydown)
delete modal._bvModalKeydown
}
modal?.remove()
}
/**
* @param {{title: string, message: string, confirmLabel?: string, cancelLabel?: string, showCancel?: boolean}} options
* @return {Promise<boolean>}
*/
static showConfirmDialog(options)
{
return new Promise((resolve) => {
let settled = false
let overlay = Utilities.makeEl('div', {class: 'bv-modal-overlay'})
let finish = (value) => {
if (settled) {
return
}
settled = true
if (overlay._bvModalConfirmKeydown) {
document.removeEventListener('keydown', overlay._bvModalConfirmKeydown)
delete overlay._bvModalConfirmKeydown
}
overlay.remove()
resolve(value)
}
let closeButton = Utilities.makeEl('button', {
class: 'bv-modal-close bv-button',
text: '×',
attrs: {type: 'button', 'aria-label': 'Close'},
on: {click: () => finish(false)},
})
let actionButtons = [
Utilities.makeEl('button', {
class: 'bv-button',
text: options.confirmLabel ?? 'OK',
attrs: {type: 'button'},
on: {click: () => finish(true)},
}),
]
if (options.showCancel !== false) {
actionButtons.push(
Utilities.makeEl('button', {
class: 'bv-button',
text: options.cancelLabel ?? 'Cancel',
attrs: {type: 'button'},
on: {click: () => finish(false)},
}),
)
}
overlay.append(Utilities.makeEl('div', {
class: 'bv-modal-dialog',
children: [
Utilities.makeEl('div', {
class: 'bv-modal-header',
children: [
Utilities.makeEl('h2', {class: 'bv-modal-title', text: options.title ?? 'Confirm'}),
closeButton,
],
}),
Utilities.makeEl('div', {
class: 'bv-modal-body',
children: [
Utilities.makeEl('p', {text: options.message ?? ''}),
Utilities.makeEl('div', {class: 'bv-actions bv-flex-column', children: actionButtons}),
],
}),
],
}))
overlay.addEventListener('click', (event) => {
if (event.target === overlay) {
finish(false)
}
})
overlay._bvModalConfirmKeydown = (event) => {
if (event.key === 'Escape') {
finish(false)
}
}
document.addEventListener('keydown', overlay._bvModalConfirmKeydown)
BrazenViewLayer.appendToBody(overlay)
})
}
/**
* Wraps sidebar tag counts with a hover-reveal action slot.
*
* @param {TagSidebarCountEnhancementOptions} options
*/
enhanceTagSidebarCounts(options = {})
{
let settings = {
sidebarSelector: '#tag-sidebar',
rowSelector: 'li[class*="tag-type-"]',
countSelector: 'span.tag-count',
hideDelayMs: 2000,
extractTag: () => ({name: ''}),
renderActions: () => {},
isBookmarked: () => false,
...options,
}
let sidebar = document.querySelector(settings.sidebarSelector)
if (!sidebar) {
return
}
let reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
let hideDelayMs = reducedMotion ? 0 : settings.hideDelayMs
for (let row of sidebar.querySelectorAll(settings.rowSelector)) {
let count = row.querySelector(settings.countSelector)
if (!count) {
continue
}
let slot = count.closest('.bv-tag-actions-slot')
let actions
if (!slot) {
slot = Utilities.makeEl('span', {class: 'bv-tag-actions-slot'})
count.before(slot)
slot.append(count)
actions = Utilities.makeEl('span', {class: 'bv-tag-actions', hidden: true})
slot.append(actions)
let hideTimer = null
let showActions = () => {
clearTimeout(hideTimer)
hideTimer = null
Utilities.setHidden(actions, false)
Utilities.setHidden(count, true)
row.classList.add('bv-tag-row-active')
}
let hideActions = () => {
Utilities.setHidden(actions, true)
Utilities.setHidden(count, false)
row.classList.remove('bv-tag-row-active')
}
let scheduleHide = () => {
clearTimeout(hideTimer)
hideTimer = setTimeout(hideActions, hideDelayMs)
}
row.addEventListener('pointerenter', showActions)
row.addEventListener('pointerleave', scheduleHide)
} else {
actions = slot.querySelector('.bv-tag-actions')
}
if (!actions) {
continue
}
let tag = settings.extractTag(row)
actions.replaceChildren()
settings.renderActions(row, tag, actions, {isBookmarked: settings.isBookmarked})
}
}
/**
* @return {HTMLElement}
*/
createSeparator()
{
return document.createElement('hr')
}
/**
* @return {HTMLElement}
*/
createSettingsSection()
{
let section = this.createContainer()
section.id = 'bv-ui'
section.classList.add('bv-dock-slide-panel', 'bv-bg-colour', 'bv-dock-panel-hidden')
section.append(this.createResizer())
return section
}
/**
* @param {string} id
* @param {string} title
* @return {HTMLElement}
*/
static createDockSlidePanel(id, title)
{
return Utilities.makeEl('section', {
class: 'bv-section bv-font-primary bv-dock-slide-panel bv-bg-colour bv-dock-panel-hidden',
attrs: {id},
children: [
Utilities.makeEl('div', {
class: 'bv-dock-slide-panel-head',
children: [
Utilities.makeEl('h2', {class: 'bv-dock-slide-panel-title', text: title}),
Utilities.makeEl('button', {
class: 'bv-dock-slide-panel-close bv-button',
text: '×',
attrs: {type: 'button', 'aria-label': 'Close'},
}),
],
}),
Utilities.makeEl('div', {class: 'bv-dock-slide-panel-body'}),
],
})
}
/**
* @return {HTMLElement}
*/
createComplianceRulesSlidePanel()
{
let panel = BrazenViewLayer.createDockSlidePanel('bv-compliance-rules', 'Active Hide Rules')
panel.querySelector('.bv-dock-slide-panel-body')?.replaceChildren(
Utilities.makeEl('p', {class: 'bv-dock-list-intro', text: 'Filters that hid items on this page.'}),
Utilities.makeEl('div', {class: 'bv-dock-list-groups'}),
)
return panel
}
/**
* @param {HTMLElement} panel
* @param {{filterKey: string, filterLabel: string, rules: {label: string, count: number}[]}[]} report
* @param {{canRemoveRule?: function(string, string): boolean, onRemoveRule?: function(string, string): void}} [options]
*/
renderComplianceRulesPanelContent(panel, report, options = {})
{
let groups = panel.querySelector('.bv-dock-list-groups')
let body = panel.querySelector('.bv-dock-slide-panel-body')
if (!groups) {
body?.replaceChildren(
Utilities.makeEl('p', {class: 'bv-dock-list-intro', text: 'Filters that hid items on this page.'}),
Utilities.makeEl('div', {class: 'bv-dock-list-groups'}),
)
groups = body?.querySelector('.bv-dock-list-groups') ?? null
} else {
groups.replaceChildren()
}
if (!groups) {
return
}
if (!report.length) {
groups.append(Utilities.makeEl('p', {class: 'bv-dock-list-intro', text: 'No items hidden on this page.'}))
return
}
for (let section of report) {
let list = Utilities.makeEl('ul', {class: 'bv-dock-list'})
for (let rule of section.rules) {
let actions = Utilities.makeEl('span', {class: 'bv-tag-actions'})
if (options.canRemoveRule?.(section.filterKey, rule.label)) {
let removeButton = this.createFormButton('Remove', 'Remove this rule from the filter list.', () => {
options.onRemoveRule?.(section.filterKey, rule.label)
})
removeButton.classList.add('bv-dock-list-action-btn')
actions.append(removeButton)
}
list.append(Utilities.makeEl('li', {
class: 'bv-dock-list-row',
children: [
Utilities.makeEl('span', {class: 'bv-dock-list-name', text: rule.label}),
Utilities.makeEl('span', {class: 'bv-dock-list-meta', text: rule.count + ' hidden'}),
actions,
],
}))
}
groups.append(Utilities.makeEl('section', {
class: 'bv-dock-list-group',
children: [
Utilities.makeEl('h3', {text: section.filterLabel}),
list,
],
}))
}
}
/**
* @param {object} field
* @param {{stateClass?: string, tooltip?: string, icon?: string, disabled?: boolean}} presentation
*/
updateDockFieldButton(field, presentation = {})
{
if (!field.dockElement) {
return
}
let dockElement = field.dockElement
let button = dockElement.classList.contains('bv-dock-slot')
? dockElement.querySelector(':scope > .bv-dock-btn')
: dockElement
if (!button) {
return
}
button.classList.remove('bv-dock-btn-active', 'bv-dock-btn-disabled')
button.disabled = false
if (presentation.disabled) {
button.disabled = true
button.classList.add('bv-dock-btn-disabled')
} else if (presentation.stateClass) {
button.classList.add(presentation.stateClass)
}
if (presentation.tooltip) {
button.setAttribute('title', presentation.tooltip)
}
if (typeof presentation.icon === 'string') {
button.replaceChildren(BrazenViewLayer.getDockIcon(presentation.icon))
}
}
/**
* @param {string} orientation
* @param {string} nameBarText
* @return {HTMLElement}
*/
createDock(orientation, nameBarText)
{
return Utilities.makeEl('div', {
class: 'bv-dock bv-dock-' + orientation,
children: [
Utilities.makeEl('div', {class: 'bv-dock-name', text: nameBarText}),
Utilities.makeEl('div', {
class: 'bv-dock-rail',
children: [
Utilities.makeEl('div', {class: 'bv-dock-rail-head'}),
Utilities.makeEl('div', {class: 'bv-dock-rail-body'}),
Utilities.makeEl('div', {class: 'bv-dock-rail-foot'}),
],
}),
],
})
}
/**
* @return {HTMLElement}
*/
createDockSeparator()
{
return Utilities.makeEl('div', {class: 'bv-dock-separator', attrs: {'aria-hidden': 'true'}})
}
/**
* Mounts a dock rail body from an array of nodes (field buttons, separators, etc.).
* Adjacent separators are hidden via CSS (`.bv-dock-separator + .bv-dock-separator`).
* @param {HTMLElement} railBody
* @param {HTMLElement[]} items
* @return {HTMLElement}
*/
mountDockRail(railBody, items)
{
railBody.replaceChildren()
for (let item of items) {
if (item) {
railBody.append(item)
}
}
return railBody
}
/**
* @param {string} message
* @return {HTMLElement}
*/
createDockMigrationStatus(message)
{
return Utilities.makeEl('div', {
class: 'bv-dock-status',
attrs: {role: 'status', 'aria-live': 'polite'},
children: [
Utilities.makeEl('span', {class: 'bv-dock-status-spinner', attrs: {'aria-hidden': 'true'}}),
Utilities.makeEl('span', {class: 'bv-dock-status-text', text: message}),
],
})
}
/**
* @param {HTMLElement} dock
* @param {string|null} message
*/
setDockMigrationStatus(dock, message)
{
if (!dock) {
return
}
let status = dock.querySelector('.bv-dock-status')
if (!message) {
status?.remove()
return
}
if (!status) {
dock.querySelector('.bv-dock-rail-body')?.prepend(this.createDockMigrationStatus(message))
return
}
let textEl = status.querySelector('.bv-dock-status-text')
if (textEl) {
textEl.textContent = message
}
}
/**
* @param {{icon?: string|HTMLElement|SVGElement, tooltip?: string, stateClass?: string, onClick?: Function, disabled?: boolean}} options
* @return {HTMLElement}
*/
createDockButton(options = {})
{
let button = Utilities.makeEl('button', {class: 'bv-dock-btn', attrs: {type: 'button'}})
if (options.stateClass) {
button.classList.add(options.stateClass)
}
if (options.disabled) {
button.disabled = true
button.classList.add('bv-dock-btn-disabled')
}
if (options.tooltip) {
button.setAttribute('title', options.tooltip)
}
if (options.icon) {
button.append(typeof options.icon === 'string'
? BrazenViewLayer.getDockIcon(options.icon)
: options.icon)
}
if (options.onClick) {
button.addEventListener('click', (event) => {
event.preventDefault()
options.onClick(event)
})
}
return button
}
/**
* @param {HTMLElement} parentButton
* @param {HTMLElement[]} childButtons
* @return {HTMLElement}
*/
createDockSlideOut(parentButton, childButtons)
{
return Utilities.makeEl('div', {
class: 'bv-dock-slot',
children: [
parentButton,
Utilities.makeEl('div', {class: 'bv-dock-slideout', children: childButtons}),
],
})
}
/**
* Always-visible inset panel (slide-out styling, darker background) for grouped dock controls.
* @param {HTMLElement[]} items
* @return {HTMLElement}
*/
createDockInsetGroup(items = [])
{
let group = Utilities.makeEl('div', {class: 'bv-dock-inset-group'})
for (let item of items) {
if (item) {
group.append(item)
}
}
return group
}
/**
* @param {{orientations: string[], value: string, onCycle?: Function}} options
* @return {HTMLElement}
*/
createDockPositionButton(options = {})
{
let orientationIcons = {left: 'dock-left', right: 'dock-right', bottom: 'dock-bottom'}
let orientationLabels = {left: 'Left', right: 'Right', bottom: 'Bottom'}
let value = options.value ?? options.orientations?.[0] ?? 'right'
let label = orientationLabels[value] ?? value
let button = this.createDockButton({
icon: orientationIcons[value] ?? 'dock-right',
tooltip: 'Dock position: ' + label + ' (click to change)',
onClick: () => options.onCycle?.(),
})
button.classList.add('bv-dock-position-btn')
return button
}
/**
* @param {string} statisticsType
* @param {string} label
* @return {HTMLElement}
*/
createStatisticsFormGroup(statisticsType, label = '')
{
if (label === '') {
label = statisticsType.split('-').map((word) => word ? word[0].toUpperCase() + word.slice(1) : '').join(' ')
}
let group = this.createFormGroup()
group.classList.add('bv-stat-group')
Utilities.appendChildren(group, [
this.createFormGroupLabel(label + ' Filter'),
this.createFormGroupStatLabel(statisticsType),
])
return group
}
/**
* @return {HTMLElement}
*/
createStatisticsTotalsGroup()
{
let group = this.createFormGroup()
Utilities.appendChildren(group, [
this.createFormGroupLabel('Total'),
this.createFormGroupStatLabel('Total'),
])
return group
}
/**
* @param {string} tabName
* @param {boolean} isFirst
* @return {HTMLElement}
*/
createTabButton(tabName, isFirst)
{
return Utilities.makeEl('button', {
class: 'bv-tab-button bv-border-primary' + (isFirst ? ' bv-active bv-font-secondary' : ' bv-font-primary'),
text: tabName,
on: {
click: (event) => {
let button = event.currentTarget
let tabSection = button.closest('.bv-tabs-section')
if (!tabSection) {
return
}
for (let tabBtn of tabSection.querySelectorAll('.bv-tab-button')) {
tabBtn.classList.remove('bv-active', 'bv-font-secondary')
tabBtn.classList.add('bv-font-primary')
}
for (let tabPanel of tabSection.querySelectorAll('.bv-tab-panel')) {
tabPanel.classList.remove('bv-active')
}
button.classList.remove('bv-font-primary')
button.classList.add('bv-active', 'bv-font-secondary')
document.getElementById(Utilities.toKebabCase(button.textContent))?.classList.add('bv-active')
},
mouseenter: (event) => event.currentTarget.classList.add('bv-font-secondary'),
mouseleave: (event) => event.currentTarget.classList.remove('bv-font-secondary'),
},
})
}
/**
* @param {string} tabName
* @param {boolean} isFirst
* @return {HTMLElement}
*/
createTabPanel(tabName, isFirst = false)
{
return Utilities.makeEl('div', {
class: 'bv-tab-panel bv-border-primary' + (isFirst ? ' bv-active' : ''),
attrs: {id: Utilities.toKebabCase(tabName)},
})
}
/**
* @param {string[]} tabNames
* @param {HTMLElement[]} tabPanels
* @return {HTMLElement}
*/
createTabsSection(tabNames, tabPanels)
{
let tabButtons = []
for (let i = 0; i < tabNames.length; i++) {
tabButtons.push(this.createTabButton(tabNames[i], i === 0))
}
let nav = Utilities.makeEl('div', {class: 'bv-tabs-nav', children: tabButtons})
return Utilities.makeEl('div', {class: 'bv-tabs-section', children: [nav, ...tabPanels]})
}
/**
* Ready-to-append Donate tab panel (Patreon link + icon).
* Include the matching tab name (`options.tabName`, default `Donate`) in {@link createTabsSection}.
*
* @param {{tabName?: string, isFirst?: boolean, patreonUrl?: string, linkLabel?: string, message?: string}} [options]
* @return {HTMLElement}
*/
createDonateTabPanel(options = {})
{
let tabName = options.tabName ?? 'Donate'
let patreonUrl = options.patreonUrl ?? 'https://www.patreon.com/c/brazen_mvs'
let linkLabel = options.linkLabel ?? 'Patreon'
let message = options.message ?? 'Support Brazen scripts on Patreon.'
let link = Utilities.makeEl('a', {
class: 'bv-button bv-donate-patreon',
attrs: {
href: patreonUrl,
target: '_blank',
rel: 'noopener noreferrer',
title: 'Open Patreon',
},
children: [
BrazenViewLayer.createPatreonIcon('bv-donate-patreon-icon'),
Utilities.makeEl('span', {class: 'bv-donate-patreon-label', text: linkLabel}),
],
})
return this.createTabPanel(tabName, !!options.isFirst).append(
Utilities.makeEl('p', {class: 'bv-donate-intro', text: message}),
Utilities.makeEl('div', {class: 'bv-donate-actions', children: link}),
)
}
/**
* @param {string} title
* @return {HTMLElement}
*/
createTitle(title)
{
return Utilities.makeEl('label', {class: 'bv-title', text: title})
}
/**
* @return {HTMLElement}
*/
getSelectedSection()
{
return this._section
}
isSettingsPaneBeingResized()
{
return this._resizing
}
/**
* @param {HTMLInputElement} input
* @param {string} text
*/
insertIntoPatternInput(input, text)
{
let start = input.selectionStart ?? input.value.length
let end = input.selectionEnd ?? input.value.length
let next = input.value.slice(0, start) + text + input.value.slice(end)
input.value = next
input.dispatchEvent(new Event('input', {bubbles: true}))
input.focus()
let caret = start + text.length
input.setSelectionRange(caret, caret)
input.classList.add('bv-pattern-insert-flash')
setTimeout(() => input.classList.remove('bv-pattern-insert-flash'), 300)
}
/**
* @param {{token?: string, insert?: string, label: string, title: string, separator?: boolean, folderOnly?: boolean}} chip
* @return {HTMLElement}
*/
createPatternTokenChip(chip)
{
let label = chip.label ?? chip.insert ?? ''
let insert = chip.insert ?? chip.label ?? ''
let button = Utilities.makeEl('button', {
class: 'bv-token-btn' +
(chip.separator ? ' bv-token-sep' : '') +
(chip.folderOnly ? ' bv-token-folder-sep' : ''),
text: label,
attrs: {
type: 'button',
title: chip.title,
...(insert ? {'data-insert': insert} : {}),
},
hidden: !!chip.folderOnly,
})
return button
}
/**
* @param {{label: string, chips: {token?: string, insert?: string, label: string, title: string}[]}[]} sections
* @param {{insert: string, label: string, title: string}[]} separators
* @return {HTMLElement}
*/
createSharedPatternTokenBuilder(sections, separators)
{
let builder = Utilities.makeEl('div', {class: 'bv-pattern-builder bv-pattern-builder-shared'})
/** @type {HTMLElement[]} */
let folderSeparatorButtons = []
for (let section of sections) {
let bar = Utilities.makeEl('div', {class: 'bv-token-bar'})
for (let chip of section.chips) {
bar.append(this.createPatternTokenChip(chip))
}
builder.append(Utilities.makeEl('div', {
class: 'bv-token-row',
children: [
Utilities.makeEl('span', {class: 'bv-token-row-label', text: section.label}),
bar,
],
}))
}
let joinBar = Utilities.makeEl('div', {class: 'bv-token-bar'})
for (let chip of separators) {
let button = this.createPatternTokenChip({...chip, separator: true, folderOnly: chip.insert === '/'})
if (chip.insert === '/') {
folderSeparatorButtons.push(button)
}
joinBar.append(button)
}
builder.append(Utilities.makeEl('div', {
class: 'bv-token-row',
children: [
Utilities.makeEl('span', {class: 'bv-token-row-label', text: 'Join'}),
joinBar,
],
}))
builder._bvFolderSeparatorButtons = folderSeparatorButtons
return builder
}
/**
* @param {HTMLInputElement} filenameInput
* @param {HTMLInputElement} subfolderInput
* @param {HTMLElement} builder
* @param {string} [eventNamespace]
* @return {{ok: boolean, reason?: string, filename?: boolean, subfolder?: boolean, builder?: boolean}}
*/
patchPatternTokenBuilder(filenameInput, subfolderInput, builder, eventNamespace = 'bvpattern')
{
if (!filenameInput || !subfolderInput || !builder) {
return {
ok: false,
reason: 'pattern-builder-missing',
filename: !!filenameInput,
subfolder: !!subfolderInput,
builder: !!builder,
}
}
let folderSeparatorButtons = builder._bvFolderSeparatorButtons ??
[...builder.querySelectorAll('.bv-token-folder-sep')]
let activeInput = filenameInput
let activate = (input) => {
activeInput = input
filenameInput.classList.toggle('bv-pattern-active', input === filenameInput)
subfolderInput.classList.toggle('bv-pattern-active', input === subfolderInput)
for (let button of folderSeparatorButtons) {
Utilities.setHidden(button, input !== subfolderInput)
}
}
builder._bvPatternAbort?.abort()
builder._bvPatternAbort = new AbortController()
let {signal} = builder._bvPatternAbort
filenameInput.addEventListener('focusin', () => activate(filenameInput), {signal})
subfolderInput.addEventListener('focusin', () => activate(subfolderInput), {signal})
activate(filenameInput)
builder.addEventListener('click', (event) => {
let button = event.target.closest('.bv-token-btn')
if (!button || !builder.contains(button)) {
return
}
event.preventDefault()
let buttonText = button.textContent.trim()
let insert = button.classList.contains('bv-token-sep')
? button.getAttribute('data-insert')
: buttonText
if (insert === null || insert === '') {
return
}
let target = activeInput ?? filenameInput
this.insertIntoPatternInput(target, insert)
button.classList.add('bv-token-flash')
setTimeout(() => button.classList.remove('bv-token-flash'), 180)
}, {signal})
return {ok: true}
}
/**
* @param {BrazenConfigurationManager} configurationManager
* @param {string[]} configKeys
* @return {{ok: boolean, stacked: string[]}}
*/
patchStackedPatternFieldGroups(configurationManager, configKeys)
{
let stacked = []
for (let key of configKeys) {
let group = configurationManager.getField(key)?.element?.closest('.bv-group')
if (!group) {
continue
}
group.classList.add('bv-pattern-group')
stacked.push(key)
}
return {ok: true, stacked}
}
/**
* @return {{ok: boolean, wrapped: number}}
*/
patchRangeInputLayout()
{
let wrapped = 0
for (let group of document.querySelectorAll('#bv-ui .bv-group.bv-range-group')) {
if (group.querySelector('.bv-range-inputs')) {
continue
}
let inputs = group.querySelectorAll(':scope > .bv-input.bv-text')
if (inputs.length !== 2) {
continue
}
let wrapper = document.createElement('div')
wrapper.className = 'bv-range-inputs'
group.insertBefore(wrapper, inputs[0])
wrapper.append(inputs[0], inputs[1])
wrapped++
}
return {ok: true, wrapped}
}
/**
* @param {{onConfirm: Function, onSkip?: Function, onOpenMedia?: Function}} options
* @return {HTMLElement}
*/
static createTagDiscoveryPanel(options = {})
{
let panel = BrazenViewLayer.createDockSlidePanel('bv-tag-discovery-panel', 'Tag Discovery')
panel.querySelector('.bv-dock-slide-panel-close')?.remove()
panel.querySelector('.bv-dock-slide-panel-body')?.replaceChildren(
Utilities.makeEl('p', {
class: 'bv-dock-list-intro',
text: 'New tags were found. Review actions, then confirm to continue.',
}),
Utilities.makeEl('div', {class: 'bv-dock-list-groups'}),
Utilities.makeEl('details', {
class: 'bv-dock-list-similar',
children: [
Utilities.makeEl('summary', {
attrs: {'data-bv-similar-label': 'Similar tags'},
children: Utilities.makeEl('h3', {
class: 'bv-dock-list-section-title',
text: 'Similar tags',
}),
}),
Utilities.makeEl('div', {
class: 'bv-dock-list-similar-body',
children: Utilities.makeEl('p', {class: 'bv-dock-list-similar-status', text: 'Loading…'}),
}),
],
}),
Utilities.makeEl('div', {
class: 'bv-dock-list-footer bv-dock-list-footer-row',
children: [
Utilities.makeEl('button', {
class: 'bv-button bv-dock-list-footer-secondary bv-tag-discovery-skip',
text: 'Skip',
attrs: {type: 'button'},
on: {click: () => options.onSkip?.()},
}),
Utilities.makeEl('button', {
class: 'bv-button bv-dock-list-footer-secondary bv-tag-discovery-open-media',
text: 'Open media',
attrs: {type: 'button'},
on: {click: () => options.onOpenMedia?.()},
}),
Utilities.makeEl('button', {
class: 'bv-button bv-tag-discovery-confirm',
text: 'Confirm',
attrs: {type: 'button'},
on: {click: () => options.onConfirm?.()},
}),
],
}),
)
return panel
}
/**
* Rate-limit human verification panel (processor tab). Does not auto-close on mouseleave.
*
* @param {{
* id?: string,
* onConfirm?: Function,
* onConfirmAndClose?: Function,
* onReopen?: Function,
* title?: string,
* message?: string,
* confirmLabel?: string,
* confirmAndCloseLabel?: string,
* reopenLabel?: string,
* showReopen?: boolean,
* }} [options] `showReopen: false` builds a standalone reload pane (no open-challenge control).
* Pass `confirmAndCloseLabel` + `onConfirmAndClose` for an optional secondary confirm
* (e.g. queue challenge tab: Done — resume — close tab).
* Pass distinct `id`s for per-lane panels (e.g. `bv-human-interaction-panel-resolution`).
* @return {HTMLElement}
*/
static createHumanInteractionPanel(options = {})
{
let showReopen = options.showReopen !== false
let showConfirmAndClose = typeof options.onConfirmAndClose === 'function' ||
!!options.confirmAndCloseLabel
let panel = BrazenViewLayer.createDockSlidePanel(
options.id ?? 'bv-human-interaction-panel',
options.title ?? 'Verification required',
)
panel.querySelector('.bv-dock-slide-panel-close')?.remove()
// Open challenge first (primary); Done — resume second (secondary) so focus stays until ready.
// Optional confirm-and-close is always a secondary confirm after Done — resume.
/** @type {HTMLElement[]} */
let footerButtons = []
if (showReopen) {
footerButtons.push(
Utilities.makeEl('button', {
class: 'bv-button bv-human-interaction-reopen',
text: options.reopenLabel ?? 'Open Cloudflare Challenge',
attrs: {type: 'button'},
on: {click: () => options.onReopen?.()},
}),
)
}
footerButtons.push(
Utilities.makeEl('button', {
class: 'bv-button bv-human-interaction-confirm' +
(showReopen ? ' bv-dock-list-footer-secondary' : ''),
text: options.confirmLabel ?? 'Done — resume',
attrs: {type: 'button'},
on: {click: () => options.onConfirm?.()},
}),
)
if (showConfirmAndClose) {
footerButtons.push(
Utilities.makeEl('button', {
class: 'bv-button bv-human-interaction-confirm-close bv-dock-list-footer-secondary',
text: options.confirmAndCloseLabel ?? 'Done — resume — close tab',
attrs: {type: 'button'},
on: {click: () => options.onConfirmAndClose?.()},
}),
)
}
panel.querySelector('.bv-dock-slide-panel-body')?.replaceChildren(
Utilities.makeEl('p', {
class: 'bv-dock-list-intro',
text: options.message ??
'Click Open Cloudflare Challenge when ready, complete it, then Done — resume here.',
}),
Utilities.makeEl('div', {class: 'bv-dock-list-footer', children: footerButtons}),
)
return panel
}
/**
* @param {HTMLElement} panel
* @param {{
* title?: string,
* message?: string,
* confirmLabel?: string,
* confirmAndCloseLabel?: string,
* reopenLabel?: string,
* }} content
*/
static updateHumanInteractionPanelContent(panel, content = {})
{
if (content.title) {
panel.querySelector('.bv-dock-slide-panel-title').textContent = content.title
}
if (content.message) {
panel.querySelector('.bv-dock-list-intro').textContent = content.message
}
if (content.confirmLabel) {
panel.querySelector('.bv-human-interaction-confirm').textContent = content.confirmLabel
}
if (content.confirmAndCloseLabel) {
let closeBtn = panel.querySelector('.bv-human-interaction-confirm-close')
if (closeBtn) {
closeBtn.textContent = content.confirmAndCloseLabel
}
}
if (content.reopenLabel) {
panel.querySelector('.bv-human-interaction-reopen').textContent = content.reopenLabel
}
}
/**
* Reusable timed action button: thin end-to-end countdown bar; hover pauses the timer.
* Returns a container with `_bvTimedButtonApi = { cancel, start }`.
*
* @param {{
* label?: string,
* durationMs?: number,
* onClick?: Function,
* onTimeout?: Function,
* autoStart?: boolean,
* }} [options]
* @return {HTMLElement}
*/
static createTimedButton(options = {})
{
let durationMs = Math.max(1, options.durationMs ?? 5000)
let remaining = durationMs
let paused = false
let settled = false
let lastTick = null
let rafId = null
let fill = Utilities.makeEl('div', {class: 'bv-timed-button-bar-fill'})
fill.style.width = '100%'
let bar = Utilities.makeEl('div', {
class: 'bv-timed-button-bar',
children: [fill],
})
let button = Utilities.makeEl('button', {
class: 'bv-button bv-timed-button',
text: options.label ?? 'Continue',
attrs: {type: 'button'},
})
let container = Utilities.makeEl('div', {
class: 'bv-timed-button-container',
children: [button, bar],
})
let stopRaf = () => {
if (rafId != null) {
cancelAnimationFrame(rafId)
rafId = null
}
lastTick = null
}
let settle = (kind) => {
if (settled) {
return
}
settled = true
stopRaf()
if (kind === 'click') {
options.onClick?.()
} else {
options.onTimeout?.()
}
}
let tick = (now) => {
if (settled) {
return
}
if (paused) {
lastTick = now
rafId = requestAnimationFrame(tick)
return
}
if (lastTick == null) {
lastTick = now
}
remaining = Math.max(0, remaining - (now - lastTick))
lastTick = now
fill.style.width = ((remaining / durationMs) * 100) + '%'
if (remaining <= 0) {
settle('timeout')
return
}
rafId = requestAnimationFrame(tick)
}
button.addEventListener('click', () => settle('click'))
container.addEventListener('mouseenter', () => {
paused = true
})
container.addEventListener('mouseleave', () => {
paused = false
})
let api = {
cancel: () => {
settled = true
stopRaf()
},
start: () => {
if (settled || rafId != null) {
return
}
lastTick = null
rafId = requestAnimationFrame(tick)
},
}
container._bvTimedButtonApi = api
if (options.autoStart !== false) {
api.start()
}
return container
}
/**
* Navigation-interrupted download decision panel (leader tab). Embeds a timed button:
* click → complete + pause; timeout → relaunch.
*
* @param {{
* title?: string,
* message?: string,
* tip?: string,
* timedLabel?: string,
* durationMs?: number,
* onComplete?: Function,
* onTimeout?: Function,
* autoStart?: boolean,
* }} [options]
* @return {HTMLElement}
*/
static createDownloadInterruptionPanel(options = {})
{
let panel = BrazenViewLayer.createDockSlidePanel(
'bv-download-interruption-panel',
options.title ?? 'Download interrupted',
)
panel.querySelector('.bv-dock-slide-panel-close')?.remove()
let timed = BrazenViewLayer.createTimedButton({
label: options.timedLabel ?? 'Complete download — pause queue',
durationMs: options.durationMs ?? 5000,
autoStart: options.autoStart !== false,
onClick: () => options.onComplete?.(),
onTimeout: () => options.onTimeout?.(),
})
// Order: why the popup appeared → timed button → leader-tab tip.
panel.querySelector('.bv-dock-slide-panel-body')?.replaceChildren(
Utilities.makeEl('p', {
class: 'bv-dock-list-intro',
text: options.message ??
'A download was in progress when this page loaded. Click to mark it complete and pause the queue, or wait to relaunch it.',
}),
Utilities.makeEl('div', {
class: 'bv-dock-list-footer',
children: [timed],
}),
Utilities.makeEl('p', {
class: 'bv-dock-list-tip bv-download-interruption-tip',
text: options.tip ??
'Tip: keep a separate lightweight homepage open as the download leader tab, and browse or pick media in other tabs so large downloads are not interrupted.',
}),
)
panel._bvTimedButtonApi = timed._bvTimedButtonApi
return panel
}
/**
* Official-style Patreon mark (circle + stem).
* @param {string} [iconClass='bv-donate-patreon-icon']
* @return {SVGSVGElement}
*/
static createPatreonIcon(iconClass = 'bv-donate-patreon-icon')
{
let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('class', iconClass)
svg.setAttribute('aria-hidden', 'true')
let circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
circle.setAttribute('cx', '14.4')
circle.setAttribute('cy', '9.6')
circle.setAttribute('r', '5.4')
circle.setAttribute('fill', 'currentColor')
let bar = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
bar.setAttribute('x', '3.2')
bar.setAttribute('y', '3.2')
bar.setAttribute('width', '4')
bar.setAttribute('height', '17.6')
bar.setAttribute('rx', '1.2')
bar.setAttribute('fill', 'currentColor')
svg.append(circle, bar)
return svg
}
/**
* Merge {@link tagTypes} into panel render options (group labels, row classes).
* Tag name colors are consumer-owned — set `color` per type and call {@link applyTagDiscoveryTypeColors}.
*
* @param {{tagTypes?: Record<string, {label?: string, rowClass?: string, color?: string}>, getGroupKey?: Function, getGroupLabel?: Function, getRowClass?: Function, groupOrder?: string[]}} config
* @return {typeof config}
*/
static prepareTagDiscoveryPanelOptions(config = {})
{
let options = {...config}
let tagTypes = config.tagTypes
if (!tagTypes) {
return options
}
if (!options.getGroupLabel) {
options.getGroupLabel = (key) => tagTypes[key]?.label ?? key
}
if (!options.getRowClass) {
options.getRowClass = (tag) => {
let key = options.getGroupKey?.(tag) ?? tag.type ?? 'unknown'
return tagTypes[key]?.rowClass ?? tagTypes.unknown?.rowClass ?? ''
}
}
return options
}
/**
* Inject tag-name colors for the discovery panel from consumer {@link tagTypes} config.
* @param {Record<string, {label?: string, rowClass?: string, color?: string}>} tagTypes
*/
static applyTagDiscoveryTypeColors(tagTypes)
{
if (!tagTypes) {
return
}
let styleId = 'bv-tag-discovery-type-colors'
let css = Object.values(tagTypes)
.filter((type) => type.rowClass && type.color)
.map((type) => `#bv-tag-discovery-panel .${type.rowClass} .bv-dock-list-name{color:${type.color};}`)
.join('')
if (!css) {
return
}
let style = document.getElementById(styleId)
if (!style) {
style = document.createElement('style')
style.id = styleId
document.head.append(style)
}
style.textContent = css
}
/**
* @param {HTMLElement} panel
* @param {Array<{name: string, type: string|null, count?: number|null}>} tags
* @param {function(HTMLElement, {name: string, type: string|null, count?: number|null}, HTMLElement): void} renderActions
* @param {{tagTypes?: Record<string, {label?: string, rowClass?: string, color?: string}>, groupOrder?: string[], getGroupKey?: function({name: string, type: string|null}): string, getGroupLabel?: function(string): string, getRowClass?: function({name: string, type: string|null}): string, knownTags?: Array<{name: string, type: string|null, count?: number|null}>, unknownSectionLabel?: string, knownSectionLabel?: string, onTagNameClick?: function({name: string, type: string|null, count?: number|null}): void, similarSectionLabel?: string, resetSimilar?: boolean, preserveSimilarOpen?: boolean, clearSimilarCount?: boolean, similarOpen?: boolean, similarShowCount?: boolean, intro?: string}} [options]
*/
static renderTagDiscoveryPanelContent(panel, tags, renderActions, options = {})
{
options = BrazenViewLayer.prepareTagDiscoveryPanelOptions(options)
if (options.tagTypes) {
BrazenViewLayer.applyTagDiscoveryTypeColors(options.tagTypes)
}
if (options.intro != null && options.intro !== '') {
panel.querySelector('.bv-dock-list-intro').textContent = options.intro
}
let groups = panel.querySelector('.bv-dock-list-groups')
groups?.replaceChildren()
let unknownTags = Array.isArray(tags) ? tags : []
let knownTags = Array.isArray(options.knownTags) ? options.knownTags : []
if (options.resetSimilar !== false) {
let resetOptions = {
label: options.similarSectionLabel,
status: 'Loading…',
preserveOpen: options.preserveSimilarOpen === true,
similarOpen: options.similarOpen === true,
}
if (options.clearSimilarCount !== false || options.similarShowCount === false) {
resetOptions.count = null
}
BrazenViewLayer.resetTagDiscoverySimilarSection(panel, resetOptions)
}
BrazenViewLayer._bindTagDiscoveryNameClicks(panel, options.onTagNameClick)
if (!groups || (!unknownTags.length && !knownTags.length)) {
return
}
let getGroupKey = options.getGroupKey ?? ((tag) => tag.type ?? 'unknown')
let getGroupLabel = options.getGroupLabel ?? ((key) => key)
let getRowClass = options.getRowClass ?? (() => '')
let groupOrder = options.groupOrder ?? null
let nameClickable = typeof options.onTagNameClick === 'function'
let appendGroupedTags = (sectionLabel, sectionTags) => {
if (!sectionTags?.length) {
return
}
if (sectionLabel) {
groups.append(Utilities.makeEl('h3', {class: 'bv-dock-list-section-title', text: sectionLabel}))
}
let buckets = new Map()
for (let tag of sectionTags) {
let key = getGroupKey(tag)
if (!buckets.has(key)) {
buckets.set(key, [])
}
buckets.get(key).push(tag)
}
let appendGroup = (key, groupTags) => {
if (!groupTags?.length) {
return
}
let list = Utilities.makeEl('ul', {class: 'bv-dock-list'})
for (let tag of groupTags) {
list.append(BrazenViewLayer._createTagDiscoveryRow(tag, renderActions, getRowClass, nameClickable))
}
groups.append(Utilities.makeEl('section', {
class: 'bv-dock-list-group',
children: [
Utilities.makeEl('h3', {text: getGroupLabel(key)}),
list,
],
}))
}
let order = groupOrder ?? [...buckets.keys()]
let seen = new Set()
for (let key of order) {
seen.add(key)
appendGroup(key, buckets.get(key))
buckets.delete(key)
}
for (let [key, groupTags] of buckets) {
if (seen.has(key)) {
continue
}
appendGroup(key, groupTags)
}
}
let showBothSections = unknownTags.length > 0 && knownTags.length > 0
appendGroupedTags(
showBothSections ? (options.unknownSectionLabel ?? 'New tags') : null,
unknownTags,
)
appendGroupedTags(
showBothSections || (!unknownTags.length && knownTags.length) ?
(options.knownSectionLabel ?? 'Known tags') :
null,
knownTags,
)
}
/**
* Reset the Similar tags block to status text (clears prior rows).
*
* @param {HTMLElement} panel
* @param {{label?: string, status?: string, count?: number|null, preserveOpen?: boolean, similarOpen?: boolean, open?: boolean}} [options]
* Pass `count` to set/clear the summary count; omit it to keep the prior count.
* `preserveOpen: true` keeps the accordion expanded/collapsed state.
* `similarOpen` / `open: true` forces expanded (ignored-pin review starts open).
*/
static resetTagDiscoverySimilarSection(panel, options = {})
{
let similar = panel.querySelector('.bv-dock-list-similar')
if (!similar) {
similar = Utilities.makeEl('details', {
class: 'bv-dock-list-similar',
children: [
Utilities.makeEl('summary', {
attrs: {'data-bv-similar-label': options.label ?? 'Similar tags'},
children: Utilities.makeEl('h3', {class: 'bv-dock-list-section-title'}),
}),
Utilities.makeEl('div', {class: 'bv-dock-list-similar-body'}),
],
})
let groups = panel.querySelector('.bv-dock-list-groups')
if (groups) {
groups.insertAdjacentElement('afterend', similar)
} else {
panel.querySelector('.bv-dock-slide-panel-body')?.append(similar)
}
}
if (options.similarOpen === true || options.open === true) {
similar.open = true
} else if (options.preserveOpen !== true) {
similar.open = false
}
let summaryOptions = {label: options.label ?? 'Similar tags'}
if (Object.prototype.hasOwnProperty.call(options, 'count')) {
summaryOptions.count = options.count
}
BrazenViewLayer._setTagDiscoverySimilarSummary(similar, summaryOptions)
similar.querySelector('.bv-dock-list-similar-body')?.replaceChildren(
Utilities.makeEl('p', {class: 'bv-dock-list-similar-status', text: options.status ?? 'Loading…'}),
)
}
/**
* Set status text inside the Similar tags body (loading / empty).
*
* @param {HTMLElement} panel
* @param {string} status
* @param {{count?: number|null}} [options]
*/
static setTagDiscoverySimilarStatus(panel, status, options = {})
{
let body = panel.querySelector('.bv-dock-list-similar-body')
if (!body) {
return
}
if (Object.prototype.hasOwnProperty.call(options, 'count')) {
BrazenViewLayer._setTagDiscoverySimilarSummary(panel, {count: options.count})
}
body.replaceChildren(Utilities.makeEl('p', {class: 'bv-dock-list-similar-status', text: status}))
}
/**
* Fill Similar tags body without re-rendering New/Known groups.
*
* @param {HTMLElement} panel
* @param {Array<{name: string, type: string|null, count?: number|null}>} tags
* @param {function(HTMLElement, {name: string, type: string|null, count?: number|null}, HTMLElement): void} renderActions
* @param {{tagTypes?: Record<string, {label?: string, rowClass?: string, color?: string}>, getGroupKey?: function({name: string, type: string|null}): string, getRowClass?: function({name: string, type: string|null}): string, onTagNameClick?: function({name: string, type: string|null, count?: number|null}): void, emptyStatus?: string, similarOpen?: boolean, similarShowCount?: boolean}} [options]
*/
static renderTagDiscoverySimilarContent(panel, tags, renderActions, options = {})
{
options = BrazenViewLayer.prepareTagDiscoveryPanelOptions(options)
if (options.tagTypes) {
BrazenViewLayer.applyTagDiscoveryTypeColors(options.tagTypes)
}
BrazenViewLayer._bindTagDiscoveryNameClicks(panel, options.onTagNameClick)
let similar = panel.querySelector('.bv-dock-list-similar')
let body = similar?.querySelector('.bv-dock-list-similar-body')
if (!body) {
return
}
// Replacing body nodes mid-click can make the browser toggle <details>; restore open.
let wasOpen = !!similar.open
let similarTags = Array.isArray(tags) ? tags : []
if (options.similarShowCount === false) {
BrazenViewLayer._setTagDiscoverySimilarSummary(panel, {count: null})
} else {
BrazenViewLayer._setTagDiscoverySimilarSummary(panel, {count: similarTags.length})
}
if (!similarTags.length) {
body.replaceChildren(
Utilities.makeEl('p', {class: 'bv-dock-list-similar-status', text: options.emptyStatus ?? 'No similar tags.'}),
)
similar.open = options.similarOpen === true ? true : wasOpen
return
}
let getRowClass = options.getRowClass ?? (() => '')
let nameClickable = typeof options.onTagNameClick === 'function'
let list = Utilities.makeEl('ul', {class: 'bv-dock-list'})
for (let tag of similarTags) {
list.append(BrazenViewLayer._createTagDiscoveryRow(tag, renderActions, getRowClass, nameClickable))
}
body.replaceChildren(list)
similar.open = options.similarOpen === true ? true : wasOpen
}
/**
* @param {string|null|undefined} baseLabel
* @param {number|null|undefined} count
* @return {string}
* @private
*/
static _formatTagDiscoverySimilarLabel(baseLabel, count)
{
let label = baseLabel || 'Similar tags'
if (count == null) {
return label
}
return `${label} (${count})`
}
/**
* @param {HTMLElement} panelOrSimilar
* @param {{label?: string, count?: number|null}} [options]
* @private
*/
static _setTagDiscoverySimilarSummary(panelOrSimilar, options = {})
{
let root = panelOrSimilar
let summary = root.matches?.('details.bv-dock-list-similar')
? root.querySelector(':scope > summary')
: root.querySelector('.bv-dock-list-similar > summary')
if (!summary) {
return
}
let base = options.label
if (base != null && base !== '') {
summary.setAttribute('data-bv-similar-label', base)
} else {
base = summary.getAttribute('data-bv-similar-label') || 'Similar tags'
}
if (Object.prototype.hasOwnProperty.call(options, 'count')) {
if (options.count == null) {
summary.removeAttribute('data-bv-similar-count')
} else {
summary.setAttribute('data-bv-similar-count', String(options.count))
}
}
let countAttr = summary.getAttribute('data-bv-similar-count')
let count = countAttr != null && countAttr !== '' ? Number(countAttr) : null
let title = summary.querySelector(':scope > .bv-dock-list-section-title')
if (!title) {
title = Utilities.makeEl('h3', {class: 'bv-dock-list-section-title'})
summary.replaceChildren(title)
}
title.textContent = BrazenViewLayer._formatTagDiscoverySimilarLabel(base, count)
}
/**
* Single delegated click handler for discovery tag names (avoids per-row listeners).
*
* @param {HTMLElement} panel
* @param {function({name: string, type: string|null, count?: number|null}): void|null|undefined} onTagNameClick
* @private
*/
static _bindTagDiscoveryNameClicks(panel, onTagNameClick)
{
if (panel._bvTagDiscoveryNameClick) {
panel.removeEventListener('click', panel._bvTagDiscoveryNameClick)
delete panel._bvTagDiscoveryNameClick
}
if (typeof onTagNameClick !== 'function') {
return
}
panel._bvTagDiscoveryNameClick = (event) => {
let el = event.target.closest('.bv-dock-list-name-link')
if (!el || !panel.contains(el)) {
return
}
event.preventDefault()
event.stopPropagation()
let countAttr = el.getAttribute('data-tag-count')
onTagNameClick({
name: el.getAttribute('data-tag-name') ?? '',
type: el.getAttribute('data-tag-type') || null,
count: countAttr != null && countAttr !== '' ? Number(countAttr) : null,
})
}
panel.addEventListener('click', panel._bvTagDiscoveryNameClick)
}
/**
* @param {{name: string, type: string|null, count?: number|null}} tag
* @param {function(HTMLElement, {name: string, type: string|null, count?: number|null}, HTMLElement): void} renderActions
* @param {function({name: string, type: string|null}): string} getRowClass
* @param {boolean} nameClickable
* @return {HTMLElement}
* @private
*/
static _createTagDiscoveryRow(tag, renderActions, getRowClass, nameClickable)
{
let actions = Utilities.makeEl('span', {class: 'bv-tag-actions'})
let row = Utilities.makeEl('li', {class: 'bv-dock-list-row'})
let rowClass = getRowClass?.(tag) ?? ''
if (rowClass) {
row.classList.add(...rowClass.split(/\s+/).filter(Boolean))
}
let nameAttrs = {}
if (nameClickable) {
nameAttrs.class = 'bv-dock-list-name bv-dock-list-name-link'
nameAttrs['data-tag-name'] = tag.name ?? ''
nameAttrs['data-tag-type'] = tag.type ?? ''
if (tag.count != null && tag.count !== '') {
nameAttrs['data-tag-count'] = String(tag.count)
}
} else {
nameAttrs.class = 'bv-dock-list-name'
}
row.append(Utilities.makeEl('span', {attrs: nameAttrs, text: tag.name}))
if (tag.count != null && tag.count !== '') {
row.append(Utilities.makeEl('span', {class: 'bv-dock-list-meta', text: String(tag.count)}))
}
row.append(actions)
renderActions?.(row, tag, actions)
return row
}
/**
* @param {HTMLElement} row
* @param {{name: string, type: string|null}} tag
* @param {HTMLElement} actionsElement
* @param {{renderButtons: function(HTMLElement, {name: string, type: string|null}, HTMLElement, object): void, getRowState?: function({name: string, type: string|null}): {muted?: boolean, hidden?: boolean}}} config
*/
static renderTagActions(row, tag, actionsElement, config)
{
actionsElement.replaceChildren()
config.renderButtons(row, tag, actionsElement, config.helpers ?? {})
let state = config.getRowState?.(tag) ?? {}
row.classList.toggle('bv-tag-muted', !!state.muted)
row.classList.toggle('bv-tag-hidden', !!state.hidden)
}
/**
* @param {'idle'|'linkingSource'|'isSubject'|'linkingTarget'} state
* @param {string} [iconClass='bv-tag-action-icon']
* @return {SVGSVGElement}
*/
static createTagSubstitutionIcon(state, iconClass = 'bv-tag-action-icon')
{
let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('class', iconClass)
svg.setAttribute('aria-hidden', 'true')
let appendSwapArrow = (centerY, direction, solid) => {
if (solid) {
if (direction === 'right') {
let shaft = document.createElementNS('http://www.w3.org/2000/svg', 'path')
shaft.setAttribute('d', `M5 ${centerY - 1.25} H14 V${centerY + 1.25} H5 Z`)
shaft.setAttribute('fill', 'currentColor')
let head = document.createElementNS('http://www.w3.org/2000/svg', 'path')
head.setAttribute('d', `M14 ${centerY - 3.5} L19 ${centerY} L14 ${centerY + 3.5} Z`)
head.setAttribute('fill', 'currentColor')
svg.append(shaft, head)
return
}
let shaft = document.createElementNS('http://www.w3.org/2000/svg', 'path')
shaft.setAttribute('d', `M10 ${centerY - 1.25} H19 V${centerY + 1.25} H10 Z`)
shaft.setAttribute('fill', 'currentColor')
let head = document.createElementNS('http://www.w3.org/2000/svg', 'path')
head.setAttribute('d', `M10 ${centerY - 3.5} L5 ${centerY} L10 ${centerY + 3.5} Z`)
head.setAttribute('fill', 'currentColor')
svg.append(shaft, head)
return
}
let shaft = document.createElementNS('http://www.w3.org/2000/svg', 'line')
shaft.setAttribute('stroke', 'currentColor')
shaft.setAttribute('stroke-width', '2.2')
shaft.setAttribute('stroke-linecap', 'round')
let head = document.createElementNS('http://www.w3.org/2000/svg', 'polyline')
head.setAttribute('fill', 'none')
head.setAttribute('stroke', 'currentColor')
head.setAttribute('stroke-width', '2.2')
head.setAttribute('stroke-linecap', 'round')
head.setAttribute('stroke-linejoin', 'round')
if (direction === 'right') {
shaft.setAttribute('x1', '5')
shaft.setAttribute('y1', String(centerY))
shaft.setAttribute('x2', '15')
shaft.setAttribute('y2', String(centerY))
head.setAttribute('points', `15,${centerY - 3.5} 19,${centerY} 15,${centerY + 3.5}`)
} else {
shaft.setAttribute('x1', '19')
shaft.setAttribute('y1', String(centerY))
shaft.setAttribute('x2', '9')
shaft.setAttribute('y2', String(centerY))
head.setAttribute('points', `9,${centerY - 3.5} 5,${centerY} 9,${centerY + 3.5}`)
}
svg.append(shaft, head)
}
if (state === 'idle') {
appendSwapArrow(9, 'right', false)
appendSwapArrow(15, 'left', false)
} else if (state === 'linkingSource') {
appendSwapArrow(9, 'right', true)
appendSwapArrow(15, 'left', true)
} else if (state === 'linkingTarget') {
let check = document.createElementNS('http://www.w3.org/2000/svg', 'polyline')
check.setAttribute('fill', 'none')
check.setAttribute('stroke', 'currentColor')
check.setAttribute('stroke-width', '2.6')
check.setAttribute('stroke-linecap', 'round')
check.setAttribute('stroke-linejoin', 'round')
check.setAttribute('points', '5,13 10,18 19,7')
svg.append(check)
} else if (state === 'isSubject') {
appendSwapArrow(9, 'right', false)
appendSwapArrow(15, 'left', false)
let slash = document.createElementNS('http://www.w3.org/2000/svg', 'line')
slash.setAttribute('x1', '4')
slash.setAttribute('y1', '20')
slash.setAttribute('x2', '20')
slash.setAttribute('y2', '4')
slash.setAttribute('stroke', 'currentColor')
slash.setAttribute('stroke-width', '2')
slash.setAttribute('stroke-linecap', 'round')
svg.append(slash)
}
return svg
}
/**
* @param {{state: 'idle'|'linkingSource'|'isSubject'|'linkingTarget', onClick: function(MouseEvent): void, className?: string, iconClass?: string}} options
* @return {HTMLElement}
*/
static createTagSubstitutionActionButton(options)
{
let titles = {
idle: 'Substitute this tag with another',
linkingSource: 'Cancel substitution link',
isSubject: 'Remove tag substitution',
linkingTarget: 'Substitute the linked tag with this one',
}
let state = options.state ?? 'idle'
return Utilities.makeEl('button', {
class: options.className ?? 'bv-tag-action-btn',
attrs: {
type: 'button',
title: titles[state] ?? titles.idle,
},
children: BrazenViewLayer.createTagSubstitutionIcon(state, options.iconClass ?? 'bv-tag-action-icon'),
on: {
click: (event) => {
event.preventDefault()
event.stopPropagation()
options.onClick?.(event)
},
},
})
}
/**
* Plus (+) icon for add actions.
* @param {string} [iconClass='bv-substitution-add-icon']
* @return {SVGSVGElement}
*/
static createPlusIcon(iconClass = 'bv-substitution-add-icon')
{
let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('class', iconClass)
svg.setAttribute('aria-hidden', 'true')
let path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', 'M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6V5z')
path.setAttribute('fill', 'currentColor')
svg.append(path)
return svg
}
/**
* @param {boolean} filled
* @param {string} [iconClass='bv-tag-action-icon']
* @return {SVGSVGElement}
*/
static createTagBookmarkStarIcon(filled, iconClass = 'bv-tag-action-icon')
{
let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '22 18 204 188')
svg.setAttribute('class', iconClass)
svg.setAttribute('aria-hidden', 'true')
let path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', 'M128 26l27.8 56.3 62.2 9-45 43.9 10.6 61.9L128 175.4 64.4 197.1l10.6-61.9-45-43.9 62.2-9L128 26z')
path.setAttribute('stroke-linejoin', 'round')
path.setAttribute('stroke-linecap', 'round')
if (filled) {
path.setAttribute('fill', 'currentColor')
path.setAttribute('stroke', 'currentColor')
path.setAttribute('stroke-width', '14')
} else {
path.setAttribute('fill', 'none')
path.setAttribute('stroke', 'currentColor')
path.setAttribute('stroke-width', '16')
}
svg.append(path)
return svg
}
/**
* @param {boolean} active
* @param {string} [iconClass='bv-tag-action-icon']
* @return {SVGSVGElement}
*/
static createTagIgnoreIcon(active, iconClass = 'bv-tag-action-icon')
{
let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 3 19 17')
svg.setAttribute('class', iconClass)
svg.setAttribute('aria-hidden', 'true')
let label = document.createElementNS('http://www.w3.org/2000/svg', 'text')
label.setAttribute('fill', 'currentColor')
label.setAttribute('font-family', 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif')
label.setAttribute('font-weight', '700')
label.setAttribute('text-rendering', 'geometricPrecision')
let cap = document.createElementNS('http://www.w3.org/2000/svg', 'tspan')
cap.setAttribute('x', '0.5')
cap.setAttribute('y', '17.5')
cap.setAttribute('font-size', '16')
cap.textContent = 'A'
let lower = document.createElementNS('http://www.w3.org/2000/svg', 'tspan')
lower.setAttribute('x', '10.5')
lower.setAttribute('y', '18.5')
lower.setAttribute('font-size', '12.5')
lower.textContent = 'a'
label.append(cap, lower)
svg.append(label)
if (active) {
let slash = document.createElementNS('http://www.w3.org/2000/svg', 'path')
slash.setAttribute('d', 'M4 16L17.5 8')
slash.setAttribute('fill', 'none')
slash.setAttribute('stroke', 'currentColor')
slash.setAttribute('stroke-width', '2')
slash.setAttribute('stroke-linecap', 'round')
svg.append(slash)
}
return svg
}
/**
* @param {boolean} active
* @param {string} [iconClass='bv-tag-action-icon']
* @return {SVGSVGElement}
*/
static createTagBlacklistIcon(active, iconClass = 'bv-tag-action-icon')
{
let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 4 24 17')
svg.setAttribute('class', iconClass)
svg.setAttribute('aria-hidden', 'true')
let eye = document.createElementNS('http://www.w3.org/2000/svg', 'path')
eye.setAttribute('d', 'M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z')
eye.setAttribute('fill', 'none')
eye.setAttribute('stroke', 'currentColor')
eye.setAttribute('stroke-width', '1.5')
eye.setAttribute('stroke-linejoin', 'round')
let pupil = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
pupil.setAttribute('cx', '12')
pupil.setAttribute('cy', '12')
pupil.setAttribute('r', '2.5')
pupil.setAttribute('fill', 'none')
pupil.setAttribute('stroke', 'currentColor')
pupil.setAttribute('stroke-width', '1.5')
svg.append(eye, pupil)
if (active) {
let slash = document.createElementNS('http://www.w3.org/2000/svg', 'line')
slash.setAttribute('x1', '4')
slash.setAttribute('y1', '20')
slash.setAttribute('x2', '20')
slash.setAttribute('y2', '4')
slash.setAttribute('stroke', 'currentColor')
slash.setAttribute('stroke-width', '2')
slash.setAttribute('stroke-linecap', 'round')
svg.append(slash)
}
return svg
}
/**
* @param {boolean} active
* @param {string} [iconClass='bv-tag-action-icon']
* @return {SVGSVGElement}
*/
static createTagExploreIcon(active, iconClass = 'bv-tag-action-icon')
{
let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 4 24 17')
svg.setAttribute('class', iconClass)
svg.setAttribute('aria-hidden', 'true')
let ring = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
ring.setAttribute('cx', '12')
ring.setAttribute('cy', '12')
ring.setAttribute('r', '8')
ring.setAttribute('fill', 'none')
ring.setAttribute('stroke', 'currentColor')
ring.setAttribute('stroke-width', '1.5')
let needle = document.createElementNS('http://www.w3.org/2000/svg', 'path')
needle.setAttribute('d', 'M12 6.5L14.5 12L12 17.5L9.5 12Z')
needle.setAttribute('fill', 'none')
needle.setAttribute('stroke', 'currentColor')
needle.setAttribute('stroke-width', '1.5')
needle.setAttribute('stroke-linejoin', 'round')
svg.append(ring, needle)
if (active) {
let slash = document.createElementNS('http://www.w3.org/2000/svg', 'line')
slash.setAttribute('x1', '4')
slash.setAttribute('y1', '20')
slash.setAttribute('x2', '20')
slash.setAttribute('y2', '4')
slash.setAttribute('stroke', 'currentColor')
slash.setAttribute('stroke-width', '2')
slash.setAttribute('stroke-linecap', 'round')
svg.append(slash)
}
return svg
}
/**
* @param {{title: string, icon: Node, onClick: function(MouseEvent): void, className?: string}} options
* @return {HTMLElement}
*/
static createTagAttributeActionButton(options)
{
return Utilities.makeEl('button', {
class: options.className ?? 'bv-tag-action-btn',
attrs: {type: 'button', title: options.title ?? ''},
children: options.icon ?? null,
on: {
click: (event) => {
event.preventDefault()
event.stopPropagation()
options.onClick?.(event)
},
},
})
}
}