Library, Notes, and the other floating tool windows (Tasks, Calendar, Gallery, Email, Cookbook, Brain, Settings, Theme, Compare, Research, Sessions) could be moved and snapped but never resized — there were no resize handles and dragging the edges did nothing. Add a shared makeWindowResizable() helper and wire it into the existing makeWindowDraggable() so every draggable window gains native-style edge/corner resizing from one place: - Grab any of the four edges or four corners to resize; the cursor reflects the active handle (ew/ns/nwse/nesw-resize). - Detects pointer proximity to the border instead of injecting handle elements, so it works regardless of each window's overflow model (.modal-content scrolls its body; .notes-pane scrolls an inner el). - Min-size clamp (320x200) and viewport clamping so a window can't be collapsed to nothing or dragged off-screen. - Per-window size is remembered and restored on reopen. - Disabled on mobile (windows are full-screen sheets there) and while a window is docked or fullscreen-snapped. - Touch supported at tablet width and up; self-heals a missed pointer-up so a lost mouseup can't leave a window stuck in resize mode.
234 lines
10 KiB
JavaScript
234 lines
10 KiB
JavaScript
// 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 (_) {}
|
|
});
|
|
}
|
|
}
|