diff --git a/src/css/main.css b/src/css/main.css index be8420f..2ca17b5 100644 --- a/src/css/main.css +++ b/src/css/main.css @@ -910,9 +910,10 @@ body.show-desc .bm-desc { display: block; } .board.blurred .board-title { filter: blur(5px); } -.board.blurred { - position: relative; -} +/* HINWEIS: KEIN `.board.blurred { position: relative }` — .board ist im Free-Layout bereits + position:absolute (= positionierter Containing-Block fuers Overlay). Ein relative hier liegt + im @layer utilities und wuerde das absolute schlagen -> geblurrtes Board faellt aus seiner + freien Position + waere nicht mehr drag-bar (Phase-5-Review). */ .board-blur-overlay { display: none; position: absolute; inset: 0; z-index: 5; diff --git a/src/js/app.js b/src/js/app.js index 510547f..d2533ca 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -27,6 +27,7 @@ async function init() { bindGlobalEvents(); bindSettingsEvents(); bindStorageSync(); + bindBoardResizeReclamp(); // Boards bei Fenster-Verkleinerung wieder in den sichtbaren Bereich holen await drainQuickSavePending(); // beim Start angesammelte Quick-Saves (kein Tab war offen) einlesen initSearch(); initPalette(); @@ -282,4 +283,20 @@ function bindStorageSync() { }); } +// Freies Layout (LAYOUT-04): Boards stehen absolut positioniert. Schrumpft das Fenster, koennen +// sie ganz aus dem sichtbaren Bereich rutschen. renderBoards() klemmt die --board-x/--board-y jedes +// Boards beim Aufbau gegen die aktuelle Viewport — ein simpler Re-Render holt sie also zurueck. +// Debounce (150ms), damit kontinuierliches Resizen nicht hunderte Renders ausloest. Waehrend eines +// aktiven Drags NICHT neu rendern: renderBoards->replaceChildren wuerde den laufenden Drag abreissen. +function bindBoardResizeReclamp() { + let resizeTimer = null; + window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + if (document.querySelector('.board.dragging, .bm-item.dragging-source')) return; + renderBoards(); + }, 150); + }); +} + document.addEventListener('DOMContentLoaded', init); diff --git a/src/js/boards.js b/src/js/boards.js index 453cb7b..2f2219f 100644 --- a/src/js/boards.js +++ b/src/js/boards.js @@ -96,11 +96,15 @@ function renderBoards() { boards.forEach(board => { const el = createBoardEl(board); - // Position als Custom-Property setzen (nicht inline left/top), damit der - // Mobil-@media-Reset sie ueberschreiben kann. - el.style.setProperty('--board-x', board.pos.x + 'px'); - el.style.setProperty('--board-y', board.pos.y + 'px'); wrapper.appendChild(el); + // Position als Custom-Property setzen (nicht inline left/top), damit der Mobil-@media-Reset + // sie ueberschreiben kann. Gegen den AKTUELLEN Viewport clampen, damit ein auf breiterem + // Fenster platziertes Board nie off-screen (und damit per Drag unerreichbar) rendert. + // board.pos bleibt unveraendert -> bei spaeterer Verbreiterung wird die Originalposition wieder erreicht. + const cx = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, board.pos.x)); + const cy = Math.max(48, Math.min(window.innerHeight - el.offsetHeight, board.pos.y)); + el.style.setProperty('--board-x', cx + 'px'); + el.style.setProperty('--board-y', cy + 'px'); }); initBoardDragDrop(); diff --git a/src/js/data.js b/src/js/data.js index 7dd0250..85d3abe 100644 --- a/src/js/data.js +++ b/src/js/data.js @@ -24,6 +24,17 @@ function initDataButtons() { } } + /** + * Validiert eine freie Layout-Position (LAYOUT-04). Liefert { x, y } nur bei + * endlichen Zahlen, sonst null — dann gridded ensureBoardPositions das Board neu. + * Ohne das wuerde ein Import jede vom Nutzer gesetzte Board-Position verwerfen. + * @param {*} pos + * @returns {{x:number,y:number}|null} + */ + function safePos(pos) { + return pos && Number.isFinite(pos.x) && Number.isFinite(pos.y) ? { x: pos.x, y: pos.y } : null; + } + // Export (inkl. Notes) btnExport.addEventListener('click', async () => { const widgetData = await Store.get('widgetStates'); @@ -56,19 +67,25 @@ function initDataButtons() { if (!Array.isArray(data.boards)) throw new Error(t('data.invalid_format')); const validBoards = data.boards .filter(b => b && typeof b.title === 'string' && Array.isArray(b.bookmarks)) - .map(b => ({ - id: b.id || uid(), - title: String(b.title).slice(0, 100), - blurred: !!b.blurred, - bookmarks: b.bookmarks - .filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url)) - .map(bm => ({ - id: bm.id || uid(), - title: String(bm.title).slice(0, 200), - url: bm.url, - desc: String(bm.desc || '').slice(0, 500) - })) - })); + .map(b => { + const board = { + id: b.id || uid(), + title: String(b.title).slice(0, 100), + blurred: !!b.blurred, + bookmarks: b.bookmarks + .filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url)) + .map(bm => ({ + id: bm.id || uid(), + title: String(bm.title).slice(0, 200), + url: bm.url, + desc: String(bm.desc || '').slice(0, 500) + })) + }; + // Freies Layout (LAYOUT-04): valide Position uebernehmen, sonst gridded ensureBoardPositions neu. + const pos = safePos(b.pos); + if (pos) board.pos = pos; + return board; + }); if (validBoards.length === 0) throw new Error(t('data.no_boards')); const ok = await HellionDialog.confirm( t('data.import_confirm', { count: validBoards.length }), @@ -92,6 +109,8 @@ function initDataButtons() { id: e.item.id || uid(), title: String(e.item.title || '').slice(0, 100), blurred: !!e.item.blurred, + // Position erhalten, damit ein wiederhergestelltes Board an seinem Platz landet. + ...(safePos(e.item.pos) ? { pos: safePos(e.item.pos) } : {}), bookmarks: Array.isArray(e.item.bookmarks) ? e.item.bookmarks .filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url)) diff --git a/src/js/drag.js b/src/js/drag.js index bd853c0..2158668 100644 --- a/src/js/drag.js +++ b/src/js/drag.js @@ -41,12 +41,19 @@ function initBoardDragDrop() { boardEl.style.setProperty('--board-y', y + 'px'); } - async function onUp() { - handle.releasePointerCapture(e.pointerId); + // Gemeinsames Aufraeumen: Pointer-Capture freigeben, ALLE Listener entfernen, + // .board.dragging entfernen. MUSS auch im Cancel-Pfad laufen — sonst klebt die Klasse + // und der app.js-Sync-Guard unterdrueckt dauerhaft Quick-Save-Renders (Phase-5-Review). + function cleanup() { + try { handle.releasePointerCapture(e.pointerId); } catch (_) { /* schon freigegeben */ } handle.removeEventListener('pointermove', onMove); handle.removeEventListener('pointerup', onUp); - boardEl.classList.remove('dragging'); // z-index zurueck + Live-Sync-Guard freigeben + handle.removeEventListener('pointercancel', onCancel); + boardEl.classList.remove('dragging'); + } + async function onUp() { + cleanup(); const id = boardEl.dataset.boardId; const board = boards.find(b => b.id === id); if (board) { @@ -58,8 +65,13 @@ function initBoardDragDrop() { } } + // pointercancel feuert STATT pointerup bei Touch-Interrupt, Browser-Geste oder wenn das + // captured Element aus dem DOM faellt -> nur aufraeumen (cleanup), pos NICHT ueberschreiben. + function onCancel() { cleanup(); } + handle.addEventListener('pointermove', onMove); handle.addEventListener('pointerup', onUp); + handle.addEventListener('pointercancel', onCancel); }); }); }