Plugin Directory

Changeset 3797176


Ignore:
Timestamp:
11/11/2026 10:35:06 PM (3 days ago)
Author:
tinotines
Message:

Tabwise 1.0.2: lock tabs, instant tab switching, stay in your tab after duplicate or trash

Location:
tabwise/trunk
Files:
5 edited

Legend:

Unmodified
Added
Removed
  • tabwise/trunk/assets/tabwise.css

    r3782325 r3797176  
    111111}
    112112.tw-star svg { width: 12px; height: 12px; display: block; fill: #eab308; }
     113.tw-lock { display: inline-flex; align-items: center; opacity: .75; }
     114.tw-lock svg { width: 12px; height: 12px; display: block; fill: currentColor; }
     115.tw-tab.is-locked { cursor: pointer; }
     116.tw-oitem .tw-lock { margin-left: 2px; }
     117.tw-mlock { background: none; border: 0; padding: 4px; cursor: pointer; border-radius: 4px; color: inherit; opacity: .45; }
     118.tw-mlock:hover { opacity: .9; }
     119.tw-c-act { white-space: nowrap; }
     120.tw-mlock.is-on { opacity: 1; }
     121.tw-mlock svg { width: 15px; height: 15px; display: block; fill: currentColor; }
     122.tw-mrow.is-locked .tw-mgrip, .tw-mrow.is-locked .tw-mdel, .tw-mdel:disabled { opacity: .25; cursor: not-allowed; }
    113123
    114124.tw-dots {
    … …  
    432442.tw-mgrip:hover { opacity: 1; }
    433443.tw-mgrip svg { width: 16px; height: 16px; fill: currentColor; }
    434 .tw-mico, .tw-mname, .tw-mcolor, .tw-mdef, .tw-mdel {
     444.tw-mico, .tw-mname, .tw-mcolor, .tw-mdef, .tw-mdel, .tw-mlock {
    435445        display: inline-flex; align-items: center; gap: 8px;
    436446        padding: 6px; margin: 0; border: 1px solid transparent; border-radius: 6px;
  • tabwise/trunk/assets/tabwise.js

    r3782325 r3797176  
    1515        function svg(path) { return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="' + path + '"/></svg>'; }
    1616        var ICON = {
     17                lock: svg('M12 2a5 5 0 0 0-5 5v3H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-1V7a5 5 0 0 0-5-5Zm-3 8V7a3 3 0 0 1 6 0v3H9Zm3 3.5a1.5 1.5 0 0 1 .75 2.8V18h-1.5v-1.7A1.5 1.5 0 0 1 12 13.5Z'),
     18                unlock: svg('M12 2a5 5 0 0 0-5 5h2a3 3 0 0 1 6 0v3H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-1V7a5 5 0 0 0-5-5Zm0 11.5a1.5 1.5 0 0 1 .75 2.8V18h-1.5v-1.7A1.5 1.5 0 0 1 12 13.5Z'),
    1719                list: svg('M4 6h2v2H4zm4 0h12v2H8zM4 11h2v2H4zm4 0h12v2H8zm-4 5h2v2H4zm4 0h12v2H8z'),
    1820                inbox: svg('M5 4h14l2 9v5.5A1.5 1.5 0 0 1 19.5 20h-15A1.5 1.5 0 0 1 3 18.5V13l2-9Zm1.6 2-1.5 7H9a3 3 0 0 0 6 0h3.9l-1.5-7H6.6Z'),
    … …  
    166168                if (key === 'unassigned') return { key: key, system: true, name: 'Unassigned', color: '#8c8f94', icon: ICON.inbox };
    167169                var t = S.tabs[key];
    168                 return t ? { key: key, id: t.id, name: t.name, color: t.color, iconKey: t.icon || 'folder', icon: iconHTML(t.icon) } : null;
    169         }
    170 
    171         var onApiError = function () {};
     170                return t ? { key: key, id: t.id, name: t.name, color: t.color, iconKey: t.icon || 'folder', icon: iconHTML(t.icon), locked: !!t.locked } : null;
     171        }
     172        function isLocked(key) { return !!(S.tabs[key] && S.tabs[key].locked); }
     173        // Locked tabs keep their place: put them back where they were in the old order.
     174        function keepLocked(oldOrder, newOrder) {
     175                var fixed = [];
     176                oldOrder.forEach(function (k, i) { if (isLocked(k)) fixed.push([i, k]); });
     177                if (!fixed.length) return newOrder;
     178                var rest = newOrder.filter(function (k) { return !isLocked(k); });
     179                fixed.forEach(function (f) { rest.splice(Math.min(f[0], rest.length), 0, f[1]); });
     180                return rest;
     181        }
     182        var LOCK_HTML = '<span class="tw-lock" title="Locked tab">' + ICON.lock + '</span>';
     183
     184        var onApiError = function () {}, afterApi = function () {};
    172185        function api(op, data) {
    173186                var body = new URLSearchParams();
    … …  
    186199                                if (!j || !j.success) throw new Error((j && j.data) || 'Something went wrong.');
    187200                                applyState(j.data);
     201                                afterApi();
    188202                                return j.data;
    189203                        })
    … …  
    352366                n.querySelectorAll('.tw-name, .tw-count, .tw-dots').forEach(function (e) { e.style.setProperty('color', k.fg, 'important'); });
    353367                n.querySelectorAll('.tw-ico svg').forEach(function (e) { e.style.setProperty('fill', k.ic, 'important'); });
     368                n.querySelectorAll('.tw-lock svg').forEach(function (e) { e.style.setProperty('fill', k.fg, 'important'); });
    354369                n.querySelectorAll('.tw-ico .dashicons').forEach(function (e) { e.style.setProperty('color', k.ic, 'important'); });
    355370        }
    … …  
    775790                        return u.toString();
    776791                }
    777                 var navCtrl = null;
    778792                function tablenavShape(root) {
    779793                        var t = root.querySelector('#posts-filter .tablenav.top');
    780794                        return t ? Array.prototype.map.call(t.querySelectorAll('select,input[type="submit"]'), function (n) { return n.name || n.id; }).join('|') : '';
    781795                }
     796
     797                /* Tab pages are fetched ahead of time (when you hover a tab, and quietly in the background
     798                 * after the screen loads), so clicking a tab shows its list straight away. A shown copy is
     799                 * always re-checked in the background, and every change you make clears the saved copies. */
     800                var cache = {}, inflight = {}, MAX_AGE = 120000;
     801                function clearCache() { cache = {}; inflight = {}; }
     802                afterApi = clearCache;
     803                function load(url) {
     804                        if (inflight[url]) return inflight[url];
     805                        var p = fetch(url, { credentials: 'same-origin' })
     806                                .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
     807                                .then(function (html) { if (inflight[url] === p) { cache[url] = { html: html, t: Date.now() }; delete inflight[url]; } return html; },
     808                                        function (e) { if (inflight[url] === p) delete inflight[url]; throw e; });
     809                        inflight[url] = p;
     810                        return p;
     811                }
     812                function cached(url) {
     813                        var c = cache[url];
     814                        return c && Date.now() - c.t < MAX_AGE ? c.html : null;
     815                }
     816                function prefetch(key) {
     817                        if (String(key) === S.current) return null;
     818                        var url = tabUrl(key);
     819                        if (cached(url)) return null;
     820                        return load(url).catch(function () {});
     821                }
     822                function prefetchBar() {
     823                        var c = navigator.connection;
     824                        if (c && (c.saveData || /2g/.test(c.effectiveType || ''))) return;
     825                        var keys = visibleCustom().concat(SYSTEM).filter(function (k) { return k !== S.current; });
     826                        (function next() {
     827                                if (!keys.length) return;
     828                                var p = prefetch(keys.shift());
     829                                if (p) p.then(function () { setTimeout(next, 150); }); else next();
     830                        })();
     831                }
     832
     833                // Put a fetched page's list, counts, filters and paging into this screen.
     834                function apply(html, key) {
     835                        var doc = new DOMParser().parseFromString(html, 'text/html');
     836                        var newList = doc.getElementById('the-list'), list = document.getElementById('the-list');
     837                        if (!newList || !list) return false;
     838                        // WordPress hides Bulk actions and some filters when a list is empty. If the toolbar
     839                        // changes shape between tabs, load the page normally so every control is there.
     840                        if (tablenavShape(document) !== tablenavShape(doc)) return false;
     841                        list.innerHTML = newList.innerHTML;
     842                        var a = document.querySelector('.subsubsub'), b = doc.querySelector('.subsubsub');
     843                        if (a && b) a.innerHTML = b.innerHTML;
     844                        var oldPages = document.querySelectorAll('#posts-filter .tablenav-pages');
     845                        var newPages = doc.querySelectorAll('#posts-filter .tablenav-pages');
     846                        oldPages.forEach(function (n, i) { if (newPages[i]) n.outerHTML = newPages[i].outerHTML; });
     847                        var oldA = document.querySelectorAll('#posts-filter thead a, #posts-filter tfoot a');
     848                        var newA = doc.querySelectorAll('#posts-filter thead a, #posts-filter tfoot a');
     849                        oldA.forEach(function (x, i) { if (newA[i]) x.href = newA[i].href; });
     850                        document.querySelectorAll('#posts-filter input[name="' + D.qv + '"]').forEach(function (i) { i.value = key; });
     851                        document.querySelectorAll('#posts-filter thead .check-column input, #posts-filter tfoot .check-column input').forEach(function (c) { c.checked = false; });
     852                        var data = doc.getElementById('tabwise-js-extra');
     853                        if (data) {
     854                                var t = data.textContent;
     855                                try { applyState(JSON.parse(t.slice(t.indexOf('{'), t.lastIndexOf('}') + 1))); } catch (e) { /* keep */ }
     856                        }
     857                        return true;
     858                }
     859
     860                var navId = 0;
    782861                function navigate(key, opts) {
    783862                        opts = opts || {};
     863                        key = String(key);
    784864                        var url = tabUrl(key, opts.keepPage);
    785                         if (navCtrl) navCtrl.abort();
    786                         navCtrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
    787                         S.current = String(key);
     865                        var id = ++navId;
     866                        S.current = key;
     867                        if (opts.fresh) { delete cache[url]; delete inflight[url]; }
     868                        if (opts.push !== false) history[opts.replace ? 'replaceState' : 'pushState']({ twtab: key }, '', url);
     869                        var list = document.getElementById('the-list');
     870                        function done(html, quiet) {
     871                                if (id !== navId) return;
     872                                clearTimeout(dim);
     873                                if (list) list.classList.remove('tw-loading');
     874                                if (!apply(html, key)) { location.href = url; return; }
     875                                render();
     876                                if (!quiet) { setupRows(); syncBulkMenu(); } else { setupRows(); }
     877                        }
     878                        var hit = cached(url);
    788879                        render();
    789                         var list = document.getElementById('the-list');
    790                         if (list) list.classList.add('tw-loading');
    791 
    792                         return fetch(url, { credentials: 'same-origin', signal: navCtrl ? navCtrl.signal : undefined })
    793                                 .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
    794                                 .then(function (html) {
    795                                         var doc = new DOMParser().parseFromString(html, 'text/html');
    796                                         var newList = doc.getElementById('the-list');
    797                                         if (!newList || !list) throw new Error('list missing');
    798                                         // WordPress hides Bulk actions and some filters when a list is empty. If the toolbar
    799                                         // changes shape between tabs, load the page normally so every control is there.
    800                                         if (tablenavShape(document) !== tablenavShape(doc)) { location.href = url; return; }
    801                                         list.innerHTML = newList.innerHTML;
    802                                         list.classList.remove('tw-loading');
    803 
    804                                         var a = document.querySelector('.subsubsub'), b = doc.querySelector('.subsubsub');
    805                                         if (a && b) a.innerHTML = b.innerHTML;
    806                                         var oldPages = document.querySelectorAll('#posts-filter .tablenav-pages');
    807                                         var newPages = doc.querySelectorAll('#posts-filter .tablenav-pages');
    808                                         oldPages.forEach(function (n, i) { if (newPages[i]) n.outerHTML = newPages[i].outerHTML; });
    809                                         var oldA = document.querySelectorAll('#posts-filter thead a, #posts-filter tfoot a');
    810                                         var newA = doc.querySelectorAll('#posts-filter thead a, #posts-filter tfoot a');
    811                                         oldA.forEach(function (x, i) { if (newA[i]) x.href = newA[i].href; });
    812                                         document.querySelectorAll('#posts-filter input[name="' + D.qv + '"]').forEach(function (i) { i.value = key; });
    813                                         document.querySelectorAll('#posts-filter thead .check-column input, #posts-filter tfoot .check-column input').forEach(function (c) { c.checked = false; });
    814 
    815                                         var data = doc.getElementById('tabwise-js-extra');
    816                                         if (data) {
    817                                                 var t = data.textContent;
    818                                                 try { applyState(JSON.parse(t.slice(t.indexOf('{'), t.lastIndexOf('}') + 1))); } catch (e) { /* keep */ }
    819                                         }
    820                                         if (opts.push !== false) history[opts.replace ? 'replaceState' : 'pushState']({ twtab: key }, '', url);
    821                                         render();
    822                                         setupRows();
    823                                         syncBulkMenu();
    824                                 })
    825                                 .catch(function (e) {
    826                                         if (e && e.name === 'AbortError') return;
    827                                         location.href = url;
    828                                 });
    829                 }
    830                 function refresh() { return navigate(S.current, { replace: true, keepPage: true }); }
     880                        if (hit) {
     881                                done(hit);
     882                                // Quietly re-check; only redraw if something changed meanwhile.
     883                                delete cache[url];
     884                                load(url).then(function (html) { if (html !== hit && id === navId) done(html, true); }).catch(function () {});
     885                                return Promise.resolve();
     886                        }
     887                        // Only dim the list if the page takes a moment.
     888                        var dim = setTimeout(function () { if (id === navId && list) list.classList.add('tw-loading'); }, 150);
     889                        return load(url).then(function (html) { done(html); }).catch(function () { if (id === navId) location.href = url; });
     890                }
     891                function refresh() { clearCache(); return navigate(S.current, { replace: true, keepPage: true, fresh: true }); }
    831892                window.addEventListener('popstate', function (e) {
    832893                        var key = (e.state && e.state.twtab) || new URL(location.href).searchParams.get(D.qv);
    … …  
    845906                                        closeToBase();
    846907                                        S.def = tab.key;
    847                                         if (!tab.system) S.order = [tab.key].concat(S.order.filter(function (k) { return k !== tab.key; }));
     908                                        if (!tab.system) S.order = keepLocked(S.order, [tab.key].concat(S.order.filter(function (k) { return k !== tab.key; })));
    848909                                        render();
    849910                                        api('default', { value: tab.key }).then(render);
    … …  
    884945                                        });
    885946                                }));
    886                                 m.appendChild(el('div', 'tw-sep'));
    887                                 m.appendChild(menuItem(ICON.trash, 'Delete tab', function () {
     947                                m.appendChild(menuItem(tab.locked ? ICON.unlock : ICON.lock, tab.locked ? 'Unlock tab' : 'Lock tab', function () {
     948                                        closeToBase();
     949                                        var on = !tab.locked;
     950                                        S.tabs[tab.key].locked = on;
     951                                        render();
     952                                        api('lock', { id: tab.id, value: on ? 1 : 0 }).then(render);
     953                                        toast(on ? '“' + tab.name + '” is locked. It can’t be moved or deleted.' : '“' + tab.name + '” is unlocked');
     954                                }));
     955                                if (!tab.locked) m.appendChild(el('div', 'tw-sep'));
     956                                if (!tab.locked) m.appendChild(menuItem(ICON.trash, 'Delete tab', function () {
    888957                                        closeToBase();
    889958                                        if (!confirm('Delete the tab “' + tab.name + '”?\n\nNo ' + D.label + ' will be deleted. Anything in this tab becomes Unassigned.')) return;
    … …  
    9531022                                '<span class="tw-name">' + esc(t.name) + '</span>' +
    9541023                                '<span class="tw-count">' + (S.counts[key] != null ? S.counts[key] : 0) + '</span>' +
    955                                 (S.def === key ? '<span class="tw-star" title="Default tab">' + ICON.star + '</span>' : '');
     1024                                (S.def === key ? '<span class="tw-star" title="Default tab">' + ICON.star + '</span>' : '') +
     1025                                (t.locked ? LOCK_HTML : '');
    9561026                        var dots = el('button', 'tw-dots', ICON.dots);
    9571027                        dots.type = 'button';
    … …  
    9701040                                if (S.current !== key) navigate(key);
    9711041                        });
    972                         if (!t.system) a.addEventListener('pointerdown', function (e) { startSort(e, a, 'bar'); });
     1042                        if (t.locked) a.classList.add('is-locked');
     1043                        if (!t.system && !t.locked) a.addEventListener('pointerdown', function (e) { startSort(e, a, 'bar'); });
     1044                        if (S.current !== key) a.addEventListener('pointerenter', function () { prefetch(key); });
    9731045                        // Drop target for dragged page rows (not "All").
    9741046                        if (key !== 'all') {
    … …  
    10291101                        hiddenKeys.forEach(function (key) {
    10301102                                var t = tabInfo(key);
    1031                                 var b = el('div', 'tw-item tw-oitem' + (S.current === key ? ' is-current' : ''), '<span class="tw-oico" style="--tw-c:' + escAttr(t.color) + '">' + t.icon + '</span><span class="tw-oname">' + esc(t.name) + '</span><em>' + (S.counts[key] || 0) + '</em>');
     1103                                var b = el('div', 'tw-item tw-oitem' + (S.current === key ? ' is-current' : ''), '<span class="tw-oico" style="--tw-c:' + escAttr(t.color) + '">' + t.icon + '</span><span class="tw-oname">' + esc(t.name) + '</span>' + (t.locked ? LOCK_HTML : '') + '<em>' + (S.counts[key] || 0) + '</em>');
    10321104                                b.dataset.key = key;
    10331105                                b.tabIndex = 0;
    … …  
    10411113                                b.querySelectorAll('.tw-oname, em').forEach(function (n) { n.style.setProperty('color', k.fg, 'important'); });
    10421114                                b.querySelectorAll('.tw-oico svg').forEach(function (n) { n.style.setProperty('fill', k.ic, 'important'); });
     1115                                b.querySelectorAll('.tw-lock svg').forEach(function (n) { n.style.setProperty('fill', k.fg, 'important'); });
    10431116                                b.querySelectorAll('.tw-oico .dashicons').forEach(function (n) { n.style.setProperty('color', k.ic, 'important'); });
    10441117                                var dots = el('button', 'tw-dots', ICON.dots);
    … …  
    10521125                                b.onclick = go;
    10531126                                b.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
    1054                                 b.addEventListener('pointerdown', function (e) { startSort(e, b, 'menu'); });
     1127                                if (!t.locked) b.addEventListener('pointerdown', function (e) { startSort(e, b, 'menu'); });
     1128                                b.addEventListener('pointerenter', function () { prefetch(key); });
    10551129                                listWrap.appendChild(b);
    10561130                        });
    … …  
    11891263                                visible.push(hidden.splice(up, 1)[0]);
    11901264                        }
    1191                         var newOrder = visible.concat(hidden);
     1265                        var newOrder = keepLocked(S.order, visible.concat(hidden));
    11921266                        if (newOrder.join() === S.order.join()) { render(); return; }
    11931267                        S.order = newOrder;
    … …  
    12851359                                window.jQuery(document).ajaxSuccess(function (e, xhr, settings) {
    12861360                                        var data = settings && typeof settings.data === 'string' ? settings.data : '';
    1287                                         if (data.indexOf('action=inline-save') !== -1 && data.indexOf('tw_tab=') !== -1) setTimeout(refresh, 60);
     1361                                        if (data.indexOf('action=inline-save') === -1) return;
     1362                                        clearCache();
     1363                                        if (data.indexOf('tw_tab=') !== -1) setTimeout(refresh, 60);
    12881364                                });
    12891365                        }
    … …  
    13011377                        hookBulk();
    13021378                        hookQuickEdit();
    1303                         history.replaceState({ twtab: S.current }, '', location.href);
     1379                        // Keep the tab in the address, so anything that sends you back here (Trash, Duplicate…) reopens it.
     1380                        var here = new URL(location.href);
     1381                        if (!here.searchParams.get(D.qv)) here.searchParams.set(D.qv, S.current);
     1382                        history.replaceState({ twtab: S.current }, '', here.toString());
     1383                        setTimeout(prefetchBar, 600);
    13041384                        var raf;
    13051385                        window.addEventListener('resize', function () { cancelAnimationFrame(raf); raf = requestAnimationFrame(render); });
    … …  
    13451425                        bar.innerHTML =
    13461426                                '<div class="tw-mgroup">' +
    1347                                         '<select class="tw-mbulk" aria-label="Bulk actions"><option value="">Bulk actions</option><option value="color">Change color</option><option value="icon">Change icon</option><option value="merge">Merge into another tab…</option><option value="delete">Delete tabs</option></select>' +
     1427                                        '<select class="tw-mbulk" aria-label="Bulk actions"><option value="">Bulk actions</option><option value="color">Change color</option><option value="icon">Change icon</option><option value="merge">Merge into another tab…</option><option value="lock">Lock tabs</option><option value="unlock">Unlock tabs</option><option value="delete">Delete tabs</option></select>' +
    13481428                                        '<button type="button" class="button tw-mapply tw-pop-trigger">Apply</button>' +
    13491429                                        '<span class="tw-msel"></span>' +
    … …  
    14131493                                '<td class="tw-c-where">' + where + '</td>' +
    14141494                                '<td class="tw-c-def"><button type="button" class="tw-mdef' + (S.def === t.key ? ' is-on' : '') + '" title="' + (S.def === t.key ? 'Default tab' : 'Set as default') + '">' + ICON.star + '</button></td>' +
    1415                                 '<td class="tw-c-act"><button type="button" class="tw-mdel" title="Delete tab" aria-label="Delete ' + escAttr(t.name) + '">' + ICON.trash + '</button></td>';
     1495                                '<td class="tw-c-act"><button type="button" class="tw-mlock' + (t.locked ? ' is-on' : '') + '" title="' + (t.locked ? 'Locked: click to unlock' : 'Lock tab') + '" aria-pressed="' + (t.locked ? 'true' : 'false') + '" aria-label="' + (t.locked ? 'Unlock ' : 'Lock ') + escAttr(t.name) + '">' + (t.locked ? ICON.lock : ICON.unlock) + '</button>' +
     1496                                '<button type="button" class="tw-mdel" title="' + (t.locked ? 'Locked tabs can’t be deleted' : 'Delete tab') + '" aria-label="Delete ' + escAttr(t.name) + '"' + (t.locked ? ' disabled' : '') + '>' + ICON.trash + '</button></td>';
     1497                        if (t.locked) tr.classList.add('tw-mrow', 'is-locked');
    14161498                        return tr;
    14171499                }
    … …  
    14381520                        bar.querySelector('.tw-msort').onclick = function () {
    14391521                                if (!confirm('Sort all ' + D.label + ' tabs alphabetically? This changes their order in the tab bar.')) return;
    1440                                 S.order.sort(function (a, b) { return S.tabs[a].name.localeCompare(S.tabs[b].name, undefined, { sensitivity: 'base', numeric: true }); });
     1522                                S.order = keepLocked(S.order, S.order.slice().sort(function (a, b) { return S.tabs[a].name.localeCompare(S.tabs[b].name, undefined, { sensitivity: 'base', numeric: true }); }));
    14411523                                draw();
    14421524                                api('reorder', { order: fullOrder() }).then(draw);
    … …  
    14541536                                if (!keys.length) { toast('Select one or more tabs first.', true); return; }
    14551537                                var ids = keys.map(function (k) { return S.tabs[k].id; });
    1456                                 if (act === 'delete') {
     1538                                var locked = keys.filter(isLocked);
     1539                                if (act === 'lock' || act === 'unlock') {
     1540                                        api('lock', { tabs: ids, value: act === 'lock' ? 1 : 0 }).then(function () { draw(); toast(act === 'lock' ? 'Tabs locked' : 'Tabs unlocked'); });
     1541                                } else if ((act === 'delete' || act === 'merge') && locked.length === keys.length) {
     1542                                        toast('Locked tabs can’t be ' + (act === 'delete' ? 'deleted' : 'merged') + '. Unlock them first.', true);
     1543                                } else if (act === 'delete') {
     1544                                        if (locked.length) toast(locked.length + ' locked tab' + (locked.length > 1 ? 's' : '') + ' will be kept.');
    14571545                                        if (!confirm('Delete ' + keys.length + ' tab' + (keys.length > 1 ? 's' : '') + '?\n\nNo ' + D.label + ' will be deleted. They become Unassigned.')) return;
    14581546                                        api('bulk_delete', { tabs: ids }).then(function () { selected = {}; draw(); toast('Tabs deleted'); });
    … …  
    15121600                                        if (S.def === key) return;
    15131601                                        S.def = key;
    1514                                         S.order = [key].concat(S.order.filter(function (k) { return k !== key; }));
     1602                                        S.order = keepLocked(S.order, [key].concat(S.order.filter(function (k) { return k !== key; })));
    15151603                                        draw();
    15161604                                        api('default', { value: key }).then(draw);
    15171605                                        toast('“' + t.name + '” is now your default tab');
    15181606                                };
     1607                                tr.querySelector('.tw-mlock').onclick = function () {
     1608                                        var on = !t.locked;
     1609                                        S.tabs[key].locked = on;
     1610                                        draw();
     1611                                        api('lock', { id: t.id, value: on ? 1 : 0 }).then(draw);
     1612                                        toast(on ? '“' + t.name + '” is locked. It can’t be moved or deleted.' : '“' + t.name + '” is unlocked');
     1613                                };
    15191614                                tr.querySelector('.tw-mdel').onclick = function () {
     1615                                        if (t.locked) return;
    15201616                                        if (!confirm('Delete the tab “' + t.name + '”?\n\nNo ' + D.label + ' will be deleted. Anything in this tab becomes Unassigned.')) return;
    15211617                                        delete selected[key];
    … …  
    15231619                                };
    15241620                                var grip = tr.querySelector('.tw-mgrip');
    1525                                 grip.addEventListener('pointerdown', function (e) { if (!filter) startRowSort(e, tr, table); });
    1526                                 if (filter) grip.title = 'Clear the filter to reorder';
     1621                                grip.addEventListener('pointerdown', function (e) { if (!filter && !t.locked) startRowSort(e, tr, table); });
     1622                                if (t.locked) grip.title = 'Locked tabs can’t be moved';
     1623                                else if (filter) grip.title = 'Clear the filter to reorder';
    15271624                        });
    15281625                }
    … …  
    18181915                                document.body.classList.remove('tw-sorting');
    18191916                                var order = Array.prototype.map.call(tbody.querySelectorAll('tr[data-key]'), function (r) { return r.dataset.key; });
     1917                                order = keepLocked(S.order, order);
    18201918                                var changed = order.join() !== S.order.join();
    18211919                                S.order = order;
  • tabwise/trunk/includes/class-tabwise.php

    r3782325 r3797176  
    282282                $raw = isset( $_GET[ self::QV ] ) ? sanitize_key( wp_unslash( $_GET[ self::QV ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
    283283                if ( '' === $raw ) {
     284                        $raw = self::referer_tab( $pt );
     285                }
     286                if ( '' === $raw ) {
    284287                        $raw = self::get_default( $pt );
    285288                }
    … …  
    297300                }
    298301                return 'all';
     302        }
     303
     304        /**
     305         * When another plugin (or WordPress itself) sends you back to the list without the tab
     306         * in the address, for example after "Duplicate" or "Trash", reopen the tab you came from.
     307         *
     308         * @param string $pt Post type.
     309         */
     310        private static function referer_tab( $pt ) {
     311                $ref = wp_get_referer();
     312                if ( ! $ref ) {
     313                        return '';
     314                }
     315                $parts = wp_parse_url( $ref );
     316                if ( empty( $parts['path'] ) || empty( $parts['query'] ) || ! str_ends_with( $parts['path'], '/edit.php' ) ) {
     317                        return '';
     318                }
     319                $admin = wp_parse_url( admin_url( 'edit.php' ) );
     320                if ( isset( $admin['path'] ) && $admin['path'] !== $parts['path'] ) {
     321                        return '';
     322                }
     323                wp_parse_str( $parts['query'], $args );
     324                $ref_pt = isset( $args['post_type'] ) ? sanitize_key( $args['post_type'] ) : 'post';
     325                if ( $ref_pt !== $pt || empty( $args[ self::QV ] ) || ! is_string( $args[ self::QV ] ) ) {
     326                        return '';
     327                }
     328                return sanitize_key( $args[ self::QV ] );
    299329        }
    300330
    … …  
    384414                }
    385415                return $order;
     416        }
     417
     418        /** Is this tab locked (can't be moved or deleted)? */
     419        public static function is_locked( $id ) {
     420                return (bool) get_term_meta( absint( $id ), 'tabwise_locked', true );
     421        }
     422
     423        /**
     424         * Put locked tabs back at their current positions in a new order.
     425         *
     426         * @param string $pt    Post type.
     427         * @param array  $order Proposed full order.
     428         */
     429        private static function keep_locked( $pt, $order ) {
     430                $custom = function ( $keys ) {
     431                        return array_values(
     432                                array_filter(
     433                                        array_map( 'strval', $keys ),
     434                                        function ( $k ) {
     435                                                return ctype_digit( $k );
     436                                        }
     437                                )
     438                        );
     439                };
     440                $old    = $custom( self::get_order( $pt ) );
     441                $locked = array();
     442                foreach ( $old as $pos => $key ) {
     443                        if ( self::is_locked( $key ) ) {
     444                                $locked[ $pos ] = $key;
     445                        }
     446                }
     447                if ( ! $locked ) {
     448                        return $order;
     449                }
     450                // Positions are counted among your own tabs only; All and Unassigned keep their spots.
     451                $rest = array_values( array_diff( $custom( $order ), $locked ) );
     452                foreach ( $locked as $pos => $key ) {
     453                        array_splice( $rest, min( $pos, count( $rest ) ), 0, array( $key ) );
     454                }
     455                $out = array();
     456                foreach ( array_map( 'strval', $order ) as $k ) {
     457                        $out[] = ctype_digit( $k ) ? array_shift( $rest ) : $k;
     458                }
     459                return array_merge( $out, $rest );
    386460        }
    387461
    … …  
    443517                                        'name'  => html_entity_decode( $t->name, ENT_QUOTES, 'UTF-8' ),
    444518                                        'color' => $color ? $color : self::DEFAULT_COLOR,
    445                                         'icon'  => self::clean_icon( $icon, true ) ? $icon : 'folder',
     519                                        'icon'   => self::clean_icon( $icon, true ) ? $icon : 'folder',
     520                                        'locked' => (bool) get_term_meta( $t->term_id, 'tabwise_locked', true ),
    446521                                );
    447522                        }
    … …  
    574649                        wp_send_json_error( 'That tab no longer exists.', 404 );
    575650                }
     651                // Locked tabs can't be deleted or merged away.
     652                $unlocked = array_values(
     653                        array_filter(
     654                                $tabs,
     655                                function ( $t ) {
     656                                        return ! self::is_locked( $t );
     657                                }
     658                        )
     659                );
    576660
    577661                switch ( $op ) {
    … …  
    609693
    610694                        case 'delete':
     695                                if ( self::is_locked( $id ) ) {
     696                                        wp_send_json_error( 'This tab is locked. Unlock it first.', 400 );
     697                                }
    611698                                // Removes the tab only; its pages become Unassigned.
    612699                                wp_delete_term( $id, $tax );
    … …  
    623710                                self::set_default( $pt, $val );
    624711                                $order = array_values( array_diff( self::get_order( $pt ), array( $val ) ) );
    625                                 array_unshift( $order, $val );          // Pinned tab moves to the front.
    626                                 self::save_order( $pt, $order );
     712                                array_unshift( $order, $val );          // Pinned tab moves to the front (locked tabs stay put).
     713                                self::save_order( $pt, self::keep_locked( $pt, $order ) );
    627714                                break;
    628715
    … …  
    631718                                $valid = self::get_order( $pt );
    632719                                $order = array_values( array_unique( array_intersect( $order, $valid ) ) );
    633                                 self::save_order( $pt, array_merge( $order, array_diff( $valid, $order ) ) );
     720                                self::save_order( $pt, self::keep_locked( $pt, array_merge( $order, array_diff( $valid, $order ) ) ) );
     721                                break;
     722
     723                        case 'lock':
     724                                $ids = ( $id && term_exists( $id, $tax ) ) ? array( $id ) : $tabs;
     725                                if ( ! $ids ) {
     726                                        wp_send_json_error( 'That tab no longer exists.', 404 );
     727                                }
     728                                $lock = ! empty( $_POST['value'] );
     729                                foreach ( $ids as $tid ) {
     730                                        if ( $lock ) {
     731                                                update_term_meta( $tid, 'tabwise_locked', 1 );
     732                                        } else {
     733                                                delete_term_meta( $tid, 'tabwise_locked' );
     734                                        }
     735                                }
    634736                                break;
    635737
    … …  
    656758
    657759                        case 'bulk_delete':
    658                                 foreach ( $tabs as $tid ) {
     760                                foreach ( $unlocked as $tid ) {
    659761                                        wp_delete_term( $tid, $tax );
    660762                                        if ( self::get_default( $pt ) === (string) $tid ) {
    … …  
    669771                                        wp_send_json_error( 'That tab no longer exists.', 404 );
    670772                                }
    671                                 foreach ( $tabs as $tid ) {
     773                                foreach ( $unlocked as $tid ) {
    672774                                        if ( $tid === $id ) {
    673775                                                continue;
    … …  
    888990                                'icon'    => $tabs[ $id ]['icon'],
    889991                                'default' => self::get_default( $pt ) === (string) $id,
     992                                'locked'  => ! empty( $tabs[ $id ]['locked'] ),
    890993                                'pages'   => $slugs,
    891994                        );
    … …  
    9211024                $custom = (bool) self::settings()['custom_icons'];
    9221025                if ( $replace ) {
     1026                        $def = self::get_default( $pt );
    9231027                        foreach ( array_keys( self::get_tabs( $pt ) ) as $id ) {
     1028                                if ( self::is_locked( $id ) ) {
     1029                                        continue; // Locked tabs are kept.
     1030                                }
    9241031                                wp_delete_term( (int) $id, $tax );
    925                         }
    926                         self::set_default( $pt, '' );
     1032                                if ( $def === (string) $id ) {
     1033                                        self::set_default( $pt, '' );
     1034                                }
     1035                        }
    9271036                }
    9281037                $order = array_values( array_diff( self::get_order( $pt ), array( 'unassigned', 'all' ) ) );
    … …  
    9551064                        if ( ! empty( $tab['default'] ) ) {
    9561065                                self::set_default( $pt, (string) $id );
     1066                        }
     1067                        if ( ! empty( $tab['locked'] ) ) {
     1068                                update_term_meta( $id, 'tabwise_locked', 1 );
    9571069                        }
    9581070                        if ( $assign && ! empty( $tab['pages'] ) && is_array( $tab['pages'] ) ) {
    … …  
    9791091                                self::move_posts( $pt, $ids, $id );
    9801092                        }
    981                         $order = array_values( array_diff( $order, array( (string) $id ) ) );
    982                         $order[] = (string) $id;
     1093                        if ( ! in_array( (string) $id, $order, true ) || ! self::is_locked( $id ) ) {
     1094                                $order   = array_values( array_diff( $order, array( (string) $id ) ) );
     1095                                $order[] = (string) $id;
     1096                        }
    9831097                        ++$count;
    9841098                }
  • tabwise/trunk/readme.txt

    r3782546 r3797176  
    66Tested up to: 7.1
    77Requires PHP: 8.3
    8 Stable tag: 1.0.1
     8Stable tag: 1.0.2
    99License: GPLv2 or later
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    2929* **All and Unassigned.** "All" shows every page, and "Unassigned" shows the pages you haven't filed yet.
    3030* **Default tab.** Pin any tab and it opens first whenever you visit Pages.
     31* **Lock tabs.** Lock a tab from its ⋯ menu so it can't be moved or deleted by accident. A small lock icon shows on locked tabs.
    3132* **Colors.** A full color picker with hue, opacity, Hex, RGB and HSL, preset colors and your own saved colors.
    3233* **Icons.** Over 100 built-in icons with search (try "phone", "legal" or "shop"), plus optional custom icons by upload or link, including Google Fonts icons.
    … …  
    4445Turn on Posts in Tabwise Settings and the Posts screen gets every feature above, with its own separate set of tabs. Post tabs never mix with page tabs.
    4546
    46 * **Tabs above the Posts list,** with All, Unassigned, a default tab and the "more tabs" list.
     47* **Tabs above the Posts list,** with All, Unassigned, a default tab, locked tabs and the "more tabs" list.
    4748* **The same colors, icons, drag and drop, Quick Edit field and Bulk actions** as Pages.
    4849* **Manage Tabs (Posts → Manage Tabs).** The full table, bulk actions, filtering and sorting, just for post tabs.
    … …  
    1471486. The layout editor: build or edit a custom layout and drag tabs into order.
    1481497. Pages → Tabwise Settings: Posts, visible tabs, custom icons, user roles and uninstall options.
    149 8. The ⋯ menu on each tab: set as default, rename, change color, change icon and delete.
     1508. The ⋯ menu on each tab: set as default, rename, change color, change icon, lock and delete.
    1501519. The full tab bar, from your tabs to All, Unassigned and New tab.
    15115210. The "more tabs" list, with each tab color-coded and its own ⋯ menu.
    … …  
    155156
    156157== Changelog ==
     158
     159= 1.0.2 =
     160* New: Lock a tab from its ⋯ menu (or in Manage Tabs). Locked tabs show a small lock icon and can't be dragged or deleted until you unlock them.
     161* Improved: Switching tabs is now instant. Tabs are loaded ahead of time, so the list appears right away.
     162* Fixed: Duplicating or trashing a page or post (including with other plugins like Duplicate Page) no longer sends you back to a different tab. You stay in the tab you were in, and the copy shows up there.
    157163
    158164= 1.0.1 =
    … …  
    180186== Upgrade Notice ==
    181187
     188= 1.0.2 =
     189Lock tabs, instant tab switching, and you now stay in your tab after duplicating or trashing a page.
     190
    182191= 1.0.1 =
    183192Tab layouts, import and export, custom icon library, readable tabs in dark mode and fixes for rows disappearing while moving pages.
  • tabwise/trunk/tabwise.php

    r3782325 r3797176  
    33 * Plugin Name:       Tabwise
    44 * Description:       Organize your Pages and Posts into clean, color-coded tabs above the admin list. Free, open source and unbranded. Sorting only: it never creates, edits or deletes your content.
    5  * Version:           1.0.1
     5 * Version:           1.0.2
    66 * Requires at least: 7.0
    77 * Requires PHP:      8.3
    … …  
    1616defined( 'ABSPATH' ) || exit;
    1717
    18 define( 'TABWISE_VERSION', '1.0.1' );
     18define( 'TABWISE_VERSION', '1.0.2' );
    1919define( 'TABWISE_FILE', __FILE__ );
    2020
Note: See TracChangeset for help on using the changeset viewer.