mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-18 20:07:44 +00:00
feat(dock): add passive edge dock tabs
This commit is contained in:
parent
25c9e735ef
commit
66755d5eb1
2 changed files with 317 additions and 0 deletions
|
|
@ -23,6 +23,7 @@ const MIN_CHAT_WIDTH = 380;
|
|||
const EMAIL_DOC_SPLIT_WIDTH_KEY = 'odysseus-email-doc-split-width';
|
||||
const EDGE_DOCK_WIDTH_KEY_PREFIX = 'odysseus-edge-dock-width';
|
||||
const MIN_EDGE_DOCK_WIDTH = 320;
|
||||
const EDGE_DOCK_SWITCHER_FLAG = 'odysseus-edge-dock-switcher';
|
||||
|
||||
let _edgeDockHandlePositioner = null;
|
||||
|
||||
|
|
@ -30,10 +31,22 @@ function _positionEdgeDockResizeHandles() {
|
|||
try { _edgeDockHandlePositioner && _edgeDockHandlePositioner(); } catch (_) {}
|
||||
}
|
||||
|
||||
function _notifyEdgeDockChanged(side, owner = null) {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('odysseus:edge-dock-changed', {
|
||||
detail: { side, owner },
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function _dockClassForSide(side) {
|
||||
return side === 'left' ? 'modal-left-docked' : 'modal-right-docked';
|
||||
}
|
||||
|
||||
function _dockOwnerSurface(owner) {
|
||||
return owner?.closest?.('.notes-pane-backdrop') || owner;
|
||||
}
|
||||
|
||||
function _hasOtherDockedWindow(side, owner) {
|
||||
const cls = _dockClassForSide(side);
|
||||
return Array.from(document.querySelectorAll(`.${cls}`)).some((el) => {
|
||||
|
|
@ -539,6 +552,7 @@ function _applyDockInternal(modal, side, dockClass) {
|
|||
modal._dockCloseWatcher = { obs };
|
||||
}
|
||||
}
|
||||
_notifyEdgeDockChanged(side, modal);
|
||||
return w;
|
||||
}
|
||||
|
||||
|
|
@ -571,6 +585,8 @@ function _onDockedModalGone(modal, dockClass) {
|
|||
}
|
||||
modal.classList.remove('modal-right-docked');
|
||||
modal.classList.remove('modal-left-docked');
|
||||
if (hadRight) _notifyEdgeDockChanged('right', modal);
|
||||
if (hadLeft) _notifyEdgeDockChanged('left', modal);
|
||||
// Clear the content's docked inline geometry. Singleton modals (plan,
|
||||
// workspace, calendar, …) reuse the same element across open/close, so if we
|
||||
// only drop the body push the element stays positioned (position:fixed;
|
||||
|
|
@ -666,6 +682,7 @@ export function clearRightDock(modal, cx, cy, dockClass) {
|
|||
delete content._preDockSnapshot;
|
||||
delete content._dockSuspended;
|
||||
_positionEdgeDockResizeHandles();
|
||||
_notifyEdgeDockChanged(side, modal);
|
||||
}
|
||||
|
||||
// Temporarily release a docked modal's body push (chat returns to full
|
||||
|
|
@ -711,6 +728,7 @@ export function suspendDock(modal) {
|
|||
}
|
||||
content._dockSuspended = side;
|
||||
_positionEdgeDockResizeHandles();
|
||||
_notifyEdgeDockChanged(side, modal);
|
||||
return side;
|
||||
}
|
||||
|
||||
|
|
@ -785,6 +803,224 @@ export function makeEdgeDockController(modal, side = 'right', dockClass) {
|
|||
};
|
||||
}
|
||||
|
||||
(function _initEdgeDockSwitcher() {
|
||||
if (typeof document === 'undefined' || typeof window === 'undefined') return;
|
||||
|
||||
const bars = { left: null, right: null };
|
||||
let refreshTimer = 0;
|
||||
let refreshInterval = 0;
|
||||
|
||||
const _enabled = () => {
|
||||
try { return localStorage.getItem(EDGE_DOCK_SWITCHER_FLAG) !== '0'; }
|
||||
catch (_) { return true; }
|
||||
};
|
||||
|
||||
const _ownerSurface = _dockOwnerSurface;
|
||||
|
||||
const _zIndexFor = (el, fallback = 250) => {
|
||||
const raw = el ? window.getComputedStyle(el).zIndex : '';
|
||||
const n = parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
|
||||
const _topToolZ = (exclude = null) => {
|
||||
const skip = new Set([exclude, _ownerSurface(exclude)].filter(Boolean));
|
||||
let top = 300;
|
||||
document.querySelectorAll('.modal, .research-overlay, .notes-pane-backdrop').forEach((el) => {
|
||||
if (!el || skip.has(el)) return;
|
||||
if (el.classList?.contains('hidden')) return;
|
||||
if (el.style?.display === 'none') return;
|
||||
top = Math.max(top, _zIndexFor(el, 250));
|
||||
});
|
||||
return top;
|
||||
};
|
||||
|
||||
const _bringOwnerToFront = (owner) => {
|
||||
const surface = _ownerSurface(owner);
|
||||
if (!surface) return;
|
||||
surface.style.setProperty('z-index', String(_topToolZ(owner) + 1), 'important');
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('odysseus:modal-opened', {
|
||||
detail: { id: owner?.id || '', modal: owner },
|
||||
}));
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
const _isVisibleDockOwner = (owner) => {
|
||||
if (!owner || !owner.isConnected) return false;
|
||||
if (owner.classList?.contains('hidden')) return false;
|
||||
if (owner.classList?.contains('modal-minimized')) return false;
|
||||
if (owner.style?.display === 'none') return false;
|
||||
const content = _resolveDockNodes(owner)?.content;
|
||||
if (!content || !content.isConnected) return false;
|
||||
if (content.classList?.contains('hidden')) return false;
|
||||
if (content.style?.display === 'none') return false;
|
||||
const r = content.getBoundingClientRect?.();
|
||||
return !!r && r.width > 0 && r.height > 0;
|
||||
};
|
||||
|
||||
const _ownersForSide = (side) => {
|
||||
const cls = _dockClassForSide(side);
|
||||
return Array.from(document.querySelectorAll(`.${cls}`)).filter(_isVisibleDockOwner);
|
||||
};
|
||||
|
||||
const _simpleTitleForOwner = (owner) => {
|
||||
const id = owner?.id || '';
|
||||
const labels = {
|
||||
'assistant-settings-modal': 'Assistant',
|
||||
'calendar-modal': 'Calendar',
|
||||
'compare-model-overlay': 'Compare',
|
||||
'cookbook-modal': 'Cookbook',
|
||||
'doc-editor-pane': 'Document',
|
||||
'doclib-modal': 'Library',
|
||||
'email-lib-modal': 'Email',
|
||||
'gallery-modal': 'Gallery',
|
||||
'library-modal': 'Library',
|
||||
'notes-pane': 'Notes',
|
||||
'research-overlay': 'Deep Research',
|
||||
'research-pane': 'Deep Research',
|
||||
'settings-modal': 'Settings',
|
||||
'tasks-modal': 'Tasks',
|
||||
};
|
||||
return labels[id] || '';
|
||||
};
|
||||
|
||||
const _cleanTabLabel = (label) => (label || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\s+\d+\s+(photos?|research|tasks?|notes?|documents?|docs?|items?|emails?|chats?|sessions?|models?)$/i, '')
|
||||
.trim();
|
||||
|
||||
const _labelForOwner = (owner) => {
|
||||
const simple = _simpleTitleForOwner(owner);
|
||||
if (simple) return simple;
|
||||
const content = _resolveDockNodes(owner)?.content || owner;
|
||||
const titleEl = content?.querySelector?.('.modal-header h4, .notes-pane-title, .modal-title, [data-window-title]');
|
||||
const text = _cleanTabLabel(titleEl?.textContent || owner?.getAttribute?.('aria-label') || owner?.id || 'Window');
|
||||
return text || 'Window';
|
||||
};
|
||||
|
||||
const _activeOwner = (owners) => {
|
||||
let best = null;
|
||||
let bestZ = -Infinity;
|
||||
owners.forEach((owner) => {
|
||||
const z = _zIndexFor(_ownerSurface(owner), 250);
|
||||
if (z >= bestZ) {
|
||||
best = owner;
|
||||
bestZ = z;
|
||||
}
|
||||
});
|
||||
return best || owners[owners.length - 1] || null;
|
||||
};
|
||||
|
||||
const _hideBar = (side) => {
|
||||
const bar = bars[side];
|
||||
if (!bar) return;
|
||||
bar.replaceChildren();
|
||||
bar.hidden = true;
|
||||
bar.parentElement?.classList?.remove('edge-dock-switcher-host');
|
||||
};
|
||||
|
||||
const _ensureBar = (side) => {
|
||||
if (!bars[side]?.isConnected) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = `edge-dock-switcher edge-dock-switcher-${side}`;
|
||||
bar.hidden = true;
|
||||
bar.setAttribute('role', 'tablist');
|
||||
bar.setAttribute('aria-label', `${side} docked windows`);
|
||||
bar.addEventListener('pointerdown', (event) => event.stopPropagation());
|
||||
bar.addEventListener('mousedown', (event) => event.stopPropagation());
|
||||
bar.addEventListener('touchstart', (event) => event.stopPropagation(), { passive: true });
|
||||
bars[side] = bar;
|
||||
}
|
||||
const oldParent = bars[side]?.parentElement;
|
||||
if (oldParent && oldParent !== document.body) oldParent.classList.remove('edge-dock-switcher-host');
|
||||
if (bars[side].parentElement !== document.body) document.body.appendChild(bars[side]);
|
||||
return bars[side];
|
||||
};
|
||||
|
||||
const _renderSide = (side) => {
|
||||
const owners = _enabled() && window.innerWidth > 768 ? _ownersForSide(side) : [];
|
||||
if (owners.length < 2) {
|
||||
_hideBar(side);
|
||||
return;
|
||||
}
|
||||
const active = _activeOwner(owners);
|
||||
const bar = _ensureBar(side);
|
||||
bar.replaceChildren();
|
||||
owners.forEach((owner) => {
|
||||
const label = _labelForOwner(owner);
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `edge-dock-switcher-tab${owner === active ? ' active' : ''}`;
|
||||
btn.title = label;
|
||||
btn.setAttribute('role', 'tab');
|
||||
btn.setAttribute('aria-selected', owner === active ? 'true' : 'false');
|
||||
btn.addEventListener('pointerdown', (event) => event.stopPropagation());
|
||||
btn.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
_bringOwnerToFront(owner);
|
||||
_scheduleRefresh(40);
|
||||
});
|
||||
const labelEl = document.createElement('span');
|
||||
labelEl.className = 'edge-dock-switcher-tab-label';
|
||||
labelEl.textContent = label;
|
||||
btn.appendChild(labelEl);
|
||||
bar.appendChild(btn);
|
||||
});
|
||||
bar.hidden = false;
|
||||
};
|
||||
|
||||
function _refresh() {
|
||||
if (!document.body) return;
|
||||
_renderSide('left');
|
||||
_renderSide('right');
|
||||
}
|
||||
|
||||
function _scheduleRefresh(delay = 180) {
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshTimer = 0;
|
||||
requestAnimationFrame(_refresh);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
const _start = () => {
|
||||
_scheduleRefresh(0);
|
||||
window.addEventListener('resize', () => _scheduleRefresh(120));
|
||||
window.addEventListener('odysseus:modal-opened', () => _scheduleRefresh(120));
|
||||
window.addEventListener('odysseus:edge-dock-changed', () => _scheduleRefresh(320));
|
||||
document.addEventListener('keyup', (event) => {
|
||||
if (event.key === 'Escape') _scheduleRefresh(120);
|
||||
}, true);
|
||||
refreshInterval = window.setInterval(() => {
|
||||
if (document.visibilityState !== 'hidden') _refresh();
|
||||
}, 1500);
|
||||
window.odysseusEdgeDockSwitcher = {
|
||||
refresh: _refresh,
|
||||
disable() {
|
||||
try { localStorage.setItem(EDGE_DOCK_SWITCHER_FLAG, '0'); } catch (_) {}
|
||||
_refresh();
|
||||
},
|
||||
enable() {
|
||||
try { localStorage.removeItem(EDGE_DOCK_SWITCHER_FLAG); } catch (_) {}
|
||||
_refresh();
|
||||
},
|
||||
stop() {
|
||||
if (refreshInterval) window.clearInterval(refreshInterval);
|
||||
refreshInterval = 0;
|
||||
bars.left?.remove();
|
||||
bars.right?.remove();
|
||||
bars.left = null;
|
||||
bars.right = null;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
if (document.body) _start();
|
||||
else document.addEventListener('DOMContentLoaded', _start, { once: true });
|
||||
})();
|
||||
|
||||
(function _initEdgeDockResizeHandles() {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (!document.body) {
|
||||
|
|
|
|||
|
|
@ -16366,6 +16366,87 @@ body.right-dock-active .chat-container,
|
|||
body.right-dock-active:not(.email-doc-split-active) .doc-editor-pane {
|
||||
margin-right: var(--right-dock-w, 0px);
|
||||
}
|
||||
.edge-dock-switcher {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-height: calc(100dvh - 36px);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.edge-dock-switcher[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.edge-dock-switcher-right {
|
||||
right: calc(var(--right-dock-w, 0px) - 1px);
|
||||
}
|
||||
.edge-dock-switcher-left {
|
||||
left: calc(48px + var(--left-dock-w, 0px) - 1px);
|
||||
}
|
||||
.edge-dock-switcher-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 124px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
background: color-mix(in srgb, var(--panel) 92%, var(--bg));
|
||||
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
|
||||
color: color-mix(in srgb, var(--fg) 78%, transparent);
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.edge-dock-switcher-right .edge-dock-switcher-tab {
|
||||
border-right-color: color-mix(in srgb, var(--panel) 85%, var(--bg));
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
.edge-dock-switcher-left .edge-dock-switcher-tab {
|
||||
border-left-color: color-mix(in srgb, var(--panel) 85%, var(--bg));
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
.edge-dock-switcher-tab-label {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: max-content;
|
||||
max-width: none;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
.edge-dock-switcher-right .edge-dock-switcher-tab-label {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.edge-dock-switcher-left .edge-dock-switcher-tab-label {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.edge-dock-switcher-tab:hover {
|
||||
color: var(--fg);
|
||||
border-color: color-mix(in srgb, var(--accent, var(--red)) 48%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 9%, var(--panel));
|
||||
}
|
||||
.edge-dock-switcher-tab.active {
|
||||
color: var(--fg);
|
||||
border-color: color-mix(in srgb, var(--accent, var(--red)) 65%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 16%, var(--panel));
|
||||
}
|
||||
.edge-dock-switcher-right .edge-dock-switcher-tab.active {
|
||||
border-right-color: color-mix(in srgb, var(--panel) 85%, var(--bg));
|
||||
}
|
||||
.edge-dock-switcher-left .edge-dock-switcher-tab.active {
|
||||
border-left-color: color-mix(in srgb, var(--panel) 85%, var(--bg));
|
||||
}
|
||||
.modal.modal-right-docked {
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue