Skip to content

Commit 1292edc

Browse files
committed
WIP smart placement of new windows
1 parent 30e492d commit 1292edc

File tree

5 files changed

+333
-38
lines changed

5 files changed

+333
-38
lines changed

helpers/NPWindows.js

Lines changed: 264 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@ import { caseInsensitiveMatch, caseInsensitiveStartsWith } from '@helpers/search
1010
import { inputIntegerBounded } from '@helpers/userInput'
1111

1212
// ----------------------------------------------------------------------------
13-
// TYPES
13+
// Types
1414

1515
export type TWindowType = 'Editor' | 'HTMLView' | 'FolderView'
1616

1717
// ----------------------------------------------------------------------------
18-
// FUNCTIONS
18+
// Constants
19+
20+
const MIN_WINDOW_WIDTH = 300
21+
const MIN_WINDOW_HEIGHT = 430
22+
23+
// ----------------------------------------------------------------------------
24+
// Functions
1925

2026
/**
2127
* Return string version of Rect's x/y/width/height attributes
@@ -301,19 +307,23 @@ export function noteOpenInEditor(openNoteFilename: string): boolean {
301307
}
302308

303309
/**
304-
* Returns the Editor object that matches a given filename (if available)
310+
* Returns the TEditor that matches a given filename (if available).
311+
* If getLastOpenEditor is true, then return the last open Editor window (which is the most recently opened one), otherwise the first one that matches the filename.
305312
* @author @jgclark
306313
* @param {string} openNoteFilename to find in list of open Editor windows
314+
* @param {boolean} getLastOpenEditor - whether to return the last open Editor window
307315
* @returns {TEditor} the matching open Editor window
308316
*/
309-
export function getOpenEditorFromFilename(openNoteFilename: string): TEditor | false {
317+
export function getOpenEditorFromFilename(openNoteFilename: string, getLastOpenEditor: boolean = false): TEditor | false {
310318
const allEditorWindows = NotePlan.editors
311-
for (const thisEditorWindow of allEditorWindows) {
312-
if (thisEditorWindow.filename === openNoteFilename) {
313-
return thisEditorWindow
314-
}
319+
const matchingEditorWindows = allEditorWindows.filter(ew => ew.filename === openNoteFilename)
320+
if (matchingEditorWindows.length === 0) {
321+
logWarn('getOpenEditorFromFilename', `No open Editor window found for filename '${openNoteFilename}'`)
322+
return false
315323
}
316-
return false
324+
return getLastOpenEditor
325+
? matchingEditorWindows[matchingEditorWindows.length - 1]
326+
: matchingEditorWindows[0]
317327
}
318328

319329
/**
@@ -335,23 +345,257 @@ export function focusHTMLWindowIfAvailable(customId: string): boolean {
335345
}
336346

337347
/**
338-
* Opens note in new floating window, if it's not already open in one
348+
* Position an Editor window at a smart placement on the screen.
349+
* @param {TEditor} editor - the Editor window to position
350+
* @param {number} requestedWidth - requested width of the window (if set at zero, treat as if not set)
351+
* @returns {boolean} success?
352+
*/
353+
function positionEditorWindowWithSmartPlacement(editor: TEditor, requestedWidth: number): boolean {
354+
const editorId = editor.id
355+
logDebug('positionEditorWindowWithSmartPlacement', `Positioning Editor window '${editorId}' for filename '${editor.filename}' (customId: '${editor.customId}')`)
356+
357+
const currentWindowRect = getLiveWindowRect(editorId)
358+
if (!currentWindowRect) {
359+
logWarn('positionEditorWindowWithSmartPlacement', `Couldn't get window rect for Editor window '${editorId}'`)
360+
return false
361+
}
362+
363+
// Calculate the smart location for the new window
364+
const newWindowRect = calculateSmartLocation(currentWindowRect, requestedWidth)
365+
logDebug('positionEditorWindowWithSmartPlacement', `Calculated smart location for new window -> ${rectToString(newWindowRect)}`)
366+
367+
// Set the window rect for the new window
368+
editor.windowRect = newWindowRect
369+
return true
370+
}
371+
372+
/**
373+
* Opens note in new floating window, optionally only if it's not already open in one, and optionally move window to a smart location on the screen, rather than the default position, which is often unhelpful.
339374
* @param {string} filename to open in window
375+
* @param {number} width - requested width of the new window (if set at zero, treat as if not set)
376+
* @param {boolean} onlyIfNotAlreadyOpen - whether to only open the window if it's not already open in one
377+
* @param {boolean} smartLocation - whether to move window to a smart location on the screen, based on the current NP window size(s), position(s) and the screen area
340378
* @returns {boolean} success?
341379
*/
342-
export async function openNoteInNewWindowIfNeeded(filename: string): Promise<boolean> {
343-
const isAlreadyOpen = isEditorWindowOpen(filename)
344-
if (isAlreadyOpen) {
345-
logDebug('openNoteInNewWindowIfNeeded', `Note '${filename}' is already open in an Editor window. Skipping.`)
380+
export async function openNoteInNewWindow(
381+
filename: string,
382+
width: number,
383+
onlyIfNotAlreadyOpen: boolean = false,
384+
smartLocation: boolean = true): Promise<boolean> {
385+
try {
386+
// Check if note is already open
387+
if (onlyIfNotAlreadyOpen && isEditorWindowOpen(filename)) {
388+
logDebug('openNoteInNewWindow', `Note '${filename}' is already open in an Editor window. Skipping.`)
389+
return false
390+
}
391+
392+
// Open the note in a new floating window
393+
const res: ?TNote = await Editor.openNoteByFilename(filename, true, 0, 0, false, false) // create new floating window
394+
if (!res) {
395+
logWarn('openNoteInNewWindow', `Failed to open floating window '${filename}'`)
396+
return false
397+
}
398+
logDebug('openNoteInNewWindow', `Opened floating window '${filename}'`)
399+
400+
// Position window at smart location if requested
401+
if (smartLocation) {
402+
const thisEditor = getOpenEditorFromFilename(filename, true)
403+
if (!thisEditor) {
404+
throw new Error(`Couldn't find open Editor window for filename '${filename}'`)
405+
}
406+
positionEditorWindowWithSmartPlacement(thisEditor, width)
407+
}
408+
409+
return true
410+
} catch (error) {
411+
logError('openNoteInNewWindow', `Error: ${error.message}`)
346412
return false
347413
}
348-
const res = await Editor.openNoteByFilename(filename, true, 0, 0, false, false) // create new floating window
349-
if (res) {
350-
logDebug('openWindowSet', `Opened floating window '${filename}'`)
351-
} else {
352-
logWarn('openWindowSet', `Failed to open floating window '${filename}'`)
414+
}
415+
416+
/**
417+
* Calculate the smart placement for the new window:
418+
* - Calculate all the areas of the screen from the existing open Editor and HTML windows.
419+
* - Then find the next available area that is big enough for the same height and requested width, that is next to an existing Editor window, but within the screen boundaries.
420+
* @param {Rect} currenthisWindowRect - the Rect of the current window
421+
* @param {number} requestedWidth - the requested width of the new window (if set at zero, treat as if not set)
422+
* @returns {Rect} the smart location for the new window
423+
*/
424+
export function calculateSmartLocation(thisWindowRect: Rect, requestedWidth: number): Rect {
425+
const allWindows = NotePlan.editors.concat(NotePlan.htmlWindows)
426+
const allWindowRects = allWindows.map(win => win.windowRect)
427+
const allWindowRectsString = allWindowRects.map(rect => rectToString(rect)).join('\n')
428+
logDebug('calculateSmartLocation', `All window rects: ${allWindowRectsString}`)
429+
const requestedHeight = thisWindowRect.height
430+
const newWindowRect = findNextClosestAvailableArea(allWindowRects, requestedHeight, requestedWidth)
431+
logDebug('calculateSmartLocation', `Calculated smart location: ${rectToString(newWindowRect)}`)
432+
return newWindowRect
433+
}
434+
435+
/**
436+
* Find the next available area that is:
437+
* - not overlapping with any existing 'allWindowRects'
438+
* - big enough for the requested height and width
439+
* - next to an existing Editor window
440+
* - within the screen boundaries
441+
* @param {Array<Rect>} allWindowRects - the Rects of the existing open Editor and HTML windows
442+
* @param {number} requestedHeight - the requested height of the new window
443+
* @param {number} requestedWidth - the requested width of the new window
444+
* @returns {Rect} the next available area
445+
*/
446+
function findNextClosestAvailableArea(allWindowRects: Array<Rect>, requestedHeight: number, requestedWidth: number): Rect {
447+
const screenWidth = NotePlan.environment.screenWidth
448+
const screenHeight = NotePlan.environment.screenHeight
449+
450+
// Helper function to check if two rects overlap
451+
function rectsOverlap(rect1: Rect, rect2: Rect): boolean {
452+
return !(
453+
rect1.x + rect1.width <= rect2.x ||
454+
rect2.x + rect2.width <= rect1.x ||
455+
rect1.y + rect1.height <= rect2.y ||
456+
rect2.y + rect2.height <= rect1.y
457+
)
458+
}
459+
460+
// Helper function to check if a rect fits within screen boundaries
461+
function rectFitsInScreen(rect: Rect): boolean {
462+
return (
463+
rect.x >= 0 &&
464+
rect.y >= 0 &&
465+
rect.x + rect.width <= screenWidth &&
466+
rect.y + rect.height <= screenHeight
467+
)
468+
}
469+
470+
// Helper function to check if a candidate rect overlaps with any existing windows
471+
function doesNotOverlapWithExisting(candidateRect: Rect): boolean {
472+
for (const existingRect of allWindowRects) {
473+
if (rectsOverlap(candidateRect, existingRect)) {
474+
return false
475+
}
476+
}
477+
return true
353478
}
354-
return !!res
479+
480+
// If no existing windows, place in top-left corner
481+
if (allWindowRects.length === 0) {
482+
return {
483+
x: 0,
484+
y: 0,
485+
width: requestedWidth > 0 ? requestedWidth : screenWidth,
486+
height: requestedHeight > 0 ? requestedHeight : screenHeight,
487+
}
488+
}
489+
490+
// Try to place the new window adjacent to each existing window
491+
// Priority: right, left, bottom, top
492+
const candidatePositions: Array<Rect> = []
493+
494+
// Helper to create and check a candidate position, pushing to array if valid
495+
function tryAddCandidate(rect: Rect, description: string) {
496+
if (rectFitsInScreen(rect) && doesNotOverlapWithExisting(rect)) {
497+
logDebug('findNextClosestAvailableArea', `Found candidate position ${description}: ${rectToString(rect)}`)
498+
candidatePositions.push(rect)
499+
}
500+
}
501+
502+
for (const existingRect of allWindowRects) {
503+
// Try placing to the right
504+
tryAddCandidate({
505+
x: existingRect.x + existingRect.width,
506+
y: existingRect.y,
507+
width: requestedWidth > 0 ? requestedWidth : Math.max(300, screenWidth - (existingRect.x + existingRect.width)),
508+
height: requestedHeight > 0 ? requestedHeight : existingRect.height,
509+
}, 'to the right')
510+
511+
// Try placing to the left
512+
tryAddCandidate({
513+
x: existingRect.x - (requestedWidth > 0 ? requestedWidth : Math.max(300, existingRect.x)),
514+
y: existingRect.y,
515+
width: requestedWidth > 0 ? requestedWidth : Math.max(300, existingRect.x),
516+
height: requestedHeight > 0 ? requestedHeight : existingRect.height,
517+
}, 'to the left')
518+
519+
// Try placing below
520+
tryAddCandidate({
521+
x: existingRect.x,
522+
y: existingRect.y + existingRect.height,
523+
width: requestedWidth > 0 ? requestedWidth : existingRect.width,
524+
height: requestedHeight > 0 ? requestedHeight : Math.max(300, screenHeight - (existingRect.y + existingRect.height)),
525+
}, 'below')
526+
527+
// Try placing above
528+
tryAddCandidate({
529+
x: existingRect.x,
530+
y: existingRect.y - (requestedHeight > 0 ? requestedHeight : Math.max(300, existingRect.y)),
531+
width: requestedWidth > 0 ? requestedWidth : existingRect.width,
532+
height: requestedHeight > 0 ? requestedHeight : Math.max(300, existingRect.y),
533+
}, 'above')
534+
}
535+
536+
// If we found candidate positions, return the first one
537+
if (candidatePositions.length > 0) {
538+
logDebug('findNextClosestAvailableArea', `Found ${candidatePositions.length} candidate positions, using first: ${rectToString(candidatePositions[0])}`)
539+
return candidatePositions[0]
540+
}
541+
542+
// Helper for fallback scanning
543+
function scanForAvailableRect(minWidth: number, minHeight: number, desc: string): Rect | null {
544+
for (let y = 0; y <= screenHeight - minHeight; y += stepSize) {
545+
for (let x = 0; x <= screenWidth - minWidth; x += stepSize) {
546+
const candidateRect: Rect = {
547+
x,
548+
y,
549+
width: minWidth,
550+
height: minHeight,
551+
}
552+
if (rectFitsInScreen(candidateRect) && doesNotOverlapWithExisting(candidateRect)) {
553+
logDebug('findNextClosestAvailableArea', `Found fallback position (${desc}): ${rectToString(candidateRect)}`)
554+
return candidateRect
555+
}
556+
}
557+
}
558+
return null
559+
}
560+
561+
// TODO: ideally we would now try to reduce the requested size in steps, down to the minimum size, find any available space on the screen
562+
563+
// Fallback 1: try to find any available space on the screen
564+
logDebug('findNextClosestAvailableArea', `No candidate positions found, trying first fallback`)
565+
const stepSize = 50 // Check every 50 pixels
566+
let minHeight = requestedHeight > 0 ? requestedHeight : 300
567+
let minWidth = requestedWidth > 0 ? requestedWidth : 300
568+
569+
let fallbackPosition = scanForAvailableRect(
570+
requestedWidth > 0 ? requestedWidth : Math.max(300, screenWidth),
571+
requestedHeight > 0 ? requestedHeight : Math.max(300, screenHeight),
572+
"requested size"
573+
)
574+
if (fallbackPosition) return fallbackPosition
575+
576+
// Fallback 2: reduce from the requested width to minimums, and try to find any available space on the screen
577+
logDebug('findNextClosestAvailableArea', `No candidate positions found, trying second fallback (width)`)
578+
minWidth = MIN_WINDOW_WIDTH
579+
fallbackPosition = scanForAvailableRect(minWidth, minHeight, "minimum width")
580+
if (fallbackPosition) return fallbackPosition
581+
582+
// Fallback 3: reduce from the requested window size to minimums, and try to find any available space on the screen
583+
logDebug('findNextClosestAvailableArea', `No candidate positions found, trying third fallback (width+height)`)
584+
minHeight = MIN_WINDOW_HEIGHT
585+
minWidth = MIN_WINDOW_WIDTH
586+
fallbackPosition = scanForAvailableRect(minWidth, minHeight, "minimum width+height")
587+
if (fallbackPosition) return fallbackPosition
588+
589+
// Last resort: place in top-right corner, constrained to screen
590+
logDebug('findNextClosestAvailableArea', `No candidate positions found, so will use last resort fallback`)
591+
const fallbackRect: Rect = {
592+
x: Math.max(0, screenWidth - (requestedWidth > 0 ? requestedWidth : screenWidth)),
593+
y: 0,
594+
width: requestedWidth > 0 ? Math.min(requestedWidth, screenWidth) : screenWidth,
595+
height: requestedHeight > 0 ? Math.min(requestedHeight, screenHeight) : screenHeight,
596+
}
597+
logWarn('findNextClosestAvailableArea', `Could not find ideal position, using fallback: ${rectToString(fallbackRect)}`)
598+
return fallbackRect
355599
}
356600

357601
/**

jgclark.WindowTools/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ _Please see the [Plugin documentation](https://noteplan.co/plugins/jgclark.Windo
44
<!-- - TODO: Extend to deal with closed main sidebars.
55
- TODO: Can now save a folder as part of a window set. (Note: not yet a particular 'folder view'.) -->
66

7+
## [1.5.0] - 2025-11-30
8+
- **open note in new window** and **open current note in new window** now don't just open the new 'floating' window wherever NP decides, which is often unhelpful. Instead it tries to place it next to, _but not on top of_, existing NP windows. You can turn off this behaviour using the new '
9+
- prevent the **open ... note in new window/split** commands from running on iOS and iPadOS, as they don't have any effect.
10+
711
## [1.4.0] - 2025-11-07
812
### New
913
- New **reset main window** command. This resets the main NP window to default widths, including the main (left) sidebar (requires NP v3.19.2 or later). Alias: /rmw.

jgclark.WindowTools/plugin.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
"plugin.author": "Jonathan Clark",
99
"plugin.url": "https://github.com/NotePlan/plugins/blob/main/jgclark.WindowTools/README.md",
1010
"plugin.changelog": "https://github.com/NotePlan/plugins/blob/main/jgclark.WindowTools/CHANGELOG.md",
11-
"plugin.version": "1.4.0",
11+
"plugin.version": "1.5.0",
1212
"plugin.releaseStatus": "full",
13-
"plugin.lastUpdateInfo": "v1.4.0: New '/reset windows' command.\nNow can save folders andsidebar width in Window Sets, and open to that width (requires NP v3.19.2).\nOther improvements to Window Sets.\nv1.3.0: Improve note and heading pickers. Support for Teamspace notes in /open note... commands. Fix to Window Set selection.\nv1.2.1: update list of plugin windows it knows about\nv1.2.0: new command 'swap splits', plus positioning bug fix\nv1.1.2: Bug fixes.\nv1.1.1: improve migration from previous plugin ('WindowSets')\nv1.1.0: added x-callbacks for /open Window Set, /open note in new split and /open note in new window commands.\nv1.0.0: 3 new commands, and renamed plugin from 'Window Sets'. Also moved 3 window-related commands from 'Note Helpers' plugin.\nv0.4.0: First public release, and requires NP v3.9.8.",
13+
"plugin.lastUpdateInfo": "v1.5.0: Use 'smart placement' when opening new Editor windows in the commands. New setting 'Use Smart Placement?' allows you to turn off this behaviour.\nv1.4.0: New '/reset windows' command.\nNow can save folders andsidebar width in Window Sets, and open to that width (requires NP v3.19.2).\nOther improvements to Window Sets.\nv1.3.0: Improve note and heading pickers. Support for Teamspace notes in /open note... commands. Fix to Window Set selection.\nv1.2.1: update list of plugin windows it knows about\nv1.2.0: new command 'swap splits', plus positioning bug fix\nv1.1.2: Bug fixes.\nv1.1.1: improve migration from previous plugin ('WindowSets')\nv1.1.0: added x-callbacks for /open Window Set, /open note in new split and /open note in new window commands.\nv1.0.0: 3 new commands, and renamed plugin from 'Window Sets'. Also moved 3 window-related commands from 'Note Helpers' plugin.\nv0.4.0: First public release, and requires NP v3.9.8.",
1414
"plugin.dependencies": [],
1515
"plugin.script": "script.js",
1616
"plugin.isRemote": "false",
@@ -284,6 +284,14 @@
284284
"default": 300,
285285
"required": false
286286
},
287+
{
288+
"key": "useSmartPlacement",
289+
"title": "Use smart placement when opening new windows?",
290+
"description": "It set, this will place the window on the screen next to, but not on top of, current open NP windows. It will also use the default editor width (if set above).",
291+
"type": "bool",
292+
"default": true,
293+
"required": true
294+
},
287295
{
288296
"type": "separator"
289297
},

jgclark.WindowTools/src/WTHelpers.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//---------------------------------------------------------------
33
// Helper functions for WindowTools plugin
44
// Jonathan Clark
5-
// last update 2025-11-07 for v1.4.0 by @jgclark
5+
// last update 2025-11-30 for v1.5.0 by @jgclark
66
//---------------------------------------------------------------
77

88
import pluginJson from '../plugin.json'
@@ -93,6 +93,7 @@ export type WindowSetsConfig = {
9393
saveMainSidebarWidth: boolean,
9494
defaultMainSidebarWidth: ?number, // only valid for macOS
9595
defaultEditorWidth: ?number, // only valid for macOS
96+
useSmartPlacement: boolean, // only valid for macOS
9697
_logDebug: string,
9798
}
9899

@@ -136,6 +137,7 @@ export async function getPluginSettings(): Promise<WindowSetsConfig> {
136137
saveMainSidebarWidth: true,
137138
defaultMainSidebarWidth: 250,
138139
defaultEditorWidth: 500,
140+
useSmartPlacement: true,
139141
_logDebug: 'DEBUG',
140142
} // for completeness
141143
}

0 commit comments

Comments
 (0)