- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient loovverture et le redimensionnement inoperants -> cblage direct sur le document. - Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw, clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks. - database-table-container margin:0 (tableau colle a gauche, marge Library). - .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px. - ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
552 lines
21 KiB
JavaScript
552 lines
21 KiB
JavaScript
// FlowDeck Notion UI — Client-side logic
|
|
// Alpine.js + SortableJS + HTMX + Mobile support
|
|
/* exported openCardDetail */ /* global owner, repo */
|
|
|
|
// ── Frontend Error Capture ──────────────────────────────────
|
|
// Intercepte TOUTES les erreurs JS et les envoie au backend.
|
|
// Hermes peut les consulter via GET /api/frontend-errors.
|
|
(function() {
|
|
'use strict';
|
|
const MAX_ERRORS = 50;
|
|
var _fd_errors = [];
|
|
var _fd_throttle = 0;
|
|
|
|
function _fd_send(error) {
|
|
_fd_errors.push(error);
|
|
if (_fd_errors.length > MAX_ERRORS) _fd_errors.shift();
|
|
// Throttle: max 1 POST per second
|
|
var now = Date.now();
|
|
if (now - _fd_throttle < 1000) return;
|
|
_fd_throttle = now;
|
|
try {
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open('POST', '/api/frontend-error', true);
|
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
|
xhr.send(JSON.stringify(error));
|
|
} catch { /* fail silently */ }
|
|
}
|
|
|
|
window.addEventListener('error', function(e) {
|
|
_fd_send({
|
|
type: 'error',
|
|
message: e.message || 'Unknown error',
|
|
source: e.filename || '',
|
|
line: e.lineno || 0,
|
|
col: e.colno || 0,
|
|
stack: (e.error && e.error.stack) ? String(e.error.stack).substring(0, 2000) : '',
|
|
url: window.location.href,
|
|
userAgent: navigator.userAgent.substring(0, 200),
|
|
time: new Date().toISOString()
|
|
});
|
|
});
|
|
|
|
window.addEventListener('unhandledrejection', function(e) {
|
|
_fd_send({
|
|
type: 'unhandledrejection',
|
|
message: e.reason ? (e.reason.message || String(e.reason)) : 'Promise rejected',
|
|
stack: (e.reason && e.reason.stack) ? String(e.reason.stack).substring(0, 2000) : '',
|
|
url: window.location.href,
|
|
userAgent: navigator.userAgent.substring(0, 200),
|
|
time: new Date().toISOString()
|
|
});
|
|
});
|
|
|
|
// Expose pour le debug console
|
|
window.__flowdeck_errors = _fd_errors;
|
|
})();
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Theme persistence
|
|
const savedTheme = localStorage.getItem('theme');
|
|
if (savedTheme) {
|
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
|
}
|
|
|
|
// Close context menu on click outside
|
|
document.addEventListener('click', function(e) {
|
|
const menu = document.getElementById('context-menu');
|
|
if (menu && menu.classList.contains('visible')) {
|
|
if (!menu.contains(e.target)) {
|
|
menu.classList.remove('visible');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Close context menu on escape
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') {
|
|
const menu = document.getElementById('context-menu');
|
|
if (menu) menu.classList.remove('visible');
|
|
}
|
|
});
|
|
|
|
// Tooltip system
|
|
document.addEventListener('mouseover', function(e) {
|
|
const target = e.target.closest('[data-tooltip]');
|
|
if (!target) return;
|
|
const tip = document.getElementById('tooltip');
|
|
if (!tip) return;
|
|
tip.textContent = target.getAttribute('data-tooltip');
|
|
tip.style.display = 'block';
|
|
const rect = target.getBoundingClientRect();
|
|
tip.style.left = rect.left + 'px';
|
|
tip.style.top = (rect.bottom + 4) + 'px';
|
|
});
|
|
|
|
document.addEventListener('mouseout', function(e) {
|
|
const target = e.target.closest('[data-tooltip]');
|
|
if (!target) return;
|
|
const tip = document.getElementById('tooltip');
|
|
if (tip) tip.style.display = 'none';
|
|
});
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', function(e) {
|
|
// Ctrl+O: New chat (placeholder)
|
|
if (e.ctrlKey && e.key === 'o') {
|
|
e.preventDefault();
|
|
console.log('New chat shortcut');
|
|
}
|
|
});
|
|
|
|
// ── Mobile: Swipe to close sidebar ──
|
|
let touchStartX = 0;
|
|
let touchStartY = 0;
|
|
|
|
document.addEventListener('touchstart', function(e) {
|
|
touchStartX = e.touches[0].clientX;
|
|
touchStartY = e.touches[0].clientY;
|
|
}, { passive: true });
|
|
|
|
document.addEventListener('touchend', function(e) {
|
|
const sidebar = document.getElementById('sidebar');
|
|
if (!sidebar || !sidebar.classList.contains('mobile-open')) return;
|
|
|
|
const touchEndX = e.changedTouches[0].clientX;
|
|
const touchEndY = e.changedTouches[0].clientY;
|
|
const dx = touchEndX - touchStartX;
|
|
const dy = Math.abs(touchEndY - touchStartY);
|
|
|
|
// Swipe left on sidebar (>50px horizontal, not too vertical)
|
|
if (dx < -50 && dy < Math.abs(dx) * 0.5) {
|
|
// Close sidebar via Alpine store
|
|
const appLayout = document.querySelector('.app-layout');
|
|
if (appLayout && appLayout.__x) {
|
|
appLayout.__x.$data.mobileSidebarOpen = false;
|
|
appLayout.__x.$data.sidebarCollapsed = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── Mobile: close sidebar on page navigation ──
|
|
document.addEventListener('htmx:beforeRequest', function() {
|
|
const appLayout = document.querySelector('.app-layout');
|
|
if (appLayout && appLayout.__x) {
|
|
appLayout.__x.$data.mobileSidebarOpen = false;
|
|
}
|
|
});
|
|
|
|
// ── Mobile: passive scroll performance ──
|
|
if ('ontouchstart' in window) {
|
|
document.querySelectorAll('.kanban-container').forEach(function(el) {
|
|
el.style.webkitOverflowScrolling = 'touch';
|
|
});
|
|
document.querySelectorAll('.table-container').forEach(function(el) {
|
|
el.style.webkitOverflowScrolling = 'touch';
|
|
});
|
|
}
|
|
|
|
// Modal functions
|
|
window.openModal = function(title) {
|
|
const modal = document.getElementById('card-modal');
|
|
const titleEl = document.getElementById('card-modal-title');
|
|
if (modal && titleEl) {
|
|
titleEl.textContent = title || 'Détail';
|
|
modal.style.display = 'flex';
|
|
// Prevent body scroll when modal is open
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
};
|
|
|
|
window.closeModal = function() {
|
|
const modal = document.getElementById('card-modal');
|
|
if (modal) {
|
|
modal.style.display = 'none';
|
|
document.body.style.overflow = '';
|
|
}
|
|
};
|
|
|
|
// Close modal on overlay click
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.id === 'card-modal') {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
// Close modal on escape
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
// HTMX event handlers
|
|
document.addEventListener('htmx:afterSwap', function(evt) {
|
|
// Reinitialize Sortable after HTMX swaps
|
|
if (evt.target.id === 'kanban-root' || evt.target.closest('.kanban-container')) {
|
|
initSortable();
|
|
}
|
|
});
|
|
|
|
// Initialize SortableJS on kanban columns
|
|
function initSortable() {
|
|
document.querySelectorAll('.kanban-cards').forEach(function(el) {
|
|
if (el._sortable) return; // Already initialized
|
|
el._sortable = new Sortable(el, {
|
|
group: 'kanban',
|
|
animation: 200,
|
|
ghostClass: 'sortable-ghost',
|
|
dragClass: 'sortable-drag',
|
|
// Touch support
|
|
delay: 0,
|
|
touchStartThreshold: 5,
|
|
onEnd: function(evt) {
|
|
const cardId = evt.item.dataset.cardId;
|
|
const fromCol = evt.from.dataset.status;
|
|
const toCol = evt.to.dataset.status;
|
|
const group = evt.to.dataset.group;
|
|
console.log('Moved card', cardId, 'from', fromCol, 'to', toCol, 'group', group);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Init on page load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initSortable();
|
|
});
|
|
|
|
// Re-init when Alpine initializes
|
|
document.addEventListener('alpine:initialized', function() {
|
|
setTimeout(initSortable, 100);
|
|
});
|
|
|
|
})();
|
|
|
|
// ── Partial navigation (HTMX) ───────────────────────────────
|
|
// The shell (sidebar + topbar) stays in place and only `.main-wrapper`
|
|
// is swapped, so clicking the left navigation no longer reloads the
|
|
// whole document. Internal links are intercepted and loaded with
|
|
// htmx.ajax(); history is managed manually so the back button works.
|
|
(function() {
|
|
'use strict';
|
|
|
|
function isPlainLeftClick(evt) {
|
|
return evt.button === 0 && !evt.ctrlKey && !evt.metaKey && !evt.shiftKey && !evt.altKey;
|
|
}
|
|
|
|
// Returns true when a link should be handled by the partial navigator.
|
|
// External links, downloads, auth, API/static files and links already
|
|
// managed by Alpine or HTMX keep their default behaviour.
|
|
function shouldIntercept(a, evt) {
|
|
if (!a || !a.getAttribute || !isPlainLeftClick(evt)) return false;
|
|
// Un handler plus profond a déjà traité le clic et appelé preventDefault
|
|
// (onglets de vue, lien interne…). Sans ce test, le navigateur AND ce
|
|
// navigateur SPA traitent le même clic : le composant rend son état, puis
|
|
// `fdLoad` remplace la zone `.main-wrapper` par la réponse serveur — le
|
|
// composant perd son DOM et la page finit vide.
|
|
if (evt.defaultPrevented) return false;
|
|
if (a.hasAttribute('data-no-nav')) return false;
|
|
var target = (a.getAttribute('target') || '').toLowerCase();
|
|
if (target && target !== '_self') return false;
|
|
if (a.hasAttribute('download')) return false;
|
|
if (a.getAttribute('hx-boost') === 'false') return false;
|
|
var attrs = a.getAttributeNames();
|
|
for (var i = 0; i < attrs.length; i++) {
|
|
var n = attrs[i];
|
|
if (n.indexOf('@click') === 0 || n.indexOf('x-on:click') === 0) return false;
|
|
if (n.indexOf('hx-') === 0) return false;
|
|
}
|
|
var href = a.getAttribute('href') || '';
|
|
if (!href || href.charAt(0) === '#') return false;
|
|
if (/^(mailto:|tel:|javascript:)/i.test(href)) return false;
|
|
var u;
|
|
try { u = new URL(a.href, window.location.href); } catch { return false; }
|
|
if (u.origin !== window.location.origin) return false;
|
|
var p = u.pathname;
|
|
if (p === '/auth' || p.indexOf('/auth/') === 0) return false;
|
|
if (p.indexOf('/api/') === 0) return false;
|
|
if (p.indexOf('/static/') === 0) return false;
|
|
if (p === '/p' || p.indexOf('/p/') === 0) return false;
|
|
return true;
|
|
}
|
|
|
|
var loading = false;
|
|
var LOAD_WATCHDOG_MS = 15000;
|
|
|
|
function fdLoad(url, push) {
|
|
if (!window.htmx) { window.location.href = url; return; }
|
|
// Une navigation est déjà en vol : on l'ABANDONNE au profit d'un chargement
|
|
// complet. Avant, ce clic etait simplement ignore (aucune URL, aucun
|
|
// changement) — et si la promesse htmx ne se resolvait jamais, `loading`
|
|
// restait a `true` indefiniment : plus aucune navigation ne repartait, la
|
|
// page restait figee (ou a moitie peinte) jusqu'au hard refresh.
|
|
if (loading) { window.location.href = url; return; }
|
|
loading = true;
|
|
var settled = false;
|
|
function done() {
|
|
if (settled) return;
|
|
settled = true;
|
|
loading = false;
|
|
clearTimeout(watchdog);
|
|
}
|
|
// Filet de securite : `loading` ne doit jamais pouvoir rester bloque.
|
|
var watchdog = setTimeout(function () {
|
|
if (settled) return;
|
|
done();
|
|
window.location.href = url;
|
|
}, LOAD_WATCHDOG_MS);
|
|
var p;
|
|
try {
|
|
p = window.htmx.ajax('GET', url, {
|
|
target: '.main-wrapper',
|
|
select: '.main-wrapper',
|
|
swap: 'outerHTML'
|
|
});
|
|
} catch {
|
|
done();
|
|
window.location.href = url;
|
|
return;
|
|
}
|
|
if (p && typeof p.then === 'function') {
|
|
p.then(function() {
|
|
done();
|
|
if (push) { try { history.pushState({ fd: true }, '', url); } catch { /* ignore */ } }
|
|
}).catch(function() {
|
|
done();
|
|
window.location.href = url;
|
|
});
|
|
} else {
|
|
done();
|
|
if (push) { try { history.pushState({ fd: true }, '', url); } catch { /* ignore */ } }
|
|
}
|
|
}
|
|
|
|
document.addEventListener('click', function(evt) {
|
|
var a = evt.target && evt.target.closest ? evt.target.closest('a[href]') : null;
|
|
if (!shouldIntercept(a, evt)) return;
|
|
evt.preventDefault();
|
|
fdLoad(a.href, true);
|
|
});
|
|
|
|
// Back/forward buttons reload the content for the target URL.
|
|
window.addEventListener('popstate', function() {
|
|
fdLoad(window.location.href, false);
|
|
});
|
|
|
|
// ── Swap partiel : monter proprement la zone swapée sous Alpine ──
|
|
// Lors d'une navigation partielle (fdLoad), les <script src> de la page
|
|
// s'exécutent APRÈS le microtask MutationObserver d'Alpine : toute la zone
|
|
// .main-wrapper serait initialisée avant que composants ET stores (menu
|
|
// contextuel $store.fdCtx, …) existent → « Undefined variable: … » puis
|
|
// « reading 'has' » sur les stores. On pose x-ignore sur TOUTE la zone
|
|
// pendant le chargement des scripts, puis on démarle quand tous les
|
|
// <script src> ont exécuté (load/error) — filet de sécurité 4 s.
|
|
// (initTree est idempotent : le _x_marker posé au premier passage évite
|
|
// tout double montage.)
|
|
var fdLastMw = document.querySelector('.main-wrapper'); // nœud du chargement complet
|
|
window.fdRefreshXData = function (name) {
|
|
document.querySelectorAll('[x-data="' + name + '()"]').forEach(function (el) {
|
|
if (el.__fdRefreshed) return;
|
|
var st = el._x_dataStack;
|
|
if (!st) return; // sous x-ignore → done() montera
|
|
if (st[0] && Object.keys(st[0]).length) return; // déjà monté correctement
|
|
el.__fdRefreshed = true; // monté cassé (hors watcher) → on remonte
|
|
var fresh = el.cloneNode(true);
|
|
el.parentNode.replaceChild(fresh, el);
|
|
if (window.htmx) { try { htmx.process(fresh); } catch { /* volontaire */ } }
|
|
});
|
|
};
|
|
|
|
document.addEventListener('htmx:afterSwap', function () {
|
|
var mw = document.querySelector('.main-wrapper');
|
|
// nœud inchangé = swap hors navigation (sidebar, vues) : rien à faire
|
|
if (!mw || mw === fdLastMw || mw.__fdSwapWatch) return;
|
|
fdLastMw = mw;
|
|
mw.__fdSwapWatch = true;
|
|
// Avant tout paint (même task que le swap) : la zone est x-ignore, donc non
|
|
// montée Alpine — tous ses [x-show] seraient peints à leur valeur brute
|
|
// (rafale de menus/panneaux/états visibles une fraction de seconde). On ne
|
|
// la peint qu'une fois initTree passé.
|
|
mw.style.opacity = '0';
|
|
mw.style.pointerEvents = 'none';
|
|
mw.setAttribute('x-ignore', '');
|
|
var scripts = Array.prototype.slice.call(mw.querySelectorAll('script[src]'));
|
|
var pending = scripts.length;
|
|
var reveal = function () {
|
|
if (!mw.isConnected) return;
|
|
mw.style.opacity = '';
|
|
mw.style.pointerEvents = '';
|
|
};
|
|
var done = function () {
|
|
reveal(); // idempotent : appelé même si la branche suivante retourne
|
|
if (!mw.__fdSwapWatch) return;
|
|
mw.__fdSwapWatch = false;
|
|
document.removeEventListener('load', onScript, true);
|
|
document.removeEventListener('error', onScript, true);
|
|
if (!mw.isConnected || !mw.hasAttribute('x-ignore')) return;
|
|
mw.removeAttribute('x-ignore');
|
|
mw._x_ignore = false; // propriété posée par Alpine au checkpoint
|
|
try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); }
|
|
reveal();
|
|
};
|
|
var onScript = function (e) {
|
|
var node = e.target;
|
|
// `load`/`error` en capture sur `document` voit TOUS les <script> de la
|
|
// page, pas seulement ceux de la zone swappée : compter les uns et les
|
|
// autres décomptait trop vite et montait l'arbre Alpine avant que les
|
|
// scripts de la page n'aient tourné (page à moitié montée, composants
|
|
// affiché à l'état brut). On ne compte donc que les noeuds attendus.
|
|
if (!node || node.tagName !== 'SCRIPT') return;
|
|
if (scripts.indexOf(node) === -1) return;
|
|
if (--pending <= 0) done();
|
|
};
|
|
if (!pending) {
|
|
// Avant le microtask MutationObserver d'Alpine : retirer x-ignore suffit,
|
|
// le MO initialisera au checkpoint (initTree de sécurité = no-op).
|
|
mw.removeAttribute('x-ignore');
|
|
mw.__fdSwapWatch = false;
|
|
setTimeout(function () {
|
|
if (mw.isConnected) { try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); } }
|
|
reveal(); // après initTree : un paint max de blanc, jamais de contenu brut
|
|
}, 0);
|
|
return;
|
|
}
|
|
document.addEventListener('load', onScript, true);
|
|
document.addEventListener('error', onScript, true);
|
|
setTimeout(done, 4000);
|
|
});
|
|
|
|
// Ctrl/Cmd + \ : toggle the sidebar (Notion-style).
|
|
document.addEventListener('keydown', function(e) {
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === '\\' || e.code === 'Backslash')) {
|
|
e.preventDefault();
|
|
document.dispatchEvent(new CustomEvent('fd-toggle-sidebar'));
|
|
}
|
|
});
|
|
|
|
// Programmatic navigation used by Alpine helpers.
|
|
window.fdNavigate = function(url, opts) {
|
|
opts = opts || {};
|
|
if (!url) return;
|
|
if (opts.full || !window.htmx) { window.location.href = url; return; }
|
|
fdLoad(url, true);
|
|
};
|
|
|
|
function syncAfterSwap(xhr) {
|
|
try {
|
|
if (xhr && xhr.responseText) {
|
|
var doc = new DOMParser().parseFromString(xhr.responseText, 'text/html');
|
|
var title = doc.querySelector('title');
|
|
if (title && title.textContent) document.title = title.textContent;
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
var path = window.location.pathname;
|
|
var search = window.location.search;
|
|
|
|
document.querySelectorAll('.sidebar-item.active').forEach(function(el) {
|
|
el.classList.remove('active');
|
|
});
|
|
document.querySelectorAll('.sidebar-item').forEach(function(el) {
|
|
var href = el.getAttribute('href');
|
|
var a = el.querySelector('a[href]');
|
|
if (a) href = a.getAttribute('href');
|
|
if (href && (href === path || href === path + search)) el.classList.add('active');
|
|
var pid = el.getAttribute('data-page-id');
|
|
if (pid && ('/pages/' + String(pid).replace('page/', '')) === path) el.classList.add('active');
|
|
var dbid = el.getAttribute('data-id');
|
|
if (dbid && search.indexOf('folder=' + dbid) !== -1) el.classList.add('active');
|
|
});
|
|
|
|
if (window.appState) {
|
|
var m = search.match(/[?&]folder=(\d+)/);
|
|
window.appState.activeNodeId = m ? parseInt(m[1], 10) : null;
|
|
}
|
|
window.scrollTo(0, 0);
|
|
}
|
|
|
|
// htmx parses the WHOLE response and executes every <script> it contains,
|
|
// including shell scripts (agent panel, base inline scripts) that live
|
|
// outside .main-wrapper. Re-running those corrupts Alpine state (x-for
|
|
// teardown errors). Before the swap, reduce the response to the
|
|
// .main-wrapper fragment so only page scripts are executed.
|
|
document.body.addEventListener('htmx:beforeSwap', function(evt) {
|
|
var d = evt.detail;
|
|
if (!d || !d.target || !d.target.classList || !d.target.classList.contains('main-wrapper')) return;
|
|
if (!d.serverResponse || typeof d.serverResponse !== 'string') return;
|
|
try {
|
|
var doc = new DOMParser().parseFromString(d.serverResponse, 'text/html');
|
|
var mw = doc.querySelector('.main-wrapper');
|
|
if (mw) d.serverResponse = mw.outerHTML;
|
|
} catch { /* keep original response on parse failure */ }
|
|
});
|
|
|
|
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
|
syncAfterSwap(evt.detail && evt.detail.xhr);
|
|
});
|
|
})();
|
|
|
|
// A38 : openCardDetail était définie (corps identique) dans board_fragment
|
|
// ET detailed_board — une seule définition globale ici, appelée par les
|
|
// onclick/@click des deux fragments (owner/repo globaux fournis par board.js).
|
|
function openCardDetail(id) {
|
|
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
|
|
target: '#card-modal-content',
|
|
swap: 'innerHTML'
|
|
});
|
|
document.getElementById('card-modal').style.display = 'flex';
|
|
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
|
|
}
|
|
|
|
|
|
// ── Side peek : poignée de redimensionnement partagée (v7.49.1) ──
|
|
// Une seule implémentation pour les peeks de Library, du workspace local,
|
|
// de My Tasks et des bases de données (le code copié trois fois dérivait :
|
|
// celui de My Tasks ne capturait pas le pointeur et ne persistait rien).
|
|
// La largeur choisie est commune à tous les peeks (localStorage fd_peek_width),
|
|
// comme l'utilisateur ne voit qu'un seul panneau à la fois.
|
|
window.fdWirePeekResize = function(panel, handle, onClose) {
|
|
if (!panel || !handle) return;
|
|
var startX, startWidth, dragging = false;
|
|
handle.addEventListener('pointerdown', function(e) {
|
|
e.preventDefault();
|
|
try { handle.setPointerCapture(e.pointerId); } catch { /* pointeur fantôme */ }
|
|
startX = e.clientX;
|
|
startWidth = panel.offsetWidth;
|
|
dragging = true;
|
|
document.body.style.userSelect = 'none';
|
|
});
|
|
handle.addEventListener('pointermove', function(e) {
|
|
if (!dragging) return;
|
|
var w = Math.max(300, Math.min(window.innerWidth * 0.9, startWidth + (startX - e.clientX)));
|
|
panel.style.width = w + 'px';
|
|
});
|
|
function stop(e) {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
document.body.style.userSelect = '';
|
|
try { handle.releasePointerCapture(e.pointerId); } catch { /* déjà relâché */ }
|
|
if (e && Math.abs(e.clientX - startX) < 3 && typeof onClose === 'function') onClose();
|
|
try { localStorage.setItem('fd_peek_width', panel.style.width); } catch { /* navigation privée */ }
|
|
}
|
|
handle.addEventListener('pointerup', stop);
|
|
handle.addEventListener('pointercancel', function() { dragging = false; document.body.style.userSelect = ''; });
|
|
try {
|
|
var saved = localStorage.getItem('fd_peek_width');
|
|
if (saved) panel.style.width = saved;
|
|
} catch { /* navigation privée */ }
|
|
};
|