From d6b5590c76a16d11e874927e3c0ca4bb9a7e1d69 Mon Sep 17 00:00:00 2001 From: Filip Date: Wed, 24 Sep 2025 22:16:27 +0200 Subject: [PATCH 1/6] Add snapping to multiple grid areas --- application.js | 9 ++++--- node_tree.js | 64 +++++++++++++++++++++++++++++++++++++------- settings-schema.json | 14 +++++++++- window-snapper.js | 12 ++++++--- 4 files changed, 82 insertions(+), 17 deletions(-) diff --git a/application.js b/application.js index 98ca835..e1638be 100644 --- a/application.js +++ b/application.js @@ -269,16 +269,17 @@ class Application { #connectWindowGrabs() { // start snapping when the user starts moving a window this.#signals.connect(global.display, 'grab-op-begin', (display, screen, window, op) => { - if (op === Meta.GrabOp.MOVING && window.window_type === Meta.WindowType.NORMAL) { + if (op === Meta.GrabOp.MOVING && window.window_type === Meta.WindowType.NORMAL) { // reload styling this.#loadThemeColors(); const enableSnappingModifiers = mapModifierSettingToModifierType(this.#settings.settingsData.enableSnappingModifiers.value); - + const enableMultiSnappingModifiers = mapModifierSettingToModifierType(this.#settings.settingsData.enableMultiSnappingModifiers.value); + // Create WindowSnapper for each monitor const nMonitors = global.display.get_n_monitors(); for (let i = 0; i < nMonitors; i++) { const layout = this.#readOrCreateLayoutForDisplay(i, LayoutOf2x2); - const snapper = new WindowSnapper(i, layout, window, enableSnappingModifiers); + const snapper = new WindowSnapper(i, layout, window, enableSnappingModifiers, enableMultiSnappingModifiers); this.#windowSnappers.push(snapper); } } @@ -300,4 +301,4 @@ class Application { } } -module.exports = { Application, LayoutOf2x2 }; \ No newline at end of file +module.exports = { Application, LayoutOf2x2 }; diff --git a/node_tree.js b/node_tree.js index d4409f0..e95f327 100644 --- a/node_tree.js +++ b/node_tree.js @@ -294,6 +294,19 @@ class LayoutNode { return this.children.reduce((found, child) => found || child.findNode(predicate), null); } + // find all node in the tree that matches the given predicate and return them in flat list + findAllNodes(predicate) { + let result = []; + + this.forSelfAndDescendants((n) => { + if(predicate(n)) { + result.push(n); + } + }) + + return result; + } + // delete the given node in the tree if found delete(node) { let index = this.children.indexOf(node); @@ -738,10 +751,12 @@ class PreviewSplitOperation extends LayoutOperation { class SnappingOperation extends LayoutOperation { showRegions = false; #enableSnappingModifiers; + #enableMultiSnappingModifiers; - constructor(tree, enableSnappingModifiers) { + constructor(tree, enableSnappingModifiers, enableMultiSnappingModifiers) { super(tree); this.#enableSnappingModifiers = enableSnappingModifiers; + this.#enableMultiSnappingModifiers = enableMultiSnappingModifiers; } onMotion(x, y, state) { @@ -751,19 +766,28 @@ class SnappingOperation extends LayoutOperation { return this.cancel(); } + const multiSnapEnabled = this.#enableMultiSnappingModifiers.some((e) => (state & e)); + // Find node at mouse position let node = this.tree.findNodeAtPosition(x, y); if (!node) { - return this.cancel(); + if(!multiSnapEnabled){ + return this.cancel(); + } + + return OperationResult.notHandled(); } // activate the region to snap into this.showRegions = true; - this.tree.forSelfAndDescendants(n => { - n.isSnappingDestination = false; - n.isHighlighted = false; - }); + if(!multiSnapEnabled) { + this.tree.forSelfAndDescendants(n => { + n.isSnappingDestination = false; + n.isHighlighted = false; + }); + } + node.isSnappingDestination = true; node.isHighlighted = true; @@ -771,11 +795,33 @@ class SnappingOperation extends LayoutOperation { } currentSnapToRect() { - var snapToNode = this.tree.findNode(n => n.isSnappingDestination); - if (!snapToNode) { + let snapToNodes = this.tree.findAllNodes(n => n.isSnappingDestination); + + if (snapToNodes.length == 0) { return null; } - return snapToNode.snapRect(); + + return snapToNodes + .map((n) => n.snapRect()) + .reduce((rect_a, rect_b) => { + let min_x = Math.min(rect_a.x, rect_b.x); + let min_y = Math.min(rect_a.y, rect_b.y); + let max_x = Math.max( + rect_a.x + rect_a.width, + rect_b.x + rect_b.width, + ); + let max_y = Math.max( + rect_a.y + rect_a.height, + rect_b.y + rect_b.height, + ); + + return { + x: min_x, + y: min_y, + width: max_x - min_x, + height: max_y - min_y, + }; + }); } cancel() { diff --git a/settings-schema.json b/settings-schema.json index 1964a39..9074e5d 100644 --- a/settings-schema.json +++ b/settings-schema.json @@ -15,5 +15,17 @@ "SUPER": "SUPER", "SHIFT": "SHIFT" } + }, + "enableMultiSnappingModifiers": { + "type": "combobox", + "description": "Key modifier required to activate snapping to multiple arias", + "default": "", + "options": { + "(disabled)": "", + "CTRL": "CTRL", + "ALT": "ALT", + "SUPER": "SUPER", + "SHIFT": "SHIFT" + } } -} \ No newline at end of file +} diff --git a/window-snapper.js b/window-snapper.js index 84eeff9..15d8dbb 100644 --- a/window-snapper.js +++ b/window-snapper.js @@ -30,9 +30,12 @@ class WindowSnapper { // the modifier key to enable snapping #enableSnappingModifiers; + // the modifier key to enable snapping to multiple areas + #enableMultiSnappingModifiers; + #signals = new SignalManager.SignalManager(null); - constructor(displayIdx, layout, window, enableSnappingModifiers) { + constructor(displayIdx, layout, window, enableSnappingModifiers, enableMultiSnappingModifiers) { // the layout to use for the snapping operation this.#layout = layout; @@ -42,6 +45,9 @@ class WindowSnapper { // the modifier key to enable snapping this.#enableSnappingModifiers = enableSnappingModifiers; + // the modifier key to enable snapping to multiple areas + this.#enableMultiSnappingModifiers = enableMultiSnappingModifiers; + // get the size of the display let workArea = getUsableScreenArea(displayIdx); @@ -65,7 +71,7 @@ class WindowSnapper { // ensure the layout is correct for the snap area this.#layout.calculateRects(workArea.x, workArea.y, workArea.width, workArea.height); - this.#snappingOperation = new SnappingOperation(this.#layout, this.#enableSnappingModifiers); + this.#snappingOperation = new SnappingOperation(this.#layout, this.#enableSnappingModifiers, this.#enableMultiSnappingModifiers); this.#signals.connect(this.#window, 'position-changed', this.#onWindowMoved.bind(this)); } @@ -134,4 +140,4 @@ class WindowSnapper { } } -module.exports = { WindowSnapper }; \ No newline at end of file +module.exports = { WindowSnapper }; From ddce3bb9e0c29a150bcd524eead03ff817b022f8 Mon Sep 17 00:00:00 2001 From: lutechi Date: Wed, 1 Oct 2025 21:30:59 -0400 Subject: [PATCH 2/6] Add zone numbering with live dimensions and text shadows --- README.md | 8 +++++-- drawing.js | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 76d833b..aa741e6 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,17 @@ Open Cinnamon Extensions, click on the Fancy Tiles extension and click the '+' b ## Quick start -After enabling the extension, press `+G` to open the layout editor. It will start by a 2x2 grid layout. Click and drag the dividers (the lines between regions) to resize the regions. If you want to split a region, press `` or `` while hovering over the region to split the region horizontally or vertically. Use the `right mouse button` to remove dividers. Use `` and `` to increase or decrease the spacing between the regions. +After enabling the extension, press `+G` to open the layout editor. It will start with a 2x2 grid layout. Click and drag the dividers (the lines between regions) to resize the regions. If you want to split a region, press `` or `` while hovering over the region to split the region horizontally or vertically. Use the `right mouse button` to remove dividers. Use `` and `` to increase or decrease the spacing between the regions. After you have crafted your desired layout, exit the editor using `+G` or ``. ![Layout editor](docs/layout-editor.png) -Now, start dragging a window and simultaneously hold the `` key. The layout will become visible. Hover your mose over the region you want the window to snap to and release the mouse button. The window will now be snapped into place. +Now, start dragging a window and simultaneously hold the `` key. The layout will become visible. Hover your mouse over the region you want the window to snap to and release the mouse button. The window will now be snapped into place. + +### Multi-zone spanning + +Windows can now span across multiple adjacent regions. When dragging a window near the edge between two adjacent regions, both regions will be highlighted, and the window will snap to cover the combined area of both regions. ![Layout editor](docs/window-snapping.png) diff --git a/drawing.js b/drawing.js index 18cc04e..7dd2692 100644 --- a/drawing.js +++ b/drawing.js @@ -74,7 +74,7 @@ function addMargins(rect, margin) { } } -function drawLayout(cr, node, displayRect, colors = DefaultColors, cornerRadius = 10) { +function drawLayout(cr, node, displayRect, colors = DefaultColors, cornerRadius = 10, zoneCounter = { count: 0 }) { if (!node) return; // Draw current node @@ -97,10 +97,71 @@ function drawLayout(cr, node, displayRect, colors = DefaultColors, cornerRadius let regionRect = addMargins({ x, y, width, height }, node.margin); drawRoundedRect(cr, regionRect, cornerRadius, c, colors.border); + + // Increment and draw zone number with size + zoneCounter.count++; + const zoneNumber = zoneCounter.count; + + // Draw zone number and size (width x height in pixels) - use regionRect for live updates + const sizeText = `${Math.round(regionRect.width)}x${Math.round(regionRect.height)}`; + + // Standard uniform font sizes - scale down only if zone is too small + const baseNumberSize = 72; // doubled for 4K visibility + const baseSizeTextSize = 36; // doubled for 4K visibility + const minDimension = Math.min(regionRect.width, regionRect.height); + const scaleFactor = Math.min(1, minDimension / 200); // scale down if smaller than 200px + + const numberFontSize = baseNumberSize * scaleFactor; + const sizeFontSize = baseSizeTextSize * scaleFactor; + + cr.setSourceRGBA(colors.border.r, colors.border.g, colors.border.b, 1); + cr.selectFontFace('Sans', 0, 1); // Cairo.FontSlant.NORMAL, Cairo.FontWeight.BOLD + + // Measure text to center it + cr.setFontSize(numberFontSize); + const numberExtents = cr.textExtents(zoneNumber.toString()); + + cr.setFontSize(sizeFontSize); + const sizeExtents = cr.textExtents(sizeText); + + // Calculate center position + const centerX = regionRect.x + regionRect.width / 2; + const centerY = regionRect.y + regionRect.height / 2; + const totalHeight = numberExtents.height + sizeExtents.height + 15; // 15px spacing (increased) + + // Draw zone number with shadow for readability (centered, larger) + cr.setFontSize(numberFontSize); + const numberX = centerX - numberExtents.width / 2; + const numberY = centerY - totalHeight / 2 + numberExtents.height; + + // Shadow/outline for zone number + cr.setSourceRGBA(0, 0, 0, 0.8); // dark shadow + cr.moveTo(numberX + 2, numberY + 2); + cr.showText(zoneNumber.toString()); + + // Main zone number text + cr.setSourceRGBA(colors.border.r, colors.border.g, colors.border.b, 1); + cr.moveTo(numberX, numberY); + cr.showText(zoneNumber.toString()); + + // Draw size with shadow for readability (centered, smaller, below number) + cr.setFontSize(sizeFontSize); + const sizeX = centerX - sizeExtents.width / 2; + const sizeY = centerY + totalHeight / 2; + + // Shadow/outline for size text + cr.setSourceRGBA(0, 0, 0, 0.8); // dark shadow + cr.moveTo(sizeX + 2, sizeY + 2); + cr.showText(sizeText); + + // Main size text + cr.setSourceRGBA(colors.border.r, colors.border.g, colors.border.b, 1); + cr.moveTo(sizeX, sizeY); + cr.showText(sizeText); } for (let child of node.children) { - drawLayout(cr, child, displayRect, colors, cornerRadius); + drawLayout(cr, child, displayRect, colors, cornerRadius, zoneCounter); } } From a01ff6663850d8b096f15fb6fd4ccb07cb888b7c Mon Sep 17 00:00:00 2001 From: lutechi Date: Wed, 1 Oct 2025 21:31:57 -0400 Subject: [PATCH 3/6] Add multi-zone window spanning feature --- node_tree.js | 135 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 124 insertions(+), 11 deletions(-) diff --git a/node_tree.js b/node_tree.js index d4409f0..be202d3 100644 --- a/node_tree.js +++ b/node_tree.js @@ -327,6 +327,63 @@ class LayoutNode { return this.children.reduce((found, child) => found || child.findNodeAtPosition(x, y), null); } + // find all leaf nodes near the given position (for multi-zone spanning) + findNodesNearPosition(x, y, threshold = 60) { + let nearbyNodes = []; + let insideNodes = []; + + this.forSelfAndDescendants(node => { + if (!node.isLeaf()) return; + + // Check if inside the node (highest priority) + let insideX = x >= node.rect.x && x <= node.rect.x + node.rect.width; + let insideY = y >= node.rect.y && y <= node.rect.y + node.rect.height; + + if (insideX && insideY) { + insideNodes.push(node); + return; + } + + // Check if position is within threshold of any edge + let nearLeft = Math.abs(x - node.rect.x) <= threshold; + let nearRight = Math.abs(x - (node.rect.x + node.rect.width)) <= threshold; + let nearTop = Math.abs(y - node.rect.y) <= threshold; + let nearBottom = Math.abs(y - (node.rect.y + node.rect.height)) <= threshold; + + // Include node if position is near edges (but prioritize inside nodes) + if ((insideX || nearLeft || nearRight) && (insideY || nearTop || nearBottom)) { + nearbyNodes.push(node); + } + }); + + // Return inside nodes with highest priority, then nearby nodes + return insideNodes.length > 0 ? [...insideNodes, ...nearbyNodes] : nearbyNodes; + } + + // check if two nodes are adjacent (share an edge) + areNodesAdjacent(node1, node2) { + if (!node1 || !node2) return false; + + let tolerance = 15; + + // Check horizontal adjacency (left/right) + let horizontalOverlap = !(node1.rect.y + node1.rect.height <= node2.rect.y + tolerance || + node2.rect.y + node2.rect.height <= node1.rect.y + tolerance); + + let node1RightOfNode2 = Math.abs(node1.rect.x - (node2.rect.x + node2.rect.width)) <= tolerance; + let node2RightOfNode1 = Math.abs(node2.rect.x - (node1.rect.x + node1.rect.width)) <= tolerance; + + // Check vertical adjacency (top/bottom) + let verticalOverlap = !(node1.rect.x + node1.rect.width <= node2.rect.x + tolerance || + node2.rect.x + node2.rect.width <= node1.rect.x + tolerance); + + let node1BelowNode2 = Math.abs(node1.rect.y - (node2.rect.y + node2.rect.height)) <= tolerance; + let node2BelowNode1 = Math.abs(node2.rect.y - (node1.rect.y + node1.rect.height)) <= tolerance; + + return (horizontalOverlap && (node1RightOfNode2 || node2RightOfNode1)) || + (verticalOverlap && (node1BelowNode2 || node2BelowNode1)); + } + // get the rectangle of the divider for this node, useful for grabbing and moving the divider getDividerRect(dividerWidth) { dividerWidth = Math.max(dividerWidth, 2 * this.margin); @@ -751,31 +808,87 @@ class SnappingOperation extends LayoutOperation { return this.cancel(); } - // Find node at mouse position - let node = this.tree.findNodeAtPosition(x, y); - if (!node) { + // Find nodes near mouse position for multi-zone spanning + let nearbyNodes = this.tree.findNodesNearPosition(x, y); + if (!nearbyNodes || nearbyNodes.length === 0) { return this.cancel(); } - // activate the region to snap into - this.showRegions = true; - + // Reset all snapping destinations and highlighting this.tree.forSelfAndDescendants(n => { n.isSnappingDestination = false; n.isHighlighted = false; }); - node.isSnappingDestination = true; - node.isHighlighted = true; + // For multi-zone spanning, check if nodes are adjacent + if (nearbyNodes.length > 1) { + let adjacentNodes = []; + let mainNode = nearbyNodes[0]; // The primary node + adjacentNodes.push(mainNode); + + // Find all nodes adjacent to the main node + for (let i = 1; i < nearbyNodes.length; i++) { + if (this.tree.areNodesAdjacent(mainNode, nearbyNodes[i])) { + adjacentNodes.push(nearbyNodes[i]); + } + } + + // Set all adjacent nodes as snapping destinations + adjacentNodes.forEach(node => { + node.isSnappingDestination = true; + node.isHighlighted = true; + }); + } else { + // Single zone + nearbyNodes[0].isSnappingDestination = true; + nearbyNodes[0].isHighlighted = true; + } + + this.showRegions = true; return OperationResult.handledAndRedraw(); } currentSnapToRect() { - var snapToNode = this.tree.findNode(n => n.isSnappingDestination); - if (!snapToNode) { + // Find all nodes that are snapping destinations + let snapToNodes = []; + this.tree.forSelfAndDescendants(n => { + if (n.isSnappingDestination) { + snapToNodes.push(n); + } + }); + + if (snapToNodes.length === 0) { return null; } - return snapToNode.snapRect(); + + if (snapToNodes.length === 1) { + // Single zone + return snapToNodes[0].snapRect(); + } + + // Multiple zones - calculate combined rectangle + let combinedRect = null; + snapToNodes.forEach(node => { + let nodeRect = node.snapRect(); + if (!combinedRect) { + combinedRect = { ...nodeRect }; + } else { + // Expand combined rectangle to include this node + let left = Math.min(combinedRect.x, nodeRect.x); + let top = Math.min(combinedRect.y, nodeRect.y); + let right = Math.max(combinedRect.x + combinedRect.width, nodeRect.x + nodeRect.width); + let bottom = Math.max(combinedRect.y + combinedRect.height, nodeRect.y + nodeRect.height); + + combinedRect = { + x: left, + y: top, + width: right - left, + height: bottom - top + }; + } + }); + + return combinedRect; } cancel() { From 9fe22c75a30ee1680b39d9bff443273e7022c7c3 Mon Sep 17 00:00:00 2001 From: Bas Geertsema Date: Mon, 6 Oct 2025 12:36:24 +0200 Subject: [PATCH 4/6] Added visual window merging --- drawing.js | 125 ++++++++++++++++++++------- node_tree.js | 34 +++++++- shapes.js | 234 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 359 insertions(+), 34 deletions(-) create mode 100644 shapes.js diff --git a/drawing.js b/drawing.js index 18cc04e..3b856da 100644 --- a/drawing.js +++ b/drawing.js @@ -1,8 +1,6 @@ // this module contains functionality to draw the layout of a node tree // on a given Cairo context -const TAU = Math.PI * 2; - // blueish default / fallback colors const DefaultColors = { background: { @@ -25,41 +23,89 @@ const DefaultColors = { } } -function drawRoundedRect(cr, rect, radius, fillColor, strokeColor) { - let { x, y, width, height } = rect; - - let drawPath = function () { - // Start a new path for the rounded rectangle - cr.newPath(); - - // Move to starting point - cr.moveTo(x + radius, y); +const { buildDifferencePath, polygonArea } = require('./shapes'); - // Top edge and top-right corner - cr.lineTo(x + width - radius, y); - cr.arc(x + width - radius, y + radius, radius, -TAU / 4, 0); +function drawRoundedRect(cr, rect, radius, fillColor, strokeColor, excludedRect) { + const pathPoints = buildDifferencePath(rect, excludedRect); + if (!pathPoints || pathPoints.length < 3) { + return; + } - // Right edge and bottom-right corner - cr.lineTo(x + width, y + height - radius); - cr.arc(x + width - radius, y + height - radius, radius, 0, TAU / 4); + const drawPath = function () { + cr.newPath(); - // Bottom edge and bottom-left corner - cr.lineTo(x + radius, y + height); - cr.arc(x + radius, y + height - radius, radius, TAU / 4, TAU / 2); + let polygon = pathPoints.slice(); + if (polygonArea(polygon) < 0) { + polygon = polygon.reverse(); + } - // Left edge and top-left corner - cr.lineTo(x, y + radius); - cr.arc(x + radius, y + radius, radius, TAU / 2, TAU * 3 / 4); + const pointCount = polygon.length; + const cornerRadiusInput = Math.max(0, radius); + + for (let i = 0; i < pointCount; i++) { + const prev = polygon[(i - 1 + pointCount) % pointCount]; + const current = polygon[i]; + const next = polygon[(i + 1) % pointCount]; + + let dirIn = { x: current.x - prev.x, y: current.y - prev.y }; + let dirOut = { x: next.x - current.x, y: next.y - current.y }; + + const lenIn = Math.hypot(dirIn.x, dirIn.y); + const lenOut = Math.hypot(dirOut.x, dirOut.y); + + if (lenIn === 0 || lenOut === 0) { + continue; + } + + dirIn = { x: dirIn.x / lenIn, y: dirIn.y / lenIn }; + dirOut = { x: dirOut.x / lenOut, y: dirOut.y / lenOut }; + + const cornerRadius = Math.min(cornerRadiusInput, lenIn / 2, lenOut / 2); + // trim the straight segments so the arc touches correct tangents + const startPoint = { + x: current.x - dirIn.x * cornerRadius, + y: current.y - dirIn.y * cornerRadius + }; + const endPoint = { + x: current.x + dirOut.x * cornerRadius, + y: current.y + dirOut.y * cornerRadius + }; + + if (i === 0) { + cr.moveTo(startPoint.x, startPoint.y); + } else { + cr.lineTo(startPoint.x, startPoint.y); + } + + const turn = dirIn.x * dirOut.y - dirIn.y * dirOut.x; + + if (cornerRadius > 1e-6 && Math.abs(turn) > 1e-6) { + // center sits in the quadrant spanned by dirIn/dirOut; sign of turn decides arc direction + const center = { + x: current.x - dirIn.x * cornerRadius + dirOut.x * cornerRadius, + y: current.y - dirIn.y * cornerRadius + dirOut.y * cornerRadius + }; + const startAngle = Math.atan2(startPoint.y - center.y, startPoint.x - center.x); + const endAngle = Math.atan2(endPoint.y - center.y, endPoint.x - center.x); + + if (turn > 0) { + cr.arc(center.x, center.y, cornerRadius, startAngle, endAngle); + } else { + cr.arcNegative(center.x, center.y, cornerRadius, startAngle, endAngle); + } + } else { + // no curved corner here; continue with straight run + cr.lineTo(endPoint.x, endPoint.y); + } + } cr.closePath(); - } + }; - // fill the region cr.setSourceRGBA(fillColor.r, fillColor.g, fillColor.b, fillColor.a); drawPath(); cr.fill(); - // draw the border cr.setSourceRGBA(strokeColor.r, strokeColor.g, strokeColor.b, strokeColor.a); drawPath(); cr.stroke(); @@ -74,11 +120,11 @@ function addMargins(rect, margin) { } } -function drawLayout(cr, node, displayRect, colors = DefaultColors, cornerRadius = 10) { +function drawLayout(cr, node, displayRect, colors = DefaultColors, cornerRadius = 10, cutoutRect = null) { if (!node) return; // Draw current node - let rect = node.rect; + let rect = node.rect; // Offset by monitor displayRect let x = rect.x - displayRect.x; @@ -86,6 +132,16 @@ function drawLayout(cr, node, displayRect, colors = DefaultColors, cornerRadius let width = rect.width; let height = rect.height; + // do we have a cutout for all children? + if(node.insetNode && !cutoutRect){ + cutoutRect = addMargins({ + x: node.insetNode.rect.x - displayRect.x, + y: node.insetNode.rect.y - displayRect.y, + width: node.insetNode.rect.width, + height: node.insetNode.rect.height + }, -node.margin*2); + } + // draw the region of a leaf node if (node.isLeaf()) { let c = colors.background; @@ -95,16 +151,21 @@ function drawLayout(cr, node, displayRect, colors = DefaultColors, cornerRadius } cr.setSourceRGBA(c.r, c.g, c.b, c.a); - let regionRect = addMargins({ x, y, width, height }, node.margin); - drawRoundedRect(cr, regionRect, cornerRadius, c, colors.border); + let regionRect = addMargins({ x, y, width, height }, node.margin); + drawRoundedRect(cr, regionRect, cornerRadius, c, colors.border, cutoutRect); } for (let child of node.children) { - drawLayout(cr, child, displayRect, colors, cornerRadius); + drawLayout(cr, child, displayRect, colors, cornerRadius, cutoutRect); + } + + // draw the cutout once here + if (node.insetNode) { + drawLayout(cr, node.insetNode, displayRect, colors, cornerRadius, null); } } module.exports = { drawLayout, DefaultColors -}; \ No newline at end of file +}; diff --git a/node_tree.js b/node_tree.js index e95f327..b08a014 100644 --- a/node_tree.js +++ b/node_tree.js @@ -60,6 +60,11 @@ class LayoutNode { // the on-screen rectangle covering the region of the node rect = { x: 0, y: 0, width: 0, height: 0 }; + // the inset node is a fixed node that is used as a cutout for this node + // and its descendants when drawing. in the resulting cutout, the node is drawn. + // this functionality is used to draw the cutout for multi-snapping. + insetNode = null; + // isResizing indicates if the divider belonging to this node is being moved by the user isResizing = false; @@ -786,6 +791,17 @@ class SnappingOperation extends LayoutOperation { n.isSnappingDestination = false; n.isHighlighted = false; }); + this.tree.insetNode = null; + } else { + const multisnapRect = this.multisnapRect(); + if(!this.tree.insetNode){ + this.tree.insetNode = new LayoutNode(0); + this.tree.insetNode.parent = this.tree; + this.tree.insetNode.margin = node.margin; + this.tree.insetNode.isSnappingDestination = true; + this.tree.insetNode.isHighlighted = true; + } + this.tree.insetNode.rect = multisnapRect; } node.isSnappingDestination = true; @@ -794,7 +810,7 @@ class SnappingOperation extends LayoutOperation { return OperationResult.handledAndRedraw(); } - currentSnapToRect() { + multisnapRect() { let snapToNodes = this.tree.findAllNodes(n => n.isSnappingDestination); if (snapToNodes.length == 0) { @@ -802,7 +818,7 @@ class SnappingOperation extends LayoutOperation { } return snapToNodes - .map((n) => n.snapRect()) + .map((n) => n.rect) .reduce((rect_a, rect_b) => { let min_x = Math.min(rect_a.x, rect_b.x); let min_y = Math.min(rect_a.y, rect_b.y); @@ -824,7 +840,21 @@ class SnappingOperation extends LayoutOperation { }); } + currentSnapToRect() { + if(this.tree.insetNode){ + return this.tree.insetNode.snapRect(); + } + + const snapToNode = this.tree.findNode(n => n.isSnappingDestination); + if (!snapToNode) { + return null; + } + return snapToNode.snapRect(); + } + cancel() { + this.tree.insetNode = null; + if (this.showRegions) { this.showRegions = false; this.tree.forSelfAndDescendants(n => { diff --git a/shapes.js b/shapes.js new file mode 100644 index 0000000..6d6adc1 --- /dev/null +++ b/shapes.js @@ -0,0 +1,234 @@ +// helper utilities to build polygon paths used for drawing + +// quantize floating point coordinates so we can safely use them as map keys +const COORD_PRECISION = 1e6; + +function toKeyCoord(value) { + return Math.round(value * COORD_PRECISION); +} + +// collect unique finite values while preserving numeric ordering +function uniqueSorted(values) { + const seen = new Set(); + const unique = []; + for (const value of values) { + if (!Number.isFinite(value)) continue; + const key = toKeyCoord(value); + if (seen.has(key)) continue; + seen.add(key); + unique.push(value); + } + unique.sort((a, b) => a - b); + return unique; +} + +function pointKey(point) { + return `${toKeyCoord(point.x)}:${toKeyCoord(point.y)}`; +} + +function edgeKey(x1, y1, x2, y2) { + const ax = toKeyCoord(x1); + const ay = toKeyCoord(y1); + const bx = toKeyCoord(x2); + const by = toKeyCoord(y2); + const minX = Math.min(ax, bx); + const minY = Math.min(ay, by); + const maxX = Math.max(ax, bx); + const maxY = Math.max(ay, by); + return `${minX}:${minY}:${maxX}:${maxY}`; +} + +function orientedEdgeKey(edge) { + return `${pointKey(edge.start)}->${pointKey(edge.end)}`; +} + +function polygonArea(points) { + if (!points || points.length < 3) return 0; + let area = 0; + for (let i = 0; i < points.length; i++) { + const { x: x1, y: y1 } = points[i]; + const { x: x2, y: y2 } = points[(i + 1) % points.length]; + area += x1 * y2 - x2 * y1; + } + return area * 0.5; +} + +// return the axis-aligned portion shared by both rectangles (if any) +function computeIntersection(rect, excludedRect) { + if (!rect || !excludedRect) return null; + + const left = Math.max(rect.x, excludedRect.x); + const right = Math.min(rect.x + rect.width, excludedRect.x + excludedRect.width); + const top = Math.max(rect.y, excludedRect.y); + const bottom = Math.min(rect.y + rect.height, excludedRect.y + excludedRect.height); + + if (right <= left || bottom <= top) return null; + + return { + x: left, + y: top, + width: right - left, + height: bottom - top + }; +} + +// create a clockwise polygon for rect minus the overlapping part of excludedRect +function buildDifferencePath(rect, excludedRect) { + if (!rect) return []; + + const rectLeft = rect.x; + const rectRight = rect.x + rect.width; + const rectTop = rect.y; + const rectBottom = rect.y + rect.height; + + if (!(rectRight > rectLeft) || !(rectBottom > rectTop)) { + return []; + } + + const intersection = computeIntersection(rect, excludedRect); + + if (!intersection) { + return [ + { x: rectLeft, y: rectTop }, + { x: rectRight, y: rectTop }, + { x: rectRight, y: rectBottom }, + { x: rectLeft, y: rectBottom } + ]; + } + + // slice the area into a minimal grid defined by unique X/Y breakpoints + const xs = uniqueSorted([rectLeft, rectRight, intersection.x, intersection.x + intersection.width]); + const ys = uniqueSorted([rectTop, rectBottom, intersection.y, intersection.y + intersection.height]); + + const cells = []; + + for (let xi = 0; xi < xs.length - 1; xi++) { + const x0 = xs[xi]; + const x1 = xs[xi + 1]; + if (x1 <= x0) continue; + const cx = (x0 + x1) / 2; + + for (let yi = 0; yi < ys.length - 1; yi++) { + const y0 = ys[yi]; + const y1 = ys[yi + 1]; + if (y1 <= y0) continue; + const cy = (y0 + y1) / 2; + + const insideRect = cx >= rectLeft && cx <= rectRight && cy >= rectTop && cy <= rectBottom; + const insideIntersection = cx >= intersection.x && cx <= intersection.x + intersection.width && + cy >= intersection.y && cy <= intersection.y + intersection.height; + + // retain cells that belong to rect but not the overlapped region + if (insideRect && !insideIntersection) { + cells.push({ x0, x1, y0, y1 }); + } + } + } + + if (cells.length === 0) { + return []; + } + + const edgeMap = new Map(); + + // add rectangle edges, removing pairs that are shared between adjacent cells + function addEdge(x1, y1, x2, y2) { + if (Math.abs(x1 - x2) < 1e-7 && Math.abs(y1 - y2) < 1e-7) { + return; + } + const key = edgeKey(x1, y1, x2, y2); + if (edgeMap.has(key)) { + edgeMap.delete(key); + } else { + edgeMap.set(key, { + start: { x: x1, y: y1 }, + end: { x: x2, y: y2 } + }); + } + } + + for (const cell of cells) { + addEdge(cell.x0, cell.y0, cell.x1, cell.y0); + addEdge(cell.x1, cell.y0, cell.x1, cell.y1); + addEdge(cell.x1, cell.y1, cell.x0, cell.y1); + addEdge(cell.x0, cell.y1, cell.x0, cell.y0); + } + + const edges = Array.from(edgeMap.values()); + if (edges.length === 0) { + return []; + } + + const edgesByStart = new Map(); + // bucket edges by start vertex so we can follow connected boundary segments + for (const edge of edges) { + const startKey = pointKey(edge.start); + if (!edgesByStart.has(startKey)) { + edgesByStart.set(startKey, []); + } + edgesByStart.get(startKey).push(edge); + } + + const visited = new Set(); + let bestLoop = null; + let bestArea = -Infinity; + + // walk every possible loop and keep the one with the largest area (outer boundary) + for (const edge of edges) { + const firstKey = orientedEdgeKey(edge); + if (visited.has(firstKey)) continue; + + const loop = []; + let currentEdge = edge; + const loopStartKey = pointKey(edge.start); + + while (true) { + const currentKey = orientedEdgeKey(currentEdge); + if (visited.has(currentKey)) break; + visited.add(currentKey); + loop.push({ x: currentEdge.start.x, y: currentEdge.start.y }); + + const nextKey = pointKey(currentEdge.end); + if (nextKey === loopStartKey) { + break; + } + + const candidates = edgesByStart.get(nextKey); + if (!candidates || candidates.length === 0) { + loop.length = 0; + break; + } + + let nextEdge = null; + for (const candidate of candidates) { + const candidateKey = orientedEdgeKey(candidate); + if (!visited.has(candidateKey)) { + nextEdge = candidate; + break; + } + } + + if (!nextEdge) { + loop.length = 0; + break; + } + + currentEdge = nextEdge; + } + + if (loop.length > 0) { + const area = Math.abs(polygonArea(loop)); + if (area > bestArea) { + bestArea = area; + bestLoop = loop; + } + } + } + + return bestLoop || []; +} + +module.exports = { + buildDifferencePath, + polygonArea +}; From 9371e6e936580388578bfcb7b65eb2218303721c Mon Sep 17 00:00:00 2001 From: Bas Geertsema Date: Mon, 6 Oct 2025 12:53:44 +0200 Subject: [PATCH 5/6] Add adjacent hover setting --- application.js | 3 ++- node_tree.js | 6 ++++-- settings-schema.json | 6 ++++++ window-snapper.js | 10 +++++++++- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/application.js b/application.js index e1638be..6cec5ed 100644 --- a/application.js +++ b/application.js @@ -274,12 +274,13 @@ class Application { this.#loadThemeColors(); const enableSnappingModifiers = mapModifierSettingToModifierType(this.#settings.settingsData.enableSnappingModifiers.value); const enableMultiSnappingModifiers = mapModifierSettingToModifierType(this.#settings.settingsData.enableMultiSnappingModifiers.value); + const enableMergeAdjacentOnHover = this.#settings.settingsData.mergeAdjacentOnHover.value; // Create WindowSnapper for each monitor const nMonitors = global.display.get_n_monitors(); for (let i = 0; i < nMonitors; i++) { const layout = this.#readOrCreateLayoutForDisplay(i, LayoutOf2x2); - const snapper = new WindowSnapper(i, layout, window, enableSnappingModifiers, enableMultiSnappingModifiers); + const snapper = new WindowSnapper(i, layout, window, enableSnappingModifiers, enableMultiSnappingModifiers, enableMergeAdjacentOnHover); this.#windowSnappers.push(snapper); } } diff --git a/node_tree.js b/node_tree.js index b08a014..542462e 100644 --- a/node_tree.js +++ b/node_tree.js @@ -757,11 +757,13 @@ class SnappingOperation extends LayoutOperation { showRegions = false; #enableSnappingModifiers; #enableMultiSnappingModifiers; - - constructor(tree, enableSnappingModifiers, enableMultiSnappingModifiers) { + #enableAdjacentMerging; + + constructor(tree, enableSnappingModifiers, enableMultiSnappingModifiers, enableAdjacentMerging) { super(tree); this.#enableSnappingModifiers = enableSnappingModifiers; this.#enableMultiSnappingModifiers = enableMultiSnappingModifiers; + this.#enableAdjacentMerging = enableAdjacentMerging; } onMotion(x, y, state) { diff --git a/settings-schema.json b/settings-schema.json index 9074e5d..fe5436f 100644 --- a/settings-schema.json +++ b/settings-schema.json @@ -27,5 +27,11 @@ "SUPER": "SUPER", "SHIFT": "SHIFT" } + }, + "mergeAdjacentOnHover": { + "type": "switch", + "description": "Merge adjacent regions when hovering over the shared border", + "tooltip": "When enabled, adjacent regions will be merged when hovering over the shared border. When disabled, adjacent regions will not be merged.", + "default": true } } diff --git a/window-snapper.js b/window-snapper.js index 15d8dbb..c333163 100644 --- a/window-snapper.js +++ b/window-snapper.js @@ -33,9 +33,12 @@ class WindowSnapper { // the modifier key to enable snapping to multiple areas #enableMultiSnappingModifiers; + // whether to merge adjacent regions when hovering over the shared border + #enableAdjacentMerging; + #signals = new SignalManager.SignalManager(null); - constructor(displayIdx, layout, window, enableSnappingModifiers, enableMultiSnappingModifiers) { + constructor(displayIdx, layout, window, enableSnappingModifiers, enableMultiSnappingModifiers, enableAdjacentMerging) { // the layout to use for the snapping operation this.#layout = layout; @@ -48,6 +51,11 @@ class WindowSnapper { // the modifier key to enable snapping to multiple areas this.#enableMultiSnappingModifiers = enableMultiSnappingModifiers; + // whether to merge adjacent regions when hovering over the shared border + this.#enableAdjacentMerging = enableAdjacentMerging; + + console.log('enableAdjacentMerging', enableAdjacentMerging); + // get the size of the display let workArea = getUsableScreenArea(displayIdx); From 04bbb6d27181d5650bf5e3af73ac3e17a10026b1 Mon Sep 17 00:00:00 2001 From: Bas Geertsema Date: Mon, 6 Oct 2025 14:57:54 +0200 Subject: [PATCH 6/6] Removing dead code and clean up --- application.js | 10 +++--- node_tree.js | 98 ++++++++++++++++++++------------------------------ 2 files changed, 43 insertions(+), 65 deletions(-) diff --git a/application.js b/application.js index 7f5f906..52e8756 100644 --- a/application.js +++ b/application.js @@ -75,12 +75,12 @@ function mapModifierSettingToModifierType(modifierSetting) { return [Clutter.ModifierType.SUPER_MASK, Clutter.ModifierType.MOD4_MASK]; case 'SHIFT': return [Clutter.ModifierType.SHIFT_MASK]; - default: + default: return []; } } -// The application class is only constructed once and is the main entry +// The application class is only constructed once and is the main entry // of the extension. class Application { // the active grid editor @@ -132,7 +132,7 @@ class Application { } #loadThemeColors() { - // hidden element to fetch the styling + // hidden element to fetch the styling let stylingActor = new St.DrawingArea({ style_class: 'tile-preview tile-hud', visible: false @@ -182,7 +182,7 @@ class Application { } #enableHotkey() { - this.#disableHotkey(); + this.#disableHotkey(); Main.keybindingManager.addHotKey('fancytiles', this.#settings.settingsData.hotkey.value, this.#toggleEditor.bind(this)); } @@ -303,4 +303,4 @@ class Application { } } -module.exports = { Application, LayoutOf2x2 }; +module.exports = { Application, LayoutOf2x2 }; diff --git a/node_tree.js b/node_tree.js index ac3e800..4920ed4 100644 --- a/node_tree.js +++ b/node_tree.js @@ -1,33 +1,33 @@ // A layout is a tree data structure that represents the layout of snapping regions // on a display. Each node in the tree represents a region. -// The internal nodes represent a partitioning (either row or column oriented) and +// The internal nodes represent a partitioning (either row or column oriented) and // the leaf nodes represent the region for a single window to snap into. // // The internal nodes, that partition the cell into multiple rows or columns, // can contain two or more child nodes. These child nodes can either be // another internal node or a leaf node. -// -// Each node holds a percentage value that represents the bottom edge (y-value) or +// +// Each node holds a percentage value that represents the bottom edge (y-value) or // right edge (x-value) of the cell as a fraction of the display width (dw) or display height (dh) -// The percentages are NOT a fraction of the width or height of the parent node. Percentages are -// used instead of absolute pixel values to allow the same layout to be used on displays with +// The percentages are NOT a fraction of the width or height of the parent node. Percentages are +// used instead of absolute pixel values to allow the same layout to be used on displays with // different resolutions. // -// NEGATIVE percentages are defined as going along the y-axis (row partitioning) and +// NEGATIVE percentages are defined as going along the y-axis (row partitioning) and // POSITIVE percentages are defined as going along the x-axis (column partitioning). -// Another perspective on this is that the x axis is positive to the right and the -// y axis is negative downwards. By having this convention, we can don´t have to store +// Another perspective on this is that the x axis is positive to the right and the +// y axis is negative downwards. By having this convention, we can don´t have to store // the partition type in the node. // -// For example, for a cell that has row partitioning, a value of -0.25 means that the +// For example, for a cell that has row partitioning, a value of -0.25 means that the // y-coordinate, the bottom edge of the cell, is 25% of the display height (0.25dh). For a cell that -// has column partitioning, a value of 0.45 means that the x-coordinate, the right edge +// has column partitioning, a value of 0.45 means that the x-coordinate, the right edge // of the cell, is 45% of the screen width (0.45dw). Percentages are floating point numbers. // // The LAST child of an internal node does not have a percentage value as the right edge or // bottom edge is defined by the parent node. I.e. it fills the remaining space. // -// The calculateRects method calculates the rectangles for all the nodes in the tree in +// The calculateRects method calculates the rectangles for all the nodes in the tree in // absolute screen coordinates. // @@ -47,7 +47,7 @@ const LastNodeYPercentageJson = -99999; // A node in the tree layout structure class LayoutNode { - // percentage of screen width (positive) or height (negative). + // percentage of screen width (positive) or height (negative). // Always INFINITY or NEGATIVE INFINITY for the last child. percentage; @@ -68,7 +68,7 @@ class LayoutNode { // isResizing indicates if the divider belonging to this node is being moved by the user isResizing = false; - // isPreview indicates that this node is part of a preview split + // isPreview indicates that this node is part of a preview split isPreview = false; // isHighlighted indicates that this node is visually highlighted @@ -109,7 +109,7 @@ class LayoutNode { return clone; } - // revert the node to the state of the snapshotRootNode, often used + // revert the node to the state of the snapshotRootNode, often used // on the root node to revert the whole layout to a previous state revert(snapshotRootNode) { this.percentage = snapshotRootNode.percentage; @@ -250,8 +250,8 @@ class LayoutNode { // validate the calculated rectangles for the node and its descendants validateRects() { - // we constrain the mnimum size to a reasonable 100 pixels - // as smaller is likely not what the user wants + // we constrain the mnimum size to a reasonable 100 pixels + // as smaller is likely not what the user wants if (this.rect.width <= 100 || this.rect.height <= 100) { return false; } @@ -283,8 +283,8 @@ class LayoutNode { child.parent = this; - // insert the child at the correct position/index - // to maintain the sorted invariant + // insert the child at the correct position/index + // to maintain the sorted invariant this.children.splice( this.children.findIndex(c => Math.abs(c.percentage) > Math.abs(child.percentage)), 0, @@ -378,28 +378,6 @@ class LayoutNode { return insideNodes.length > 0 ? [...insideNodes, ...nearbyNodes] : nearbyNodes; } - // check if two nodes are adjacent (share an edge) - areNodesAdjacent(node1, node2) { - if (!node1 || !node2) return false; - - // Check horizontal adjacency (left/right) - let horizontalOverlap = !(node1.rect.y + node1.rect.height <= node2.rect.y || - node2.rect.y + node2.rect.height <= node1.rect.y); - - let node1RightOfNode2 = Math.abs(node1.rect.x - (node2.rect.x + node2.rect.width)); - let node2RightOfNode1 = Math.abs(node2.rect.x - (node1.rect.x + node1.rect.width)); - - // Check vertical adjacency (top/bottom) - let verticalOverlap = !(node1.rect.x + node1.rect.width <= node2.rect.x || - node2.rect.x + node2.rect.width <= node1.rect.x); - - let node1BelowNode2 = Math.abs(node1.rect.y - (node2.rect.y + node2.rect.height)); - let node2BelowNode1 = Math.abs(node2.rect.y - (node1.rect.y + node1.rect.height)); - - return (horizontalOverlap && (node1RightOfNode2 || node2RightOfNode1)) || - (verticalOverlap && (node1BelowNode2 || node2BelowNode1)); - } - // get the rectangle of the divider for this node, useful for grabbing and moving the divider getDividerRect(dividerWidth) { dividerWidth = Math.max(dividerWidth, 2 * this.margin); @@ -703,7 +681,7 @@ class PreviewSplitOperation extends LayoutOperation { } _handlePreview(x, y, state) { - // Check for preview partition creation + // Check for preview partition creation let ctrlPressed = (state & Clutter.ModifierType.CONTROL_MASK) !== 0; let shiftPressed = (state & Clutter.ModifierType.SHIFT_MASK) !== 0; let previewModeEnabled = ctrlPressed || shiftPressed; @@ -726,9 +704,9 @@ class PreviewSplitOperation extends LayoutOperation { result = OperationResult.handledAndRedraw(); } - // start a new preview if - // 1) there is no preview yet and - // 2) we are moving over a cell and + // start a new preview if + // 1) there is no preview yet and + // 2) we are moving over a cell and // 3) ctrl or shift is pressed (preview mode) if (!previewNode && node && node.isLeaf() @@ -751,7 +729,7 @@ class PreviewSplitOperation extends LayoutOperation { // move around the divider on a resizing (preview)node if (previewNode) { - // calculate the percentages + // calculate the percentages let percentage = previewNode.isColumn() ? ((x - this.tree.rect.x) / this.tree.rect.width) : -((y - this.tree.rect.y) / this.tree.rect.height); let oldPercentage = previewNode.percentage; @@ -773,7 +751,7 @@ class PreviewSplitOperation extends LayoutOperation { } _startPreview(splittingNode, percentage) { - // Split a leaf node into two nodes, with the given percentage as starting point + // Split a leaf node into two nodes, with the given percentage as starting point let previewNode = new LayoutNode(percentage); previewNode.isPreview = true; previewNode.isHighlighted = true; @@ -814,13 +792,13 @@ class SnappingOperation extends LayoutOperation { #enableMultiSnappingModifiers; #enableAdjacentMerging; #mergingRadius; - + constructor(tree, enableSnappingModifiers, enableMultiSnappingModifiers, enableAdjacentMerging, mergingRadius) { super(tree); this.#enableSnappingModifiers = enableSnappingModifiers; this.#enableMultiSnappingModifiers = enableMultiSnappingModifiers; this.#enableAdjacentMerging = enableAdjacentMerging; - this.#mergingRadius = mergingRadius; + this.#mergingRadius = mergingRadius; } onMotion(x, y, state) { @@ -828,7 +806,7 @@ class SnappingOperation extends LayoutOperation { if (!snappingEnabled) { return this.cancel(); } - + let node = this.tree.findNodeAtPosition(x, y); if(!node){ return OperationResult.notHandled(); @@ -836,23 +814,23 @@ class SnappingOperation extends LayoutOperation { // first check for multi-region snapping using the key modifier const multiSnapEnabled = this.#enableMultiSnappingModifiers.some((e) => (state & e)); - + // if multi-region snapping is enabled the regions that are snapping destinations are retained // this allows the user to expand the snapping region by moving around. // if multi-region snapping is not enabled we first clear all snapping destinations - if(!multiSnapEnabled) { + if(!multiSnapEnabled) { this.tree.forSelfAndDescendants(n => { n.isSnappingDestination = false; n.isHighlighted = false; }); - this.tree.insetNode = null; + this.tree.insetNode = null; } // the regions in a radius around the mouse position will become snapping destinations - const regionSelectionRadius = this.#enableAdjacentMerging ? this.#mergingRadius : 0; - + const regionSelectionRadius = this.#enableAdjacentMerging ? this.#mergingRadius : 0; + // find the regions with the radius and set them as snapping destinations - let nearbyNodes = this.tree.findNodesNearPosition(x, y, regionSelectionRadius); + let nearbyNodes = this.tree.findNodesNearPosition(x, y, regionSelectionRadius); nearbyNodes.forEach(node => { node.isSnappingDestination = true; node.isHighlighted = true; @@ -864,16 +842,16 @@ class SnappingOperation extends LayoutOperation { const multisnapRect = this.multisnapRect(); if(!this.tree.insetNode){ this.tree.insetNode = new LayoutNode(0); - this.tree.insetNode.parent = this.tree; + this.tree.insetNode.parent = this.tree; this.tree.insetNode.margin = node.margin; this.tree.insetNode.isSnappingDestination = true; this.tree.insetNode.isHighlighted = true; - } + } this.tree.insetNode.rect = multisnapRect; } else { this.tree.insetNode = null; } - + this.showRegions = true; return OperationResult.handledAndRedraw(); } @@ -908,7 +886,7 @@ class SnappingOperation extends LayoutOperation { }); } - currentSnapToRect() { + currentSnapToRect() { if(this.tree.insetNode){ return this.tree.insetNode.snapRect(); } @@ -1012,4 +990,4 @@ module.exports = { SnappingOperation, MarginsOperation, PresetShortcutOperation -}; +};