// ==UserScript== // @name Mammoth // @namespace https://musicbrainz.org/ // @version 2026.10.3 // @description Edit-note memory for MusicBrainz: auto-remembers your last edit notes and lets you save reusable ones, recalling them from a compact panel beside the edit-note field on every edit form. A nicer replacement for Elephant Editor. // @author majkinetor // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48dGV4dCB4PSI2NCIgeT0iNjgiIGZvbnQtc2l6ZT0iMTA0IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCI+8J+mozwvdGV4dD48L3N2Zz4= // @homepageURL https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/mammoth/README.md // @match https://*.musicbrainz.org/* // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @run-at document-idle // ==/UserScript== // // Mammoth puts a compact saved-notes panel to the RIGHT of MusicBrainz's native // Edit note field (textarea.edit-note), which appears on every edit form, and // widens that (centered) field to make room. // // - AUTO-HISTORY: remembers the last N edit notes you submit (default 10, deduped). // - SAVED notes: β˜… favourite (sorts to top), drag (β Ώ) to reorder, πŸ—‘ delete. // One line each (full text on hover). // - INSERT: a click applies your default action (append or replace, see βš™); // right-click does the other. Ctrl/⌘ + ↑/↓ cycles saved notes, replacing the // field. Append skips a line already present. Never auto-overwrites blindly, // so it won't clobber notes Apollo / Credit Hoarder / Platform Check write. // - BABY MAMMOTHS (βš™ "Show mammoth babies"): the same save/reuse idea on other // controls β€” catalog number, label, artist, status, language… A small 🦣 pin // on each field recalls values you've saved for it; β˜… pins one as an always- // visible button under the field; one entry can be the default (auto-fills an // empty field). Targets: a built-in release-editor set + any element another // script tags class="mmth-pin". Stored separately (mammoth-fields:data). (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('mammoth', 'Mammoth')) return; try { console.info('[Mammoth] ' + mbuStartupInfo('Mammoth')); } catch (e) { /* no console */ } // Mammoth keeps no session log const KEY = 'mammoth:data'; const SKEY = 'mammoth:settings'; const DEFAULTS = { historySize: 10, hideHelp: false, defaultInsert: 'replace', visibleRows: 6, sideWidth: 300, appendNewline: true, minimized: false, showBabies: true, noteSort: 'manual', btnChars: 24, scopePerResource: false, customFields: [] }; // defaultInsert: 'replace' | 'append'; noteSort: 'manual' | 'uses' | 'recent'; btnChars: pinned-button label length; scopePerResource: per-type note pools (#309); customFields: user-defined baby fields [{match,label,key,dx,entity}] const VERSION = '?'; // GM_info carries the real one; every script manager provides it (a hard-coded copy only ever went stale) const SYNTAX_URL = 'https://musicbrainz.org/doc/Edit_Note'; const scriptVersion = () => { try { return GM_info.script.version || VERSION; } catch (e) { return VERSION; } }; // #308: the 🦣 emoji (U+1F9A3) renders as a tofu box in Chrome on systems whose // emoji font lacks it (Firefox bundles its own, hence the inconsistency). Use a // self-contained vector mammoth everywhere the icon shows, so it's font-independent. // #564 (majkinetor): "Mammoth baby could be a bit lighter". The fur was a // fixed #7a4a1f, chosen against a white field; on a dark one it is a brown // smudge. Both colours are tokens now (see --mmth-fur / --mmth-tusk below), // written through style= rather than the fill= attribute, because a // presentation attribute is not a reliable place for var(). const MAMMOTH_SVG = ''; // #309: optionally keep notes SEPARATE per edit-note entity type (release / // artist / recording / …), derived from the page URL β€” opt-in via the // "Scope per resource" setting. Off (default) = one shared pool ('all'). const ENTITY_TYPES = ['area', 'artist', 'event', 'genre', 'instrument', 'label', 'place', 'recording', 'release', 'release-group', 'series', 'work']; const GLOBAL_SCOPE = 'all'; function noteScope() { const seg = (location.pathname.split('/').filter(Boolean)[0] || '').toLowerCase(); return ENTITY_TYPES.includes(seg) ? seg : 'other'; } const SCOPE = noteScope(); const scopeLabel = s => s === 'other' ? 'other' : s.replace(/-/g, ' '); // Multi-scope store: { [scope]: { saved, history } }. Migrate the old flat // { saved, history } into the shared 'all' pool (scoping is off by default). function loadStore() { let raw; try { raw = JSON.parse(GM_getValue(KEY, '{}') || '{}'); } catch (e) { raw = {}; } if (!raw || typeof raw !== 'object') raw = {}; if (Array.isArray(raw.saved) || Array.isArray(raw.history)) raw = { [GLOBAL_SCOPE]: { saved: raw.saved || [], history: raw.history || [] } }; return raw; } const loadSet = () => { try { return Object.assign({}, DEFAULTS, JSON.parse(GM_getValue(SKEY, '{}') || '{}')); } catch (e) { return Object.assign({}, DEFAULTS); } }; const persistSet = () => { try { GM_setValue(SKEY, JSON.stringify(SET)); } catch (e) {} }; // quiet save (no re-render) const saveSet = () => { persistSet(); applyHelp(); render(); }; // Built-in "OTB" baby fields β€” now seeded INTO the config (SET.customFields) so they're visible/editable // in the Fields tab like any custom field. Storage keys are derived from the LABEL (same-label fields share // one list) β€” no explicit `key`. `mbid:true` (JSON-only flag) enables entity-MBID capture on Label/Artist. const BUILTIN_FIELDS = [ { match: 'input[id^="catno-"]', label: 'Catalog number' }, { match: '#primary-type', label: 'Primary type' }, { match: '#packaging', label: 'Packaging' }, { match: '#status', label: 'Status' }, { match: '#language', label: 'Language' }, { match: '#script', label: 'Script' }, { match: 'select[id^="country-"]', label: 'Country' }, { match: 'input[id^="label-"]', label: 'Label', mbid: true }, { match: '#ac-source-single-artist, input[id^="ac-source-artist-"]', label: 'Artist', mbid: true }, { match: '.attribute-container.text.task input[type="text"]', label: 'Task' }, ]; let SET = loadSet(); // seed the built-ins once (merge-in any not already present by selector; never re-adds after you delete one) if (!SET.fieldsSeeded) { const have = new Set((SET.customFields || []).map(c => c && c.match)); SET.customFields = [...BUILTIN_FIELDS.filter(b => !have.has(b.match)).map(b => ({ ...b })), ...(SET.customFields || [])]; SET.fieldsSeeded = true; persistSet(); } // keys are now derived from the label β€” drop any explicit `key` left over from older seeds / pasted JSON if ((SET.customFields || []).some(c => c && c.key != null)) { SET.customFields.forEach(c => { if (c) delete c.key; }); persistSet(); } let STORE = loadStore(); const dataKey = () => SET.scopePerResource ? SCOPE : GLOBAL_SCOPE; let DATA; function useScope() { // (re)point DATA at the current pool (called on load + when the setting toggles) const k = dataKey(); DATA = STORE[k] || (STORE[k] = { saved: [], history: [] }); if (!Array.isArray(DATA.saved)) DATA.saved = []; if (!Array.isArray(DATA.history)) DATA.history = []; } useScope(); const saveData = () => { try { GM_setValue(KEY, JSON.stringify(STORE)); } catch (e) {} render(); }; const updateScopeChips = () => document.querySelectorAll('.mmth-scope').forEach(c => { c.style.display = SET.scopePerResource ? '' : 'none'; }); const uid = () => 'n' + Math.random().toString(36).slice(2, 9); const babyMammoths = createBabyMammoths(); // field-memory module (gated by SET.showBabies) // ── data ops ───────────────────────────────────────────────────────────────── function recordHistory(text) { text = (text || '').trim(); if (!text) return; DATA.history = DATA.history.filter(h => h.text !== text); DATA.history.unshift({ text, ts: Date.now() }); DATA.history = DATA.history.slice(0, Math.max(1, Math.min(50, SET.historySize | 0 || 10))); saveData(); } function addSaved(text) { text = (text || '').trim(); if (!text) return false; if (DATA.saved.some(s => s.text === text)) return false; DATA.saved.push({ id: uid(), text, ts: Date.now() }); saveData(); return true; } const removeSaved = id => { DATA.saved = DATA.saved.filter(s => s.id !== id); saveData(); }; const removeHistory = text => { DATA.history = DATA.history.filter(h => h.text !== text); saveData(); }; function reorder(srcId, tgtId, before) { if (srcId === tgtId) return; const a = DATA.saved, si = a.findIndex(s => s.id === srcId); if (si < 0) return; const [it] = a.splice(si, 1); let ti = a.findIndex(s => s.id === tgtId); if (ti < 0) { a.splice(si, 0, it); return; } a.splice(before ? ti : ti + 1, 0, it); saveData(); } // #304: scaling helpers for big note lists. const togglePinNote = id => { const s = DATA.saved.find(x => x.id === id); if (s) { s.pinned = !s.pinned; saveData(); } }; // record that a saved note was used (drives the "Most used" / "Recent" sort) function bumpUse(id) { const s = DATA.saved.find(x => x.id === id); if (!s) return; s.uses = (s.uses | 0) + 1; s.lastUsed = Date.now(); saveData(); } // display order for the Saved list β€” pinned never reorder the list (they get their // own quick-button bar); only the chosen sort mode reshuffles. Manual = stored order. function sortedSaved() { const a = DATA.saved.slice(); const mode = SET.noteSort || 'manual'; if (mode === 'uses') a.sort((x, y) => (y.uses | 0) - (x.uses | 0) || (y.lastUsed || y.ts || 0) - (x.lastUsed || x.ts || 0)); else if (mode === 'recent') a.sort((x, y) => (y.lastUsed || y.ts || 0) - (x.lastUsed || x.ts || 0)); return a; } // #304: parse pasted notes β€” each line a note, or (byBlock) blank-line-separated // blocks so multi-line notes survive. Blank lines are dropped either way, so a // readable (blank-line) export round-trips fine in "1 note per line" mode too. function parseNotes(text, byBlock) { const parts = byBlock ? String(text || '').split(/\r?\n[ \t]*\r?\n/) : String(text || '').split(/\r?\n/); return parts.map(s => s.replace(/^\s+|\s+$/g, '')).filter(Boolean); } // add parsed notes to the edit-note saved list (dedup). Returns the added count. function addSavedNotes(notes) { let added = 0; const have = new Set(DATA.saved.map(s => s.text)); for (const t of notes) { if (have.has(t)) continue; have.add(t); DATA.saved.push({ id: uid(), text: t, ts: Date.now() }); added++; } if (added) saveData(); return added; } // ── insert (React-safe + undoable) ─────────────────────────────────────────── const NATIVE_SET = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; // Set the whole field value via the native edit pipeline so the change joins // the browser's undo stack β€” ctrl/⌘+Z restores the previous note (#226). // `execCommand` also fires a genuine `input` event, so the React-controlled // edit-note field (release editor) still updates. Falls back to the native // value setter + synthetic events if execCommand is unavailable or no-ops. function setValue(ta, val) { let ok = false; try { ta.focus(); ta.setSelectionRange(0, ta.value.length); // select all β†’ replace as one undoable step ok = val ? document.execCommand('insertText', false, val) : document.execCommand('delete', false, null); if (ok && ta.value !== val) ok = false; // some engines return true but no-op } catch (e) { ok = false; } if (!ok) { NATIVE_SET.call(ta, val); ta.dispatchEvent(new Event('input', { bubbles: true })); } ta.dispatchEvent(new Event('change', { bubbles: true })); } // #304: set when Ctrl/⌘+, un-minimized the panel just to reach search β€” so we // re-minimize once a note is applied (or the search is cancelled). let restoredFromMin = false; const reMinIfRestored = () => { if (restoredFromMin) { restoredFromMin = false; setMinimized(true); } }; function applyNote(ta, text, replace) { const cur = ta.value || ''; if (!replace && cur.trim()) { // #212: don't append a note already in the field β€” match whole-field, a // blank-line-separated block, or a single line (handles multi-line notes). const norm = s => s.split('\n').map(l => l.replace(/[ \t]+/g, ' ').trim()).filter(Boolean).join('\n').trim(); const tN = norm(text); const cands = [cur, ...cur.split(/\n{2,}/), ...cur.split('\n')].map(norm); if (tN && cands.includes(tN)) { toast('Already in the note'); reMinIfRestored(); return; } } setValue(ta, (replace || !cur.trim()) ? text : cur.replace(/\s+$/, '') + (SET.appendNewline ? '\n\n' : '\n') + text); ta.focus(); try { ta.setSelectionRange(ta.value.length, ta.value.length); } catch (e) {} reMinIfRestored(); } // Toggle `marker` around the selection (GitHub-style): if the text is already // wrapped, strip the markers; otherwise add them. With no selection, act on the // word the caret is in (or just insert/remove an empty pair). function wrapSel(ta, marker) { const v = ta.value; const m = marker, ml = m.length; let s = ta.selectionStart ?? v.length, e = ta.selectionEnd ?? s; if (s === e) { let a = s, b = e; while (a > 0 && /\S/.test(v[a - 1])) a--; while (b < v.length && /\S/.test(v[b])) b++; if (b > a) { s = a; e = b; } } const sel = v.slice(s, e); const reselect = (from, len) => { ta.focus(); try { ta.setSelectionRange(from, from + len); } catch (x) {} }; // #326: already wrapped β€” markers INSIDE the selection β†’ unwrap if (sel.length >= 2 * ml && sel.startsWith(m) && sel.endsWith(m)) { const inner = sel.slice(ml, sel.length - ml); setValue(ta, v.slice(0, s) + inner + v.slice(e)); reselect(s, inner.length); return; } // #326: already wrapped β€” markers just OUTSIDE the selection β†’ unwrap if (v.slice(s - ml, s) === m && v.slice(e, e + ml) === m) { setValue(ta, v.slice(0, s - ml) + sel + v.slice(e + ml)); reselect(s - ml, sel.length); return; } // otherwise wrap setValue(ta, v.slice(0, s) + m + sel + m + v.slice(e)); if (sel) reselect(s + ml, sel.length); else { const caret = s + ml; ta.focus(); try { ta.setSelectionRange(caret, caret); } catch (x) {} } } // ── capture on submit ──────────────────────────────────────────────────────── const captureNote = () => document.querySelectorAll('textarea.edit-note').forEach(ta => recordHistory(ta.value)); document.addEventListener('submit', captureNote, true); document.addEventListener('click', e => { const b = e.target.closest && e.target.closest('button, input[type="submit"]'); if (!b) return; if (b.closest('.mmth-side, .mmth-pop')) return; // our own buttons aren't edit submits const t = (b.textContent || b.value || '').trim().toLowerCase(); if (b.id === 'enter-edit' || /^(enter edit|submit|add edit|save)/.test(t) || (b.classList && b.classList.contains('submit'))) captureNote(); }, true); // ── styles ─────────────────────────────────────────────────────────────────── // The shared design tokens (#562). Values live in dev/tokens/design-tokens.mjs and are // inlined here by dev/tokens/sync-tokens.mjs β€” edit them THERE, never in this block. // β€” generated by dev/tokens/sync-tokens.mjs from dev/tokens/design-tokens.mjs β€” DO NOT EDIT const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}'; // // The shared UI components (#563). Definitions live in dev/ui/ui-components.mjs // and are inlined here by dev/ui/sync-ui.mjs β€” edit them THERE, never here. // β€” generated by dev/ui/sync-ui.mjs from dev/ui/ui-components.mjs β€” DO NOT EDIT const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(720px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min{font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x{display:none}#mbu-logpop.min{max-height:none;width:auto}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}#mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}'; // Help link markup. Every script's help link is this, pointing at its own README. // `name` is the userscript folder, e.g. mbuHelpHref('art_station'). function mbuHelpHref(name) { return 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/' + name + '/README.md'; } function mbuHelpHtml(name, label) { return '' + (label || '? Help') + ''; } function mbuHelpEl(name, label) { var a = document.createElement('a'); a.className = 'mbu-help'; a.href = mbuHelpHref(name); a.target = '_blank'; a.rel = 'noopener'; a.title = 'open the README in a new tab'; a.textContent = label || '? Help'; return a; } // The config (settings) icon every script's settings button shows: the gear in text // presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650 var MBU_CFG_ICON = '\u2699\uFE0E'; // HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a // plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy // passes the string through; the markup is the script's own. Elsewhere it is the string. var _mbuTT; function mbuHtml(s) { if (_mbuTT === undefined) { _mbuTT = null; try { var tt = (typeof window !== 'undefined' && window.trustedTypes) || null; if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } }); } catch (e) { /* the page allows no new policy: plain strings, as before */ } } return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s); } // The first line of every script's log: the script, its version, and what runs it, so a // pasted log says which manager and browser it came from (#282 started it in Art Station): // Log.info(mbuStartupInfo('Fusion')); // -> Fusion v2026.10.1 Β· Violentmonkey 2.31.0 Β· firefox 143.0 (win) // Inside String Theory GM_info describes the bundle, so the line names it: // -> Fusion (String Theory v2026.10.1) Β· Tampermonkey 5.3.3 Β· chrome 140.0 (win) function mbuStartupInfo(name) { var g = null; try { g = (typeof GM_info !== 'undefined' && GM_info) || null; } catch (e) { /* no GM_info */ } var s = (g && g.script) || {}, p = (g && g.platform) || {}; var host = String(s.name || '').replace(/\*$/, ''), ver = s.version || '?'; var line = !name ? (host || 'Script') + ' v' + ver : (host && host !== name) ? name + ' (' + host + ' v' + ver + ')' : name + ' v' + ver; if (g) line += ' Β· ' + (g.scriptHandler || 'unknown manager') + (g.version ? ' ' + g.version : ''); if (p.browserName) line += ' Β· ' + p.browserName + (p.browserVersion ? ' ' + p.browserVersion : '') + (p.os ? ' (' + p.os + ')' : ''); else { try { line += ' Β· ' + navigator.userAgent; } catch (e) { /* no navigator */ } } return line; } // One copy per page (#653). With String Theory and a standalone install of the same script // both on, two copies build the same element ids and fight over them: each settings window // fills in the other's checkboxes, rows flip between two rule sets. So one copy runs, the // one with the higher version, and the other stays off without a word; only the running // copy notes it in its log (majkinetor: "disable copy that has lower version without any // info (except in log of active copy)"): // if (!mbuClaim('platform_check', 'Platform Check')) return; // first line of the script // The claim is a data- attribute on , which every copy sees whatever its sandbox. // It is decided at once, so no script starts late. The copy that starts first takes the // page. When it is the older one, the newer copy stays off for this page and leaves a note // in localStorage, and from the next page load the older copy finds the note and steps // aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the // page loads, so it runs again from the load after. function mbuClaimVer(v) { return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; }); } function mbuClaimCmp(a, b) { var x = mbuClaimVer(a), y = mbuClaimVer(b); for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function mbuClaim(key, label) { var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {}; var name = String(info.name || label || key), ver = String(info.version || '0'); var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver; var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key; var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } }; var note = null; try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ } if (note && mbuClaimCmp(note.ver, ver) <= 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } note = null; } var held = root && root.getAttribute(attr); var off = function (why) { // tell the running copy (any sandbox hears a DOM event), or the copy that runs after // this one (it reads the attribute), and stay quiet try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ } try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ } return false; }; if (held) { var heldVer = (root.getAttribute(attr + '-ver') || '0'); if (mbuClaimCmp(ver, heldVer) > 0) { try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ } return off('newer, from the next page load'); } return off('older or the same'); } if (note) { // a newer copy said it is installed: leave the page to it, unless it never shows up var watch = function () { setTimeout(function () { if (root.getAttribute(attr)) return; try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ } }, 3000); }; if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true }); return off('older: a newer copy runs'); } if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); } var told = function (o) { log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off' + (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.'); }; document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); }); // a copy that stepped aside before this one started (it found a note): log it once the script's log is up var before = root && root.getAttribute(attr + '-off'); if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0); return true; } // Toast. mbuToast(msg) or mbuToast(msg, { ms, kind, at:{x,y}, action:{ label, onClick } }). // // An action adds one button to the toast (e.g. "Copy log"): the toast is then clickable, // stays up longer (12 s unless ms says otherwise), and closes when the button is used. // // Severity is inferred from a leading warning/tick glyph when not given β€” Art // Station already did that and it is why its toasts reached its log with the // right level. Set mbuToast.log = function (level, message) {...} once at // startup and every toast mirrors into that script's own log; leave it unset // and the toast still shows. var _mbuToastT = null; function mbuToast(msg, opts) { opts = opts || {}; var s = String(msg); var kind = opts.kind || (/^\s*[βš βœ—Γ—]/.test(s) ? 'warn' : /[βœ“βœ…]/.test(s) ? 'ok' : 'info'); try { if (typeof mbuToast.log === 'function') mbuToast.log(kind, s.replace(/^\s*[βš βœ—Γ—βœ“βœ…]\s*/, '')); } catch (e) { /* a broken log sink must never swallow the toast */ } var el = document.getElementById('mbu-toast'); if (!el) { el = document.createElement('div'); el.id = 'mbu-toast'; (document.body || document.documentElement).appendChild(el); } el.className = 'mbu-toast-on' + (kind !== 'info' ? ' mbu-toast-' + kind : '') + (opts.action ? ' mbu-toast-act' : ''); el.textContent = s; if (opts.action) { var b = document.createElement('button'); b.type = 'button'; b.className = 'mbu-toast-btn'; b.textContent = opts.action.label || 'OK'; b.onclick = function () { try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { /* the toast still closes */ } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, 900); }; el.appendChild(b); } // Anchor above a click point when asked, clamped into the viewport; otherwise // fall back to the centred default by clearing the inline placement. if (opts.at) { var w = el.offsetWidth, h = el.offsetHeight; el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + 'px'; el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + 'px'; el.style.bottom = 'auto'; el.style.transform = 'none'; } else { el.style.left = ''; el.style.top = ''; el.style.bottom = ''; el.style.transform = ''; } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, opts.ms || (opts.action ? 12000 : 2600)); return el; } // Config-window title bar. // // mbuCfgHeader({ script:'art_station', name:'Art Station', version:'2026.9.2', // icon:'' | '', log:true, logClass:'as-setup-logbtn' }) // // Returns the markup for the whole bar. 'log' adds the Log button; a script with // no log window leaves it out rather than shipping a dead control. logClass / // logId are carried through IN ADDITION to the shared class so a script's // existing click handler keeps working β€” adopting the component must not mean // rewiring every listener at the same time. function mbuCfgHeader(o) { o = o || {}; var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; var html = '
'; if (o.icon) html += '' + o.icon + ''; html += '' + esc(o.name) + ''; if (o.version) html += 'v' + esc(o.version) + ''; html += ''; if (o.log) { html += ''; } html += mbuHelpHtml(o.script); return html + '
'; } // Test hooks. A script puts its test hook on window only when the test harness has // marked the page (dev/test/harness.mjs sets window.__mbuTest before any script runs): // if (mbuTestHooks()) window.__fooTest = { … }; // On a user's page the hooks are never built. (#623) function mbuTestHooks() { try { return typeof window !== 'undefined' && window.__mbuTest === true; } catch (e) { return false; } } // Corner slots (#468). Every floating launcher (Apollo Editor, Art Station, Falcon, // Fusion, Scribe) tags its element with data-mb-corner (which screen corner: 'br', // 'bl', 'tr', 'tl') and data-mb-corner-order (lower sits closer to the corner), and // calls mbRestackCorner(corner) right after it shows, hides, creates or removes it. // That recomputes every element in the corner, whichever script owns it and // whatever order they loaded in, so two launchers never land on the same pixel. // Orders in use: Apollo and Art Station 10 (never on the same page), Falcon 20, // Fusion above Falcon. Scribe is not on the shared block and keeps a copy of this. function mbRestackCorner(corner) { var bottom = corner[0] === 'b', right = corner[1] === 'r'; var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]')) // offsetParent is always null for position:fixed, so it can't tell visibility here .filter(function (el) { return getComputedStyle(el).display !== 'none'; }) .sort(function (a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); }); var pos = 14; els.forEach(function (el) { el.style[bottom ? 'bottom' : 'top'] = pos + 'px'; el.style[right ? 'right' : 'left'] = '14px'; pos += el.getBoundingClientRect().height + 8; }); } // Activity log: the session's log lines plus the floating window that shows them // (#283's viewer, shared since X12 of #623). A script makes its log once: // // var LOG = mbuLog({ name: 'Fusion', version: VERSION, key: 'fusion.logwin' }); // LOG.info('…'); LOG.warn(…); LOG.err(…) (or .error); LOG.ok(…); LOG.debug(…) // LOG.open(); LOG.close(); LOG.reopen() // reopen: only if it was left open // LOG.markdown(); LOG.copy(btn); LOG.clear(); LOG.lines(); LOG.messages(); LOG.counts() // // o.name / o.version the Markdown summary's title (version may be a function) // o.subtitle optional function; its text follows the title (e.g. the release) // o.header the window's title (default 'Activity log') // o.key storage key for the window's open/minimised/position state // o.load / o.save that storage (default GM_getValue / GM_setValue) // o.before called before the window opens (e.g. to inject the script's CSS) // o.max lines kept (default 20000: about 4 MB; 2000 dropped a long session's start) // // A long run keeps only the last o.max lines, and the Markdown says how many went // before them; the copies this replaced grew for the whole session. An open // window appends each new line and drops the oldest row past the cap; the copies // rebuilt the whole list with innerHTML on every line, which is quadratic over a // long matching run. function mbuLog(o) { o = o || {}; var max = o.max || 20000, buf = [], dropped = 0, warn = 0, error = 0, win = null; var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; var ts = function (d) { return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3); }; var str = function (v) { if (typeof v === 'string') return v; if (v instanceof Error) return v.message || String(v); if (v && v.nodeType) return '<' + (v.tagName || 'node').toLowerCase() + '>'; try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } catch (e) { return String(v); } }; var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; // escape, then make http(s) URLs clickable, keeping trailing punctuation out of them var linkify = function (s) { return esc(s).replace(/(https?:\/\/[^\s<]+)/g, function (m) { var t = (m.match(/[.,;:!?)\]]+$/) || [''])[0]; var url = m.slice(0, m.length - t.length); return '' + url + '' + t; }); }; var load = o.load || function (k) { try { return GM_getValue(k, undefined); } catch (e) { return undefined; } }; var save = o.save || function (k, v) { try { GM_setValue(k, v); } catch (e) { /* no storage: the window just forgets */ } }; var state = function () { try { return JSON.parse(load(o.key) || '{}') || {}; } catch (e) { return {}; } }; var remember = function (patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { /* see save */ } }; var tally = function (e, d) { if (e.sev === 'warn') warn += d; else if (e.sev === 'error') error += d; }; var PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' }; var line = function (e) { return ts(e.t) + ' ' + (PRE[e.sev] || '') + e.msg; }; function add(sev, args) { var msg = Array.prototype.map.call(args, str).join(' ').replace(/\s+/g, ' ').trim(); if (!msg) return; var e = { t: new Date(), sev: sev === 'err' ? 'error' : sev, msg: msg }; buf.push(e); tally(e, 1); // trim in chunks, not one shift per line if (buf.length > max + Math.ceil(max / 10)) { var gone = buf.splice(0, buf.length - max); gone.forEach(function (g) { tally(g, -1); }); dropped += gone.length; } if (win) win.append(e); } function title() { var v = typeof o.version === 'function' ? (function () { try { return o.version(); } catch (e) { return ''; } })() : o.version; var t = (o.name || 'Log') + (v ? ' v' + v : ''); try { var s = o.subtitle && o.subtitle(); if (s) t += ' β€” ' + s; } catch (e) { /* no subtitle */ } return t; } function markdown() { var body = buf.length ? buf.map(line).join('\n') : '(no activity logged)'; if (dropped) body = '(' + dropped + ' earlier line' + (dropped === 1 ? '' : 's') + ' not kept)\n' + body; var n = (warn || error) ? ' (' + warn + ' warning' + (warn === 1 ? '' : 's') + ', ' + error + ' error' + (error === 1 ? '' : 's') + ')' : ''; var fence = String.fromCharCode(96, 96, 96); return '
' + title() + ' β€” session log' + n + '\n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n
'; } function copy(btn) { var md = markdown(); var done = function (ok) { if (!btn) return; var was = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = was; btn.textContent = ok ? 'Copied βœ“' : 'Copy failed'; setTimeout(function () { btn.textContent = was; }, 1500); }; var fallback = function () { var ok = false; try { var ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch (x) { /* nothing left to try */ } done(ok); }; try { navigator.clipboard.writeText(md).then(function () { done(true); }, fallback); } catch (e) { fallback(); } } function open() { close(true); if (typeof o.before === 'function') { try { o.before(); } catch (e) { /* the window still opens */ } } remember({ open: true }); var st = state(); var pop = document.createElement('div'); pop.id = 'mbu-logpop'; pop.className = 'mbu-logpop'; pop.innerHTML = mbuHtml('
' + esc(o.header || 'Activity log') + ' ' + '' + '' + '' + '
' + '
'); document.body.appendChild(pop); if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; } var restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; var list = pop.querySelector('.mbu-log-list'), badge = pop.querySelector('.mbu-log-badge'); var row = function (e) { var d = document.createElement('div'); d.className = 'mbu-log-li mbu-log-' + e.sev; d.innerHTML = mbuHtml('' + ts(e.t) + '' + linkify(e.msg) + ''); return d; }; var showBadge = function () { badge.textContent = '(' + buf.length + ')' + (warn || error ? ' Β· ' + warn + '⚠ ' + error + 'βœ–' : ''); }; // the rows, once; later lines are appended one by one var frag = document.createDocumentFragment(); buf.forEach(function (e) { frag.appendChild(row(e)); }); if (buf.length) list.appendChild(frag); else list.innerHTML = mbuHtml('
No activity yet.
'); showBadge(); list.scrollTop = list.scrollHeight; // badge and scroll once per frame, however many lines arrive in it var queued = false, follow = true; list.addEventListener('scroll', function () { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; }); var paint = function () { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; }; var onKey = function (e) { if (e.key === 'Escape') close(); }; win = { el: pop, append: function (e) { var empty = list.querySelector('.mbu-log-empty'); if (empty) empty.remove(); list.appendChild(row(e)); while (list.childElementCount > buf.length) list.firstElementChild.remove(); if (!queued) { queued = true; requestAnimationFrame(paint); } }, off: function () { document.removeEventListener('keydown', onKey); }, cleared: function () { list.innerHTML = mbuHtml('
No activity yet.
'); showBadge(); }, }; pop.querySelector('.mbu-logpop-clear').onclick = function () { clear(); }; pop.querySelector('.mbu-logpop-copy').onclick = function () { copy(pop.querySelector('.mbu-logpop-copy')); }; var minBtn = pop.querySelector('.mbu-logpop-min'); var setMin = function (m) { minBtn.textContent = m ? 'β–’' : '–'; minBtn.title = m ? 'Restore' : 'Minimize'; if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to the bottom else Object.assign(pop.style, restore); }; minBtn.onclick = function () { var m = pop.classList.toggle('min'); setMin(m); remember({ min: m }); }; if (st.min) { pop.classList.add('min'); setMin(true); } pop.querySelector('.mbu-logpop-x').onclick = function () { close(); }; // floating and non-modal: dragged by its header pop.querySelector('.mbu-logpop-h').addEventListener('mousedown', function (e) { if (e.target.closest('button')) return; e.preventDefault(); var r = pop.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none'; var ox = e.clientX - r.left, oy = e.clientY - r.top; var mv = function (ev) { pop.style.left = Math.max(0, Math.min(window.innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px'; pop.style.top = Math.max(0, Math.min(window.innerHeight - 36, ev.clientY - oy)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!pop.classList.contains('min')) { restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); document.addEventListener('keydown', onKey); return pop; } // empty the log: the lines, the counts and the "earlier lines not kept" note function clear() { buf = []; dropped = 0; warn = 0; error = 0; if (win) win.cleared(); } // quiet: closing to reopen, so the remembered "open" stays as it is function close(quiet) { var stray = document.getElementById('mbu-logpop'); if (win) { win.off(); win.el.remove(); win = null; if (!quiet) remember({ open: false }); } if (stray) stray.remove(); // another script's window: one log window at a time } var api = { info: function () { add('info', arguments); }, warn: function () { add('warn', arguments); }, err: function () { add('error', arguments); }, error: function () { add('error', arguments); }, ok: function () { add('ok', arguments); }, debug: function () { add('debug', arguments); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1)); }, open: open, close: function () { close(); }, reopen: function () { if (state().open) open(); }, isOpen: function () { return !!win; }, markdown: markdown, copy: copy, clear: clear, lines: function () { return buf.map(line); }, messages: function () { return buf.map(function (e) { return e.msg; }); }, counts: function () { return { warn: warn, error: error }; }, }; mbuLog.active = api; // the script's own log, for helpers that note things in it (mbuClaim) return api; } // Dismiss-on-outside-click, with the trailing click SWALLOWED. // // var off = mbuDismissOn(popoverEl, close); // off() to detach early // // #305: a popover torn down on mousedown removes what was under the cursor, so // the click that follows lands on whatever the page reflowed into that spot and // activates it. Tearing down on click instead just moves the problem. So: close // on outside mousedown, then eat exactly one click in the capture phase. This is // the single most repeated interaction bug in these scripts and it belongs in // one place β€” it is why #563 says interaction is part of the contract. // // Esc closes too, innermost first: the handler is registered in capture and stops // propagation, so a popover inside a modal does not close the modal as well. function mbuDismissOn(el, close, opts) { opts = opts || {}; var closed = false; var onDown = function (e) { if (closed || !el || el.contains(e.target)) return; if (opts.ignore && e.target.closest && e.target.closest(opts.ignore)) return; finish(); // swallow the click this mousedown will produce, once var eat = function (ev) { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', eat, true); }; document.addEventListener('click', eat, true); setTimeout(function () { document.removeEventListener('click', eat, true); }, 400); }; var onKey = function (e) { if (closed || e.key !== 'Escape') return; e.stopPropagation(); finish(); }; function finish() { if (closed) return; closed = true; document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onKey, true); try { close(); } catch (err) { /* a throwing closer must not leave listeners behind */ } } document.addEventListener('mousedown', onDown, true); document.addEventListener('keydown', onKey, true); return finish; } // Collapse a toolbar to icon-only when its buttons would wrap. // // mbuFitToolbar(barEl) // call on build, and on resize // // Measured by SUMMING child widths rather than reading scrollWidth or comparing // offsetTop: a bar with flex:1 spacers never overflows its own scroll box, so // both of those report "fits" right up until it visibly wraps. Art Station // learned that the hard way (#234) and it is the only reason this is a helper // rather than one CSS rule. // // opts.gap inter-item gap in px (default 11) // opts.pad horizontal padding to leave (default 24) // opts.spacer selector for flexible spacers, which must not count (default .mbu-sp) function mbuFitToolbar(bar, opts) { if (!bar) return false; opts = opts || {}; var gap = opts.gap == null ? 11 : opts.gap; var pad = opts.pad == null ? 24 : opts.pad; var spacer = opts.spacer || '.mbu-sp'; bar.classList.remove('mbu-compact'); // measure at full labels var kids = [].slice.call(bar.children); var need = gap * Math.max(0, kids.length - 1); for (var i = 0; i < kids.length; i++) { if (kids[i].matches && kids[i].matches(spacer)) continue; need += kids[i].offsetWidth; } var compact = need > bar.clientWidth - pad; bar.classList.toggle('mbu-compact', compact); return compact; } // Publish the components on a shared namespace. Three reasons, in order: // // 1. it is the cross-userscript contract #563 is about β€” another script (or a // future one) gets the standard widgets without copying them, the same way // Mammoth already exposes its field-memory through a documented convention; // 2. it makes the components testable from outside, which is the only way to // assert the *behaviour* half of the contract rather than just the markup; // 3. it costs nothing when several scripts do it β€” the definitions are // byte-identical, so first writer wins and the rest are no-ops. // // Guarded per key, never clobbering: a script that loaded first keeps its copy, // and a page that defines an unrelated window.MBU is left alone. // Theme recognition. #564: "we don't have to conform to Stylus vars, we could // probably use them as a recognition signal to enable our own dark theme." // // That is the right way round. Reading --background/--text and hoping every // derived colour lands somewhere readable is guesswork that fails one token at a // time; knowing WHICH theme we are in lets the token set say so outright, and // lets us hand the browser the one thing CSS variables cannot express β€” // color-scheme, which is what actually paints a checkbox dark instead of leaving // a white (Firefox: black) box on a dark panel. // // The signal is the rendered page, not a particular userstyle's variable names: // whatever painted the body, we measure its luminance. So this works for Stylus, // for a browser extension, for MusicBrainz shipping its own dark mode one day, // and for a user who just set --background by hand. // // Β· an explicit --mbu-theme (light|dark) always wins β€” the escape hatch; // Β· otherwise the page background decides; // Β· re-checked when stylesheets arrive, because Stylus often lands after us. function mbuThemeOf(bg) { var m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(bg || ''); if (!m) return null; if (m[4] !== undefined && +m[4] < 0.5) return null; // transparent tells us nothing var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; var L = 0.2126 * f(+m[1]) + 0.7152 * f(+m[2]) + 0.0722 * f(+m[3]); return L < 0.35 ? 'dark' : 'light'; } // #569 (chaban-mb) β€” write only when the value actually changes. // // The DOM does not do this for you. classList.add of a token already present, // classList.toggle to the state it is already in, setAttribute with the value it // already has: each one re-sets the attribute and dispatches a mutation record. // Harmless once; these run from 2Hz heartbeats and from observers that react to // each other, and the measured idle cost on the release editor was 66 records a // second, of which 93% came from writes that changed nothing (see // dev/ui/measure-569-idle-mutations.mjs). // // Semantically these are exact no-ops: they skip a write ONLY when the value is // already the one being written, so nothing that reads the DOM afterwards can // tell the difference. That is the whole reason they are safe to sprinkle around // a 2Hz loop. function mbuCls(el, token, on) { if (!el || !el.classList) return; if (el.classList.contains(token) !== !!on) el.classList.toggle(token, !!on); } function mbuAttr(el, name, value) { if (!el) return; if (value === null || value === undefined || value === false) { if (el.hasAttribute(name)) el.removeAttribute(name); } else if (el.getAttribute(name) !== String(value)) { el.setAttribute(name, String(value)); } } // For IDL properties (disabled, title, textContent, style.display …). Reading // them is cheap; writing them is not, and textContent in particular replaces // every child node. // // ⚠ textContent is the one to think twice about: its getter concatenates the // text of ALL descendants, so on an element with child ELEMENTS the comparison // can match while the DOM shape is wrong, and the guard then skips a write that // would have flattened it. Only use it where the target holds text and nothing // else. function mbuProp(obj, prop, value) { if (!obj) return; if (obj[prop] !== value) obj[prop] = value; } // #569: the one element mbuTheme resolves --background through. Looked up by id // rather than kept in a variable, so the seven scripts of a bundle share ONE // probe instead of adding seven, and so it heals itself if anything removes it. // It lives in : a permanent stray node under , outside head and // body, is the sort of thing another script's document scan trips over. function mbuProbe() { var p = document.getElementById('mbu-theme-probe'); if (p) return p; if (!document.body) return null; p = document.createElement('span'); p.id = 'mbu-theme-probe'; p.setAttribute('aria-hidden', 'true'); p.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;background:var(--background)'; document.body.appendChild(p); return p; } function mbuTheme() { var root = document.documentElement; try { var cs = getComputedStyle(root); var forced = (cs.getPropertyValue('--mbu-theme') || '').trim(); var t = (forced === 'dark' || forced === 'light') ? forced : (mbuThemeOf(getComputedStyle(document.body).backgroundColor) || mbuThemeOf(cs.backgroundColor) || mbuThemeOf(cs.getPropertyValue('--mbu-bg')) || 'light'); if (root.getAttribute('data-mbu-theme') !== t) root.setAttribute('data-mbu-theme', t); // Should we adopt the userstyle's OWN shades, or use our own palette? // Only if its --background actually agrees with the theme we detected. // A userstyle can paint the page dark with ordinary rules and still leave // --background at a light value for its own purposes; taking that on // trust hands us a light surface under a correct dark theme, which is // indistinguishable from the bug it looks like. Measured, not assumed. var seed = null; var raw = (cs.getPropertyValue('--background') || '').trim(); if (raw) { // Resolved through a real element, because --background may itself be // a var(), a named colour, or anything else CSS accepts. // // #569 (chaban-mb): this used to CREATE and REMOVE that element on // every call, as a direct child of . mbuTheme re-runs whenever // the root or body class changes, Mammoth watches the whole document // for childList changes and reacts by toggling classes on , and // those class changes wake mbuTheme again β€” a self-feeding loop, // measured at 12 root-node mutations a second on an idle page, which // is what makes DevTools blink. // // One element, created once and left in place, breaks it: the value // is still resolved LIVE on every call (a cached reading would freeze // the theme at whatever it was before Stylus injected, which is the // bug this whole function exists to avoid) but nothing is added to or // removed from the DOM to read it. var probe = mbuProbe(); var got = null; if (probe) { got = mbuThemeOf(getComputedStyle(probe).backgroundColor); } else { // No yet β€” document-start. Fall back to the transient // element for these first one or two calls; the idle loop this // avoids cannot exist before the page has a body anyway. var tmp = document.createElement('span'); tmp.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;background:var(--background)'; document.documentElement.appendChild(tmp); got = mbuThemeOf(getComputedStyle(tmp).backgroundColor); tmp.remove(); } if (got === t) seed = 'theme'; } // guarded: setAttribute dispatches a mutation record even when the value // is unchanged, and this runs several times a second if (seed) { if (root.getAttribute('data-mbu-seed') !== seed) root.setAttribute('data-mbu-seed', seed); } else if (root.hasAttribute('data-mbu-seed')) root.removeAttribute('data-mbu-seed'); return t; } catch (e) { return 'light'; } } // A document-start script runs before the document is parsed: documentElement can // still be null, and and don't exist. Observing a null root threw, the // catch below swallowed it, and nothing (the watches, the re-checks) was ever set up, // so such a script never read the theme at all (#625). It starts on the parsed page. function mbuThemeStart() { try { mbuTheme(); // Stylus and friends inject after us often enough that a one-shot read is // wrong about half the time. Watch for stylesheets ARRIVING β€” head childList // plus the root's own attributes β€” and never the whole subtree: this runs on // the release editor, where a subtree observer calling getComputedStyle is a // layout thrash on every keystroke. var _mbuThemeT = 0; var _mbuThemeSoon = function () { clearTimeout(_mbuThemeT); _mbuThemeT = setTimeout(mbuTheme, 150); }; var _mbuThemeObs = new MutationObserver(_mbuThemeSoon); _mbuThemeObs.observe(document.documentElement, { attributeFilter: ['style', 'class'] }); // ⚠ #569: characterData, not just childList. Until the idle thrash was fixed // this function ran several times a second whether or not anything had // changed β€” Apollo re-added a body class at 2Hz, which woke this observer, // which is how a theme change was ever noticed. That accidental polling was // LOAD-BEARING: with the thrash gone and only head-childList watched, a // userstyle that REWRITES ITSELF (Stylus editing it live, or one switching // palette) adds and removes no nodes, so nothing woke us and the theme went // stale. Caught by verify-569-theme-still-tracks.mjs, which passes on the // pre-fix build and failed on the first version of this one. if (document.head) _mbuThemeObs.observe(document.head, { childList: true, subtree: true, characterData: true }); if (document.body) _mbuThemeObs.observe(document.body, { attributeFilter: ['style', 'class'] }); // …and the case that produces no DOM mutation at all: the OS flipping to dark // under a userstyle with a prefers-color-scheme query. Nothing above can see // that, and nothing did before either β€” it was simply never noticed while the // page was re-checking itself several times a second. try { var _mbuMq = matchMedia('(prefers-color-scheme: dark)'); if (_mbuMq.addEventListener) _mbuMq.addEventListener('change', _mbuThemeSoon); else if (_mbuMq.addListener) _mbuMq.addListener(_mbuThemeSoon); } catch (e) {} setTimeout(mbuTheme, 400); setTimeout(mbuTheme, 2000); } catch (e) { /* no observer, no theme switching β€” the light defaults still apply */ } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mbuThemeStart, { once: true }); else mbuThemeStart(); try { var _mbuNs = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); if (!_mbuNs.MBU) _mbuNs.MBU = {}; if (!_mbuNs.MBU.theme) _mbuNs.MBU.theme = mbuTheme; if (!_mbuNs.MBU.helpHref) _mbuNs.MBU.helpHref = mbuHelpHref; if (!_mbuNs.MBU.helpHtml) _mbuNs.MBU.helpHtml = mbuHelpHtml; if (!_mbuNs.MBU.helpEl) _mbuNs.MBU.helpEl = mbuHelpEl; if (!_mbuNs.MBU.toast) _mbuNs.MBU.toast = mbuToast; if (!_mbuNs.MBU.cfgHeader) _mbuNs.MBU.cfgHeader = mbuCfgHeader; if (!_mbuNs.MBU.dismissOn) _mbuNs.MBU.dismissOn = mbuDismissOn; if (!_mbuNs.MBU.fitToolbar) _mbuNs.MBU.fitToolbar = mbuFitToolbar; } catch (e) { /* a locked-down page must not stop the script loading */ } //
const css = MBU_TOKENS + MBU_UI_CSS + ` fieldset.editnote, .editnote { max-width:100% !important; } /* On the release editor the edit note sits in a 540px .half-width column whose only sibling is the changes warning (not a guidelines column), so give that column the full form width when Mammoth is active. The :has() selector scopes it to our column. min-width:0 lets the editnote fieldset (min-content by default) take that width so margin:auto can center it. SCOPED to the release editor (body.mmth-reledit): on entity-creation/edit pages (artist/label/… /create, /edit) the .editnote sits in a genuine half-width column beside the guidelines, and widening it to 100% broke that two-column layout β€” visibly so alongside scripts that write into it (#268). */ .mmth-reledit .half-width:has(> .editnote.mmth-on), .mmth-reledit .col:has(> .editnote.mmth-on) { width:100% !important; max-width:100% !important; } .editnote.mmth-on { width:100% !important; max-width:100% !important; min-width:0 !important; box-sizing:border-box; } .editnote.mmth-on > .row { width:100% !important; box-sizing:border-box; } /* hide only the redundant inline "Edit note:" label next to the field β€” keep the section header (the fieldset's legend) visible (#212). */ .editnote.mmth-on > .row > label[for] { display:none !important; } /* align-items:flex-start (not stretch) so the panel keeps its own bounded height. Stretch made the panel grow to match the field, and the #229 floor (field min-height = panel height) then fed back through it β€” each pass added the field's padding/border, inflating both without bound (#245). The field is still floored to the panel via JS, so it's never shorter. */ .mmth-wrap { display:flex; gap:0; align-items:flex-start; width:100%; max-width:1040px; margin:6px auto; box-sizing:border-box; position:relative; } /* #304: field column = textarea + pinned-quick-buttons bar, stacked */ .mmth-fieldcol { flex:1 1 auto; min-width:0; display:flex; flex-direction:column; } .mmth-fieldcol > textarea.edit-note { width:100% !important; min-width:0; box-sizing:border-box; } /* #288/#290: foreign edit-note error/warning

s that MB (and other scripts) insert next to the textarea are RELOCATED out of the flex row by JS (see relocateForeign) so they never sit beside the field. No flex-wrap here β€” that made the panel itself wrap below the field in a narrow column (#290). */ /* Minimized mode (#265): the panel collapses to a small Mammoth badge in the field's top-right corner; the field takes the full width and the panel floats in only on hover. No width/height coupling, so it can't feed the #245 loop. */ .mmth-min .mmth-vsep { display:none !important; } .mmth-min > .mmth-side { position:absolute; top:30px; right:2px; z-index:60; display:none; box-shadow:0 8px 26px rgba(20,50,35,.22); } .mmth-min > .mmth-side.mmth-open { display:flex; } .mmth-badge { display:none; position:absolute; top:4px; right:5px; z-index:61; width:25px; height:25px; align-items:center; justify-content:center; cursor:pointer; border:1px solid var(--mbu-border); border-radius:7px; background:var(--mbu-bg); box-shadow:0 1px 3px rgba(0,0,0,.12); font-size:15px; line-height:1; user-select:none; } .mmth-badge:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); } .mmth-min > .mmth-badge { display:flex; } .mmth-vsep { flex:none; width:9px; align-self:flex-start; cursor:col-resize; position:relative; } /* #304: height synced to the field (not the field+pinbar column) */ .mmth-vsep::before { content:''; position:absolute; left:4px; top:0; bottom:0; width:1px; background:var(--mbu-bg-sunken); } .mmth-vsep:hover::before, .mmth-vsep.mmth-dragv::before { background:#5aa67e; width:3px; left:3px; } .mmth-hidehelp > p { display:none !important; } .mmth-side { flex:0 0 300px; max-width:300px; display:flex; flex-direction:column; border:1px solid var(--mbu-border); border-radius:8px; background:var(--mbu-bg); font:12px/1.35 -apple-system,Segoe UI,Arial,sans-serif; overflow:hidden; } .mmth-ft { display:flex; align-items:center; gap:2px; padding:3px 5px; border-bottom:1px solid var(--mbu-border); background:var(--mbu-bg-raised); } .mmth-fb { cursor:pointer; border:none; background:none; font-size:13px; line-height:1; padding:3px 6px; border-radius:5px; color:var(--mbu-text-dim); } .mmth-fb:hover { background:var(--mbu-ok-bg); } .mmth-fb.on { background:var(--mbu-ok-bg); color:var(--mbu-ok); } .mmth-fb.mmth-spacer { flex:1; pointer-events:none; } .mmth-fb.mmth-grp { margin-left:10px; } /* #309: per-type scope indicator (release / artist / recording / …) */ .mmth-scope { align-self:center; flex:none; font-size:10px; color:var(--mbu-text-dim); background:var(--mbu-bg-raised); border:1px solid var(--mbu-border); border-radius:9px; padding:1px 7px; margin-right:4px; white-space:nowrap; max-width:72px; overflow:hidden; text-overflow:ellipsis; } .mmth-ft { flex-wrap:wrap; } /* #304: never clip toolbar buttons β€” wrap as a last resort below the min width */ /* #304: opt-in search row (search box + count) between the toolbar and the list */ .mmth-filterrow { display:flex; align-items:center; gap:5px; padding:3px 5px; border-bottom:1px solid var(--mbu-border); background:var(--mbu-bg); } /* width:auto !important defends against MB's form CSS (#content input), which otherwise forces a fixed width and squashes the flex layout (#304) */ /* display:block !important beats MB's ".add-edit-note input { display:none }" (which otherwise hides our search box on /edit and /edits pages); width:auto !important beats MB's fixed input width. #304 */ .mmth-filter { display:block !important; flex:1 1 auto; min-width:0; width:auto !important; box-sizing:border-box; border:1px solid var(--mbu-border); border-radius:5px; padding:2px 6px; font:12px -apple-system,Segoe UI,Arial,sans-serif; } .mmth-filter:focus { outline:none; border-color:var(--mbu-ok); } .mmth-count { flex:none; font-size:11px; color:var(--mbu-text-weak); white-space:nowrap; } /* #304: pinned saved notes as quick-insert buttons BELOW the field (like baby-field bars) */ :root { --mmth-fur:#7a4a1f; --mmth-tusk:#efe7d2; } :root[data-mbu-theme="dark"] { --mmth-fur:#c98a44; --mmth-tusk:#f6efdd; } .mmth-pinbar { display:flex; flex-wrap:wrap; gap:5px; margin:5px 0 2px; } .mmth-segb { border:1px solid var(--mbu-border) !important; background:var(--mbu-bg); border-radius:7px; padding:3px 10px !important; font:12px/1.2 -apple-system,Segoe UI,Arial,sans-serif !important; color:var(--mbu-ok); cursor:pointer; max-width:200px; height:auto !important; min-height:0 !important; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; box-shadow:0 1px 2px rgba(0,0,0,.06); } .mmth-segb:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); } .mmth-row.mmth-pinned .mmth-txt::before { content:'β˜…'; color:var(--mbu-warn); margin-right:4px; font-size:10px; vertical-align:1px; } /* #304: tabbed config window (Settings / Import-Export) */ .mmth-cfgtabs { display:flex; gap:4px; margin:0 0 8px; border-bottom:1px solid var(--mbu-border); } .mmth-cfgtab { border:none; background:none; padding:4px 9px; font-size:12px; color:var(--mbu-text-dim); cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; } .mmth-cfgtab:hover { color:var(--mbu-ok); } .mmth-cfgtab.on { color:var(--mbu-ok); border-bottom-color:var(--mbu-ok); font-weight:600; } .mmth-cfgsec { font-weight:600; font-size:11px; color:var(--mbu-text-dim); text-transform:uppercase; letter-spacing:.04em; margin:10px 0 4px; padding-bottom:2px; border-bottom:1px solid var(--mbu-border); } .mmth-cfgpane > .mmth-cfgsec:first-child { margin-top:2px; } /* custom baby fields (config) */ /* Custom fields β€” flat, borderless grid */ .mmth-cf-top { display:flex; align-items:center; gap:6px; margin:2px 0 8px; } .mmth-cf-total { margin-left:auto; font-size:11px; color:var(--mbu-text-dim); white-space:nowrap; } .mmth-cf-total b { font-weight:700; color:var(--mbu-ok); } .mmth-cf-total .mmth-cf-off-n { color:var(--mbu-warn); } .mmth-cf-list { display:flex; flex-direction:column; gap:0; margin:0 0 4px; max-height:46vh; overflow-y:auto; overflow-x:hidden; } .mmth-cf-list::-webkit-scrollbar { width:8px; } .mmth-cf-list::-webkit-scrollbar-thumb { background:var(--mbu-bg-sunken); border-radius:4px; } .mmth-cf-row { display:flex; align-items:center; gap:5px; flex-wrap:nowrap; padding:0 3px; border-radius:5px; } .mmth-cf-row:hover { background:var(--mbu-bg-raised); } .mmth-cf-in { border:none; border-bottom:1px solid var(--mbu-border); border-radius:0; background:transparent; padding:2px 3px; font:11.5px -apple-system,Segoe UI,Arial,sans-serif; box-sizing:border-box; min-width:0; } .mmth-cf-in:focus { outline:none; border-bottom-color:var(--mbu-ok); } .mmth-cf-in::placeholder { color:var(--mbu-text-weak); } .mmth-cf-match { flex:1 1 auto; min-width:90px; } .mmth-cf-in[type=number] { border:none; border-bottom:1px solid var(--mbu-border); -moz-appearance:textfield; text-align:center; } /* -moz-appearance:textfield hides FF spinners; keep the flat border (appearance:textfield re-added a native box in Chromium) */ .mmth-cf-in[type=number]::-webkit-inner-spin-button, .mmth-cf-in[type=number]::-webkit-outer-spin-button { -webkit-appearance:none; margin:0; } .mmth-cf-sub { display:inline-flex; align-items:center; gap:3px; font-size:11px; color:var(--mbu-text-weak); white-space:nowrap; cursor:pointer; } /* matches count β€” a compact green pill; grey when 0, red when the selector is invalid */ .mmth-cf-cnt { min-width:18px; text-align:center; font:700 10px/16px -apple-system,Segoe UI,Arial,sans-serif; color:var(--mbu-ok); background:var(--mbu-ok-bg); border-radius:9px; padding:0 5px; } .mmth-cf-cnt:empty { background:none; } .mmth-cf-cnt.mmth-cf-zero { color:var(--mbu-text-weak); background:var(--mbu-bg-raised); } .mmth-cf-cnt.mmth-cf-bad { color:var(--mbu-text-on-accent); background:#c0392b; } .mmth-cf-del { border:none; background:none; cursor:pointer; font-size:13px; padding:2px 4px; border-radius:4px; visibility:hidden; } .mmth-cf-row:hover .mmth-cf-del { visibility:visible; } .mmth-cf-del:hover { background:var(--mbu-error-bg); } /* enable/disable toggle β€” shown on row hover, and always on a disabled row so it can be switched back */ .mmth-cf-tog { border:none; background:none; cursor:pointer; font-size:11px; line-height:1; padding:2px 3px; border-radius:4px; color:var(--mbu-ok); visibility:hidden; } .mmth-cf-tog:hover { background:var(--mbu-ok-bg); } .mmth-cf-row:hover .mmth-cf-tog, .mmth-cf-row.mmth-cf-off .mmth-cf-tog { visibility:visible; } .mmth-cf-row.mmth-cf-off { background:var(--mbu-bg); } .mmth-cf-row.mmth-cf-off .mmth-cf-in, .mmth-cf-row.mmth-cf-off .mmth-cf-cnt, .mmth-cf-row.mmth-cf-off .mmth-cf-sub { opacity:.4; } .mmth-cf-row.mmth-cf-off .mmth-cf-tog { color:var(--mbu-text-weak); } .mmth-cf-add { border:1px dashed var(--mbu-border); background:var(--mbu-bg); border-radius:var(--mbu-radius); padding:5px 12px; cursor:pointer; color:var(--mbu-ok); font-size:12px;font-family:inherit; } .mmth-cf-add:hover { background:var(--mbu-ok-bg); } .mmth-cf-empty { color:var(--mbu-text-weak); font-style:italic; font-size:12px; padding:2px 0; } .mmth-cf-reset { margin-left:6px; border:1px solid var(--mbu-border); background:var(--mbu-bg); border-radius:5px; padding:2px 9px; font:11px -apple-system,Segoe UI,Arial,sans-serif; color:var(--mbu-ok); cursor:pointer; text-transform:none; letter-spacing:normal; } .mmth-cf-reset:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); } .mmth-cf-mode { border:1px solid var(--mbu-border); background:var(--mbu-bg); border-radius:5px; padding:2px 9px; font:11px -apple-system,Segoe UI,Arial,sans-serif; color:var(--mbu-ok); cursor:pointer; text-transform:none; letter-spacing:normal; } .mmth-cf-mode:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); } .mmth-cf-json { width:100%; box-sizing:border-box; min-height:150px; font:12px/1.4 ui-monospace,Consolas,monospace; border:1px solid var(--mbu-border); border-radius:var(--mbu-radius); padding:7px; resize:vertical; } .mmth-cf-json:focus { outline:none; border-color:var(--mbu-ok); } .mmth-cf-jsonrow { display:flex; align-items:center; gap:9px; margin-top:6px; } .mmth-cf-jsonapply { border:1px dashed var(--mbu-border); background:var(--mbu-bg); border-radius:var(--mbu-radius); padding:5px 12px; cursor:pointer; color:var(--mbu-ok); font-size:12px;font-family:inherit; } .mmth-cf-jsonapply:hover { background:var(--mbu-ok-bg); } .mmth-cf-jsonmsg { font-size:11px; color:var(--mbu-text-weak); } .mmth-cf-jsonmsg.mmth-cf-bad { color:var(--mbu-error); } /* #304: import/export pane (the pane IS the flex column β€” no inner .mmth-io wrapper) */ .mmth-cfgpane[data-pane="io"] { display:flex; flex-direction:column; gap:8px; } .mmth-io-modes { display:flex; flex-flow:row wrap; gap:6px 18px; font-size:12px; align-items:center; } .mmth-io-modes label { margin:0; display:inline-flex; align-items:center; gap:6px; } /* width/height !important to beat MB's #content textarea form CSS (#304) */ .mmth-io-ta { width:100% !important; box-sizing:border-box; height:160px !important; min-height:120px; resize:vertical; border:1px solid var(--mbu-border); border-radius:5px; padding:6px 8px; font:13px/1.55 -apple-system,Segoe UI,Arial,sans-serif; } .mmth-io-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; } .mmth-io-btn { cursor:pointer; border:1px solid var(--mbu-border); background:var(--mbu-bg); border-radius:5px; padding:2px 8px; font-size:12px; color:var(--mbu-ok); } .mmth-io-btn:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); } .mmth-io-msg { font-size:11px; color:var(--mbu-text-weak); } .mmth-list { flex:1 1 auto; overflow-y:auto; scrollbar-width:none; } .mmth-list::-webkit-scrollbar { width:0; height:0; } .mmth-row { display:flex; align-items:center; gap:4px; padding:4px 6px; border-top:1px solid var(--mbu-border); cursor:pointer; } .mmth-row:first-child { border-top:none; } .mmth-row:hover { background:var(--mbu-ok-bg); } .mmth-row.mmth-cyc { background:var(--mbu-ok-bg); } .mmth-row.mmth-drop-before { box-shadow:inset 0 2px 0 #2c7a51; } .mmth-row.mmth-drop-after { box-shadow:inset 0 -2px 0 #2c7a51; } .mmth-row.mmth-dragging { opacity:.45; } .mmth-grab { flex:none; cursor:grab; color:var(--mbu-text-weak); font-size:12px; user-select:none; opacity:0; } .mmth-row:hover .mmth-grab { opacity:1; } .mmth-grab:active { cursor:grabbing; } .mmth-txt { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--mbu-text); } .mmth-rowacts { flex:none; display:flex; gap:1px; opacity:0; } .mmth-row:hover .mmth-rowacts { opacity:1; } .mmth-ra { cursor:pointer; border:none; background:none; color:var(--mbu-text-dim); font-size:11px; line-height:1; padding:1px 2px; border-radius:3px; } .mmth-ra:hover { background:var(--mbu-ok-bg); color:var(--mbu-ok); } .mmth-empty { padding:12px 8px; color:var(--mbu-text-weak); font-style:italic; text-align:center; } .mmth-pop { position:fixed; z-index:2147483647; background:var(--mbu-bg); border:1px solid var(--mbu-border); border-radius:8px; box-shadow:0 8px 26px rgba(20,50,35,.2); /* max β€” the config/syntax popup must clear any host modal (GT/ISRC live near max) */ padding:10px 12px; font:13px/1.45 -apple-system,Segoe UI,Arial,sans-serif; color:var(--mbu-text); width:280px; } .mmth-cfg { width:360px; max-height:90vh; overflow:hidden; } /* #304 wider config window; cap height β€” the Babies list scrolls internally */ .mmth-cfg.mmth-cfg-wide { width:min(660px,94vw); } /* Fields tab: roomier so each custom-field row fits one line */ .mmth-pop h4 { margin:-10px -12px 8px; padding:6px 10px; font-size:13px; display:flex; align-items:center; gap:6px; background:var(--mbu-bg-raised); border-bottom:1px solid var(--mbu-border); border-radius:8px 8px 0 0; } .mmth-tip { color:var(--mbu-text-weak); font-size:11px; margin:0 0 4px 22px; } .mmth-pop h4 .mmth-ver { color:var(--mbu-text-weak); font-weight:400; font-size:11px; } .mmth-pop h4 a { margin-left:auto; font-size:11px; color:var(--mbu-ok); text-decoration:none; font-weight:600; } .mmth-pop h4 .mmth-h4ic { display:inline-flex; width:18px; height:18px; flex:none; } /* #308 vector mammoth */ .mmth-badge svg { width:19px; height:19px; } /* #308 vector mammoth */ .mmth-pop label { display:flex; align-items:center; gap:6px; margin:5px 0; cursor:pointer; } .mmth-pop input[type="number"]:not(.mmth-cf-in) { width:46px; border:1px solid var(--mbu-border); border-radius:4px; padding:1px 4px; } .mmth-pop select:not(.mmth-cf-in) { border:1px solid var(--mbu-border); border-radius:4px; padding:1px 4px; } .mmth-pop code { background:var(--mbu-bg-raised); border-radius:3px; padding:0 3px; font-size:12px; } .mmth-pop .mmth-syn { display:grid; grid-template-columns:auto 1fr; gap:3px 10px; margin:4px 0; } .mmth-pop .mmth-sub { font-weight:600; font-size:12px; margin:8px 0 2px; } /* While an MB dialog/popover is open, hide Mammoth's panel (incl. the minimized badge) and its popovers β€” MB's dialogs carry no z-index, so our UI would float on top of them. visibility keeps the docked panel's layout (no jump). #313 Uses mmthf-anydialog (set for ANY open dialog), not mmthf-dialog (blocking only): a dialog that HOSTS babies is non-blocking so its own pins stay, but the main panel/badge must still hide under it (else it floats over β€” #400). */ html.mmthf-anydialog .mmth-side, html.mmthf-anydialog .mmth-badge, html.mmthf-anydialog .mmth-pop { visibility:hidden !important; pointer-events:none !important; } `; (function () { const s = document.createElement('style'); s.textContent = css; (document.head || document.documentElement).appendChild(s); })(); // #268: only the release editor wants its edit-note .half-width column widened to // full width. Tag it so the widening rule above is scoped to it and never disturbs // the two-column layout of entity create/edit pages (artist, label, work, …). if (/^\/release\/(?:add|[0-9a-f-]{36}\/edit)(?:[/?#]|$)/.test(location.pathname)) document.documentElement.classList.add('mmth-reledit'); // Show a toast near where the user is acting (the Mammoth panel / button they just // clicked) instead of pinned to the top of the page, which reads as unrelated (#268 // follow-up). Falls back to top-centre when there's no recent Mammoth interaction. let _toastPt = null; document.addEventListener('pointerdown', e => { const t = e.target.closest && e.target.closest('.mmth-side, .mmth-pop, .mmth-wrap, .mmth-badge'); if (t) _toastPt = { x: e.clientX, y: e.clientY }; }, true); // #563: the shared toast. Mammoth was the only script anchoring a toast to the // click point, so that became the standard component's `at` option rather than // being dropped β€” the 1.5s duration is kept too, it is deliberately quick. function toast(msg) { return mbuToast(msg, { ms: 1500, at: _toastPt || undefined }); } // #305: dismiss our popovers on the outside *click* (capture), NOT mousedown. // Tearing the popover down on mousedown removed what was under the cursor, so the // trailing mouseup/click fell through to whatever sat beneath it (e.g. Apollo's // cover-art thumbnail under the field) and activated it β€” "cover art opened when // selecting a label". Handling the click lets us swallow that exact event so the // dismiss can never double as activating something below. // ── popovers (settings + syntax help) ──────────────────────────────────────── let pop = null; function closePop() { if (pop) { pop.remove(); pop = null; document.removeEventListener('click', onPopDown, true); document.removeEventListener('keydown', onPopKey, true); } } function onPopDown(e) { if (pop && !pop.contains(e.target) && !e.target.closest('.mmth-pop-anchor')) { e.preventDefault(); e.stopPropagation(); closePop(); } } function onPopKey(e) { if (pop && e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closePop(); } } // #347: ESC closes the settings / syntax popup function placePop(p, anchor) { const W = p.offsetWidth, H = p.offsetHeight, vw = window.innerWidth, vh = window.innerHeight; const r = anchor && anchor.getBoundingClientRect(); if (!r || (!r.width && !r.height)) { // anchor gone/hidden (e.g. a baby pin that re-hid) β€” sensible centred fallback p.style.left = Math.max(6, Math.round((vw - W) / 2)) + 'px'; p.style.top = '70px'; } else { // #304: open just BELOW the trigger and clamp into the viewport, so the popover // stays next to what opened it instead of jumping above (which clamped to the // top-left corner when opened from a baby pin low on a tall page). const left = Math.max(6, Math.min(vw - W - 6, r.left)); let top = r.bottom + 4; // below the trigger if (top + H > vh - 6) { // would overflow the bottom const above = r.top - H - 6; // try above if it fits, else clamp top = above >= 6 ? above : (vh - H - 6); } // …and never below the viewport: an anchor already off screen (the page grew, or was // scrolled, since it was clicked) put "above it" off screen too, out of reach. top = Math.min(top, vh - H - 6); p.style.left = left + 'px'; p.style.top = Math.max(6, top) + 'px'; } setTimeout(() => { document.addEventListener('click', onPopDown, true); document.addEventListener('keydown', onPopKey, true); }, 0); } // #304: drag the popover by its header (so it can be moved out of the way). function makeDraggable(popEl, handle) { // A missing handle means "not draggable", not "throw". This threw for real: // losing the drag affordance is a small thing, taking the rest of the caller // down with it is not. if (!handle) return; handle.classList.add('mmth-draghandle'); let sx = 0, sy = 0, sl = 0, st = 0, on = false; const move = e => { if (!on) return; let l = sl + (e.clientX - sx), t = st + (e.clientY - sy); l = Math.max(6, Math.min(window.innerWidth - popEl.offsetWidth - 6, l)); t = Math.max(6, Math.min(window.innerHeight - popEl.offsetHeight - 6, t)); popEl.style.left = l + 'px'; popEl.style.top = t + 'px'; }; const up = () => { on = false; document.removeEventListener('mousemove', move, true); document.removeEventListener('mouseup', up, true); }; handle.addEventListener('mousedown', e => { if (e.target.closest('a, button, input, select, textarea')) return; // don't hijack the Help link etc. on = true; popEl.dataset.moved = '1'; const r = popEl.getBoundingClientRect(); sl = r.left; st = r.top; sx = e.clientX; sy = e.clientY; e.preventDefault(); document.addEventListener('mousemove', move, true); document.addEventListener('mouseup', up, true); }); } // #304/#309: tabbed config window β€” Settings + Import / Export. `io` lets the // caller scope import/export to a specific field (e.g. a baby field's values). function openSettings(anchor, tab, io) { closePop(); const p = document.createElement('div'); p.className = 'mmth-pop mmth-cfg'; p.innerHTML = ` ${mbuCfgHeader({ script: 'mammoth', name: 'Mammoth', version: scriptVersion(), icon: MAMMOTH_SVG })}

Edit note settings
General
`; document.body.appendChild(p); pop = p; // #304: movable config window. The handle is the SHARED config header // (.mbu-cfg-h) β€” #563 replaced the old

with it, and this line still asked // for the

. It got null, makeDraggable threw on handle.classList, and // every line of openSettings after this one β€” the tab wiring, all the option // handlers, the close button β€” never ran. The window opened and did nothing. makeDraggable(p, p.querySelector('.mbu-cfg-h, h4')); // tab switching β€” only toggles the pane; position stays put (re-placing here made // the window jump because the two tabs differ in height). #304 const tabs = [...p.querySelectorAll('.mmth-cfgtab')], panes = [...p.querySelectorAll('.mmth-cfgpane')]; // Fields (one row per field) and Import/Export (long "name " lines) need more horizontal room β†’ // widen the window on those tabs. Re-place only when the wide state actually flips, so it doesn't jump. const showTab = name => { const wasWide = p.classList.contains('mmth-cfg-wide'); tabs.forEach(t => t.classList.toggle('on', t.dataset.tab === name)); panes.forEach(pn => { pn.style.display = pn.dataset.pane === name ? '' : 'none'; }); const wide = name === 'fields' || name === 'io'; p.classList.toggle('mmth-cfg-wide', wide); if (wide !== wasWide) placePop(p, anchor); }; tabs.forEach(t => t.onclick = () => { showTab(t.dataset.tab); SET.lastTab = t.dataset.tab; persistSet(); }); // remember the tab across opens // ── Settings pane ── const scope = p.querySelector('.mmth-s-scope'); scope.checked = SET.scopePerResource === true; scope.onchange = () => { SET.scopePerResource = scope.checked; persistSet(); useScope(); updateScopeChips(); render(); }; const help = p.querySelector('.mmth-s-help'); help.checked = !!SET.hideHelp; const ins = p.querySelector('.mmth-s-ins'); ins.value = SET.defaultInsert; const nl = p.querySelector('.mmth-s-nl'); nl.checked = SET.appendNewline !== false; const sort = p.querySelector('.mmth-s-sort'); sort.value = SET.noteSort || 'manual'; const btnc = p.querySelector('.mmth-s-btnchars'); btnc.value = SET.btnChars || 24; const rows = p.querySelector('.mmth-s-rows'); rows.value = SET.visibleRows; const hist = p.querySelector('.mmth-s-hist'); hist.value = SET.historySize; help.onchange = () => { SET.hideHelp = help.checked; saveSet(); }; ins.onchange = () => { SET.defaultInsert = ins.value; saveSet(); }; nl.onchange = () => { SET.appendNewline = nl.checked; saveSet(); }; sort.onchange = () => { SET.noteSort = sort.value; persistSet(); render(); }; btnc.onchange = () => { SET.btnChars = Math.max(4, Math.min(80, parseInt(btnc.value, 10) || 24)); btnc.value = SET.btnChars; saveSet(); babyMammoths.relabel(); }; rows.onchange = () => { SET.visibleRows = Math.max(1, Math.min(30, parseInt(rows.value, 10) || 6)); rows.value = SET.visibleRows; saveSet(); }; hist.onchange = () => { SET.historySize = Math.max(1, Math.min(50, parseInt(hist.value, 10) || 10)); hist.value = SET.historySize; saveSet(); recordHistory(''); }; const babies = p.querySelector('.mmth-s-babies'); babies.checked = SET.showBabies !== false; babies.onchange = () => { SET.showBabies = babies.checked; persistSet(); babyMammoths.toggle(babies.checked); }; // ── Fields pane ── custom baby fields: a user-editable list of {match,label,key,dx,entity}. Persisted in // SET; each edit re-scans the page (debounced) so pins appear/disappear live. Bad/empty selectors show a // "bad selector" / match count so a typo is caught before it litters pins. const cfList = p.querySelector('.mmth-cf-list'), cfTotal = p.querySelector('.mmth-cf-total'); const cfCount = sel => { try { return document.querySelectorAll(sel).length; } catch (e) { return -1; } }; let cfRefreshT = 0; const cfApply = () => { persistSet(); clearTimeout(cfRefreshT); cfRefreshT = setTimeout(() => { try { babyMammoths.refresh(); } catch (e) {} }, 400); }; function renderFields() { SET.customFields = SET.customFields || []; cfList.textContent = ''; if (cfTotal) { const n = SET.customFields.length, off = SET.customFields.filter(c => c && c.enable === false).length; cfTotal.innerHTML = `${n} ${n === 1 ? 'field' : 'fields'}` + (off ? `, ${off} disabled` : ''); } if (!SET.customFields.length) { const e = document.createElement('div'); e.className = 'mmth-cf-empty'; e.textContent = 'No custom fields yet.'; cfList.appendChild(e); } SET.customFields.forEach((cf, i) => { const row = document.createElement('div'); row.className = 'mmth-cf-row' + (cf.enable === false ? ' mmth-cf-off' : ''); const cnt = document.createElement('span'); cnt.className = 'mmth-cf-cnt'; const paint = () => { if (!cf.match) { cnt.textContent = ''; cnt.className = 'mmth-cf-cnt'; cnt.title = ''; return; } const n = cfCount(cf.match); cnt.textContent = n < 0 ? '!' : String(n); cnt.title = n < 0 ? 'invalid selector' : `matches ${n} field(s) now`; cnt.className = 'mmth-cf-cnt' + (n < 0 ? ' mmth-cf-bad' : n === 0 ? ' mmth-cf-zero' : ''); }; const mk = (ph, key, w, type) => { const inp = document.createElement('input'); inp.type = type || 'text'; inp.placeholder = ph; inp.className = 'mmth-cf-in mmth-cf-' + key; inp.value = cf[key] != null ? cf[key] : ''; if (w) inp.style.width = w; inp.oninput = () => { cf[key] = type === 'number' ? (inp.value === '' ? '' : +inp.value) : inp.value; if (key === 'match') paint(); cfApply(); }; return inp; }; const dvIn = mk('lvl', 'dv', '34px', 'number'); dvIn.title = 'Bar level (deltav) β€” attach the pinned-button bar in-flow after the Nth ancestor of the field (0 = floating; raise it so the bar pushes the UI below instead of overlapping)'; dvIn.min = '0'; const subCb = document.createElement('input'); subCb.type = 'checkbox'; subCb.checked = !!cf.submit; subCb.onchange = () => { cf.submit = subCb.checked; cfApply(); }; const subL = document.createElement('label'); subL.className = 'mmth-cf-sub'; subL.title = 'Submit the field’s form ~200ms after a value is recalled β€” commits a tag, runs a header search, etc. (like pressing Enter). Only enable it on fields whose form is safe to submit on recall.'; subL.append(subCb, Object.assign(document.createElement('span'), { textContent: '↡' })); const tog = document.createElement('button'); tog.type = 'button'; tog.className = 'mmth-cf-tog'; const off0 = cf.enable === false; tog.textContent = off0 ? 'β—‹' : 'β—‰'; tog.title = off0 ? 'Field is off β€” click to enable' : 'Field is on β€” click to disable'; tog.onclick = () => { cf.enable = (cf.enable === false); renderFields(); cfApply(); }; const del = document.createElement('button'); del.type = 'button'; del.className = 'mmth-cf-del'; del.textContent = 'πŸ—‘'; del.title = 'Remove this field'; del.onclick = () => { SET.customFields.splice(i, 1); renderFields(); cfApply(); }; row.append(mk('CSS selector β€” comma-separate for several', 'match', '', 'text'), mk('Label', 'label', '84px'), mk('px', 'dx', '34px', 'number'), dvIn, subL, cnt, tog, del); cfList.appendChild(row); paint(); }); } p.querySelector('.mmth-cf-add').onclick = () => { SET.customFields = SET.customFields || []; SET.customFields.unshift({ match: '', label: '', dx: '', dv: '', submit: false }); renderFields(); const first = cfList.querySelector('.mmth-cf-row .mmth-cf-match'); if (first) first.focus(); }; p.querySelector('.mmth-cf-reset').onclick = () => { SET.customFields = SET.customFields || []; const have = new Set(SET.customFields.map(c => c && c.match)); const missing = BUILTIN_FIELDS.filter(b => !have.has(b.match)).map(b => ({ ...b })); if (missing.length) { SET.customFields = [...missing, ...SET.customFields]; renderFields(); cfApply(); } }; renderFields(); // JSON text mode β€” the same list as an editable/copy-pasteable JSON blob (friendly keys: selector / // label / key / deltax). Doubles as export (copy the box) and import (paste + Apply). const jsonTa = p.querySelector('.mmth-cf-json'), jsonMsg = p.querySelector('.mmth-cf-jsonmsg'), jsonRow = p.querySelector('.mmth-cf-jsonrow'), modeBtn = p.querySelector('.mmth-cf-mode'), addBtn = p.querySelector('.mmth-cf-add'), resetBtn = p.querySelector('.mmth-cf-reset'); const cfOut = cf => { const o = { selector: cf.match || '' }; if (cf.label) o.label = cf.label; if (cf.dx != null && cf.dx !== '') o.deltax = +cf.dx; if (cf.dv) o.deltav = +cf.dv; if (cf.submit) o.submit = true; if (cf.mbid) o.mbid = true; if (cf.enable === false) o.enable = false; return o; }; // key is derived from the label; `submit` submits the field's form after recall (row ↡ checkbox); `mbid` JSON-only; `enable:false` disables const cfToJson = () => JSON.stringify((SET.customFields || []).map(cfOut), null, 2); const cfFromJson = txt => { const arr = JSON.parse(txt.replace(/,(\s*[}\]])/g, '$1')); // tolerate trailing commas if (!Array.isArray(arr)) throw new Error('expected a JSON array'); return arr.filter(o => o && typeof o === 'object').map(o => ({ match: String(o.selector != null ? o.selector : (o.match || '')).trim(), label: o.label ? String(o.label) : '', dx: (o.deltax != null && o.deltax !== '') ? +o.deltax : ((o.dx != null && o.dx !== '') ? +o.dx : ''), dv: (o.deltav != null && o.deltav !== '') ? +o.deltav : ((o.dv != null && o.dv !== '') ? +o.dv : ''), submit: !!(o.submit || o.enter), mbid: !!o.mbid, enable: o.enable !== false, // legacy `enter` β†’ submit })).filter(c => c.match); }; const applyJson = () => { try { SET.customFields = cfFromJson(jsonTa.value); jsonMsg.textContent = `${SET.customFields.length} field(s) applied`; jsonMsg.classList.remove('mmth-cf-bad'); renderFields(); cfApply(); return true; } catch (e) { jsonMsg.textContent = 'Invalid JSON: ' + (e.message || e); jsonMsg.classList.add('mmth-cf-bad'); return false; } }; const setJsonMode = on => { if (on) { const h = cfList.offsetHeight; if (h > 0) jsonTa.style.height = h + 'px'; } // match the visual list's height (measure while it's still shown) cfList.style.display = addBtn.style.display = resetBtn.style.display = on ? 'none' : ''; jsonTa.style.display = jsonRow.style.display = on ? '' : 'none'; modeBtn.textContent = on ? 'β–€ Visual' : '{ } JSON'; if (on) { jsonTa.value = cfToJson(); jsonMsg.textContent = ''; jsonMsg.classList.remove('mmth-cf-bad'); } }; modeBtn.onclick = () => { const toJson = modeBtn.textContent.indexOf('JSON') >= 0; if (toJson) setJsonMode(true); else if (applyJson()) setJsonMode(false); }; p.querySelector('.mmth-cf-jsonapply').onclick = applyJson; // ── Import / Export pane ── (#304/#309: scoped to `io` β€” edit-note notes by // default, or a specific field's values when opened from a baby field) const ctx = io || { items: () => DATA.saved.map(s => s.text), add: notes => addSavedNotes(notes), help: 'Import adds to your saved notes; Export copies them all to the clipboard.' }; const ioTa = p.querySelector('.mmth-io-ta'), ioMsg = p.querySelector('.mmth-io-msg'); const ioBlock = () => p.querySelector('input[name="mmth-iomode"]:checked').value === 'block'; p.querySelector('.mmth-io-help').textContent = ctx.help || ''; p.querySelector('.mmth-io-import').onclick = () => { const notes = parseNotes(ioTa.value, ioBlock()); if (!notes.length) { ioMsg.textContent = 'Paste some notes first'; return; } const added = ctx.add(notes); ioMsg.textContent = `Added ${added} of ${notes.length}` + (added < notes.length ? ' (rest were duplicates)' : ''); if (added) ioTa.value = ''; }; p.querySelector('.mmth-io-export').onclick = async () => { const items = ctx.items(); const text = items.join(ioBlock() ? '\n\n' : '\n'); ioTa.value = text; ioTa.focus(); ioTa.select(); let copied = false; try { await navigator.clipboard.writeText(text); copied = true; } catch (e) { try { copied = document.execCommand('copy'); } catch (x) {} } ioMsg.textContent = `${items.length} item(s)` + (copied ? ' β€” copied to clipboard' : ' β€” select & copy'); }; // open on the explicitly-requested tab (baby field β†’ 'io'), else the last tab used, else Settings const TABS = ['settings', 'fields', 'io']; showTab(TABS.includes(tab) ? tab : (TABS.includes(SET.lastTab) ? SET.lastTab : 'settings')); placePop(p, anchor); // position once; tab switches no longer move it } function openSyntax(anchor) { closePop(); const p = document.createElement('div'); p.className = 'mmth-pop'; p.innerHTML = `

Edit-note syntaxdoc β†—

''italic''italic '''bold'''bold edit #123456link to an edit doc:Pageor [Page_Name] β€” wiki doc link
URLs become links automatically. HTML is not supported.
Shortcuts
Ctrl/⌘ Bbold the selection / word Ctrl/⌘ Iitalicise the selection / word Ctrl/⌘ ↑/↓cycle saved notes
`; document.body.appendChild(p); pop = p; placePop(p, anchor); } // ── sidebars (one per edit-note textarea) ──────────────────────────────────── const instances = []; function applyHelp() { document.querySelectorAll('.editnote').forEach(en => mbuCls(en, 'mmth-hidehelp', !!SET.hideHelp)); } const after = (e, el) => (e.clientY - el.getBoundingClientRect().top) > el.offsetHeight / 2; const clearMarks = host => host && host.querySelectorAll('.mmth-drop-before,.mmth-drop-after').forEach(r => r.classList.remove('mmth-drop-before', 'mmth-drop-after')); let _drag = null; const MIN_PANEL = 300; // #304: keep the toolbar (incl. βš™) from clipping when narrow function setSideWidth(side, w) { w = Math.max(MIN_PANEL, Math.min(640, Math.round(w))); side.style.flex = '0 0 ' + w + 'px'; side.style.maxWidth = w + 'px'; return w; } // #263: never let the panel be wider than the field β€” cap it to half the row so // the ratio is at most 1:1 (was up to ~1:10 in a narrow Art Station modal). The // cap reads ONLY the wrap's width, which the container fixes and setting the // panel never changes, so this can't oscillate (unlike a field-width-based cap, // which would: shrinking the panel grows the field, re-raising the cap…). function capPanel(wrap, vsep, side) { if (SET.minimized) return; // panel is out of flow when minimized const row = wrap.clientWidth - (vsep ? vsep.offsetWidth : 0); if (!(row > 0)) return; const max = Math.floor(row / 2); const want = Math.max(MIN_PANEL, Math.min(SET.sideWidth || 300, max)); if (Math.round(side.getBoundingClientRect().width) !== want) { side.style.flex = '0 0 ' + want + 'px'; side.style.maxWidth = want + 'px'; } } // ── minimized mode (#265) ───────────────────────────────────────────────────── // A less-intrusive mode: the panel collapses to a small Mammoth badge in the // field's top-right corner and floats back in on hover. Persisted, so it stays // minimized across edit pages. WIDTH/position only β€” never touches the field's // height, so it can't reintroduce the #245 growth loop. function applyMinState(inst) { const wrap = inst.ta && inst.ta.closest('.mmth-wrap'); if (!wrap) return; const on = !!SET.minimized; wrap.classList.toggle('mmth-min', on); if (inst.minBtn) { inst.minBtn.textContent = on ? 'β€’' : '–'; inst.minBtn.title = on ? 'Restore the panel' : 'Minimize to corner'; } if (on) { try { inst.ta.style.minHeight = ''; } catch (x) {} } // drop the panel-height floor β€” panel is out of flow now if (!on) { if (inst.unpin) inst.unpin(); if (inst.side) inst.side.classList.remove('mmth-open'); } } function setMinimized(on) { SET.minimized = !!on; persistSet(); instances.forEach(i => { applyMinState(i); if (i.recap) i.recap(); }); } // drag the separator to resize the panel vs. the field (persisted) function wireResize(vsep, side) { let startX = 0, startW = 0, on = false; vsep.addEventListener('pointerdown', e => { on = true; startX = e.clientX; startW = side.getBoundingClientRect().width; try { vsep.setPointerCapture(e.pointerId); } catch (x) {} vsep.classList.add('mmth-dragv'); document.body.style.userSelect = 'none'; e.preventDefault(); }); vsep.addEventListener('pointermove', e => { // panel is on the right β†’ drag left widens it; cap at half the row (#263) if (!on) return; const wrap = side.parentNode, row = (wrap ? wrap.clientWidth : 0) - vsep.offsetWidth; const max = row > 0 ? Math.floor(row / 2) : 640; setSideWidth(side, Math.min(max, startW - (e.clientX - startX))); }); const end = e => { if (!on) return; on = false; vsep.classList.remove('mmth-dragv'); document.body.style.userSelect = ''; SET.sideWidth = setSideWidth(side, side.getBoundingClientRect().width); saveSet(); try { vsep.releasePointerCapture(e.pointerId); } catch (x) {} }; vsep.addEventListener('pointerup', end); vsep.addEventListener('pointercancel', end); } function buildSide(ta) { const side = document.createElement('div'); side.className = 'mmth-side'; setSideWidth(side, SET.sideWidth || 300); const ft = document.createElement('div'); ft.className = 'mmth-ft'; // toolbar ON TOP (#212) const filterRow = document.createElement('div'); filterRow.className = 'mmth-filterrow'; // #304 search (opt-in) const list = document.createElement('div'); list.className = 'mmth-list'; const pinbar = document.createElement('div'); pinbar.className = 'mmth-pinbar'; // #304 pinned quick-buttons β€” placed BELOW the field (in injectAll) side.appendChild(ft); side.appendChild(filterRow); side.appendChild(list); const inst = { ta, list, side, pinbar, filterRow, view: 'saved', cycId: null, filter: '', viewItems: [] }; instances.push(inst); // search box + N/total count (always shown) const fInput = document.createElement('input'); fInput.type = 'text'; fInput.className = 'mmth-filter'; fInput.placeholder = 'Search notes…'; fInput.addEventListener('input', () => { inst.filter = fInput.value; inst.cycId = null; renderInst(inst); }); fInput.addEventListener('keydown', e => { if (e.key === 'Escape') { fInput.value = ''; inst.filter = ''; inst.cycId = null; renderInst(inst); ta.focus(); reMinIfRestored(); return; } // #304: backing out re-minimizes if we'd auto-restored // #304: ↑/↓ move the highlighted match; Enter uses the highlighted one (or the first) if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { const items = inst.viewItems || []; if (!items.length) return; e.preventDefault(); const dir = e.key === 'ArrowDown' ? 1 : -1; let i = items.findIndex(x => x.id === inst.cycId); if (i < 0) i = dir > 0 ? -1 : 0; i = (i + dir + items.length) % items.length; inst.cycId = items[i].id; renderInst(inst); const cur = inst.list.querySelector('.mmth-cyc'); // keep the selection visible within the list if (cur) { const lr = inst.list.getBoundingClientRect(), cr = cur.getBoundingClientRect(); if (cr.top < lr.top) inst.list.scrollTop -= (lr.top - cr.top); else if (cr.bottom > lr.bottom) inst.list.scrollTop += (cr.bottom - lr.bottom); } return; } if (e.key === 'Enter') { e.preventDefault(); const items = inst.viewItems || []; const it = items.find(x => x.id === inst.cycId) || items[0]; if (it) { applyNote(ta, it.text, SET.defaultInsert === 'replace'); if (inst.view === 'saved') bumpUse(it.id); } } }); const fCount = document.createElement('span'); fCount.className = 'mmth-count'; inst.countEl = fCount; filterRow.appendChild(fInput); filterRow.appendChild(fCount); inst.filterInput = fInput; const fb = (glyph, title, cls, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'mmth-fb' + (cls ? ' ' + cls : ''); b.textContent = glyph; b.title = title; b.onclick = fn; return b; }; ft.appendChild(fb('οΌ‹', 'Save current edit note', '', () => { const v = (ta.value || '').trim(); if (!v) return toast('Edit note is empty'); toast(addSaved(v) ? 'Saved' : 'Already saved'); })); const bSaved = fb('β˜…', 'Saved notes', 'mmth-grp', () => { inst.view = 'saved'; renderInst(inst); }); const bHist = fb('πŸ•˜', 'History (last used)', '', () => { inst.view = 'history'; renderInst(inst); }); ft.appendChild(bSaved); ft.appendChild(bHist); ft.appendChild(fb('βœ•', 'Clear the edit note', 'mmth-grp', () => { setValue(ta, ''); ta.focus(); })); const sp = document.createElement('span'); sp.className = 'mmth-fb mmth-spacer'; ft.appendChild(sp); // #309: which edit-note type these notes belong to (notes are kept separate per type) const scopeChip = document.createElement('span'); scopeChip.className = 'mmth-scope'; scopeChip.textContent = scopeLabel(SCOPE); scopeChip.title = 'Saved notes & history are kept separate per edit-note type β€” these are your "' + scopeLabel(SCOPE) + '" notes (#309)'; scopeChip.style.display = SET.scopePerResource ? '' : 'none'; // only shown when scoping is on ft.appendChild(scopeChip); inst.minBtn = fb('–', 'Minimize to corner', 'mmth-min-btn', () => { restoredFromMin = false; setMinimized(!SET.minimized); }); // #265: left of the ? button ft.appendChild(inst.minBtn); ft.appendChild(fb('?', 'Edit-note syntax', 'mmth-pop-anchor', e => openSyntax(e.currentTarget))); ft.appendChild(fb('βš™οΈŽ', 'Settings', 'mmth-pop-anchor', e => openSettings(e.currentTarget))); inst.tabs = { saved: bSaved, history: bHist }; ta.addEventListener('keydown', e => { if (!(e.ctrlKey || e.metaKey)) return; const k = e.key.toLowerCase(); // Ctrl/⌘+B / +I wrap the selection in MB edit-note bold / italic markup if (k === 'b' || k === 'i') { e.preventDefault(); wrapSel(ta, k === 'b' ? "'''" : "''"); return; } // Ctrl/⌘+↑/↓ cycle through saved notes, replacing the field (focus stays here) if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; if (inst.view !== 'saved') { inst.view = 'saved'; inst.tabs && inst.tabs.saved.classList.add('on'); inst.tabs && inst.tabs.history.classList.remove('on'); renderInst(inst); } // cycle through exactly what's shown (respects the current sort + filter) #304 const items = inst.viewItems; if (!items || !items.length) return; e.preventDefault(); const dir = e.key === 'ArrowDown' ? 1 : -1; let i = items.findIndex(x => x.id === inst.cycId); if (i < 0) i = dir > 0 ? -1 : 0; i = (i + dir + items.length) % items.length; inst.cycId = items[i].id; setValue(ta, items[i].text); renderInst(inst); // keep the highlighted item visible β€” scroll WITHIN the list only (not the page) const cur = inst.list.querySelector('.mmth-cyc'); if (cur) { const lr = inst.list.getBoundingClientRect(), cr = cur.getBoundingClientRect(); if (cr.top < lr.top) inst.list.scrollTop -= (lr.top - cr.top); else if (cr.bottom > lr.bottom) inst.list.scrollTop += (cr.bottom - lr.bottom); } // setValue can trigger a React re-render on the release editor that steals // focus; re-assert it now AND after the re-render so the editor stays focused. const refocus = () => { try { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); } catch (x) {} }; refocus(); requestAnimationFrame(refocus); setTimeout(refocus, 0); }); renderInst(inst); return inst; } // #304: pinned saved notes shown as one-click quick-insert buttons BELOW the // edit-note field (like baby-field bars) β€” always available, independent of view. function renderPinbar(inst) { const bar = inst.pinbar; bar.innerHTML = ''; const pinned = DATA.saved.filter(s => s.pinned); if (!pinned.length) { bar.style.display = 'none'; return; } bar.style.display = 'flex'; const n = Math.max(4, Math.min(80, SET.btnChars | 0 || 24)); const cap = t => { t = t.replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n) + '…' : t; }; const dflt = SET.defaultInsert; pinned.forEach(it => { const b = document.createElement('button'); b.type = 'button'; b.className = 'mmth-segb'; b.textContent = cap(it.text); b.style.maxWidth = (n + 1) + 'ch'; b.title = it.text + `\n\n(click: ${dflt} Β· right-click: ${dflt === 'replace' ? 'append' : 'replace'} Β· Ctrl/⌘-click: replace + submit)`; b.onclick = e => { e.preventDefault(); // #328: Ctrl/⌘-click = replace the note and submit the edit (parity with Ctrl/⌘+Enter; // shift was prone to text selection). Same as a list row. if (e.ctrlKey || e.metaKey) { applyNote(inst.ta, it.text, true); bumpUse(it.id); const sb = findSubmitBtn(inst.ta); if (sb) sb.click(); return; } applyNote(inst.ta, it.text, dflt === 'replace'); bumpUse(it.id); }; b.oncontextmenu = e => { e.preventDefault(); applyNote(inst.ta, it.text, dflt !== 'replace'); bumpUse(it.id); }; bar.appendChild(b); }); } function renderInst(inst) { const { ta, list } = inst; list.style.maxHeight = (Math.max(1, Math.min(30, SET.visibleRows | 0 || 6)) * 26) + 'px'; // show N items, then scroll (#212) list.innerHTML = ''; if (inst.tabs) { inst.tabs.saved.classList.toggle('on', inst.view === 'saved'); inst.tabs.history.classList.toggle('on', inst.view === 'history'); } const saved = inst.view === 'saved'; renderPinbar(inst); // the search box is always shown inst.filterRow.style.display = 'flex'; // sort (Saved, from config) then search-filter on the full note text; show N/total const all = saved ? sortedSaved() : DATA.history; const q = (inst.filter || '').trim().toLowerCase(); const items = q ? all.filter(it => it.text.toLowerCase().includes(q)) : all; inst.viewItems = items; // Ctrl+↑/↓ and Enter-in-search use exactly what's shown if (inst.countEl) inst.countEl.textContent = q ? (items.length + ' / ' + all.length) : (all.length ? String(all.length) : ''); // drag-reorder only makes sense in the unfiltered manual order const manual = saved && (SET.noteSort || 'manual') === 'manual' && !q; if (!items.length) { const e = document.createElement('div'); e.className = 'mmth-empty'; e.textContent = all.length ? 'No notes match the search' : (saved ? 'No saved notes β€” οΌ‹ saves the current one' : 'No history yet'); list.appendChild(e); return; } items.forEach((it) => { const row = document.createElement('div'); row.className = 'mmth-row'; if (saved && it.pinned) row.classList.add('mmth-pinned'); if (it.id === inst.cycId) row.classList.add('mmth-cyc'); // arrow-key / cycle selection (both views) #304 const dflt = SET.defaultInsert; row.title = it.text + `\n\n(click: ${dflt} Β· right-click: ${dflt === 'replace' ? 'append' : 'replace'} Β· Ctrl/⌘-click: set + submit)`; const txt = document.createElement('span'); txt.className = 'mmth-txt'; txt.textContent = it.text.replace(/\s+/g, ' ').trim(); row.appendChild(txt); // right-side hover actions: pin/unpin + delete (saved); save + remove (history) const acts = document.createElement('div'); acts.className = 'mmth-rowacts'; const ra = (glyph, title, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'mmth-ra'; b.textContent = glyph; b.title = title; b.onclick = e => { e.stopPropagation(); fn(); }; acts.appendChild(b); }; if (saved) { ra(it.pinned ? 'β˜…' : 'β˜†', it.pinned ? 'Unpin from quick buttons' : 'Pin as a quick button', () => togglePinNote(it.id)); ra('πŸ—‘', 'Delete', () => removeSaved(it.id)); } else { ra('β˜…', 'Save (pin to Saved)', () => { if (addSaved(it.text)) toast('Saved'); }); ra('πŸ—‘', 'Remove', () => removeHistory(it.text)); } row.appendChild(acts); if (manual) { const grab = document.createElement('span'); grab.className = 'mmth-grab'; grab.textContent = 'β Ώ'; grab.title = 'Drag to reorder'; grab.draggable = true; grab.addEventListener('dragstart', e => { _drag = { id: it.id }; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', 'row'); } catch (x) {} row.classList.add('mmth-dragging'); }); grab.addEventListener('dragend', () => { row.classList.remove('mmth-dragging'); clearMarks(list); _drag = null; }); row.appendChild(grab); } row.onclick = e => { // #289/#328: Ctrl/⌘-click sets the note (replace) AND submits the edit β€” parity // with Ctrl/⌘+Enter (reuses findSubmitBtn); shift was prone to text selection. if (e.ctrlKey || e.metaKey) { applyNote(ta, it.text, true); if (saved) bumpUse(it.id); const b = findSubmitBtn(ta); if (b) b.click(); return; } applyNote(ta, it.text, SET.defaultInsert === 'replace'); if (saved) bumpUse(it.id); }; row.oncontextmenu = e => { e.preventDefault(); applyNote(ta, it.text, SET.defaultInsert !== 'replace'); if (saved) bumpUse(it.id); }; if (manual) { row.addEventListener('dragover', e => { if (!_drag) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearMarks(list); row.classList.add(after(e, row) ? 'mmth-drop-after' : 'mmth-drop-before'); }); row.addEventListener('dragleave', () => row.classList.remove('mmth-drop-before', 'mmth-drop-after')); row.addEventListener('drop', e => { if (!_drag) return; e.preventDefault(); reorder(_drag.id, it.id, !after(e, row)); clearMarks(list); _drag = null; }); } list.appendChild(row); }); } function render() { instances.forEach(i => { if (i.list.isConnected) renderInst(i); }); } // ── attach ─────────────────────────────────────────────────────────────────── function injectAll() { applyHelp(); document.querySelectorAll('textarea.edit-note').forEach(ta => { if (ta.dataset.mmth) return; ta.dataset.mmth = '1'; const en = ta.closest('.editnote'); if (en) en.classList.add('mmth-on'); // hides the redundant inline "Edit note:" label (#212) const wrap = document.createElement('div'); wrap.className = 'mmth-wrap'; ta.parentNode.insertBefore(wrap, ta); // #304: the textarea and its pinned-quick-buttons bar share one column, so the // bar always sits directly below the field and tracks its left edge / width, // whatever the surrounding form layout does. const fieldCol = document.createElement('div'); fieldCol.className = 'mmth-fieldcol'; wrap.appendChild(fieldCol); fieldCol.appendChild(ta); // remember the textarea height the user sets with the native resize grip (vertical); // the splitter (below) remembers the field/panel split (horizontal). if (SET.taHeight) ta.style.height = SET.taHeight + 'px'; // Persist ONLY a deliberate user resize (height changes between mouse down // and up on the field). The old ResizeObserver fired on any layout-driven // size change too, so visiting a differently-sized edit page (e.g. the // full-width release editor) silently overwrote the saved height. let _downH = null; ta.addEventListener('mousedown', () => { _downH = ta.offsetHeight; }); window.addEventListener('mouseup', () => { if (_downH == null) return; const h = ta.offsetHeight; const was = _downH; _downH = null; if (h > 40 && h !== was && h !== SET.taHeight) { SET.taHeight = h; ta.style.height = h + 'px'; persistSet(); } }); const vsep = document.createElement('div'); vsep.className = 'mmth-vsep'; vsep.title = 'Drag to resize'; wrap.appendChild(vsep); // resizable separator between field & panel (#212) const inst = buildSide(ta); const side = inst.side; wrap.appendChild(side); fieldCol.appendChild(inst.pinbar); // #304: pinned quick-buttons directly below the field, in its column wireResize(vsep, side); // #265 minimized mode: badge in the field's top-right corner; hover (or click // to pin) floats the panel back in. mouseleave closes after a short grace. const badge = document.createElement('div'); badge.className = 'mmth-badge'; badge.title = 'Mammoth β€” saved notes (click or hover)'; badge.innerHTML = MAMMOTH_SVG; // #308 vector, not the 🦣 emoji wrap.appendChild(badge); inst.badge = badge; // #288/#290: MB (and other scripts, e.g. jesus2099's MERGE HELPOR) insert // edit-note error/warning

s as siblings of the textarea β€” which now lives // inside our flex .mmth-wrap, so they'd be laid out BESIDE the field. Keep the // wrap a clean row (field | sep | panel | badge) and relocate any foreign child // OUT to normal flow: warnings above the wrap, validation/other notices below. // (Doing this in JS instead of flex-wrap avoids the panel itself wrapping below // the field in a narrow left-column layout β€” #290.) // #304: the textarea now lives in .mmth-fieldcol, so MB inserts those

s into // fieldCol (not wrap) β€” observe both, and treat fieldCol/ta/pinbar as ours. const isOurs = el => el === ta || el === inst.pinbar || (el.classList && (el.classList.contains('mmth-fieldcol') || el.classList.contains('mmth-vsep') || el.classList.contains('mmth-side') || el.classList.contains('mmth-badge'))); const relocateForeign = node => { if (!node || node.nodeType !== 1 || isOurs(node) || !wrap.parentNode) return; const above = node.classList && node.classList.contains('error') && !node.classList.contains('invalid'); wrap.parentNode.insertBefore(node, above ? wrap : wrap.nextSibling); }; [...wrap.children, ...fieldCol.children].forEach(relocateForeign); const fObs = muts => muts.forEach(m => m.addedNodes.forEach(relocateForeign)); new MutationObserver(fObs).observe(wrap, { childList: true }); new MutationObserver(fObs).observe(fieldCol, { childList: true }); let closeT = null, pinned = false; const openFloat = () => { clearTimeout(closeT); if (SET.minimized) side.classList.add('mmth-open'); }; const closeFloat = () => { clearTimeout(closeT); if (pinned) return; closeT = setTimeout(() => side.classList.remove('mmth-open'), 220); }; badge.addEventListener('mouseenter', openFloat); badge.addEventListener('mouseleave', closeFloat); side.addEventListener('mouseenter', openFloat); side.addEventListener('mouseleave', closeFloat); // click the badge to pin the panel open (so it survives mouse-out); click again to unpin badge.addEventListener('click', () => { if (!SET.minimized) return; pinned = !pinned; pinned ? openFloat() : side.classList.remove('mmth-open'); }); inst.unpin = () => { pinned = false; }; applyMinState(inst); // #263: keep the panel ≀ half the row (never wider than the field). Driven by // the WRAP's width only β€” stable, no feedback loop. const cap = () => capPanel(wrap, vsep, side); cap(); requestAnimationFrame(cap); setTimeout(cap, 200); try { new ResizeObserver(cap).observe(wrap); } catch (x) {} inst.recap = cap; // The saved-notes panel's height (driven by the Items Shown setting) is the // field's floor, so it's never shorter than the sidebar. With no user-saved // height the field STARTS at exactly that height too β€” so its initial size // tracks Items Shown β€” until the user drags the grip (which is remembered). const syncFloor = () => { try { // Minimized: the panel floats out of flow, so the field needs no floor β€” // applying one here while the panel shows on hover would couple field // height to panel height (the #245 loop). Clear it and bail. if (SET.minimized) { if (ta.style.minHeight) ta.style.minHeight = ''; return; } const h = side.offsetHeight; if (!(h > 0)) return; if ((parseInt(ta.style.minHeight, 10) || 0) !== h) ta.style.minHeight = h + 'px'; if (!SET.taHeight && (parseInt(ta.style.height, 10) || 0) !== h) ta.style.height = h + 'px'; vsep.style.height = ta.offsetHeight + 'px'; // #304: separator spans only the field, not the field+pinbar column } catch (x) {} }; syncFloor(); requestAnimationFrame(syncFloor); setTimeout(syncFloor, 150); setTimeout(syncFloor, 600); // catch the sidebar's final layout try { new ResizeObserver(syncFloor).observe(side); new ResizeObserver(syncFloor).observe(ta); } catch (x) {} }); } // Toggle the "an MB dialog is open" flag so Mammoth's floating UI (main panel // badge/pop + babies) hides under it β€” MB's dialogs/popovers carry no z-index // and no backdrop, so they can't otherwise win the stack. Global (runs even // when babies are off). #313 // #333/#397/#config: a dialog (.dialog.popover / .relationship-dialog) whose fields we pin must NOT be // treated as blocking, or we'd hide the very baby it hosts. That was a hardcoded allowlist (artist-credit // rows, then the "Task" field) β€” generalise it: a dialog blocks only if it contains NONE of our baby // fields (`[data-mmthf]`, set by scan() on every pinned field, custom ones included). This is what let // instrument-type relationship dialogs β€” bass/guitar/… have no Task field β€” wrongly hide a custom // "Credited as" baby while producer/mixer (which DO have Task) worked. Fields BEHIND a dialog are already // hidden per-pin by fieldOnTop (elementFromPoint), so keeping non-blocking here can't float them over it. const syncDialog = () => { const dlgs = [...document.querySelectorAll('.dialog.popover, .relationship-dialog')]; const blocking = dlgs.some(d => !d.querySelector('[data-mmthf]')); // #569: guarded. These fire from a document-wide subtree observer, so an // unconditional toggle on re-triggers every other script's root // watcher, which mutates, which brings us back here β€” the loop chaban // measured at 12 root mutations a second on an idle page. mbuCls(document.documentElement, 'mmthf-dialog', blocking); // baby pins: hide only when a dialog hosts none of ours mbuCls(document.documentElement, 'mmthf-anydialog', dlgs.length > 0); // main panel/badge: hide under ANY open dialog (#400) }; // #462: MB's jQuery-UI autocomplete menu (ul.ui-autocomplete) opens by toggling `display` // β€” a style mutation the childList observer above never sees β€” so watch each menu's attrs // directly and flag `mmthf-acopen` while any is visible, which hides overlapping babies. const syncAc = () => { const open = [...document.querySelectorAll('ul.ui-autocomplete')].some(u => u.offsetParent !== null && getComputedStyle(u).display !== 'none'); mbuCls(document.documentElement, 'mmthf-acopen', open); }; const acObs = new MutationObserver(syncAc); const watchAcMenus = () => { document.querySelectorAll('ul.ui-autocomplete').forEach(u => { if (!u._mmthfAc) { u._mmthfAc = 1; acObs.observe(u, { attributes: true, attributeFilter: ['style', 'class'] }); } }); syncAc(); }; // #623 (sweep, M1): this observer sees every node on every MusicBrainz page, and each // mutation re-queried the edit notes, dialogs and autocomplete menus (a // getComputedStyle per menu) β€” hundreds of times while a page renders in bursts. Now a // burst runs it once: the first mutation schedules a run 60 ms later, the rest join // it. A timer, not requestAnimationFrame, which never fires in a background tab β€” and // background edit tabs (other scripts' "add in the background") need the defaults too. let _mmthPending = 0; const onPageMutation = () => { if (_mmthPending) return; _mmthPending = setTimeout(() => { _mmthPending = 0; injectAll(); syncDialog(); watchAcMenus(); }, 60); }; new MutationObserver(onPageMutation).observe(document.documentElement, { childList: true, subtree: true }); syncDialog(); watchAcMenus(); // #252 Ctrl/Cmd+Enter submits the edit. The submit control differs per page, so // look in order: the release editor's "Enter edit" button, then the edit form's // own submit, then a visible button labelled Enter edit / Submit / Finish. Only // act when focus is in the edit-note field or Mammoth's panel (or nowhere), so it // never hijacks Ctrl+Enter in some unrelated field. const isVisible = b => !!(b && b.offsetParent !== null && !b.disabled); function findSubmitBtn(ta) { const re = document.getElementById('enter-edit'); if (isVisible(re)) return re; const form = ta && ta.closest('form'); if (form) { const s = form.querySelector('button.submit, button[type="submit"], button.positive'); if (isVisible(s)) return s; } return [...document.querySelectorAll('button')].find(b => isVisible(b) && /^\s*(enter edit|submit|finish)\b/i.test(b.textContent || '')) || null; } document.addEventListener('keydown', e => { if (e.key !== 'Enter' || !(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return; const ta = document.querySelector('textarea.edit-note'); if (!ta) return; const t = e.target; if (t !== ta && !(t && t.closest && t.closest('.mmth-wrap, .mmth-side, .mmth-pop')) && t !== document.body) return; const btn = findSubmitBtn(ta); if (btn) { e.preventDefault(); btn.click(); } }); // #304: Ctrl/Cmd+, focuses the note search box (the panel nearest the focused // field, else the first visible one). When the panel is minimized, restore it // first so the box is reachable. No-op when search is off. document.addEventListener('keydown', e => { if (e.key !== ',' || !(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return; const onScreen = () => [...document.querySelectorAll('.mmth-filter')].filter(i => i.offsetParent !== null); let visible = onScreen(); if (!visible.length) { // search box is hidden because the panel is minimized β€” restore and retry if (SET.minimized) { setMinimized(false); restoredFromMin = true; visible = onScreen(); } if (!visible.length) return; } const wrap = e.target && e.target.closest && e.target.closest('.mmth-wrap'); const near = wrap && wrap.querySelector('.mmth-filter'); const target = (near && near.offsetParent !== null) ? near : visible[0]; e.preventDefault(); target.focus(); target.select(); }); injectAll(); if (SET.showBabies !== false) babyMammoths.start(); // ════════════════════════════════════════════════════════════════════════════ // BABY MAMMOTHS β€” field memory for arbitrary input controls // Self-contained (own storage key, own CSS, own DOM), gated by SET.showBabies. // start()/stop() let the βš™ toggle add/remove it cleanly at runtime. // ════════════════════════════════════════════════════════════════════════════ function createBabyMammoths() { const FKEY = 'mammoth-fields:data'; // { [fieldKey]: [{ v, label, ts, pinned?, default? }] } const MAX_PER_FIELD = 25; // #296 follow-up β€” capture the selected ENTITY's MBID for autocomplete fields, // so a saved Label/Artist resolves the real entity on recall (writeField pastes // the MBID, which MB resolves) instead of being text-only. The gid comes from // the live release editor model; unsafeWindow reaches the page's MB from the // userscript sandbox. Falls back to text when nothing is selected / off-editor. const PAGEWIN = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window; const relEntity = () => { try { return PAGEWIN.MB.releaseEditor.rootField.release(); } catch (e) { return null; } }; const labelGid = el => { const i = +((String(el.id).match(/-(\d+)$/) || [])[1] || 0); const r = relEntity(); const labs = r && r.labels && r.labels(); const L = labs && labs[i] && labs[i].label && labs[i].label(); return (L && L.gid) || null; }; const _unwrap = x => (typeof x === 'function' ? x() : x); const artistGid = () => { // single-artist box only: exactly one real artist (names has a trailing blank) const r = relEntity(); const ac = r && r.artistCredit && r.artistCredit(); let ns = ac && ac.names; ns = _unwrap(ns); if (!ns) return null; const real = [...ns].map(n => _unwrap(n.artist)).filter(a => a && a.gid); return real.length === 1 ? real[0].gid : null; }; // a specific row of the artist-credit editor bubble (ac-source-artist-) const artistRowGid = el => { const i = +((String(el.id).match(/-(\d+)$/) || [])[1] || 0); const r = relEntity(); const ac = r && r.artistCredit && r.artistCredit(); const ns = _unwrap(ac && ac.names); const a = ns && ns[i] && _unwrap(ns[i].artist); return (a && a.gid) || null; }; // MBID capture for the built-in Label/Artist fields (`mbid:true`). Reads the release editor's model for // the known label/artist inputs; a no-op on any other field (β†’ text). The generic probe is gone; this is // the specific labelGid/artistGid readers, exposed behind the flag only for the two fields where it works. const builtinGid = el => { const id = String(el.id || ''); if (/^label-\d+$/.test(id)) return labelGid(el); if (id === 'ac-source-single-artist') return artistGid(el); if (/^ac-source-artist-/.test(id)) return artistRowGid(el); return null; }; // Every baby field β€” built-in or user β€” comes from SET.customFields. The storage key is derived from the // LABEL (namespaced 'cf:'), so any two same-label fields share one saved list. `mbid:true` (JSON only) // wires the Label/Artist MBID reader; `submit:true` submits the field's form after a recall (see recallInto). const customDefs = () => (SET.customFields || []).filter(c => c && c.match && c.enable !== false).map(c => { // enable:false β†’ not scanned (no pin) const key = String(c.label || '').trim() ? 'cf:' + String(c.label).trim() : null; // no label β†’ keyFor derives one from the element return { match: c.match, key, label: c.label || '', dx: (c.dx != null && c.dx !== '') ? +c.dx : undefined, deltav: (c.dv | 0) || 0, submit: !!c.submit, gid: c.mbid ? builtinGid : null }; }); const loadF = () => { try { return JSON.parse(GM_getValue(FKEY, '{}') || '{}'); } catch (e) { return {}; } }; const saveF = () => { try { GM_setValue(FKEY, JSON.stringify(FDATA)); } catch (e) {} }; let FDATA = loadF(); const listFor = key => (FDATA[key] = FDATA[key] || []); function rememberValue(key, rec) { if (!rec || !rec.v) return false; const a = listFor(key); const e = a.find(x => x.v === rec.v); if (e) { e.label = rec.label || e.label; e.ts = Date.now(); } // already saved β†’ keep it (and its β˜… / default / order) else { a.unshift({ v: rec.v, label: rec.label || rec.v, ts: Date.now() }); FDATA[key] = a.slice(0, MAX_PER_FIELD); } saveF(); return true; } const forgetValue = (key, v) => { FDATA[key] = listFor(key).filter(x => x.v !== v); saveF(); }; // edit a saved value in place (keeps its β˜…/default/order) β€” used to paste/append an MBID onto a value function editValue(key, oldV, newV) { newV = String(newV == null ? '' : newV).trim(); if (!newV) return false; const a = listFor(key); const e = a.find(x => x.v === oldV); if (!e) return false; if (newV !== oldV) { const other = a.findIndex(x => x.v === newV); if (other >= 0 && a[other] !== e) a.splice(other, 1); } // dedup a collision e.v = newV; e.label = newV; e.ts = Date.now(); saveF(); return true; } const togglePin = (key, v) => { const e = listFor(key).find(x => x.v === v); if (e) { e.pinned = !e.pinned; saveF(); } }; function setDefault(key, v) { const a = listFor(key); const e = a.find(x => x.v === v); if (!e) return; const was = e.default; a.forEach(x => x.default = false); e.default = !was; saveF(); } const defaultOf = key => listFor(key).find(x => x.default); // drag-reorder a saved value relative to another (like the edit-note panel's β Ώ) function reorder(key, srcV, tgtV, before) { if (srcV === tgtV) return; const a = listFor(key); const si = a.findIndex(x => x.v === srcV); if (si < 0) return; const [it] = a.splice(si, 1); let ti = a.findIndex(x => x.v === tgtV); if (ti < 0) { a.splice(si, 0, it); return; } a.splice(before ? ti : ti + 1, 0, it); saveF(); } // #304: caption = the full label, truncated with an ellipsis at the configured // length (βš™ "Button label length") β€” no longer just the first word. const btnChars = () => Math.max(4, Math.min(80, SET.btnChars | 0 || 24)); // Strip an MBID (a bare GUID, or a full musicbrainz.org URL ending in one) from a value for DISPLAY only. // The stored value + export keep it, because saving "name " lets an autocomplete resolve the entity // straight away (no search / no Enter) β€” but the raw id is ugly, so hide it in the rows and the buttons. const MMTH_MBID_RE = /\s*\(?(?:https?:\/\/(?:beta\.)?musicbrainz\.org\/[a-z-]+\/)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\)?\/?\s*/ig; const cleanLabel = s => { const raw = String(s == null ? '' : s); const t = raw.replace(MMTH_MBID_RE, ' ').replace(/\s{2,}/g, ' ').trim(); return t || raw.trim(); }; const captionOf = it => { if (it.cap) return it.cap; const n = btnChars(); const t = cleanLabel(it.label || it.v); return t.length > n ? t.slice(0, n) + '…' : t; }; const isSelect = el => el.tagName === 'SELECT'; const isAuto = el => el.classList.contains('ui-autocomplete-input') || el.classList.contains('lookup-performed'); function setNative(el, val) { const proto = isSelect(el) ? HTMLSelectElement.prototype : el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const d = Object.getOwnPropertyDescriptor(proto, 'value'); if (d && d.set) d.set.call(el, val); else el.value = val; } function readField(el) { if (isSelect(el)) { const o = el.options[el.selectedIndex]; return { v: el.value, label: o ? o.textContent.trim() : el.value }; } const v = el.value || ''; return { v, label: v.trim() }; } // What to STORE for the current value. Entity fields (p.gid) store the selected // MBID as the value with the visible name as the label, so recall resolves the // real entity; everything else (and unresolved/empty entity fields) stores text. function captureField(p) { const t = readField(p.el); if (!t.v || !p.gid) return t; let g; try { g = p.gid(p.el); } catch (e) {} return g ? { v: g, label: t.label || g } : t; } function writeField(el, rec) { if (isSelect(el)) { let opt = [...el.options].find(o => o.value === rec.v); if (!opt && rec.label) opt = [...el.options].find(o => o.textContent.trim() === rec.label.trim()); if (!opt) return false; setNative(el, opt.value); el.dispatchEvent(new Event('change', { bubbles: true })); return true; } setNative(el, rec.v); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); if (isAuto(el)) { el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'a' })); el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'a' })); } return true; } // Submit the field's enclosing

β€” the only reliable way to commit fields the browser only // submits on a real Enter (the tag box ``, the header search form, …). A synthetic // KeyboardEvent can't do it (untrusted). requestSubmit() runs the form's submit handler + validation. const submitField = el => { const form = el.closest('form'); if (!form) return; // nothing to submit if (form.requestSubmit) { form.requestSubmit(); return; } const btn = form.querySelector('button[type="submit"], input[type="submit"]'); if (btn) { btn.click(); return; } try { form.submit(); } catch (e) {} }; // recall a saved value into a field. writeField pastes it ("name " resolves the entity via the id). // A field flagged `submit` (row ↡ checkbox / JSON "submit": true) then submits its ~200ms later. function recallInto(p, rec) { const ok = writeField(p.el, rec); if (ok && p.submit) { const el = p.el; setTimeout(() => { if (el.isConnected) { try { el.focus(); } catch (e) {} try { submitField(el); } catch (e) {} } }, 200); } return ok; } function clearField(el) { if (isSelect(el)) { const o = [...el.options].find(o => o.value === ''); if (!o) return; setNative(el, ''); el.dispatchEvent(new Event('change', { bubbles: true })); } else { setNative(el, ''); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } try { el.focus(); } catch (e) {} } // return focus to the field after an interactive apply (caret at end for text inputs) const focusField = el => { try { el.focus(); if (!isSelect(el) && el.setSelectionRange) { const n = (el.value || '').length; el.setSelectionRange(n, n); } } catch (e) {} }; const fLabelText = el => { const l = el.id && document.querySelector(`label[for="${CSS.escape(el.id)}"]`); return (l && l.textContent.trim().replace(/:$/, '')) || el.getAttribute('aria-label') || el.placeholder || ''; }; function keyFor(el, def) { if (def && def.key) return def.key; if (el.dataset.mmthKey) return 'k:' + el.dataset.mmthKey; const base = el.id ? el.id.replace(/-\d+$/, '') : (el.name || ''); return 'auto:' + (base || fLabelText(el).toLowerCase().replace(/\s+/g, '-') || 'field'); } const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const after = (e, el) => (e.clientY - el.getBoundingClientRect().top) > el.offsetHeight / 2; const clearMarks = host => host && host.querySelectorAll('.mmthf-drop-before,.mmthf-drop-after').forEach(r => r.classList.remove('mmthf-drop-before', 'mmthf-drop-after')); let pins = [], pop = null, mo = null, raf = 0, running = false, _fdrag = null, settleCap = 0; const listeners = []; function injectCss() { if (document.getElementById('mmthf-css')) return; const s = document.createElement('style'); s.id = 'mmthf-css'; s.textContent = ` .mmthf-pin { position:absolute; z-index:9998; width:16px; height:16px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:none; background:none; box-shadow:none; padding:0; font-size:13px; line-height:1; user-select:none; opacity:.35; transition:opacity .12s; filter:grayscale(.3); } .mmthf-pin:hover { opacity:1; filter:none; } .mmthf-pin.has { opacity:.8; filter:none; } /* #296: keep the overlays invisible while the release editor is still reflowing on load (so they don't visibly jump), then FADE them in once the layout goes quiet β€” instead of bumping into place. */ html.mmthf-settling .mmthf-pin, html.mmthf-settling .mmthf-bar { opacity:0 !important; pointer-events:none !important; } html.mmthf-fadein .mmthf-pin, html.mmthf-fadein .mmthf-bar { transition:opacity .4s ease !important; } /* MB's dialogs/popovers (Add/Edit relationship, …) carry no z-index, so our high-z babies would float on top of them and their dropdowns. Hide the babies while any MB dialog is open. */ html.mmthf-dialog .mmthf-pin, html.mmthf-dialog .mmthf-bar, html.mmthf-dialog .mmthf-pop { opacity:0 !important; pointer-events:none !important; } /* #462: MB's autocomplete menu (ul.ui-autocomplete @ z-index:100) drops down over the field's own pin/bar β€” our high-z babies would cover the results. Hide them while any lookup dropdown is open (it reappears the moment the menu closes). */ html.mmthf-acopen .mmthf-pin, html.mmthf-acopen .mmthf-bar { opacity:0 !important; pointer-events:none !important; } .mmthf-hl { outline:2px solid #5aa67e !important; outline-offset:1px; } .mmthf-bar { position:absolute; z-index:9996; display:none; } /* deltav>0: the bar is injected in-flow after an ancestor, so it takes real layout space (pushes the UI below it down) instead of floating over it. */ .mmthf-bar.mmthf-bar-inflow { position:static !important; z-index:auto; max-width:none !important; margin:5px 0 3px; } /* #304: individual rounded "tag" buttons that wrap to new rows β€” matches the main edit-note pins */ .mmthf-seg { display:flex; flex-wrap:wrap; gap:5px; max-width:100%; } .mmthf-segb { border:1px solid var(--mbu-border) !important; background:var(--mbu-bg) !important; border-radius:7px !important; padding:3px 10px !important; font:12px/1.2 -apple-system,Segoe UI,Arial,sans-serif !important; color:var(--mbu-ok) !important; cursor:pointer; max-width:200px; height:auto !important; min-height:0 !important; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; box-shadow:0 1px 2px rgba(0,0,0,.06); } .mmthf-segb:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); } .mmthf-pop { position:fixed; z-index:9999; background:var(--mbu-bg); border:1px solid var(--mbu-border); border-radius:8px; box-shadow:0 8px 26px rgba(20,50,35,.2); font:12px/1.35 -apple-system,Segoe UI,Arial,sans-serif; color:var(--mbu-text); width:260px; max-width:calc(100vw - 12px); overflow:hidden; } .mmthf-ft { display:flex; align-items:center; gap:1px; padding:3px 5px; background:var(--mbu-bg-raised); border-bottom:1px solid var(--mbu-border); } .mmthf-fb { cursor:pointer; border:none; background:none; font-size:14px; line-height:1; padding:3px 7px; border-radius:5px; color:var(--mbu-text-dim); } .mmthf-fb:hover { background:var(--mbu-ok-bg); } .mmthf-fb[aria-disabled="true"] { color:var(--mbu-text-weak); cursor:default; background:none; } .mmthf-ft-title { flex:1 1 auto; min-width:0; text-align:center; font-weight:700; font-size:13px; color:var(--mbu-text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; padding:0 4px; } .mmthf-filterrow { padding:5px 6px; border-bottom:1px solid var(--mbu-border); background:var(--mbu-bg); } .mmthf-filter { display:block; width:100%; box-sizing:border-box; border:1px solid var(--mbu-border); border-radius:5px; padding:3px 7px; font:12px -apple-system,Segoe UI,Arial,sans-serif; } .mmthf-filter:focus { outline:none; border-color:var(--mbu-ok); } .mmthf-row.mmthf-sel { background:var(--mbu-ok-bg); } .mmthf-list { max-height:240px; overflow-y:auto; } .mmthf-row { position:relative; display:flex; align-items:center; gap:6px; padding:5px 10px; border-top:1px solid var(--mbu-border); cursor:pointer; } .mmthf-row:first-child { border-top:none; } .mmthf-row:hover { background:var(--mbu-ok-bg); } .mmthf-rtxt { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; padding-right:42px; } /* stationary indicators at the right edge (β˜… rightmost so it stays aligned; β—‰ when default). Non-interactive β€” no reserved hover slots. */ .mmthf-ind { position:absolute; right:10px; top:0; height:100%; display:flex; align-items:center; gap:5px; font-size:12px; color:var(--mbu-ok); pointer-events:none; } /* the full action toolbar OVERLAYS the right on hover β€” reserves no space when idle */ .mmthf-acts { position:absolute; right:5px; top:2px; bottom:2px; display:none; align-items:center; gap:1px; padding:0 3px 0 12px; border-radius:5px; background:var(--mbu-ok-bg); } .mmthf-row:hover .mmthf-acts { display:flex; } .mmthf-row:hover .mmthf-ind { display:none; } .mmthf-ra { width:18px; box-sizing:border-box; text-align:center; border:none; background:none; color:var(--mbu-text-dim); cursor:pointer; font-size:11px; padding:1px 0; border-radius:3px; } .mmthf-ra:hover { background:var(--mbu-ok-bg); color:var(--mbu-ok); } .mmthf-edit-in { flex:1 1 auto; min-width:0; box-sizing:border-box; border:1px solid var(--mbu-ok); border-radius:4px; padding:1px 5px; font:12px -apple-system,Segoe UI,Arial,sans-serif; } .mmthf-edit-in:focus { outline:none; } .mmthf-row.mmthf-editing .mmthf-acts, .mmthf-row.mmthf-editing .mmthf-ind { display:none !important; } .mmthf-grab { width:14px; text-align:center; cursor:grab; color:var(--mbu-text-weak); font-size:12px; user-select:none; } .mmthf-grab:active { cursor:grabbing; } .mmthf-row.mmthf-dragging { opacity:.45; } .mmthf-row.mmthf-drop-before { box-shadow:inset 0 2px 0 #2c7a51; } .mmthf-row.mmthf-drop-after { box-shadow:inset 0 -2px 0 #2c7a51; } .mmthf-empty { padding:10px; color:var(--mbu-text-weak); font-style:italic; text-align:center; } tr.mmthf-rrow > td { vertical-align:top; } /* keep sibling cells from dropping when a cell reserves strip space */ `; (document.head || document.documentElement).appendChild(s); } // highest z-index among a field's positioned ancestors β€” so a pin inside a high-z modal can sit just // above it rather than behind. 0 β‡’ keep the CSS default. function fieldStackZ(el) { let z = 0; for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) { const cs = getComputedStyle(n); if (cs.position !== 'static') { const v = parseInt(cs.zIndex, 10); if (!isNaN(v) && v > z) z = v; } } return z; } function scan() { const map = new Map(); const add = (el, def) => { if (el && !map.has(el)) map.set(el, def || {}); }; for (const d of customDefs()) { try { document.querySelectorAll(d.match).forEach(el => add(el, d)); } catch (e) {} } // built-ins + user fields, all from SET.customFields (invalid selectors ignored) document.querySelectorAll('.mmth-pin').forEach(el => add(el, { key: el.dataset.mmthKey ? 'k:' + el.dataset.mmthKey : null, label: el.dataset.mmthLabel || '', submit: el.dataset.mmthSubmit != null })); for (const [el, def] of map) { if (el.dataset.mmthf || !el.matches('input, select, textarea')) continue; el.dataset.mmthf = '1'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'mmthf-pin'; btn.innerHTML = MAMMOTH_SVG; // #308 vector, not the 🦣 emoji const sel = isSelect(el); // shift the pin left of a native affordance: the ' : ''}
${items.map(rowHtml).join('') || '
No saved values yet
'}
`; document.body.appendChild(el); pop = el; const list = el.querySelector('.mmthf-list'); if (searchOn) { const fin = el.querySelector('.mmthf-filter'); let hl = -1; // highlighted row among the currently-visible ones const vis = () => [...el.querySelectorAll('.mmthf-row')].filter(r => r.style.display !== 'none'); const paint = () => { const rows = vis(); rows.forEach((r, i) => r.classList.toggle('mmthf-sel', i === hl)); if (rows[hl]) rows[hl].scrollIntoView({ block: 'nearest' }); }; const applyFilter = () => { const q = (fin.value || '').trim().toLowerCase(); el.querySelectorAll('.mmthf-row').forEach(r => { const t = (r.querySelector('.mmthf-rtxt') || {}).textContent || ''; r.style.display = (!q || t.toLowerCase().includes(q)) ? '' : 'none'; }); hl = vis().length ? 0 : -1; paint(); }; fin.addEventListener('input', applyFilter); fin.addEventListener('keydown', e => { const rows = vis(); if (e.key === 'Escape') { e.stopPropagation(); if (fin.value) { fin.value = ''; applyFilter(); } else closePop(); return; } if (e.key === 'ArrowDown') { e.preventDefault(); if (rows.length) { hl = (hl + 1) % rows.length; paint(); } return; } if (e.key === 'ArrowUp') { e.preventDefault(); if (rows.length) { hl = (hl - 1 + rows.length) % rows.length; paint(); } return; } if (e.key === 'Enter') { e.preventDefault(); const r = rows[hl] || rows[0]; if (r) { recallInto(p, items[+r.dataset.i]); closePop(); focusField(p.el); } return; } // keyboard apply β†’ refocus the field }); applyFilter(); // highlight the first item so Enter works immediately setTimeout(() => { try { fin.focus(); } catch (e) {} }, 0); } el.querySelector('.mmthf-save').addEventListener('click', () => { if (!cur.v) return; rememberValue(p.key, cur); refreshState(p); reopen(p); }); el.querySelector('.mmthf-clear').addEventListener('click', () => { clearField(p.el); reopen(p); }); // #309: open the config with Import/Export scoped to THIS field's values el.querySelector('.mmthf-cfg').addEventListener('click', () => { const a = p.btn; // #309: entity fields (Artist/Label) store v=MBID, label=name. Export both as // "nameMBID" so a re-import resolves the real entity (text fields stay plain). const io = { items: () => listFor(p.key).map(x => (x.v && x.label && x.v !== x.label) ? (x.label + '\t' + x.v) : (x.label || x.v)), add: notes => { const arr = listFor(p.key); let added = 0; const have = new Set(arr.map(x => x.v)); for (const line of notes) { const i = line.indexOf('\t'); const label = (i >= 0 ? line.slice(0, i) : line).trim(); const v = (i >= 0 ? line.slice(i + 1) : line).trim(); if (!v || have.has(v)) continue; have.add(v); arr.unshift({ v, label: label || v, ts: Date.now() }); added++; } FDATA[p.key] = arr.slice(0, MAX_PER_FIELD); if (added) { saveF(); refreshState(p); } return added; }, help: 'Import / export the β€œ' + p.label + '” values (entity fields keep their MBID).', }; closePop(); openSettings(a, 'io', io); }); el.querySelectorAll('.mmthf-row').forEach(row => { const it = items[+row.dataset.i]; row.addEventListener('click', e => { if (e.target.closest('.mmthf-grab') || e.target.closest('.mmthf-edit-in')) return; // dragging / mid-edit if (e.target.closest('.mmthf-star')) { togglePin(p.key, it.v); refreshState(p); reopen(p); return; } if (e.target.closest('.mmthf-def')) { setDefault(p.key, it.v); applyDefault(p); reopen(p); return; } if (e.target.closest('.mmthf-edit')) { startEditRow(row, p, it); return; } if (e.target.closest('.mmthf-del')) { forgetValue(p.key, it.v); refreshState(p); reopen(p); return; } recallInto(p, it); closePop(); // don't refocus the field on apply β€” it re-triggers autocomplete }); const grab = row.querySelector('.mmthf-grab'); grab.addEventListener('dragstart', e => { _fdrag = { v: it.v }; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', 'row'); } catch (x) {} row.classList.add('mmthf-dragging'); }); grab.addEventListener('dragend', () => { row.classList.remove('mmthf-dragging'); clearMarks(list); _fdrag = null; }); row.addEventListener('dragover', e => { if (!_fdrag) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearMarks(list); row.classList.add(after(e, row) ? 'mmthf-drop-after' : 'mmthf-drop-before'); }); row.addEventListener('dragleave', () => row.classList.remove('mmthf-drop-before', 'mmthf-drop-after')); row.addEventListener('drop', e => { if (!_fdrag) return; e.preventDefault(); reorder(p.key, _fdrag.v, it.v, !after(e, row)); clearMarks(list); _fdrag = null; refreshState(p); reopen(p); }); }); el._atField = atField; place(el, atField ? p.el : p.btn); // #345: hotkey opens at the field's left edge; a pin CLICK opens at the pin, by the cursor setTimeout(() => { document.addEventListener('click', onDown, true); document.addEventListener('keydown', onKey, true); }, 0); } const reopen = p => { const af = pop ? pop._atField : false; closePop(); openPop(p, af); }; // keep the same anchor across a reopen // inline-edit a saved value (to paste/append its MBID). Enter commits + reopens (stripped rows), Esc // cancels, blur commits silently (no reopen β€” avoids racing an outside-click close). function startEditRow(row, p, it) { const rtxt = row.querySelector('.mmthf-rtxt'); if (!rtxt || row.querySelector('.mmthf-edit-in')) return; const inp = document.createElement('input'); inp.type = 'text'; inp.className = 'mmthf-edit-in'; inp.spellcheck = false; inp.value = it.v; rtxt.replaceWith(inp); row.classList.add('mmthf-editing'); let done = false; const commit = () => { if (done) return; done = true; if (inp.value.trim() && inp.value.trim() !== it.v) editValue(p.key, it.v, inp.value); }; inp.addEventListener('mousedown', e => e.stopPropagation()); inp.addEventListener('click', e => e.stopPropagation()); inp.addEventListener('keydown', e => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); commit(); refreshState(p); reopen(p); } else if (e.key === 'Escape') { e.preventDefault(); done = true; reopen(p); } }); inp.addEventListener('blur', () => { commit(); refreshState(p); }); setTimeout(() => { try { inp.focus(); inp.select(); } catch (e) {} }, 0); } const relayout = () => { syncDialog(); if (running && !raf) raf = requestAnimationFrame(() => { raf = 0; layout(); }); }; function start() { if (running) return; running = true; injectCss(); const on = (t, ev, fn, cap) => { t.addEventListener(ev, fn, cap); listeners.push([t, ev, fn, cap]); }; on(window, 'scroll', relayout, true); on(window, 'resize', relayout, false); ['focusin', 'focusout', 'click', 'input', 'keyup'].forEach(ev => on(document, ev, relayout, true)); // Ctrl/Cmd+, β€” if a baby popover is open, focus its filter; else if a baby field is focused, open // its popover at the field's left edge (which auto-focuses the filter). on(document, 'keydown', e => { if (e.key !== ',' || !(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return; // #397: when this handler acts on a baby field, swallow the event so the global edit-note // Ctrl+, handler (bubble phase) doesn't ALSO fire and steal focus to the note panel. if (pop) { const f = pop.querySelector('.mmthf-filter'); if (f) { e.preventDefault(); e.stopPropagation(); f.focus(); if (f.select) f.select(); return; } } const el = document.activeElement; const p = el && pins.find(pp => pp.el === el); if (p) { e.preventDefault(); e.stopPropagation(); openPop(p, true); } // hotkey β†’ anchor to the field's left edge }, true); // #296: the release editor keeps reflowing for a few hundred ms after load, so // the absolutely-positioned overlays would chase the moving fields and visibly // jump. Keep them hidden until the DOM goes quiet for 300ms (capped at 1.5s), // then reveal them already in their final spots. const html = document.documentElement; html.classList.add('mmthf-settling'); let revealed = false, quietT = 0; const reveal = () => { if (revealed) return; revealed = true; clearTimeout(quietT); clearTimeout(settleCap); html.classList.remove('mmthf-settling'); html.classList.add('mmthf-fadein'); relayout(); setTimeout(() => html.classList.remove('mmthf-fadein'), 450); // drop the slow transition once faded in }; const bump = () => { if (revealed) return; clearTimeout(quietT); quietT = setTimeout(reveal, 300); }; settleCap = setTimeout(reveal, 1500); let st = 0; mo = new MutationObserver(() => { syncDialog(); clearTimeout(st); st = setTimeout(scan, 150); bump(); }); mo.observe(document.documentElement, { childList: true, subtree: true }); bump(); scan(); } function stop() { if (!running) return; running = false; if (mo) { mo.disconnect(); mo = null; } clearTimeout(settleCap); document.documentElement.classList.remove('mmthf-settling', 'mmthf-fadein'); listeners.forEach(([t, ev, fn, cap]) => t.removeEventListener(ev, fn, cap)); listeners.length = 0; closePop(); pins.forEach(p => { try { setReserve(p, false); } catch (e) {} p.btn.remove(); p.bar.remove(); delete p.el.dataset.mmthf; }); pins = []; } return { start, stop, toggle(on) { on ? start() : stop(); }, relabel() { pins.forEach(p => renderBar(p)); }, refresh() { if (running) { stop(); start(); } } }; // refresh: re-scan after custom fields change } })();