From 526c434d820fddcefb2d78cd4118192f7740951c Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 21:28:42 +0300 Subject: [PATCH 1/2] feat(tabs): close document tabs with middle mouse click Use the existing closeTab workflow for middle-button auxclicks without activating inactive tabs. Cancel middle-button autoscroll over tab controls. Cover browser and desktop closure, draft preservation, linked originals, right-click behavior, and nested close buttons. No custom Neutralino runtime dependency. --- desktop-app/resources/js/script.js | 16 ++++++++ script.js | 16 ++++++++ tests/e2e/tab-middle-close.spec.js | 60 ++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 tests/e2e/tab-middle-close.spec.js diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 0484e8c7..a8dac918 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -11678,6 +11678,12 @@ document.addEventListener("DOMContentLoaded", async function () { if (tabMenu) item.appendChild(tabMenu.button); item.appendChild(closeButton); + // Suppress middle-button autoscroll, including over nested action buttons. + // Capture runs before their mousedown propagation guards. + item.addEventListener('mousedown', function(event) { + if (event.button === 1) event.preventDefault(); + }, { capture: true }); + item.addEventListener('contextmenu', function(event) { event.preventDefault(); event.stopPropagation(); @@ -11729,6 +11735,16 @@ document.addEventListener("DOMContentLoaded", async function () { if (tabId) switchTab(tabId); }; + tabList.onauxclick = function(event) { + if (event.button !== 1) return; + const tabItem = event.target.closest('.tab-item'); + if (!tabItem) return; + event.preventDefault(); + event.stopPropagation(); + const tabId = tabItem.getAttribute('data-tab-id'); + if (tabId) closeTab(tabId); + }; + tabList.oncontextmenu = function(event) { if (event.target.closest('.tab-item')) return; const activeTab = tabs.find(function(tab) { return tab.id === activeTabId && isTabOpen(tab); }); diff --git a/script.js b/script.js index 0484e8c7..a8dac918 100644 --- a/script.js +++ b/script.js @@ -11678,6 +11678,12 @@ document.addEventListener("DOMContentLoaded", async function () { if (tabMenu) item.appendChild(tabMenu.button); item.appendChild(closeButton); + // Suppress middle-button autoscroll, including over nested action buttons. + // Capture runs before their mousedown propagation guards. + item.addEventListener('mousedown', function(event) { + if (event.button === 1) event.preventDefault(); + }, { capture: true }); + item.addEventListener('contextmenu', function(event) { event.preventDefault(); event.stopPropagation(); @@ -11729,6 +11735,16 @@ document.addEventListener("DOMContentLoaded", async function () { if (tabId) switchTab(tabId); }; + tabList.onauxclick = function(event) { + if (event.button !== 1) return; + const tabItem = event.target.closest('.tab-item'); + if (!tabItem) return; + event.preventDefault(); + event.stopPropagation(); + const tabId = tabItem.getAttribute('data-tab-id'); + if (tabId) closeTab(tabId); + }; + tabList.oncontextmenu = function(event) { if (event.target.closest('.tab-item')) return; const activeTab = tabs.find(function(tab) { return tab.id === activeTabId && isTabOpen(tab); }); diff --git a/tests/e2e/tab-middle-close.spec.js b/tests/e2e/tab-middle-close.spec.js new file mode 100644 index 00000000..8140c903 --- /dev/null +++ b/tests/e2e/tab-middle-close.spec.js @@ -0,0 +1,60 @@ +const { test, expect } = require('@playwright/test'); +const { openApp } = require('../helpers/app'); +const { installDesktopFilesystem } = require('../helpers/desktop-filesystem'); + +for (const desktop of [false, true]) { + test(`middle-click closes active and inactive tabs without deleting drafts (${desktop ? 'desktop' : 'web'})`, async ({ page }) => { + if (desktop) await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); + await openApp(page); + const firstId = await page.locator('#tab-list .tab-item.active').getAttribute('data-tab-id'); + await page.locator('#tab-new-btn').click(); + const secondId = await page.locator('#tab-list .tab-item.active').getAttribute('data-tab-id'); + const tab = id => page.locator(`#tab-list .tab-item[data-tab-id="${id}"]`); + await page.locator('#markdown-editor').fill('# preserved draft'); + // A genuine middle-button click must not activate the inactive tab first. + await tab(firstId).locator('.tab-title').click({button:'middle'}); + await expect(tab(firstId)).toHaveCount(0); + await expect(tab(secondId)).toHaveClass(/active/); + await expect(page.locator('#markdown-editor')).toHaveValue('# preserved draft'); + await page.locator('#tab-new-btn').click(); + const thirdId = await page.locator('#tab-list .tab-item.active').getAttribute('data-tab-id'); + await tab(thirdId).locator('.tab-close-btn').click({button:'middle'}); + await expect(tab(thirdId)).toHaveCount(0); + await expect(tab(secondId)).toHaveClass(/active/); + await tab(secondId).locator('.tab-title').click({button:'middle'}); + await expect(page.locator('#tab-list .tab-item')).toHaveCount(0); + const row = page.locator(`#document-tree [data-document-id="${secondId}"]`); + await expect(row).toBeVisible(); + await row.locator('.document-tree-main').click(); + await expect(tab(secondId)).toHaveClass(/active/); + await expect(page.locator('#markdown-editor')).toHaveValue('# preserved draft'); + // Only the middle button closes; right-click retains its context menu. + await tab(secondId).locator('.tab-title').click({button:'right'}); + await expect(tab(secondId)).toBeVisible(); + await expect(page.locator('[data-tab-context-menu="true"]')).toBeVisible(); + }); +} + +test('middle-click on a linked tab keeps its workspace link and original file', async ({ page }) => { + await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); + await openApp(page); + await page.evaluate(async () => { + window.__desktopFiles.set('C:/Original/notes.md', '# original'); + await window.NL_IMPORT_EXTERNAL_FILE('# original', 'notes', 'C:/Original/notes.md'); + }); + const active = page.locator('#tab-list .tab-item.active'); + const id = await active.getAttribute('data-tab-id'); + await active.locator('.tab-title').click({button:'middle'}); + await expect(page.locator(`#tab-list [data-tab-id="${id}"]`)).toHaveCount(0); + await expect(page.locator(`#document-tree [data-document-id="${id}"]`)).toHaveClass(/is-linked-document/); + expect(await page.evaluate(() => window.__desktopFiles.get('C:/Original/notes.md'))).toBe('# original'); +}); + +test('middle mousedown is cancelled on both the tab title and nested close button', async ({ page }) => { + await openApp(page); + for (const selector of ['.tab-title', '.tab-close-btn']) { + expect(await page.locator('#tab-list .tab-item').first().locator(selector).evaluate(element => + element.dispatchEvent(new MouseEvent('mousedown', {button:1,bubbles:true,cancelable:true})) + )).toBe(false); + } +}); From ae68019c7ee7cc7bd443d1e0cd37122a601d34f2 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 22:49:35 +0300 Subject: [PATCH 2/2] test(tabs): keep middle-click regressions independent of linked workspace --- tests/e2e/tab-middle-close.spec.js | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/tests/e2e/tab-middle-close.spec.js b/tests/e2e/tab-middle-close.spec.js index 8140c903..014919fe 100644 --- a/tests/e2e/tab-middle-close.spec.js +++ b/tests/e2e/tab-middle-close.spec.js @@ -1,10 +1,7 @@ const { test, expect } = require('@playwright/test'); const { openApp } = require('../helpers/app'); -const { installDesktopFilesystem } = require('../helpers/desktop-filesystem'); -for (const desktop of [false, true]) { - test(`middle-click closes active and inactive tabs without deleting drafts (${desktop ? 'desktop' : 'web'})`, async ({ page }) => { - if (desktop) await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); +test('middle-click closes active and inactive tabs without deleting drafts', async ({ page }) => { await openApp(page); const firstId = await page.locator('#tab-list .tab-item.active').getAttribute('data-tab-id'); await page.locator('#tab-new-btn').click(); @@ -33,22 +30,6 @@ for (const desktop of [false, true]) { await expect(tab(secondId)).toBeVisible(); await expect(page.locator('[data-tab-context-menu="true"]')).toBeVisible(); }); -} - -test('middle-click on a linked tab keeps its workspace link and original file', async ({ page }) => { - await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); - await openApp(page); - await page.evaluate(async () => { - window.__desktopFiles.set('C:/Original/notes.md', '# original'); - await window.NL_IMPORT_EXTERNAL_FILE('# original', 'notes', 'C:/Original/notes.md'); - }); - const active = page.locator('#tab-list .tab-item.active'); - const id = await active.getAttribute('data-tab-id'); - await active.locator('.tab-title').click({button:'middle'}); - await expect(page.locator(`#tab-list [data-tab-id="${id}"]`)).toHaveCount(0); - await expect(page.locator(`#document-tree [data-document-id="${id}"]`)).toHaveClass(/is-linked-document/); - expect(await page.evaluate(() => window.__desktopFiles.get('C:/Original/notes.md'))).toBe('# original'); -}); test('middle mousedown is cancelled on both the tab title and nested close button', async ({ page }) => { await openApp(page);