// ==UserScript== // @name Mammoth // @namespace https://musicbrainz.org/ // @version 2026.6.21 // @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 // @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. (function () { 'use strict'; const KEY = 'mammoth:data'; const SKEY = 'mammoth:settings'; const DEFAULTS = { historySize: 10, hideHelp: false, defaultInsert: 'replace', visibleRows: 6, sideWidth: 300, appendNewline: true, minimized: false }; // defaultInsert: 'replace' | 'append' const VERSION = '2026.6.20.233000'; // keep in sync with @version (fallback when GM_info is unavailable) const HELP_URL = 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/mammoth/README.md'; const SYNTAX_URL = 'https://musicbrainz.org/doc/Edit_Note'; const scriptVersion = () => { try { return GM_info.script.version || VERSION; } catch (e) { return VERSION; } }; const loadData = () => { try { return Object.assign({ saved: [], history: [] }, JSON.parse(GM_getValue(KEY, '{}') || '{}')); } catch (e) { return { saved: [], history: [] }; } }; const saveData = () => { try { GM_setValue(KEY, JSON.stringify(DATA)); } catch (e) {} render(); }; 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(); }; let DATA = loadData(); let SET = loadSet(); const uid = () => 'n' + Math.random().toString(36).slice(2, 9); // โ”€โ”€ 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(); } // โ”€โ”€ 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 })); } 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'); 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) {} } // wrap the selection in `marker`; with no selection, wrap the word the caret // is in (or just drop the markers if the caret isn't inside a word). function wrapSel(ta, marker) { const v = ta.value; 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); setValue(ta, v.slice(0, s) + marker + sel + marker + v.slice(e)); ta.focus(); const caret = sel ? s + marker.length * 2 + sel.length : s + marker.length; try { ta.setSelectionRange(sel ? s + marker.length : caret, sel ? s + marker.length + sel.length : 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const 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; } .mmth-wrap > textarea.edit-note { flex:1 1 auto; width:auto !important; min-width:0; } /* 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 #cfd9d3; border-radius:7px; background:#fbfdfc; box-shadow:0 1px 3px rgba(0,0,0,.12); font-size:15px; line-height:1; user-select:none; } .mmth-badge:hover { background:#eaf5ee; border-color:#5aa67e; } .mmth-min > .mmth-badge { display:flex; } .mmth-vsep { flex:none; width:9px; align-self:stretch; cursor:col-resize; position:relative; } .mmth-vsep::before { content:''; position:absolute; left:4px; top:0; bottom:0; width:1px; background:#d7e0db; } .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 #cfd9d3; border-radius:8px; background:#fbfdfc; 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 #e7eee9; background:#f1f6f3; } .mmth-fb { cursor:pointer; border:none; background:none; font-size:13px; line-height:1; padding:3px 6px; border-radius:5px; color:#566; } .mmth-fb:hover { background:#dcefe2; } .mmth-fb.on { background:#cfe9d8; color:#1f5c3d; } .mmth-fb.mmth-spacer { flex:1; pointer-events:none; } .mmth-fb.mmth-grp { margin-left:10px; } .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 #f0f4f2; cursor:pointer; } .mmth-row:first-child { border-top:none; } .mmth-row:hover { background:#eaf5ee; } .mmth-row.mmth-cyc { background:#d9efe1; } .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:#b7c2bb; 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:#293330; } .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:#7d8a82; font-size:11px; line-height:1; padding:1px 2px; border-radius:3px; } .mmth-ra:hover { background:#cfe9d8; color:#1f5c3d; } .mmth-empty { padding:12px 8px; color:#9aa6a0; font-style:italic; text-align:center; } .mmth-pop { position:fixed; z-index:99999; background:#fff; border:1px solid #c7d3cc; border-radius:8px; box-shadow:0 8px 26px rgba(20,50,35,.2); padding:10px 12px; font:13px/1.45 -apple-system,Segoe UI,Arial,sans-serif; color:#222; width:280px; } .mmth-pop h4 { margin:-10px -12px 8px; padding:6px 10px; font-size:13px; display:flex; align-items:center; gap:6px; background:#f1f6f3; border-bottom:1px solid #e7eee9; border-radius:8px 8px 0 0; } .mmth-tip { color:#8a978f; font-size:11px; margin:0 0 4px 22px; } .mmth-pop h4 .mmth-ver { color:#8a978f; font-weight:400; font-size:11px; } .mmth-pop h4 a { margin-left:auto; font-size:11px; color:#2c7a51; text-decoration:none; font-weight:600; } .mmth-pop label { display:flex; align-items:center; gap:6px; margin:5px 0; cursor:pointer; } .mmth-pop input[type="number"] { width:46px; border:1px solid #d7e0db; border-radius:4px; padding:1px 4px; } .mmth-pop select { border:1px solid #d7e0db; border-radius:4px; padding:1px 4px; } .mmth-pop code { background:#f1f4f2; 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; } .mmth-toast { position:fixed; z-index:100000; background:#2c3a33; color:#fff; padding:6px 12px; border-radius:6px; font:13px sans-serif; box-shadow:0 4px 14px rgba(0,0,0,.25); left:50%; top:14px; transform:translateX(-50%); } `; (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); function toast(msg) { const t = document.createElement('div'); t.className = 'mmth-toast'; t.textContent = msg; document.body.appendChild(t); if (_toastPt) { // anchor just above the click point, clamped into the viewport const w = t.offsetWidth, h = t.offsetHeight; const left = Math.max(6, Math.min(window.innerWidth - w - 6, _toastPt.x - w / 2)); const top = Math.max(6, Math.min(window.innerHeight - h - 6, _toastPt.y - h - 10)); t.style.left = left + 'px'; t.style.top = top + 'px'; t.style.transform = 'none'; } setTimeout(() => t.remove(), 1500); } // โ”€โ”€ popovers (settings + syntax help) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let pop = null; function closePop() { if (pop) { pop.remove(); pop = null; document.removeEventListener('mousedown', onPopDown, true); } } function onPopDown(e) { if (pop && !pop.contains(e.target) && !e.target.closest('.mmth-pop-anchor')) closePop(); } function placePop(p, anchor) { const r = anchor.getBoundingClientRect(); p.style.top = Math.max(6, r.top - p.offsetHeight - 6) + 'px'; p.style.left = Math.max(6, Math.min(window.innerWidth - p.offsetWidth - 6, r.right - p.offsetWidth)) + 'px'; setTimeout(() => document.addEventListener('mousedown', onPopDown, true), 0); } function openSettings(anchor) { closePop(); const p = document.createElement('div'); p.className = 'mmth-pop'; p.innerHTML = `

๐Ÿฆฃ Mammoth v${scriptVersion()}? Help

Right-click does the other action.
`; document.body.appendChild(p); pop = p; 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 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(); }; 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(''); }; placePop(p, anchor); } 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 => en.classList.toggle('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; function setSideWidth(side, w) { w = Math.max(160, 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(160, 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 list = document.createElement('div'); list.className = 'mmth-list'; side.appendChild(ft); side.appendChild(list); const inst = { ta, list, side, view: 'saved', cyc: -1 }; instances.push(inst); 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); inst.minBtn = fb('โ€“', 'Minimize to corner', 'mmth-min-btn', () => 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; const items = DATA.saved; if (!items.length) return; e.preventDefault(); inst.cyc = (inst.cyc + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length; setValue(ta, items[inst.cyc].text); 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); // 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; } 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'; const items = saved ? DATA.saved : DATA.history; if (!items.length) { const e = document.createElement('div'); e.className = 'mmth-empty'; e.textContent = saved ? 'No saved notes โ€” ๏ผ‹ saves the current one' : 'No history yet'; list.appendChild(e); return; } items.forEach((it, idx) => { const row = document.createElement('div'); row.className = 'mmth-row'; if (saved && idx === inst.cyc) row.classList.add('mmth-cyc'); const dflt = SET.defaultInsert; row.title = it.text + `\n\n(click: ${dflt} ยท right-click: ${dflt === 'replace' ? 'append' : 'replace'})`; const txt = document.createElement('span'); txt.className = 'mmth-txt'; txt.textContent = it.text.replace(/\s+/g, ' ').trim(); row.appendChild(txt); // right-side hover actions (delete / pin), then the drag handle (saved only) 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('๐Ÿ—‘', '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 (saved) { 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 = () => applyNote(ta, it.text, SET.defaultInsert === 'replace'); row.oncontextmenu = e => { e.preventDefault(); applyNote(ta, it.text, SET.defaultInsert !== 'replace'); }; if (saved) { 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); wrap.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); 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.textContent = '๐Ÿฆฃ'; wrap.appendChild(badge); inst.badge = badge; 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'; } catch (x) {} }; syncFloor(); requestAnimationFrame(syncFloor); setTimeout(syncFloor, 150); setTimeout(syncFloor, 600); // catch the sidebar's final layout try { new ResizeObserver(syncFloor).observe(side); } catch (x) {} }); } new MutationObserver(() => injectAll()).observe(document.documentElement, { childList: true, subtree: true }); // #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(); } }); injectAll(); })();