diff --git a/static/js/windowDrag.js b/static/js/windowDrag.js index c06c38f..87b3115 100644 --- a/static/js/windowDrag.js +++ b/static/js/windowDrag.js @@ -37,6 +37,7 @@ // Default true when onEnterFullscreen is supplied. import { makeEdgeDockController } from './modalSnap.js'; +import { makeWindowResizable } from './windowResize.js'; const SNAP_PX = 6; // cursor distance from top edge for fullscreen snap const UNSNAP_PX = 24; // cursor distance from top before fullscreen exits @@ -70,6 +71,26 @@ export function makeWindowDraggable(modal, options = {}) { header.style.cursor = 'move'; header.style.userSelect = 'none'; + // Edge/corner resize. Every draggable window also becomes resizable — the + // same gesture a native desktop window uses (grab an edge or corner, drag). + // Skipped on mobile (windows are full-screen sheets there) and while the + // window is fullscreen-snapped or docked. Wired here so all ~12 callsites + // get it without per-file changes. + if (options.enableResize !== false) { + const _dockClasses = ['modal-right-docked', 'modal-left-docked']; + makeWindowResizable(content, { + modal, + mobileSkip, + minWidth: options.minWidth, + minHeight: options.minHeight, + isLocked: () => (fsClass && modal && modal.classList.contains(fsClass)) + || (modal && _dockClasses.some((c) => modal.classList.contains(c))), + storageKey: options.resizeStorageKey + || (modal && modal.id ? 'winsize-' + modal.id + : (content.id ? 'winsize-' + content.id : null)), + }); + } + const rightDock = enableDock ? makeEdgeDockController(modal, 'right') : null; // Left dock is opt-in (enableLeftDock). For most windows it's off — the // sidebar lives on the left, so a left dock collides with it. The email diff --git a/static/js/windowResize.js b/static/js/windowResize.js new file mode 100644 index 0000000..5782892 --- /dev/null +++ b/static/js/windowResize.js @@ -0,0 +1,233 @@ +// Shared window-resize helper. Companion to makeWindowDraggable: gives every +// draggable tool window (Library, Notes, Tasks, Calendar, Gallery, Email, +// Cookbook, Memory, Settings, Theme, Compare, Research, Sessions) edge- and +// corner-resize, the same way a native desktop window resizes — grab any of +// the four edges or four corners and drag. +// +// Why edge-proximity detection instead of injected handle elements: +// The windows differ structurally. `.modal-content` scrolls its own body +// (overflow:auto) while `.notes-pane` keeps overflow:hidden and scrolls an +// inner element. Absolutely-positioned handle children would scroll away +// with the content in the first case. Detecting pointer proximity to the +// window's border works uniformly regardless of the overflow model and +// matches the user's mental model ("drag the edges or corners"). +// +// API: +// makeWindowResizable(content, { +// modal, // optional wrapping .modal (for id-based size persistence) +// mobileSkip, // viewport width at/below which resize is disabled (sheets) +// isLocked, // () => bool — skip while fullscreen / docked +// minWidth, minHeight, +// storageKey, // localStorage key to persist {w,h}; null disables +// onResizeEnd, // ({rect}) => void +// }) + +const EDGE = 7; // px proximity to a border that arms a resize grip +const MIN_W = 320; // smallest a window may be dragged to +const MIN_H = 200; +// Controls that must keep their own click/drag behaviour even when they sit +// within EDGE px of the window border (close buttons, sliders, inputs, links). +const INTERACTIVE = 'button, input, select, textarea, a, [contenteditable=""], [contenteditable="true"]'; + +export function makeWindowResizable(content, options = {}) { + if (!content) return; + const modal = options.modal || null; + const mobileSkip = (typeof options.mobileSkip === 'number') ? options.mobileSkip : 768; + const minW = options.minWidth || MIN_W; + const minH = options.minHeight || MIN_H; + const isLocked = options.isLocked || (() => false); + const onResizeEnd = options.onResizeEnd || null; + const storageKey = options.storageKey || null; + + const _skip = () => (mobileSkip > 0 && window.innerWidth <= mobileSkip) || isLocked(); + + // Which borders is (cx,cy) within EDGE px of? Only counts when the pointer + // is also within the window's span on the perpendicular axis, so the corners + // resolve to true diagonal grips rather than the whole side. + function edgesAt(cx, cy) { + const r = content.getBoundingClientRect(); + const within = (cy >= r.top - EDGE && cy <= r.bottom + EDGE && cx >= r.left - EDGE && cx <= r.right + EDGE); + if (!within) return { l: false, r: false, t: false, b: false, rect: r }; + const onY = cy >= r.top - EDGE && cy <= r.bottom + EDGE; + const onX = cx >= r.left - EDGE && cx <= r.right + EDGE; + return { + l: Math.abs(cx - r.left) <= EDGE && onY, + r: Math.abs(cx - r.right) <= EDGE && onY, + t: Math.abs(cy - r.top) <= EDGE && onX, + b: Math.abs(cy - r.bottom) <= EDGE && onX, + rect: r, + }; + } + + function cursorFor(e) { + if ((e.l && e.t) || (e.r && e.b)) return 'nwse-resize'; + if ((e.r && e.t) || (e.l && e.b)) return 'nesw-resize'; + if (e.l || e.r) return 'ew-resize'; + if (e.t || e.b) return 'ns-resize'; + return ''; + } + + let hoverCursor = false; + function clearHoverCursor() { + if (hoverCursor) { content.style.cursor = ''; hoverCursor = false; } + } + function onHover(ev) { + if (resizing) return; + if (_skip()) { clearHoverCursor(); return; } + if (ev.target && ev.target.closest && ev.target.closest(INTERACTIVE)) { clearHoverCursor(); return; } + const c = cursorFor(edgesAt(ev.clientX, ev.clientY)); + if (c) { content.style.cursor = c; hoverCursor = true; } + else clearHoverCursor(); + } + + let resizing = false; + let active = null; + let startRect = null, startX = 0, startY = 0; + + function begin(cx, cy, edges) { + resizing = true; + active = edges; + // Kill the modal/pane open-animation (a scale transform that runs for the + // first ~200-250ms) BEFORE measuring. Done as a permanent inline style + // rather than a toggled class on purpose: a class that flips animation + // off→on would re-trigger the scale-in on mouseup, mis-measuring the final + // size and visibly popping the window. The open animation is a one-shot, + // so killing it for this instance is harmless (it replays on next open). + content.style.animation = 'none'; + content.classList.add('window-resizing'); + const r = content.getBoundingClientRect(); + startRect = { left: r.left, top: r.top, width: r.width, height: r.height }; + startX = cx; startY = cy; + // Pin to fixed with explicit box, same as the drag helper does, so the + // centering transform / margin stops fighting the new dimensions. Drop the + // max-width/height caps (e.g. 85vh) so the window can actually grow. + content.style.position = 'fixed'; + content.style.margin = '0'; + content.style.transform = 'none'; + content.style.left = r.left + 'px'; + content.style.top = r.top + 'px'; + content.style.width = r.width + 'px'; + content.style.height = r.height + 'px'; + content.style.maxWidth = 'none'; + content.style.maxHeight = 'none'; + document.body.classList.add('window-resizing-active'); + document.body.style.cursor = cursorFor(edges); + } + + function move(cx, cy) { + if (!resizing) return; + const dx = cx - startX, dy = cy - startY; + let { left, top, width, height } = startRect; + const vw = window.innerWidth, vh = window.innerHeight; + if (active.r) width = startRect.width + dx; + if (active.b) height = startRect.height + dy; + if (active.l) { width = startRect.width - dx; left = startRect.left + dx; } + if (active.t) { height = startRect.height - dy; top = startRect.top + dy; } + // Min-size clamps — keep the opposite edge anchored when pulling from + // the left/top so the window doesn't jump. + if (width < minW) { if (active.l) left = startRect.left + (startRect.width - minW); width = minW; } + if (height < minH) { if (active.t) top = startRect.top + (startRect.height - minH); height = minH; } + // Keep the window on-screen and never larger than the viewport. + if (active.l && left < 0) { width += left; left = 0; } + if (active.t && top < 0) { height += top; top = 0; } + if (left + width > vw) width = Math.max(minW, vw - left); + if (top + height > vh) height = Math.max(minH, vh - top); + content.style.left = left + 'px'; + content.style.top = top + 'px'; + content.style.width = width + 'px'; + content.style.height = height + 'px'; + } + + function end() { + if (!resizing) return; + resizing = false; + content.classList.remove('window-resizing'); + document.body.classList.remove('window-resizing-active'); + document.body.style.cursor = ''; + clearHoverCursor(); + const r = content.getBoundingClientRect(); + if (storageKey) { + try { localStorage.setItem(storageKey, JSON.stringify({ w: Math.round(r.width), h: Math.round(r.height) })); } catch (_) {} + } + if (onResizeEnd) { try { onResizeEnd({ rect: r }); } catch (_) {} } + } + + function armFrom(target, cx, cy) { + if (_skip()) return false; + if (target && target.closest && target.closest(INTERACTIVE)) return false; + const edges = edgesAt(cx, cy); + if (!(edges.l || edges.r || edges.t || edges.b)) return false; + begin(cx, cy, edges); + return true; + } + + // Capture phase: pre-empt the header's drag listener (which lives on a + // descendant and fires in the bubble phase) when the grab lands on a border. + content.addEventListener('mousedown', (ev) => { + if (ev.button !== 0) return; + if (!armFrom(ev.target, ev.clientX, ev.clientY)) return; + ev.preventDefault(); + ev.stopPropagation(); + const mu = () => { + end(); + document.removeEventListener('mousemove', mm); + document.removeEventListener('mouseup', mu); + }; + // Self-heal a missed mouseup (released outside the window, dropped event, + // window blur): a move with no buttons pressed means the drag is over — + // finish instead of running away on every subsequent mousemove. + const mm = (e) => { + if (e.buttons === 0) { mu(); return; } + move(e.clientX, e.clientY); + }; + document.addEventListener('mousemove', mm); + document.addEventListener('mouseup', mu); + }, true); + + content.addEventListener('mousemove', onHover); + content.addEventListener('mouseleave', clearHoverCursor); + + content.addEventListener('touchstart', (ev) => { + const t = ev.touches[0]; + if (!t) return; + if (!armFrom(ev.target, t.clientX, t.clientY)) return; + ev.preventDefault(); + ev.stopPropagation(); + const tm = (e) => { const tt = e.touches[0]; if (tt) move(tt.clientX, tt.clientY); }; + const te = () => { + end(); + document.removeEventListener('touchmove', tm); + document.removeEventListener('touchend', te); + document.removeEventListener('touchcancel', te); + }; + document.addEventListener('touchmove', tm, { passive: false }); + document.addEventListener('touchend', te); + document.addEventListener('touchcancel', te); + }, true); + + // Restore a previously chosen size on (re)open. Applying width/height inline + // while the window is still centered by its overlay keeps it centered at the + // new size; once dragged/resized it pins to fixed as usual. + // + // Deferred one frame on purpose: some windows (e.g. Notes) snap to an edge + // dock or fullscreen synchronously right AFTER this helper is wired. Waiting a + // frame lets that settle so we can re-check _skip() and NOT stretch a + // docked/fullscreen window to a stale windowed size. The open animation masks + // the one-frame delay, so there is no visible jump. + if (storageKey) { + requestAnimationFrame(() => { + if (_skip() || !content.isConnected) return; + try { + const saved = JSON.parse(localStorage.getItem(storageKey) || 'null'); + if (saved && saved.w && saved.h) { + const w = Math.max(minW, Math.min(saved.w, window.innerWidth)); + const h = Math.max(minH, Math.min(saved.h, window.innerHeight)); + content.style.width = w + 'px'; + content.style.height = h + 'px'; + content.style.maxWidth = 'none'; + content.style.maxHeight = 'none'; + } + } catch (_) {} + }); + } +} diff --git a/static/style.css b/static/style.css index 52c7c70..3f19b81 100644 --- a/static/style.css +++ b/static/style.css @@ -4596,6 +4596,21 @@ body.bg-pattern-sparkles { background-color: inherit; } .modal-header:active { cursor:grabbing; } + /* Edge/corner window resize (windowResize.js). While a resize is in + progress, suppress text selection and force the active resize cursor + across the whole document so it does not flicker as the pointer passes + over child elements mid-drag. */ + body.window-resizing-active { user-select:none !important; } + body.window-resizing-active * { cursor:inherit !important; } + /* Suppress only TRANSITIONS while resizing so the edge tracks the cursor + crisply. We deliberately do NOT toggle `animation` here: toggling + animation off→on re-triggers the modal open-animation (a scale-in) on + mouseup, which both mis-measures the final size and visibly "pops" the + window. windowResize.js instead kills the one-shot open animation inline + once, in begin(). */ + .window-resizing { + transition:none !important; + } /* Cookbook's modal-content is var(--bg) (inline) instead of the default var(--panel), so its sticky header — which defaults to var(--panel) — read as a different-coloured band. Match the header to the cookbook