Changeset 3797176
- Timestamp:
- 11/11/2026 10:35:06 PM (3 days ago)
- Location:
- tabwise/trunk
- Files:
-
- 5 edited
-
assets/tabwise.css (modified) (2 diffs)
-
assets/tabwise.js (modified) (22 diffs)
-
includes/class-tabwise.php (modified) (14 diffs)
-
readme.txt (modified) (6 diffs)
-
tabwise.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
tabwise/trunk/assets/tabwise.css
r3782325 r3797176 111 111 } 112 112 .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; } 113 123 114 124 .tw-dots { … … 432 442 .tw-mgrip:hover { opacity: 1; } 433 443 .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 { 435 445 display: inline-flex; align-items: center; gap: 8px; 436 446 padding: 6px; margin: 0; border: 1px solid transparent; border-radius: 6px; -
tabwise/trunk/assets/tabwise.js
r3782325 r3797176 15 15 function svg(path) { return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="' + path + '"/></svg>'; } 16 16 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'), 17 19 list: svg('M4 6h2v2H4zm4 0h12v2H8zM4 11h2v2H4zm4 0h12v2H8zm-4 5h2v2H4zm4 0h12v2H8z'), 18 20 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'), … … 166 168 if (key === 'unassigned') return { key: key, system: true, name: 'Unassigned', color: '#8c8f94', icon: ICON.inbox }; 167 169 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 () {}; 172 185 function api(op, data) { 173 186 var body = new URLSearchParams(); … … 186 199 if (!j || !j.success) throw new Error((j && j.data) || 'Something went wrong.'); 187 200 applyState(j.data); 201 afterApi(); 188 202 return j.data; 189 203 }) … … 352 366 n.querySelectorAll('.tw-name, .tw-count, .tw-dots').forEach(function (e) { e.style.setProperty('color', k.fg, 'important'); }); 353 367 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'); }); 354 369 n.querySelectorAll('.tw-ico .dashicons').forEach(function (e) { e.style.setProperty('color', k.ic, 'important'); }); 355 370 } … … 775 790 return u.toString(); 776 791 } 777 var navCtrl = null;778 792 function tablenavShape(root) { 779 793 var t = root.querySelector('#posts-filter .tablenav.top'); 780 794 return t ? Array.prototype.map.call(t.querySelectorAll('select,input[type="submit"]'), function (n) { return n.name || n.id; }).join('|') : ''; 781 795 } 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; 782 861 function navigate(key, opts) { 783 862 opts = opts || {}; 863 key = String(key); 784 864 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); 788 879 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 }); } 831 892 window.addEventListener('popstate', function (e) { 832 893 var key = (e.state && e.state.twtab) || new URL(location.href).searchParams.get(D.qv); … … 845 906 closeToBase(); 846 907 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; }))); 848 909 render(); 849 910 api('default', { value: tab.key }).then(render); … … 884 945 }); 885 946 })); 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 () { 888 957 closeToBase(); 889 958 if (!confirm('Delete the tab “' + tab.name + '”?\n\nNo ' + D.label + ' will be deleted. Anything in this tab becomes Unassigned.')) return; … … 953 1022 '<span class="tw-name">' + esc(t.name) + '</span>' + 954 1023 '<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 : ''); 956 1026 var dots = el('button', 'tw-dots', ICON.dots); 957 1027 dots.type = 'button'; … … 970 1040 if (S.current !== key) navigate(key); 971 1041 }); 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); }); 973 1045 // Drop target for dragged page rows (not "All"). 974 1046 if (key !== 'all') { … … 1029 1101 hiddenKeys.forEach(function (key) { 1030 1102 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>'); 1032 1104 b.dataset.key = key; 1033 1105 b.tabIndex = 0; … … 1041 1113 b.querySelectorAll('.tw-oname, em').forEach(function (n) { n.style.setProperty('color', k.fg, 'important'); }); 1042 1114 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'); }); 1043 1116 b.querySelectorAll('.tw-oico .dashicons').forEach(function (n) { n.style.setProperty('color', k.ic, 'important'); }); 1044 1117 var dots = el('button', 'tw-dots', ICON.dots); … … 1052 1125 b.onclick = go; 1053 1126 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); }); 1055 1129 listWrap.appendChild(b); 1056 1130 }); … … 1189 1263 visible.push(hidden.splice(up, 1)[0]); 1190 1264 } 1191 var newOrder = visible.concat(hidden);1265 var newOrder = keepLocked(S.order, visible.concat(hidden)); 1192 1266 if (newOrder.join() === S.order.join()) { render(); return; } 1193 1267 S.order = newOrder; … … 1285 1359 window.jQuery(document).ajaxSuccess(function (e, xhr, settings) { 1286 1360 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); 1288 1364 }); 1289 1365 } … … 1301 1377 hookBulk(); 1302 1378 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); 1304 1384 var raf; 1305 1385 window.addEventListener('resize', function () { cancelAnimationFrame(raf); raf = requestAnimationFrame(render); }); … … 1345 1425 bar.innerHTML = 1346 1426 '<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>' + 1348 1428 '<button type="button" class="button tw-mapply tw-pop-trigger">Apply</button>' + 1349 1429 '<span class="tw-msel"></span>' + … … 1413 1493 '<td class="tw-c-where">' + where + '</td>' + 1414 1494 '<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'); 1416 1498 return tr; 1417 1499 } … … 1438 1520 bar.querySelector('.tw-msort').onclick = function () { 1439 1521 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 }); })); 1441 1523 draw(); 1442 1524 api('reorder', { order: fullOrder() }).then(draw); … … 1454 1536 if (!keys.length) { toast('Select one or more tabs first.', true); return; } 1455 1537 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.'); 1457 1545 if (!confirm('Delete ' + keys.length + ' tab' + (keys.length > 1 ? 's' : '') + '?\n\nNo ' + D.label + ' will be deleted. They become Unassigned.')) return; 1458 1546 api('bulk_delete', { tabs: ids }).then(function () { selected = {}; draw(); toast('Tabs deleted'); }); … … 1512 1600 if (S.def === key) return; 1513 1601 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; }))); 1515 1603 draw(); 1516 1604 api('default', { value: key }).then(draw); 1517 1605 toast('“' + t.name + '” is now your default tab'); 1518 1606 }; 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 }; 1519 1614 tr.querySelector('.tw-mdel').onclick = function () { 1615 if (t.locked) return; 1520 1616 if (!confirm('Delete the tab “' + t.name + '”?\n\nNo ' + D.label + ' will be deleted. Anything in this tab becomes Unassigned.')) return; 1521 1617 delete selected[key]; … … 1523 1619 }; 1524 1620 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'; 1527 1624 }); 1528 1625 } … … 1818 1915 document.body.classList.remove('tw-sorting'); 1819 1916 var order = Array.prototype.map.call(tbody.querySelectorAll('tr[data-key]'), function (r) { return r.dataset.key; }); 1917 order = keepLocked(S.order, order); 1820 1918 var changed = order.join() !== S.order.join(); 1821 1919 S.order = order; -
tabwise/trunk/includes/class-tabwise.php
r3782325 r3797176 282 282 $raw = isset( $_GET[ self::QV ] ) ? sanitize_key( wp_unslash( $_GET[ self::QV ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification 283 283 if ( '' === $raw ) { 284 $raw = self::referer_tab( $pt ); 285 } 286 if ( '' === $raw ) { 284 287 $raw = self::get_default( $pt ); 285 288 } … … 297 300 } 298 301 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 ] ); 299 329 } 300 330 … … 384 414 } 385 415 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 ); 386 460 } 387 461 … … 443 517 'name' => html_entity_decode( $t->name, ENT_QUOTES, 'UTF-8' ), 444 518 '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 ), 446 521 ); 447 522 } … … 574 649 wp_send_json_error( 'That tab no longer exists.', 404 ); 575 650 } 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 ); 576 660 577 661 switch ( $op ) { … … 609 693 610 694 case 'delete': 695 if ( self::is_locked( $id ) ) { 696 wp_send_json_error( 'This tab is locked. Unlock it first.', 400 ); 697 } 611 698 // Removes the tab only; its pages become Unassigned. 612 699 wp_delete_term( $id, $tax ); … … 623 710 self::set_default( $pt, $val ); 624 711 $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 ) ); 627 714 break; 628 715 … … 631 718 $valid = self::get_order( $pt ); 632 719 $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 } 634 736 break; 635 737 … … 656 758 657 759 case 'bulk_delete': 658 foreach ( $ tabsas $tid ) {760 foreach ( $unlocked as $tid ) { 659 761 wp_delete_term( $tid, $tax ); 660 762 if ( self::get_default( $pt ) === (string) $tid ) { … … 669 771 wp_send_json_error( 'That tab no longer exists.', 404 ); 670 772 } 671 foreach ( $ tabsas $tid ) {773 foreach ( $unlocked as $tid ) { 672 774 if ( $tid === $id ) { 673 775 continue; … … 888 990 'icon' => $tabs[ $id ]['icon'], 889 991 'default' => self::get_default( $pt ) === (string) $id, 992 'locked' => ! empty( $tabs[ $id ]['locked'] ), 890 993 'pages' => $slugs, 891 994 ); … … 921 1024 $custom = (bool) self::settings()['custom_icons']; 922 1025 if ( $replace ) { 1026 $def = self::get_default( $pt ); 923 1027 foreach ( array_keys( self::get_tabs( $pt ) ) as $id ) { 1028 if ( self::is_locked( $id ) ) { 1029 continue; // Locked tabs are kept. 1030 } 924 1031 wp_delete_term( (int) $id, $tax ); 925 } 926 self::set_default( $pt, '' ); 1032 if ( $def === (string) $id ) { 1033 self::set_default( $pt, '' ); 1034 } 1035 } 927 1036 } 928 1037 $order = array_values( array_diff( self::get_order( $pt ), array( 'unassigned', 'all' ) ) ); … … 955 1064 if ( ! empty( $tab['default'] ) ) { 956 1065 self::set_default( $pt, (string) $id ); 1066 } 1067 if ( ! empty( $tab['locked'] ) ) { 1068 update_term_meta( $id, 'tabwise_locked', 1 ); 957 1069 } 958 1070 if ( $assign && ! empty( $tab['pages'] ) && is_array( $tab['pages'] ) ) { … … 979 1091 self::move_posts( $pt, $ids, $id ); 980 1092 } 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 } 983 1097 ++$count; 984 1098 } -
tabwise/trunk/readme.txt
r3782546 r3797176 6 6 Tested up to: 7.1 7 7 Requires PHP: 8.3 8 Stable tag: 1.0. 18 Stable tag: 1.0.2 9 9 License: GPLv2 or later 10 10 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 29 29 * **All and Unassigned.** "All" shows every page, and "Unassigned" shows the pages you haven't filed yet. 30 30 * **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. 31 32 * **Colors.** A full color picker with hue, opacity, Hex, RGB and HSL, preset colors and your own saved colors. 32 33 * **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. … … 44 45 Turn 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. 45 46 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. 47 48 * **The same colors, icons, drag and drop, Quick Edit field and Bulk actions** as Pages. 48 49 * **Manage Tabs (Posts → Manage Tabs).** The full table, bulk actions, filtering and sorting, just for post tabs. … … 147 148 6. The layout editor: build or edit a custom layout and drag tabs into order. 148 149 7. 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.150 8. The ⋯ menu on each tab: set as default, rename, change color, change icon, lock and delete. 150 151 9. The full tab bar, from your tabs to All, Unassigned and New tab. 151 152 10. The "more tabs" list, with each tab color-coded and its own ⋯ menu. … … 155 156 156 157 == 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. 157 163 158 164 = 1.0.1 = … … 180 186 == Upgrade Notice == 181 187 188 = 1.0.2 = 189 Lock tabs, instant tab switching, and you now stay in your tab after duplicating or trashing a page. 190 182 191 = 1.0.1 = 183 192 Tab 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 3 3 * Plugin Name: Tabwise 4 4 * 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. 15 * Version: 1.0.2 6 6 * Requires at least: 7.0 7 7 * Requires PHP: 8.3 … … 16 16 defined( 'ABSPATH' ) || exit; 17 17 18 define( 'TABWISE_VERSION', '1.0. 1' );18 define( 'TABWISE_VERSION', '1.0.2' ); 19 19 define( 'TABWISE_FILE', __FILE__ ); 20 20
Note:
See TracChangeset
for help on using the changeset viewer.