View layer for the Brazen userscripts framework
Ce script ne devrait pas être installé directement. C'est une librairie créée pour d'autres scripts. Elle doit être inclus avec la commande // @require https://update.sleazyfork.org/scripts/416104/1881858/Brazen%20Framework%20-%20View%20Layer.js
// ==UserScript==
// @name Brazen Framework - View Layer
// @namespace brazenvoid
// @version 3.10.1
// @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-3.10.1'
/**
* @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,.bv-group.bv-text-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}/* Shared dock-slide list chrome (tag discovery,compliance rules,human interaction) */ .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}/* Button dock */ .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)}`
)
GM_addStyle(
`.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|JQuery)[],
* }} BrazenBookmarksPanelOptions
*/
/**
* @typedef {{
* element: JQuery,
* 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 {JQuery} nodes
*/
static appendToBody(nodes)
{
$('body').append(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 '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)
{
return !panel.classList.contains('bv-dock-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)
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')
$('.bv-dock-main-ui-btn').toggleClass('bv-dock-btn-active', !!(mainPanel && BrazenViewLayer.isDockSlidePanelVisible(mainPanel)))
$('.bv-dock-compliance-rules-btn').toggleClass('bv-dock-btn-active', !!(compliancePanel && BrazenViewLayer.isDockSlidePanelVisible(compliancePanel)))
}
/**
* @param {JQuery} slot
* @param {boolean} visible
*/
static setDownloadManagerProgressSlotVisible(slot, visible)
{
slot.toggleClass('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 {JQuery}
*/
static createDownloadManagerProgressSlot()
{
return $('<div class="bv-dock-progress-panel">').attr(
'title',
'Download queue: primary bar and completed/total. Resolution queue: secondary bar (below on bottom dock; beside counters on left/right).'
).append(
$('<div class="bv-dock-progress-slot">').append([
$('<div class="bv-dock-progress-bar bv-dock-progress-download">').append($('<span>')),
$('<div class="bv-dock-progress-label-wrap">').append($('<span class="bv-dock-progress-label">')),
$('<div class="bv-dock-progress-bar bv-dock-progress-resolution">').append($('<span>')),
]),
)
}
/**
* @param {JQuery} 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').length > 0
// 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) => {
let ratio = total > 0 ? Math.min(1, Math.max(0, current / total)) : 0
if (fillVertical) {
fill.css({
width: '100%',
height: '100%',
transform: `scaleY(${ratio})`,
transformOrigin: 'bottom center',
})
} else {
fill.css({
width: '100%',
height: '100%',
transform: `scaleX(${ratio})`,
transformOrigin: 'left center',
})
}
}
setFill(slot.find('.bv-dock-progress-download > span'), download.current, download.total, vertical)
setFill(slot.find('.bv-dock-progress-resolution > span'), resolution.current, resolution.total, vertical)
slot.find('.bv-dock-progress-label').text(`${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 {JQuery}
* @private
*/
_section = null
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
/**
* @param {string} selectorPrefix
*/
constructor(selectorPrefix)
{
this._selectorGenerator = new SelectorGenerator(selectorPrefix)
this._selectorPrefix = selectorPrefix
}
// -------------------------------------------------------------------------
// Public class methods
// -------------------------------------------------------------------------
/**
* @param {JQuery[]} children
* @return {JQuery}
*/
createBottomSection(children)
{
return $('<div class="bv-bottom-section">').append(...children)
}
/**
* @return {JQuery}
*/
createBreakSeparator()
{
return $('<br class="bv-break"/>')
}
/**
* @return {JQuery}
*/
createContainer()
{
this._section = $('<section class="bv-section bv-font-primary">')
return this._section
}
/**
* @param {JQuery|JQuery[]} children
* @param {string} wrapperClasses
* @return {JQuery}
*/
createFormActions(children, wrapperClasses = '')
{
return $('<div class="bv-actions"/>').addClass(wrapperClasses).append(children)
}
/**
* @param {string} caption
* @param {JQuery.EventHandler} onClick
* @param {string} hoverHelp
* @return {JQuery}
*/
createFormButton(caption, hoverHelp, onClick)
{
return $('<button class="bv-button">').attr('title', hoverHelp).text(caption).on('click', onClick)
}
createFormCheckBoxesGroupSection(label, keyValuePairs, hoverHelp)
{
return this._createFormMultiInputSection(label, keyValuePairs, hoverHelp, 'checkbox', 'bv-checkboxes-group')
}
/**
* @return {JQuery}
*/
createFormGroup()
{
return $('<div class="bv-group"/>')
}
/**
* @param {string} id
* @param {Array} keyValuePairs
*
* @return {JQuery}
*/
createFormGroupDropdown(id, keyValuePairs)
{
let dropdown = $('<select>').attr('id', id).addClass('bv-input')
for (let i = 0; i < keyValuePairs.length; i++) {
dropdown.append($('<option>').attr('value', keyValuePairs[i][0]).text(keyValuePairs[i][1]).prop('selected', (i === 0)))
}
return dropdown
}
/**
* @param {string} type
*
* @return {JQuery}
*/
createFormGroupInput(type)
{
let input = $('<input class="bv-input">').attr('type', type)
switch (type) {
case 'number':
case 'text':
input.addClass('bv-text')
break
case 'radio':
case 'checkbox':
input.addClass('bv-checkbox-radio')
break
}
return input
}
/**
* @param {string} label
* @param {string} inputType
* @return {JQuery}
*/
createFormGroupLabel(label, inputType = '')
{
let labelFormGroup = $('<label class="bv-label">').text(label)
if (inputType !== '') {
switch (inputType) {
case 'number':
case 'text':
labelFormGroup.addClass('bv-text')
labelFormGroup.text(labelFormGroup.text() + ': ')
break
case 'radio':
case 'checkbox':
labelFormGroup.addClass('bv-checkbox-radio')
break
}
}
return labelFormGroup
}
/**
* @param {string} statisticType
* @return {JQuery}
*/
createFormGroupStatLabel(statisticType)
{
return $('<label class="bv-stat-label">').attr('id', this._selectorGenerator.getStatLabelSelector(statisticType)).text('0')
}
/**
* @param {string} label
* @param {string} inputType
* @param {string} hoverHelp
* @return {JQuery}
*/
createFormInputGroup(label, inputType, hoverHelp = '')
{
return this.createFormGroup().attr('title', hoverHelp).append([
this.createFormGroupLabel(label, inputType),
this.createFormGroupInput(inputType),
])
}
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 {JQuery}
* @private
*/
_createFormMultiInputSection(label, keyValuePairs, hoverHelp, inputType, sectionClass)
{
let section = this.createFormSection(label).addClass(sectionClass).attr('title', hoverHelp)
for (let i = 0; i < keyValuePairs.length; i++) {
let pair = keyValuePairs[i]
let input = this.createFormGroupInput(inputType).attr('data-value', pair[1])
if (inputType === 'radio') {
input.prop('checked', i === 0).on('change', (event) => {
$(event.currentTarget).parents('.' + sectionClass).first().find('input').each((index, element) => {
if (!element.isSameNode(event.currentTarget)) {
$(element).prop('checked', false)
}
})
})
}
section.append(
this.createFormGroup().append([
this.createFormGroupLabel(pair[0], inputType),
input,
]),
)
}
return section
}
/**
* @param {string} label
* @param {string} inputsType
* @param {number} minimum
* @param {number} maximum
* @param {string} hoverHelp
* @return {JQuery}
*/
createFormRangeInputGroup(label, inputsType, minimum, maximum, hoverHelp)
{
return this.createFormGroup().addClass('bv-range-group').attr('title', hoverHelp).append([
this.createFormGroupLabel(label, inputsType),
this.createFormGroupInput(inputsType).attr('min', minimum).attr('max', maximum),
this.createFormGroupInput(inputsType).attr('min', minimum).attr('max', maximum),
])
}
/**
* @param {string} title
* @return {JQuery}
*/
createFormSection(title = '')
{
return $('<div>').append($('<label class="bv-title">').text(title))
}
/**
* @param {string} label
* @param {int} rows
* @param {string} hoverHelp
* @return {JQuery}
*/
createFormTextAreaGroup(label, rows, hoverHelp = '')
{
return this.createFormGroup().attr('title', hoverHelp).addClass('bv-textarea-group').append([
this.createFormGroupLabel(label),
$('<textarea class="bv-input" spellcheck="false">').attr('rows', rows),
])
}
/**
* 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 {JQuery}
*/
createFormSubstitutionComposer(options = {})
{
let settings = {
subjectPlaceholder: 'Subject',
aliasPlaceholder: 'Alias',
addHelpText: 'Add substitution',
onAdd: () => {},
...options,
}
let subjectInput = this.createFormGroupInput('text').
attr('placeholder', settings.subjectPlaceholder).
attr('autocomplete', 'off').
attr('spellcheck', 'false')
let aliasInput = this.createFormGroupInput('text').
attr('placeholder', settings.aliasPlaceholder).
attr('autocomplete', 'off').
attr('spellcheck', 'false')
let submit = () => {
let subject = String(subjectInput.val() ?? '').trim()
let alias = String(aliasInput.val() ?? '').trim()
if (!subject || !alias) {
return
}
Utilities.callEventHandler(settings.onAdd, [subject, alias])
subjectInput.val('')
aliasInput.val('')
subjectInput.trigger('focus')
}
subjectInput.on('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault()
aliasInput.trigger('focus')
}
})
aliasInput.on('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault()
submit()
}
})
let addButton = $('<button type="button" class="bv-button bv-substitution-add">').
attr('title', settings.addHelpText).
append(BrazenViewLayer.createPlusIcon('bv-substitution-add-icon')).
on('click', (event) => {
event.preventDefault()
submit()
})
return $('<div class="bv-substitution-composer">').append([
subjectInput,
$('<span class="bv-substitution-arrow" 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: () => [],
...options,
}
let filterQuery = ''
let lastBookmarks = []
let listEl = $('<div class="bv-bookmark-list">')
let emptyEl = $('<div class="bv-bookmark-empty">').text(settings.emptyMessage)
let searchEl = $('<input type="search" class="bv-input bv-text bv-bookmark-search">').
attr('placeholder', settings.searchPlaceholder).
attr('title', settings.searchHelpText)
searchEl.on('input', () => {
filterQuery = String(searchEl.val() ?? '')
widget.render(lastBookmarks)
})
let sortButton = $('<button type="button" class="bv-bookmark-sort bv-button">').
text('⇅').
attr('title', settings.sortHelpText).
on('click', () => settings.onSort())
let toolbar = $('<div class="bv-bookmark-toolbar">').append(searchEl, sortButton)
let scrollEl = $('<div class="bv-bookmark-scroll">')
scrollEl.append(listEl)
let columnsEl = $('<div class="bv-bookmark-columns">')
columnsEl.append(toolbar, emptyEl, scrollEl)
let syncBookmarkColumnInset = () => {
let scrollNode = scrollEl[0]
if (!scrollNode) {
return
}
columnsEl[0].style.setProperty('--bv-bookmark-scroll-inset', `${scrollNode.offsetWidth - scrollNode.clientWidth}px`)
}
/** @type {ResizeObserver|null} */
let bookmarkResizeObserver = null
if (typeof ResizeObserver !== 'undefined') {
bookmarkResizeObserver = new ResizeObserver(() => syncBookmarkColumnInset())
bookmarkResizeObserver.observe(scrollEl[0])
}
/** @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 = $('<div class="bv-bookmarks-group">').append(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
listEl.empty()
if (lastBookmarks.length === 0) {
emptyEl.text(settings.emptyMessage).toggle(true)
} else if (visible.length === 0) {
emptyEl.text(settings.noMatchMessage).toggle(true)
} else {
emptyEl.toggle(false)
}
for (let bookmark of visible) {
let row = $('<div class="bv-bookmark-row">')
let main = $('<div class="bv-bookmark-main">')
let link = $('<button type="button" class="bv-bookmark-link">').text(bookmark.label).attr('title', bookmark.tags)
link.on('click', () => settings.onNavigate(bookmark.url))
main.append(link)
for (let action of settings.getRowActions(bookmark) ?? []) {
main.append(action)
}
let remove = $('<button type="button" class="bv-bookmark-remove">').attr('title', 'Remove bookmark').text('×')
remove.on('click', (event) => {
event.stopPropagation()
settings.onRemove(bookmark.entryId ?? bookmark.id)
})
row.append(main, remove)
listEl.append(row)
}
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()
{
return $('<div id="bv-resizer" title="Resize"><span id="bv-resizer-icon">↔</span></div>').
on('mousedown', (event) => {
event.preventDefault()
this._resizing = true
let resizeHorizontal = (event) => {
this._section[0].style.width = Math.max(150, event.clientX - this._section[0].getBoundingClientRect().left) + `px`
}
let stopResize = () => {
removeEventListener('mousemove', resizeHorizontal)
removeEventListener('mouseup', stopResize)
}
addEventListener('mousemove', resizeHorizontal)
addEventListener('mouseup', stopResize)
}).
on('mouseup', (event) => {
this._resizing = false
})
}
createModal(title, bodyNodes)
{
let body = $('<div class="bv-modal-body">')
let nodes = Array.isArray(bodyNodes) ? bodyNodes : [bodyNodes]
for (let node of nodes) {
body.append(node instanceof $ ? node : $(node))
}
let dialog = $('<div class="bv-modal-dialog">').append([
$('<div class="bv-modal-header">').append([
$('<h2 class="bv-modal-title">').text(title),
$('<button type="button" class="bv-modal-close bv-button" aria-label="Close">').text('×'),
]),
body,
])
return $('<div class="bv-modal-overlay">').append(dialog)
}
/**
* @param {JQuery} modal
*/
showModal(modal)
{
let $modal = $(modal)
let hide = () => this.hideModal($modal)
$modal.on('click', (event) => {
if (event.target === $modal[0]) {
hide()
}
})
$modal.find('.bv-modal-close').on('click', hide)
$(document).on('keydown.bvModal', (event) => {
if (event.key === 'Escape') {
hide()
}
})
BrazenViewLayer.appendToBody($modal)
}
/**
* @param {JQuery} modal
*/
hideModal(modal)
{
$(document).off('keydown.bvModal')
$(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 finish = (value) => {
if (settled) {
return
}
settled = true
$(document).off('keydown.bvModalConfirm')
overlay.remove()
resolve(value)
}
let overlay = $('<div class="bv-modal-overlay">')
let dialog = $('<div class="bv-modal-dialog">')
let header = $('<div class="bv-modal-header">').append([
$('<h2 class="bv-modal-title">').text(options.title ?? 'Confirm'),
$('<button type="button" class="bv-modal-close bv-button" aria-label="Close">').text('×'),
])
let actionButtons = [
$('<button type="button" class="bv-button">').text(options.confirmLabel ?? 'OK').on('click', () => finish(true)),
]
if (options.showCancel !== false) {
actionButtons.push(
$('<button type="button" class="bv-button">').text(options.cancelLabel ?? 'Cancel').on('click', () => finish(false)),
)
}
let body = $('<div class="bv-modal-body">').append([
$('<p>').text(options.message ?? ''),
$('<div class="bv-actions bv-flex-column">').append(actionButtons),
])
dialog.append(header, body)
overlay.append(dialog)
overlay.on('click', (event) => {
if (event.target === overlay[0]) {
finish(false)
}
})
dialog.find('.bv-modal-close').on('click', () => finish(false))
$(document).on('keydown.bvModalConfirm', (event) => {
if (event.key === 'Escape') {
finish(false)
}
})
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 = $(settings.sidebarSelector)
if (!$sidebar.length) {
return
}
let reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
let hideDelayMs = reducedMotion ? 0 : settings.hideDelayMs
$sidebar.find(settings.rowSelector).each((_, rowNode) => {
let $row = $(rowNode)
let $count = $row.find(settings.countSelector).first()
if (!$count.length) {
return
}
let $slot = $count.closest('.bv-tag-actions-slot')
let $actions
if (!$slot.length) {
$slot = $('<span class="bv-tag-actions-slot">')
$count.before($slot)
$slot.append($count)
$actions = $('<span class="bv-tag-actions">').prop('hidden', true)
$slot.append($actions)
let hideTimer = null
let showActions = () => {
clearTimeout(hideTimer)
hideTimer = null
$actions.prop('hidden', false)
$count.prop('hidden', true)
$row.addClass('bv-tag-row-active')
}
let hideActions = () => {
$actions.prop('hidden', true)
$count.prop('hidden', false)
$row.removeClass('bv-tag-row-active')
}
let scheduleHide = () => {
clearTimeout(hideTimer)
hideTimer = setTimeout(hideActions, hideDelayMs)
}
$row.on('pointerenter', showActions).on('pointerleave', scheduleHide)
} else {
$actions = $slot.find('.bv-tag-actions').first()
}
if (!$actions.length) {
return
}
let tag = settings.extractTag($row[0])
$actions.empty()
settings.renderActions($row[0], tag, $actions[0], {isBookmarked: settings.isBookmarked})
})
}
/**
* @return {JQuery}
*/
createSeparator()
{
return $('<hr/>')
}
/**
* @return {JQuery}
*/
createSettingsSection()
{
return this.createContainer().
attr('id', 'bv-ui').
addClass('bv-dock-slide-panel bv-bg-colour').
hide().
append(this.createResizer())
}
/**
* @param {string} id
* @param {string} title
* @return {JQuery}
*/
static createDockSlidePanel(id, title)
{
return $('<section class="bv-section bv-font-primary bv-dock-slide-panel bv-bg-colour bv-dock-panel-hidden">').
attr('id', id).
css('display', 'none').
append([
$('<div class="bv-dock-slide-panel-head">').append([
$('<h2 class="bv-dock-slide-panel-title">').text(title),
$('<button type="button" class="bv-dock-slide-panel-close bv-button" aria-label="Close">').text('×'),
]),
$('<div class="bv-dock-slide-panel-body">'),
])
}
/**
* @return {JQuery}
*/
createComplianceRulesSlidePanel()
{
let $panel = BrazenViewLayer.createDockSlidePanel('bv-compliance-rules', 'Active Hide Rules')
$panel.find('.bv-dock-slide-panel-body').empty().append(
$('<p class="bv-dock-list-intro">').text('Filters that hid items on this page.'),
$('<div class="bv-dock-list-groups">'),
)
return $panel
}
/**
* @param {JQuery} 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.find('.bv-dock-list-groups').empty()
let body = panel.find('.bv-dock-slide-panel-body')
if (!$groups.length) {
body.empty().append(
$('<p class="bv-dock-list-intro">').text('Filters that hid items on this page.'),
$('<div class="bv-dock-list-groups">'),
)
$groups = body.find('.bv-dock-list-groups')
}
if (!report.length) {
$groups.append($('<p class="bv-dock-list-intro">').text('No items hidden on this page.'))
return
}
for (let section of report) {
let $list = $('<ul class="bv-dock-list">')
for (let rule of section.rules) {
let $actions = $('<span class="bv-tag-actions">')
if (options.canRemoveRule?.(section.filterKey, rule.label)) {
$actions.append(
this.createFormButton('Remove', 'Remove this rule from the filter list.', () => {
options.onRemoveRule?.(section.filterKey, rule.label)
}).addClass('bv-dock-list-action-btn'),
)
}
$list.append(
$('<li class="bv-dock-list-row">').append(
$('<span class="bv-dock-list-name">').text(rule.label),
$('<span class="bv-dock-list-meta">').text(rule.count + ' hidden'),
$actions,
),
)
}
$groups.append(
$('<section class="bv-dock-list-group">').append(
$('<h3>').text(section.filterLabel),
$list,
),
)
}
}
/**
* @param {object} field
* @param {{stateClass?: string, tooltip?: string, icon?: string, disabled?: boolean}} presentation
*/
updateDockFieldButton(field, presentation = {})
{
if (!field.dockElement?.length) {
return
}
let button = field.dockElement.hasClass('bv-dock-slot') ? field.dockElement.find('> .bv-dock-btn').first() : field.dockElement
if (!button.length) {
return
}
button.removeClass('bv-dock-btn-active bv-dock-btn-disabled')
button.prop('disabled', false)
if (presentation.disabled) {
button.prop('disabled', true).addClass('bv-dock-btn-disabled')
} else if (presentation.stateClass) {
button.addClass(presentation.stateClass)
}
if (presentation.tooltip) {
button.attr('title', presentation.tooltip)
}
if (typeof presentation.icon === 'string') {
button.empty().append(BrazenViewLayer.getDockIcon(presentation.icon))
}
}
/**
* @param {string} orientation
* @param {string} nameBarText
* @return {JQuery}
*/
createDock(orientation, nameBarText)
{
return $('<div class="bv-dock bv-dock-' + orientation + '">').
append($('<div class="bv-dock-name">').text(nameBarText)).
append($('<div class="bv-dock-rail">').
append($('<div class="bv-dock-rail-head">')).
append($('<div class="bv-dock-rail-body">')).
append($('<div class="bv-dock-rail-foot">')))
}
/**
* @return {JQuery}
*/
createDockSeparator()
{
return $('<div class="bv-dock-separator" 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 {JQuery} railBody
* @param {JQuery[]} items
* @return {JQuery}
*/
mountDockRail(railBody, items)
{
railBody.empty()
for (let item of items) {
if (item?.length) {
railBody.append(item)
}
}
return railBody
}
/**
* @param {string} message
* @return {JQuery}
*/
createDockMigrationStatus(message)
{
return $('<div class="bv-dock-status" role="status" aria-live="polite">').
append($('<span class="bv-dock-status-spinner" aria-hidden="true">')).
append($('<span class="bv-dock-status-text">').text(message))
}
/**
* @param {JQuery} dock
* @param {string|null} message
*/
setDockMigrationStatus(dock, message)
{
if (!dock?.length) {
return
}
let status = dock.find('.bv-dock-status')
if (!message) {
status.remove()
return
}
if (!status.length) {
dock.find('.bv-dock-rail-body').prepend(this.createDockMigrationStatus(message))
return
}
status.find('.bv-dock-status-text').text(message)
}
/**
* @param {{icon?: string|HTMLElement|SVGElement, tooltip?: string, stateClass?: string, onClick?: Function}} options
* @return {JQuery}
*/
createDockButton(options = {})
{
let button = $('<button type="button" class="bv-dock-btn">')
if (options.stateClass) {
button.addClass(options.stateClass)
}
if (options.disabled) {
button.prop('disabled', true).addClass('bv-dock-btn-disabled')
}
if (options.tooltip) {
button.attr('title', options.tooltip)
}
if (options.icon) {
if (typeof options.icon === 'string') {
button.append(BrazenViewLayer.getDockIcon(options.icon))
} else {
button.append(options.icon)
}
}
if (options.onClick) {
button.on('click', (event) => {
event.preventDefault()
options.onClick(event)
})
}
return button
}
/**
* @param {JQuery} parentButton
* @param {JQuery[]} childButtons
* @return {JQuery}
*/
createDockSlideOut(parentButton, childButtons)
{
return $('<div class="bv-dock-slot">').
append(parentButton).
append($('<div class="bv-dock-slideout">').append(childButtons))
}
/**
* Always-visible inset panel (slide-out styling, darker background) for grouped dock controls.
* @param {JQuery[]} items
* @return {JQuery}
*/
createDockInsetGroup(items = [])
{
let group = $('<div class="bv-dock-inset-group">')
for (let item of items) {
if (item?.length) {
group.append(item)
}
}
return group
}
/**
* @param {{orientations: string[], value: string, onCycle?: Function}} options
* @return {JQuery}
*/
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
return this.createDockButton({
icon: orientationIcons[value] ?? 'dock-right',
tooltip: 'Dock position: ' + label + ' (click to change)',
onClick: () => options.onCycle?.(),
}).addClass('bv-dock-position-btn')
}
/**
* @param {string} statisticsType
* @param {string} label
* @return {JQuery}
*/
createStatisticsFormGroup(statisticsType, label = '')
{
if (label === '') {
label = statisticsType.split('-').map((word) => word ? word[0].toUpperCase() + word.slice(1) : '').join(' ')
}
return this.createFormGroup().addClass('bv-stat-group').append([
this.createFormGroupLabel(label + ' Filter'),
this.createFormGroupStatLabel(statisticsType),
])
}
/**
* @return {JQuery}
*/
createStatisticsTotalsGroup()
{
return this.createFormGroup().append([
this.createFormGroupLabel('Total'),
this.createFormGroupStatLabel('Total'),
])
}
/**
* @param {string} tabName
* @param {boolean} isFirst
* @return {JQuery}
*/
createTabButton(tabName, isFirst)
{
let tabButton = $('<button class="bv-tab-button bv-border-primary">').
text(tabName).
on('click', (event) => {
let button = $(event.currentTarget)
let tabSection = button.parents('.bv-tabs-section:first')
tabSection.find('.bv-tab-button').removeClass('bv-active bv-font-secondary').addClass('bv-font-primary')
tabSection.find('.bv-tab-panel').removeClass('bv-active')
button.removeClass('bv-font-primary').addClass('bv-active bv-font-secondary')
$('#' + Utilities.toKebabCase(button.text())).addClass('bv-active')
}).
on('mouseenter', (event) => $(event.currentTarget).addClass('bv-font-secondary')).
on('mouseleave', (event) => $(event.currentTarget).removeClass('bv-font-secondary'))
return isFirst ? tabButton.addClass('bv-active bv-font-secondary') : tabButton.addClass('bv-font-primary')
}
/**
* @param {string} tabName
* @param {boolean} isFirst
* @return {JQuery}
*/
createTabPanel(tabName, isFirst = false)
{
let tabPanel = $('<div class="bv-tab-panel bv-border-primary">').attr('id', Utilities.toKebabCase(tabName))
if (isFirst) {
tabPanel.addClass('bv-active')
}
return tabPanel
}
/**
* @param {string[]} tabNames
* @param {JQuery[]} tabPanels
* @return {JQuery}
*/
createTabsSection(tabNames, tabPanels)
{
let tabButtons = []
for (let i = 0; i < tabNames.length; i++) {
tabButtons.push(this.createTabButton(tabNames[i], i === 0))
}
let nav = $('<div class="bv-tabs-nav">').append(tabButtons)
return $('<div class="bv-tabs-section">').append(nav).append(...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 {JQuery}
*/
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 = $('<a class="bv-button bv-donate-patreon">').
attr({
href: patreonUrl,
target: '_blank',
rel: 'noopener noreferrer',
title: 'Open Patreon',
}).
append(
BrazenViewLayer.createPatreonIcon('bv-donate-patreon-icon'),
$('<span class="bv-donate-patreon-label">').text(linkLabel),
)
return this.createTabPanel(tabName, !!options.isFirst).append(
$('<p class="bv-donate-intro">').text(message),
$('<div class="bv-donate-actions">').append($link),
)
}
/**
* @param {string} title
* @return {JQuery}
*/
createTitle(title)
{
return $('<label class="bv-title">' + title + '</label>')
}
/**
* @return {JQuery}
*/
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).val(next).trigger('input')
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 {JQuery}
*/
createPatternTokenChip(chip)
{
let label = chip.label ?? chip.insert ?? ''
let insert = chip.insert ?? chip.label ?? ''
let button = $('<button type="button" class="bv-token-btn">').text(label).attr('title', chip.title)
if (insert) {
button.attr('data-insert', insert)
}
if (chip.separator) {
button.addClass('bv-token-sep')
}
if (chip.folderOnly) {
button.addClass('bv-token-folder-sep').hide()
}
return button
}
/**
* @param {{label: string, chips: {token?: string, insert?: string, label: string, title: string}[]}[]} sections
* @param {{insert: string, label: string, title: string}[]} separators
* @return {JQuery}
*/
createSharedPatternTokenBuilder(sections, separators)
{
let builder = $('<div class="bv-pattern-builder bv-pattern-builder-shared">')
let folderSeparatorButtons = $()
for (let section of sections) {
let row = $('<div class="bv-token-row">')
row.append($('<span class="bv-token-row-label">').text(section.label))
let bar = $('<div class="bv-token-bar">')
for (let chip of section.chips) {
bar.append(this.createPatternTokenChip(chip))
}
row.append(bar)
builder.append(row)
}
let row = $('<div class="bv-token-row">')
row.append($('<span class="bv-token-row-label">').text('Join'))
let bar = $('<div class="bv-token-bar">')
for (let chip of separators) {
let button = this.createPatternTokenChip({...chip, separator: true, folderOnly: chip.insert === '/'})
if (chip.insert === '/') {
folderSeparatorButtons = folderSeparatorButtons.add(button)
}
bar.append(button)
}
row.append(bar)
builder.append(row)
builder.data('folderSeparatorButtons', folderSeparatorButtons)
return builder
}
/**
* @param {HTMLInputElement} filenameInput
* @param {HTMLInputElement} subfolderInput
* @param {JQuery} 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).find('.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)
folderSeparatorButtons.toggle(input === subfolderInput)
}
$(filenameInput).off('focusin.' + eventNamespace).on('focusin.' + eventNamespace, () => activate(filenameInput))
$(subfolderInput).off('focusin.' + eventNamespace).on('focusin.' + eventNamespace, () => activate(subfolderInput))
activate(filenameInput)
$(builder).off('click.' + eventNamespace).on('click.' + eventNamespace, '.bv-token-btn', (event) => {
event.preventDefault()
let button = event.currentTarget
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).addClass('bv-token-flash')
setTimeout(() => $(button).removeClass('bv-token-flash'), 180)
})
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')?.[0]
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.find('.bv-dock-slide-panel-close').remove()
$panel.find('.bv-dock-slide-panel-body').empty().append(
$('<p class="bv-dock-list-intro">').text('New tags were found. Review actions, then confirm to continue.'),
$('<div class="bv-dock-list-groups">'),
$('<details class="bv-dock-list-similar">').append(
$('<summary>').attr('data-bv-similar-label', 'Similar tags').append(
$('<h3 class="bv-dock-list-section-title">').text('Similar tags'),
),
$('<div class="bv-dock-list-similar-body">').append(
$('<p class="bv-dock-list-similar-status">').text('Loading…'),
),
),
$('<div class="bv-dock-list-footer bv-dock-list-footer-row">').append(
$('<button type="button" class="bv-button bv-dock-list-footer-secondary bv-tag-discovery-skip">').
text('Skip').
on('click', () => options.onSkip?.()),
$('<button type="button" class="bv-button bv-dock-list-footer-secondary bv-tag-discovery-open-media">').
text('Open media').
on('click', () => options.onOpenMedia?.()),
$('<button type="button" class="bv-button bv-tag-discovery-confirm">').
text('Confirm').
on('click', () => options.onConfirm?.()),
),
)
return $panel[0]
}
/**
* Rate-limit human verification panel (processor tab). Does not auto-close on mouseleave.
*
* @param {{
* onConfirm?: Function,
* onReopen?: Function,
* title?: string,
* message?: string,
* confirmLabel?: string,
* reopenLabel?: string,
* showReopen?: boolean,
* }} [options] `showReopen: false` builds a standalone reload pane (no open-challenge control).
* @return {HTMLElement}
*/
static createHumanInteractionPanel(options = {})
{
let showReopen = options.showReopen !== false
let $panel = BrazenViewLayer.createDockSlidePanel(
'bv-human-interaction-panel',
options.title ?? 'Verification required',
)
$panel.find('.bv-dock-slide-panel-close').remove()
// Open challenge first (primary); Done — resume second (secondary) so focus stays until ready.
let footerButtons = []
if (showReopen) {
footerButtons.push(
$('<button type="button" class="bv-button bv-human-interaction-reopen">').
text(options.reopenLabel ?? 'Open Cloudflare Challenge').
on('click', () => options.onReopen?.()),
)
}
footerButtons.push(
$('<button type="button" class="bv-button bv-human-interaction-confirm' +
(showReopen ? ' bv-dock-list-footer-secondary' : '') + '">').
text(options.confirmLabel ?? 'Done — resume').
on('click', () => options.onConfirm?.()),
)
$panel.find('.bv-dock-slide-panel-body').empty().append(
$('<p class="bv-dock-list-intro">').text(
options.message ??
'Click Open Cloudflare Challenge when ready, complete it, then Done — resume here.',
),
$('<div class="bv-dock-list-footer">').append(footerButtons),
)
return $panel[0]
}
/**
* @param {HTMLElement|JQuery} panel
* @param {{title?: string, message?: string, confirmLabel?: string, reopenLabel?: string}} content
*/
static updateHumanInteractionPanelContent(panel, content = {})
{
let $panel = $(panel)
if (content.title) {
$panel.find('.bv-dock-slide-panel-title').text(content.title)
}
if (content.message) {
$panel.find('.bv-dock-list-intro').text(content.message)
}
if (content.confirmLabel) {
$panel.find('.bv-human-interaction-confirm').text(content.confirmLabel)
}
if (content.reopenLabel) {
$panel.find('.bv-human-interaction-reopen').text(content.reopenLabel)
}
}
/**
* 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 = $('#' + styleId)
if (!$style.length) {
$style = $('<style>').attr('id', styleId).appendTo(document.head)
}
$style.text(css)
}
/**
* @param {HTMLElement|JQuery} 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, intro?: string}} [options]
*/
static renderTagDiscoveryPanelContent(panel, tags, renderActions, options = {})
{
options = BrazenViewLayer.prepareTagDiscoveryPanelOptions(options)
if (options.tagTypes) {
BrazenViewLayer.applyTagDiscoveryTypeColors(options.tagTypes)
}
let $panel = $(panel)
if (options.intro != null && options.intro !== '') {
$panel.find('.bv-dock-list-intro').text(options.intro)
}
let $groups = $panel.find('.bv-dock-list-groups').empty()
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,
}
if (options.clearSimilarCount !== false) {
resetOptions.count = null
}
BrazenViewLayer.resetTagDiscoverySimilarSection(panel, resetOptions)
}
BrazenViewLayer._bindTagDiscoveryNameClicks(panel, options.onTagNameClick)
if (!$groups.length || (!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($('<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 = $('<ul class="bv-dock-list">')
for (let tag of groupTags) {
$list.append(BrazenViewLayer._createTagDiscoveryRow(tag, renderActions, getRowClass, nameClickable))
}
$groups.append(
$('<section class="bv-dock-list-group">').append(
$('<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|JQuery} panel
* @param {{label?: string, status?: string, count?: number|null, preserveOpen?: 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.
*/
static resetTagDiscoverySimilarSection(panel, options = {})
{
let $panel = $(panel)
let $similar = $panel.find('.bv-dock-list-similar')
if (!$similar.length) {
$similar = $('<details class="bv-dock-list-similar">').append(
$('<summary>').attr('data-bv-similar-label', options.label ?? 'Similar tags').append(
$('<h3 class="bv-dock-list-section-title">'),
),
$('<div class="bv-dock-list-similar-body">'),
)
let $groups = $panel.find('.bv-dock-list-groups')
if ($groups.length) {
$similar.insertAfter($groups)
} else {
$panel.find('.bv-dock-slide-panel-body').append($similar)
}
}
if (options.preserveOpen !== true) {
$similar.prop('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.find('.bv-dock-list-similar-body').empty().append(
$('<p class="bv-dock-list-similar-status">').text(options.status ?? 'Loading…'),
)
}
/**
* Set status text inside the Similar tags body (loading / empty).
*
* @param {HTMLElement|JQuery} panel
* @param {string} status
* @param {{count?: number|null}} [options]
*/
static setTagDiscoverySimilarStatus(panel, status, options = {})
{
let $body = $(panel).find('.bv-dock-list-similar-body')
if (!$body.length) {
return
}
if (Object.prototype.hasOwnProperty.call(options, 'count')) {
BrazenViewLayer._setTagDiscoverySimilarSummary(panel, {count: options.count})
}
$body.empty().append($('<p class="bv-dock-list-similar-status">').text(status))
}
/**
* Fill Similar tags body without re-rendering New/Known groups.
*
* @param {HTMLElement|JQuery} 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}} [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).find('.bv-dock-list-similar')
let $body = $similar.find('.bv-dock-list-similar-body')
if (!$body.length) {
return
}
// Replacing body nodes mid-click can make the browser toggle <details>; restore open.
let wasOpen = !!$similar.prop('open')
let similarTags = Array.isArray(tags) ? tags : []
BrazenViewLayer._setTagDiscoverySimilarSummary(panel, {count: similarTags.length})
if (!similarTags.length) {
$body.empty().append(
$('<p class="bv-dock-list-similar-status">').text(options.emptyStatus ?? 'No similar tags.'),
)
$similar.prop('open', wasOpen)
return
}
let getRowClass = options.getRowClass ?? (() => '')
let nameClickable = typeof options.onTagNameClick === 'function'
let $list = $('<ul class="bv-dock-list">')
for (let tag of similarTags) {
$list.append(BrazenViewLayer._createTagDiscoveryRow(tag, renderActions, getRowClass, nameClickable))
}
$body.empty().append($list)
$similar.prop('open', 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|JQuery} panelOrSimilar
* @param {{label?: string, count?: number|null}} [options]
* @private
*/
static _setTagDiscoverySimilarSummary(panelOrSimilar, options = {})
{
let $root = $(panelOrSimilar)
let $summary = $root.is('details.bv-dock-list-similar')
? $root.children('summary').first()
: $root.find('.bv-dock-list-similar > summary').first()
if (!$summary.length) {
return
}
let base = options.label
if (base != null && base !== '') {
$summary.attr('data-bv-similar-label', base)
} else {
base = $summary.attr('data-bv-similar-label') || 'Similar tags'
}
if (Object.prototype.hasOwnProperty.call(options, 'count')) {
if (options.count == null) {
$summary.removeAttr('data-bv-similar-count')
} else {
$summary.attr('data-bv-similar-count', String(options.count))
}
}
let countAttr = $summary.attr('data-bv-similar-count')
let count = countAttr != null && countAttr !== '' ? Number(countAttr) : null
let $title = $summary.children('.bv-dock-list-section-title').first()
if (!$title.length) {
$title = $('<h3 class="bv-dock-list-section-title">')
$summary.empty().append($title)
}
$title.text(BrazenViewLayer._formatTagDiscoverySimilarLabel(base, count))
}
/**
* Single delegated click handler for discovery tag names (avoids per-row listeners).
*
* @param {HTMLElement|JQuery} panel
* @param {function({name: string, type: string|null, count?: number|null}): void|null|undefined} onTagNameClick
* @private
*/
static _bindTagDiscoveryNameClicks(panel, onTagNameClick)
{
let $panel = $(panel)
$panel.off('click.bvTagDiscoveryName')
if (typeof onTagNameClick !== 'function') {
return
}
$panel.on('click.bvTagDiscoveryName', '.bv-dock-list-name-link', (event) => {
event.preventDefault()
event.stopPropagation()
let el = event.currentTarget
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,
})
})
}
/**
* @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 {JQuery}
* @private
*/
static _createTagDiscoveryRow(tag, renderActions, getRowClass, nameClickable)
{
let $actions = $('<span class="bv-tag-actions">')
let $row = $('<li class="bv-dock-list-row">')
let rowClass = getRowClass?.(tag) ?? ''
if (rowClass) {
$row.addClass(rowClass)
}
let $name = $('<span class="bv-dock-list-name">').text(tag.name)
if (nameClickable) {
$name.
addClass('bv-dock-list-name-link').
attr('data-tag-name', tag.name ?? '').
attr('data-tag-type', tag.type ?? '')
if (tag.count != null && tag.count !== '') {
$name.attr('data-tag-count', String(tag.count))
}
}
$row.append($name)
if (tag.count != null && tag.count !== '') {
$row.append($('<span class="bv-dock-list-meta">').text(String(tag.count)))
}
$row.append($actions)
renderActions?.($row[0], tag, $actions[0])
return $row
}
/**
* @param {HTMLElement|JQuery} row
* @param {{name: string, type: string|null}} tag
* @param {HTMLElement|JQuery} 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)
{
let $row = $(row)
let $actions = $(actionsElement).empty()
config.renderButtons($row[0], tag, $actions[0], config.helpers ?? {})
let state = config.getRowState?.(tag) ?? {}
$row.toggleClass('bv-tag-muted', !!state.muted).toggleClass('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 {JQuery}
*/
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 $('<button type="button">').
addClass(options.className ?? 'bv-tag-action-btn').
attr('title', titles[state] ?? titles.idle).
append(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|JQuery, onClick: function(MouseEvent): void, className?: string}} options
* @return {JQuery}
*/
static createTagAttributeActionButton(options)
{
let $button = $('<button type="button">').
addClass(options.className ?? 'bv-tag-action-btn').
attr('title', options.title ?? '')
if (options.icon) {
$button.append(options.icon)
}
return $button.on('click', (event) => {
event.preventDefault()
event.stopPropagation()
options.onClick?.(event)
})
}
}