Merge branch 'pr-668' into visual-pr-playground
This commit is contained in:
@@ -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
|
||||
|
||||
233
static/js/windowResize.js
Normal file
233
static/js/windowResize.js
Normal file
@@ -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 (_) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4682,6 +4682,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
|
||||
|
||||
Reference in New Issue
Block a user