1// Shared window-drag helper. Replaces the duplicated mousedown / mousemove 2// / mouseup + snap-to-top fullscreen + left/right edge dock patterns that 3// were copy-pasted across calendar.js, tasks.js, gallery.js, emailLibrary.js, 4// documentLibrary.js, theme.js. Behavior stays identical to the old per-file 5// copies â each callsite provides its own enter/exit-fullscreen callbacks 6// since the CSS class + inline styles differ per modal. 7// 8// API: 9// makeWindowDraggable(modal, { content, header, ...options }) 10// modal: the wrapping .modal element (or a standalone pane) 11// content: the element being moved (usually .modal-content) 12// header: the drag handle (usually .modal-header) 13// fsClass: optional class name representing "fullscreen" state 14// onEnterFullscreen: optional () => void â called when cursor releases 15// near the top edge (within SNAP_PX). Caller is 16// responsible for adding fsClass + applying inline 17// styles that produce the fullscreen layout. 18// onExitFullscreen: optional (cx, cy) => void â called mid-drag when 19// the cursor leaves the fullscreen "unsnap" band 20// (down > UNSNAP_PX OR near either horizontal edge 21// in dock-snap range). Caller restores windowed 22// inline styles centered around the cursor. 23// skipSelector: CSS selector for elements inside `header` whose 24// clicks should NOT start a drag (close button, 25// form fields, etc). Default: 'button, input, select' 26// onDragEnd: optional (state) => void â fires after mouseup 27// WHEN no snap was committed. state = { rect } so 28// callers can persist the final position. 29// enableTouch: bool â also wire touchstart/touchmove/touchend 30// with the same drag (no fs/dock on touch). Default 31// true on desktop, irrelevant on mobile (mobileSkip). 32// mobileSkip: drag is disabled below this viewport width. 33// Default 768. Set to 0 to never skip. 34// enableDock: bool â enable left + right edge docks. 35// Default true. 36// enableFullscreen: bool â enable top-edge fullscreen snap. 37// Default true when onEnterFullscreen is supplied. 38 39import { makeEdgeDockController } from './modalSnap.js'; 40import { makeWindowResizable } from './windowResize.js'; 41 42const SNAP_PX = 6; // cursor distance from top edge for fullscreen snap 43const UNSNAP_PX = 24; // cursor distance from top before fullscreen exits 44const DOCK_EDGE_PX = 60; // cursor distance from L/R edge to trigger dock 45 // exit while still in fullscreen state 46 47// CSS-var lookup for the rail+sidebar width â used to decide where the 48// "left edge" effectively is during a fullscreen drag-out (the cursor 49// has to pass the rail to count as "near left"). 50function _leftNavWidth() { 51 const rs = getComputedStyle(document.documentElement); 52 const rail = parseInt(rs.getPropertyValue('--icon-rail-w') || '48', 10) || 0; 53 const sb = parseInt(rs.getPropertyValue('--sidebar-w') || '0', 10) || 0; 54 return rail + sb; 55} 56 57export function makeWindowDraggable(modal, options = {}) { 58 const content = options.content; 59 const header = options.header; 60 if (!content || !header) return; 61 const fsClass = options.fsClass || null; 62 const onEnterFullscreen = options.onEnterFullscreen || null; 63 const onExitFullscreen = options.onExitFullscreen || null; 64 const enableFullscreen = options.enableFullscreen !== false && !!onEnterFullscreen; 65 const onDragEnd = options.onDragEnd || null; 66 const onDragStart = options.onDragStart || null; 67 const skipSelector = options.skipSelector || 'button, input, select'; 68 const mobileSkip = (typeof options.mobileSkip === 'number') ? options.mobileSkip : 768; 69 const enableTouch = options.enableTouch !== false; 70 const enableDock = options.enableDock !== false && !!modal; 71 72 header.style.cursor = 'move'; 73 header.style.userSelect = 'none'; 74 75 // Edge/corner resize. Every draggable window also becomes resizable â the 76 // same gesture a native desktop window uses (grab an edge or corner, drag). 77 // Skipped on mobile (windows are full-screen sheets there) and while the 78 // window is fullscreen-snapped or docked. Wired here so all ~12 callsites 79 // get it without per-file changes. 80 if (options.enableResize !== false) {
81 const _dockClasses = ['modal-right-docked', 'modal-left-docked']; 82 makeWindowResizable(content, { 83 modal, 84 mobileSkip, 85 minWidth: options.minWidth, 86 minHeight: options.minHeight, 87 isLocked: () => (fsClass && modal && modal.classList.contains(fsClass)) 88 || (modal && _dockClasses.some((c) => modal.classList.contains(c))), 89 storageKey: options.resizeStorageKey 90 || (modal && modal.id ? 'winsize-' + modal.id 91 : (content.id ? 'winsize-' + content.id : null)), 92 }); 93 } 94 95 const rightDock = enableDock ? makeEdgeDockController(modal, 'right') : null; 96 // Left dock is enabled by default too. modalSnap collapses the wide sidebar 97 // and anchors the panel beside the icon rail, so it no longer collides with 98 // the navigation. Callers can still pass enableLeftDock:false for a special 99 // modal that should only dock right. 100 const leftDock = (enableDock && options.enableLeftDock !== false) ? makeEdgeDockController(modal, 'left') : null; 101 102 // Per-drag state, reset on mousedown. 103 let dragging = false; 104 let startX = 0, startY = 0; 105 let startLeft = 0, startTop = 0; 106 let snapHint = null; 107 // Whether the pointer actually moved beyond a small threshold this drag. 108 // Used to suppress the synthetic click the browser fires on mouseup â 109 // header click handlers (e.g. "collapse expanded card / back to list") 110 // would otherwise fire after a drag and collapse the modal contents. 111 let movedDuringDrag = false; 112 const MOVE_THRESHOLD = 4; 113 114 const _showSnapHint = (on) => { 115 // Top-edge fullscreen hint. Side hints come from the dock controllers. 116 if (!on) { 117 if (snapHint) { snapHint.remove(); snapHint = null; } 118 return; 119 } 120 if (snapHint) return; 121 snapHint = document.createElement('div'); 122 snapHint.className = 'modal-snap-hint'; 123 snapHint.style.cssText = 124 'position:fixed;left:0;top:0;right:0;bottom:0;' + 125 'background:color-mix(in srgb, var(--accent-primary, #60a5fa) 12%, transparent);' + 126 'border:2px dashed color-mix(in srgb, var(--accent-primary, #60a5fa) 60%, transparent);' + 127 'z-index:9998;pointer-events:none;'; 128 document.body.appendChild(snapHint); 129 }; 130 131 const _enterFs = () => { 132 if (!onEnterFullscreen) return; 133 if (fsClass && modal && modal.classList.contains(fsClass)) return; 134 onEnterFullscreen(); 135 }; 136 const _exitFs = (cx, cy) => { 137 if (!onExitFullscreen) return; 138 if (fsClass && modal && !modal.classList.contains(fsClass)) return; 139 onExitFullscreen(cx, cy); 140 // After exit, re-anchor the drag offsets to the new windowed rect so 141 // the drag continues smoothly from the cursor's position. 142 const r = content.getBoundingClientRect(); 143 startX = cx; startY = cy; 144 startLeft = r.left; startTop = r.top; 145 }; 146 147 const _isFullscreen = () => fsClass && modal && modal.classList.contains(fsClass); 148 149 const _startDrag = (cx, cy) => { 150 dragging = true; 151 if (modal) modal.classList.add('modal-dragging'); 152 // Cancel any in-flight open animation so we don't pin a mid-animation 153 // rect and then jump once the animation settles. 154 try { 155 content.getAnimations() 156 .filter(a => a.playState !== 'finished') 157 .forEach(a => a.cancel()); 158 } catch (_) {} 159 const rect = content.getBoundingClientRect(); 160 if (onDragStart) { 161 try { onDragStart({ rect, cx, cy }); } catch (_) {} 162 } 163 startX = cx; startY = cy; 164 startLeft = rect.left; startTop = rect.top; 165 // Pin position so the drag follows the cursor instead of fighting a 166 // centering transform / margin. Inline styles win unless CSS uses 167 // !important (the fullscreen rules do, by design). 168 content.style.position = 'fixed'; 169 content.style.left = startLeft + 'px'; 170 content.style.top = startTop + 'px'; 171 content.style.transform = 'none'; 172 content.style.margin = '0'; 173 }; 174 175 const _onMove = (cx, cy) => { 176 if (!dragging) return; 177 // Fullscreen state: unsnap on drag-down or drag toward either horizontal 178 // edge. Update dock hover immediately after exit so a fast release 179 // commits the dock instead of dropping the modal mid-air. 180 if (_isFullscreen()) { 181 // Corner guard: ignore the side edges while the cursor is still in the 182 // top fullscreen band, so dragging across the top corners keeps 183 // fullscreen instead of flipping into a corner dock. 184 const inTopBand = cy <= SNAP_PX; 185 const nearRight = !inTopBand && (window.innerWidth - cx) <= DOCK_EDGE_PX; 186 const nearLeft = !inTopBand && (cx - _leftNavWidth()) <= DOCK_EDGE_PX; 187 // Dragging a fullscreen window to a SIDE edge â keep it fullscreen and 188 // just arm the side-dock hint; releasing there docks it (handled in 189 // _onEnd, which drops the fullscreen class). Previously this exited 190 // fullscreen first, which re-CENTERED the window â so it looked like 191 // it "centered instead of docking". Only a downward drag unsnaps to a 192 // windowed (centered) modal. 193 if (nearRight && rightDock) { 194 if (leftDock) leftDock.release(); 195 rightDock.onMove(cx, cy); 196 return; 197 } 198 if (nearLeft && leftDock) { 199 if (rightDock) rightDock.release(); 200 leftDock.onMove(cx, cy); 201 return; 202 } 203 if (cy > UNSNAP_PX) { 204 _exitFs(cx, cy); 205 if (rightDock) rightDock.onMove(cx, cy); 206 if (leftDock) leftDock.onMove(cx, cy); 207 } else { 208 if (rightDock) rightDock.release(); 209 if (leftDock) leftDock.release(); 210 } 211 return; 212 } 213 // Right-docked: pulling away from the right edge un-docks. Same for left. 214 if (rightDock && modal && modal.classList.contains('modal-right-docked')) { 215 if (rightDock.onMove(cx, cy)) { 216 const r = content.getBoundingClientRect(); 217 startX = cx; startY = cy; 218 startLeft = r.left; startTop = r.top; 219 } 220 return; 221 } 222 if (leftDock && modal && modal.classList.contains('modal-left-docked')) { 223 if (leftDock.onMove(cx, cy)) { 224 const r = content.getBoundingClientRect(); 225 startX = cx; startY = cy; 226 startLeft = r.left; startTop = r.top; 227 } 228 return; 229 } 230 // Windowed: just follow the cursor. 231 if (Math.abs(cx - startX) > MOVE_THRESHOLD || Math.abs(cy - startY) > MOVE_THRESHOLD) { 232 movedDuringDrag = true; 233 } 234 content.style.left = (startLeft + cx - startX) + 'px'; 235 content.style.top = (startTop + cy - startY) + 'px'; 236 // Corner guard: in the top fullscreen band the side docks stay OFF, so a 237 // top corner only ever snaps to fullscreen â never the corner hybrid. 238 const inTopBand = cy <= SNAP_PX; 239 _showSnapHint(enableFullscreen && inTopBand);
240 if (inTopBand) { 241 if (rightDock) rightDock.release(); 242 if (leftDock) leftDock.release(); 243 } else { 244 if (rightDock) rightDock.onMove(cx, cy); 245 if (leftDock) leftDock.onMove(cx, cy); 246 } 247 }; 248 249 const _onEnd = (cx, cy) => { 250 if (!dragging) return; 251 dragging = false; 252 if (modal) modal.classList.remove('modal-dragging'); 253 _showSnapHint(false); 254 // Top edge wins over side edges â fullscreen is the more common gesture. 255 if (enableFullscreen && typeof cy === 'number' && cy <= SNAP_PX) { 256 if (rightDock) rightDock.release(); 257 if (leftDock) leftDock.release(); 258 _enterFs(); 259 return; 260 } 261 if (rightDock && rightDock.hovering()) { 262 if (leftDock) leftDock.release(); 263 if (fsClass && modal) modal.classList.remove(fsClass); // dock takes over from fullscreen 264 rightDock.commit(); 265 return; 266 } 267 if (leftDock && leftDock.hovering()) { 268 if (rightDock) rightDock.release(); 269 if (fsClass && modal) modal.classList.remove(fsClass); 270 leftDock.commit(); 271 return; 272 } 273 if (rightDock) rightDock.release(); 274 if (leftDock) leftDock.release(); 275 if (onDragEnd) { 276 const r = content.getBoundingClientRect(); 277 try { onDragEnd({ rect: r }); } catch (_) {} 278 } 279 }; 280 281 header.addEventListener('mousedown', (e) => { 282 if (mobileSkip > 0 && window.innerWidth <= mobileSkip) return; 283 if (skipSelector && e.target.closest(skipSelector)) return; 284 e.preventDefault(); 285 movedDuringDrag = false; 286 _startDrag(e.clientX, e.clientY); 287 const onMove = (ev) => _onMove(ev.clientX, ev.clientY); 288 const onUp = (ev) => { 289 _onEnd(ev.clientX, ev.clientY); 290 document.removeEventListener('mousemove', onMove); 291 document.removeEventListener('mouseup', onUp); 292 // If the pointer actually moved, swallow the synthetic click the 293 // browser fires next â otherwise a header click handler (collapse 294 // expanded card / "back to list") runs and undoes the drag intent. 295 if (movedDuringDrag) { 296 const swallow = (clickEv) => { 297 clickEv.stopPropagation(); 298 clickEv.preventDefault(); 299 }; 300 header.addEventListener('click', swallow, { capture: true, once: true }); 301 // Safety: if no click fires (some browsers), drop the listener. 302 setTimeout(() => header.removeEventListener('click', swallow, { capture: true }), 50); 303 } 304 }; 305 document.addEventListener('mousemove', onMove); 306 document.addEventListener('mouseup', onUp); 307 }); 308 309 if (enableTouch) { 310 header.addEventListener('touchstart', (e) => { 311 if (mobileSkip > 0 && window.innerWidth <= mobileSkip) return; 312 if (skipSelector && e.target.closest(skipSelector)) return; 313 const t = e.touches[0]; 314 if (!t) return; 315 movedDuringDrag = false; 316 _startDrag(t.clientX, t.clientY); 317 const onMove = (ev) => { 318 const tt = ev.touches[0]; 319 if (tt) _onMove(tt.clientX, tt.clientY); 320 }; 321 const onEnd = (ev) => { 322 const tt = (ev.changedTouches && ev.changedTouches[0]) || null; 323 _onEnd(tt ? tt.clientX : null, tt ? tt.clientY : null); 324 document.removeEventListener('touchmove', onMove); 325 document.removeEventListener('touchend', onEnd); 326 document.removeEventListener('touchcancel', onEnd); 327 }; 328 document.addEventListener('touchmove', onMove, { passive: true }); 329 document.addEventListener('touchend', onEnd); 330 document.addEventListener('touchcancel', onEnd); 331 }, { passive: true }); 332 } 333}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.