From 3cacb9c474cb2a81950d4ff4c7eeeb2784af02a4 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sat, 10 Oct 2026 11:45:00 +0530 Subject: [PATCH 1/5] fix(editor): align slash menu with shared dropdown styles --- desktop-app/resources/js/script.js | 25 ++++----- desktop-app/resources/styles.css | 77 ++++++---------------------- script.js | 25 ++++----- styles.css | 77 ++++++---------------------- tests/e2e/slash-command-menu.spec.js | 4 +- wiki/Features.md | 2 + 6 files changed, 65 insertions(+), 145 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 4c25a960..d89e2941 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -18270,11 +18270,13 @@ ${selector} .arrowheadPath { if (slashCommandMenu) return slashCommandMenu; slashCommandMenu = document.createElement('div'); slashCommandMenu.id = 'slash-command-menu'; - slashCommandMenu.className = 'slash-command-menu'; + slashCommandMenu.className = 'slash-command-menu app-command-menu'; slashCommandMenu.hidden = true; const header = document.createElement('div'); - header.className = 'slash-command-header'; - header.textContent = 'Insert block'; + header.className = 'slash-command-header settings-menu-header'; + const title = document.createElement('strong'); + title.textContent = 'Insert block'; + header.appendChild(title); slashCommandList = document.createElement('div'); slashCommandList.id = 'slash-command-list'; slashCommandList.className = 'slash-command-list'; @@ -18335,8 +18337,7 @@ ${selector} .arrowheadPath { if (!editor || !slashCommandMenu || slashCommandMenu.hidden) return; const caret = getSlashMenuCaretPosition(editor); const margin = 8; - const width = Math.min(340, window.innerWidth - margin * 2); - slashCommandMenu.style.width = width + 'px'; + const width = slashCommandMenu.offsetWidth; const preferredLeft = window.getComputedStyle(editor).direction === 'rtl' ? caret.left - width : caret.left; const left = Math.max(margin, Math.min(preferredLeft, window.innerWidth - width - margin)); let top = caret.top + caret.lineHeight + 6; @@ -18423,18 +18424,18 @@ ${selector} .arrowheadPath { const item = document.createElement('button'); item.type = 'button'; item.id = 'slash-command-' + command.id; - item.className = 'slash-command-item'; + item.className = 'slash-command-item app-menu-item'; item.tabIndex = -1; item.setAttribute('role', 'option'); item.setAttribute('aria-selected', 'false'); - item.innerHTML = '' + (command.textIcon - ? '' - : '') + '' + - ''; + item.innerHTML = (command.textIcon + ? '' + : '') + + ''; const textIcon = item.querySelector('.slash-command-text-icon'); if (textIcon) textIcon.textContent = command.textIcon; - item.querySelector('strong').textContent = command.label; - item.querySelector('small').textContent = command.description; + item.querySelector('.app-menu-label').textContent = command.label; + item.querySelector('.app-menu-description').textContent = command.description; item.addEventListener('mouseenter', function() { selectSlashCommand(index); }); item.addEventListener('click', function() { executeSlashCommand(command); }); slashCommandList.appendChild(item); diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index 5671d94c..561859b5 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -3242,7 +3242,8 @@ body { .app-menu-item:hover, .app-menu-item:focus-visible, -.app-menu-item:active { +.app-menu-item:active, +.slash-command-item.is-active { background: var(--button-hover); color: var(--text-color); } @@ -3636,7 +3637,8 @@ body { /* Unified menu surface: header New is the reference for every command menu. */ .dropdown-menu, .markdown-tool-menu, -.tab-menu-dropdown { +.tab-menu-dropdown, +.slash-command-menu { border: 1px solid var(--menu-border); border-radius: var(--menu-radius); background-color: var(--menu-surface); @@ -14439,81 +14441,36 @@ body.release-notes-active .mobile-stats-container { .slash-command-menu { position: fixed; z-index: 2200; + display: flex; + flex-direction: column; + min-width: 0; + max-width: calc(100vw - 16px); max-height: min(420px, calc(100vh - 24px)); overflow: hidden; - border: 1px solid var(--menu-border); - border-radius: 12px; - background: var(--menu-surface); color: var(--text-color); - box-shadow: var(--menu-shadow); } .slash-command-menu[hidden] { display: none; } .slash-command-header { - padding: 10px 12px 7px; - color: var(--text-secondary); - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; + flex: 0 0 auto; } .slash-command-list { - max-height: min(372px, calc(100vh - 72px)); + min-height: 0; overflow-y: auto; - padding: 4px 6px 7px; -} - -.slash-command-item { - display: flex; - width: 100%; - align-items: center; - gap: 10px; - padding: 9px 10px; - border: 0; - border-radius: 8px; - background: transparent; - color: inherit; - text-align: left; -} - -.slash-command-item:hover, -.slash-command-item.is-active { background: var(--toolbar-control-hover); } - -.slash-command-icon { - display: inline-flex; - width: 30px; - height: 30px; - flex: 0 0 30px; - align-items: center; - justify-content: center; - border: 1px solid var(--border-color); - border-radius: 7px; - background: var(--editor-bg); + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); } -.slash-command-text-icon { +.slash-command-empty { + padding: 7px 9px; color: var(--text-secondary); - font-size: 0.72rem; - font-weight: 700; - letter-spacing: -0.03em; -} - -.slash-command-copy { - display: flex; - min-width: 0; - flex-direction: column; - gap: 1px; + font-size: var(--ui-font-sm); + line-height: 1.3; } -.slash-command-copy strong { font-size: 0.86rem; font-weight: 650; } - -.slash-command-copy small, -.slash-command-empty { color: var(--text-secondary); font-size: 0.75rem; } - -.slash-command-empty { padding: 14px 10px; } - .slash-command-caret-mirror { position: absolute; top: 0; diff --git a/script.js b/script.js index 4c25a960..d89e2941 100644 --- a/script.js +++ b/script.js @@ -18270,11 +18270,13 @@ ${selector} .arrowheadPath { if (slashCommandMenu) return slashCommandMenu; slashCommandMenu = document.createElement('div'); slashCommandMenu.id = 'slash-command-menu'; - slashCommandMenu.className = 'slash-command-menu'; + slashCommandMenu.className = 'slash-command-menu app-command-menu'; slashCommandMenu.hidden = true; const header = document.createElement('div'); - header.className = 'slash-command-header'; - header.textContent = 'Insert block'; + header.className = 'slash-command-header settings-menu-header'; + const title = document.createElement('strong'); + title.textContent = 'Insert block'; + header.appendChild(title); slashCommandList = document.createElement('div'); slashCommandList.id = 'slash-command-list'; slashCommandList.className = 'slash-command-list'; @@ -18335,8 +18337,7 @@ ${selector} .arrowheadPath { if (!editor || !slashCommandMenu || slashCommandMenu.hidden) return; const caret = getSlashMenuCaretPosition(editor); const margin = 8; - const width = Math.min(340, window.innerWidth - margin * 2); - slashCommandMenu.style.width = width + 'px'; + const width = slashCommandMenu.offsetWidth; const preferredLeft = window.getComputedStyle(editor).direction === 'rtl' ? caret.left - width : caret.left; const left = Math.max(margin, Math.min(preferredLeft, window.innerWidth - width - margin)); let top = caret.top + caret.lineHeight + 6; @@ -18423,18 +18424,18 @@ ${selector} .arrowheadPath { const item = document.createElement('button'); item.type = 'button'; item.id = 'slash-command-' + command.id; - item.className = 'slash-command-item'; + item.className = 'slash-command-item app-menu-item'; item.tabIndex = -1; item.setAttribute('role', 'option'); item.setAttribute('aria-selected', 'false'); - item.innerHTML = '' + (command.textIcon - ? '' - : '') + '' + - ''; + item.innerHTML = (command.textIcon + ? '' + : '') + + ''; const textIcon = item.querySelector('.slash-command-text-icon'); if (textIcon) textIcon.textContent = command.textIcon; - item.querySelector('strong').textContent = command.label; - item.querySelector('small').textContent = command.description; + item.querySelector('.app-menu-label').textContent = command.label; + item.querySelector('.app-menu-description').textContent = command.description; item.addEventListener('mouseenter', function() { selectSlashCommand(index); }); item.addEventListener('click', function() { executeSlashCommand(command); }); slashCommandList.appendChild(item); diff --git a/styles.css b/styles.css index 5671d94c..561859b5 100644 --- a/styles.css +++ b/styles.css @@ -3242,7 +3242,8 @@ body { .app-menu-item:hover, .app-menu-item:focus-visible, -.app-menu-item:active { +.app-menu-item:active, +.slash-command-item.is-active { background: var(--button-hover); color: var(--text-color); } @@ -3636,7 +3637,8 @@ body { /* Unified menu surface: header New is the reference for every command menu. */ .dropdown-menu, .markdown-tool-menu, -.tab-menu-dropdown { +.tab-menu-dropdown, +.slash-command-menu { border: 1px solid var(--menu-border); border-radius: var(--menu-radius); background-color: var(--menu-surface); @@ -14439,81 +14441,36 @@ body.release-notes-active .mobile-stats-container { .slash-command-menu { position: fixed; z-index: 2200; + display: flex; + flex-direction: column; + min-width: 0; + max-width: calc(100vw - 16px); max-height: min(420px, calc(100vh - 24px)); overflow: hidden; - border: 1px solid var(--menu-border); - border-radius: 12px; - background: var(--menu-surface); color: var(--text-color); - box-shadow: var(--menu-shadow); } .slash-command-menu[hidden] { display: none; } .slash-command-header { - padding: 10px 12px 7px; - color: var(--text-secondary); - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; + flex: 0 0 auto; } .slash-command-list { - max-height: min(372px, calc(100vh - 72px)); + min-height: 0; overflow-y: auto; - padding: 4px 6px 7px; -} - -.slash-command-item { - display: flex; - width: 100%; - align-items: center; - gap: 10px; - padding: 9px 10px; - border: 0; - border-radius: 8px; - background: transparent; - color: inherit; - text-align: left; -} - -.slash-command-item:hover, -.slash-command-item.is-active { background: var(--toolbar-control-hover); } - -.slash-command-icon { - display: inline-flex; - width: 30px; - height: 30px; - flex: 0 0 30px; - align-items: center; - justify-content: center; - border: 1px solid var(--border-color); - border-radius: 7px; - background: var(--editor-bg); + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); } -.slash-command-text-icon { +.slash-command-empty { + padding: 7px 9px; color: var(--text-secondary); - font-size: 0.72rem; - font-weight: 700; - letter-spacing: -0.03em; -} - -.slash-command-copy { - display: flex; - min-width: 0; - flex-direction: column; - gap: 1px; + font-size: var(--ui-font-sm); + line-height: 1.3; } -.slash-command-copy strong { font-size: 0.86rem; font-weight: 650; } - -.slash-command-copy small, -.slash-command-empty { color: var(--text-secondary); font-size: 0.75rem; } - -.slash-command-empty { padding: 14px 10px; } - .slash-command-caret-mirror { position: absolute; top: 0; diff --git a/tests/e2e/slash-command-menu.spec.js b/tests/e2e/slash-command-menu.spec.js index 25ac93d7..091d7237 100644 --- a/tests/e2e/slash-command-menu.spec.js +++ b/tests/e2e/slash-command-menu.spec.js @@ -36,7 +36,9 @@ test('opens the full alert chooser from the slash menu', async ({ page }) => { test('uses available icons for every slash command', async ({ page }) => { await setEditorContent(page, '/'); - const missingIcons = await page.locator('#slash-command-menu .slash-command-icon i').evaluateAll(nodes => nodes + const icons = page.locator('#slash-command-menu .slash-command-icon.lucide'); + await expect(icons).toHaveCount(15); + const missingIcons = await icons.evaluateAll(nodes => nodes .filter(node => getComputedStyle(node).maskImage === 'none' && getComputedStyle(node).webkitMaskImage === 'none') .map(node => node.className)); expect(missingIcons).toEqual([]); diff --git a/wiki/Features.md b/wiki/Features.md index 09e0f2af..76a3e4ef 100644 --- a/wiki/Features.md +++ b/wiki/Features.md @@ -125,6 +125,8 @@ View-only Share Snapshot tabs and view-only Live Share participant tabs block mu Type `/` at the start of an Editor line to open a searchable block-command menu. Continue typing to filter, use the arrow keys to move, press **Enter** to run the selected command, or press **Escape** to close it. Pointer selection is also supported. +The menu shares the application's compact dropdown typography, spacing, surfaces, and Lucide icons in light and dark themes. Heading symbols match the toolbar's text-style menu. + Commands cover headings, paragraphs, lists, quotes, horizontal rules, code and terminal blocks, math, links, references, date/time, alerts, tables, diagrams, and images/media. Commands that need more information open the same alert, table, diagram, or media chooser used by the toolbar. The menu opens only for a literal slash at column zero. A slash after other text or indentation, inside a URL, during IME composition, or outside an Editor remains normal text. The menu follows the active primary or secondary Editor, closes when the document or focus context changes, exposes listbox state to assistive technology, and positions correctly for RTL documents. From dca6a12bf6eb34abce200b72ef9af47c0ff91ea3 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sat, 10 Oct 2026 12:26:19 +0530 Subject: [PATCH 2/5] fix(editor): keep slash menu clear of the typing line --- desktop-app/resources/js/script.js | 16 +++++- script.js | 16 +++++- tests/e2e/slash-command-menu.spec.js | 83 ++++++++++++++++++++++++++++ wiki/Features.md | 2 +- 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index d89e2941..95ea03a8 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -18340,11 +18340,21 @@ ${selector} .arrowheadPath { const width = slashCommandMenu.offsetWidth; const preferredLeft = window.getComputedStyle(editor).direction === 'rtl' ? caret.left - width : caret.left; const left = Math.max(margin, Math.min(preferredLeft, window.innerWidth - width - margin)); - let top = caret.top + caret.lineHeight + 6; - const menuHeight = slashCommandMenu.offsetHeight || 360; - if (top + menuHeight > window.innerHeight - margin) top = Math.max(margin, caret.top - menuHeight - 6); + const belowTop = Math.max(margin, caret.top + caret.lineHeight + 6); + const aboveBottom = Math.min(window.innerHeight - margin, caret.top - 6); + const spaceBelow = Math.max(0, window.innerHeight - margin - belowTop); + const spaceAbove = Math.max(0, aboveBottom - margin); + // Measure the natural height again after filtering or resizing the viewport. + slashCommandMenu.style.maxHeight = ''; + const menuHeight = Math.ceil(slashCommandMenu.getBoundingClientRect().height); + const showBelow = menuHeight <= spaceBelow || spaceBelow >= spaceAbove; + // Constrain the list on the chosen side instead of clamping a tall menu over the caret. + slashCommandMenu.style.maxHeight = Math.min(menuHeight, showBelow ? spaceBelow : spaceAbove) + 'px'; + const top = showBelow ? belowTop : aboveBottom - slashCommandMenu.getBoundingClientRect().height; slashCommandMenu.style.left = left + 'px'; slashCommandMenu.style.top = top + 'px'; + const activeItem = slashCommandList.querySelector('[aria-selected="true"]'); + if (activeItem) activeItem.scrollIntoView({ block: 'nearest' }); } function closeSlashCommandMenu(options) { diff --git a/script.js b/script.js index d89e2941..95ea03a8 100644 --- a/script.js +++ b/script.js @@ -18340,11 +18340,21 @@ ${selector} .arrowheadPath { const width = slashCommandMenu.offsetWidth; const preferredLeft = window.getComputedStyle(editor).direction === 'rtl' ? caret.left - width : caret.left; const left = Math.max(margin, Math.min(preferredLeft, window.innerWidth - width - margin)); - let top = caret.top + caret.lineHeight + 6; - const menuHeight = slashCommandMenu.offsetHeight || 360; - if (top + menuHeight > window.innerHeight - margin) top = Math.max(margin, caret.top - menuHeight - 6); + const belowTop = Math.max(margin, caret.top + caret.lineHeight + 6); + const aboveBottom = Math.min(window.innerHeight - margin, caret.top - 6); + const spaceBelow = Math.max(0, window.innerHeight - margin - belowTop); + const spaceAbove = Math.max(0, aboveBottom - margin); + // Measure the natural height again after filtering or resizing the viewport. + slashCommandMenu.style.maxHeight = ''; + const menuHeight = Math.ceil(slashCommandMenu.getBoundingClientRect().height); + const showBelow = menuHeight <= spaceBelow || spaceBelow >= spaceAbove; + // Constrain the list on the chosen side instead of clamping a tall menu over the caret. + slashCommandMenu.style.maxHeight = Math.min(menuHeight, showBelow ? spaceBelow : spaceAbove) + 'px'; + const top = showBelow ? belowTop : aboveBottom - slashCommandMenu.getBoundingClientRect().height; slashCommandMenu.style.left = left + 'px'; slashCommandMenu.style.top = top + 'px'; + const activeItem = slashCommandList.querySelector('[aria-selected="true"]'); + if (activeItem) activeItem.scrollIntoView({ block: 'nearest' }); } function closeSlashCommandMenu(options) { diff --git a/tests/e2e/slash-command-menu.spec.js b/tests/e2e/slash-command-menu.spec.js index 091d7237..5efc8da9 100644 --- a/tests/e2e/slash-command-menu.spec.js +++ b/tests/e2e/slash-command-menu.spec.js @@ -1,6 +1,27 @@ const { test, expect } = require('@playwright/test'); const { openApp, setEditorContent, editorValue } = require('../helpers/app'); +async function slashMenuPlacement(page) { + return page.evaluate(() => { + const editor = document.getElementById('markdown-editor'); + const style = getComputedStyle(editor); + const lineIndex = editor.value.slice(0, editor.selectionStart).split('\n').length - 1; + const lineTop = editor.getBoundingClientRect().top + parseFloat(style.borderTopWidth) + + parseFloat(style.paddingTop) + lineIndex * parseFloat(style.lineHeight) - editor.scrollTop; + const lineBottom = lineTop + parseFloat(style.lineHeight); + const menu = document.getElementById('slash-command-menu').getBoundingClientRect(); + const list = document.getElementById('slash-command-list'); + return { + belowLine: menu.top >= lineBottom, + aboveLine: menu.bottom <= lineTop, + withinViewport: menu.top >= 8 && menu.bottom <= window.innerHeight - 8 && + menu.left >= 8 && menu.right <= window.innerWidth - 8, + scrollable: list.scrollHeight > list.clientHeight, + height: menu.height + }; + }); +} + test.beforeEach(async ({ page }) => { await openApp(page); await page.getByRole('button', { name: 'Edit Markdown' }).click(); @@ -177,3 +198,65 @@ test('still inserts a command selected with the mouse', async ({ page }) => { await page.locator('#slash-command-menu').getByRole('option', { name: /Heading 2/ }).click(); await expect.poll(() => editorValue(page)).toBe('## '); }); + +for (const width of [900, 375]) { + test(`keeps the full and filtered menu below the typing line at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 500 }); + await setEditorContent(page, 'text\n\n'); + const editor = page.locator('#markdown-editor'); + const menu = page.locator('#slash-command-menu'); + await editor.pressSequentially('/'); + await expect(menu.getByRole('option')).toHaveCount(21); + await expect.poll(() => slashMenuPlacement(page)).toMatchObject({ + belowLine: true, withinViewport: true, scrollable: true + }); + + await editor.pressSequentially('pa'); + await expect(menu.getByRole('option')).toHaveCount(2); + await expect.poll(() => slashMenuPlacement(page)).toMatchObject({ + belowLine: true, withinViewport: true, scrollable: false + }); + + await editor.press('Backspace'); + await editor.press('Backspace'); + await expect(menu.getByRole('option')).toHaveCount(21); + await expect.poll(() => slashMenuPlacement(page)).toMatchObject({ + belowLine: true, withinViewport: true, scrollable: true + }); + await editor.press('ArrowUp'); + await expect(menu.getByRole('option').last()).toHaveAttribute('aria-selected', 'true'); + await expect.poll(() => menu.getByRole('option').last().evaluate(option => { + const list = option.parentElement.getBoundingClientRect(); + const rect = option.getBoundingClientRect(); + return rect.top >= list.top && rect.bottom <= list.bottom + 1; + })).toBe(true); + await expect(editor).toBeFocused(); + await expect.poll(() => editorValue(page)).toBe('text\n\n/'); + }); +} + +test('constrains the menu above a typing line near the bottom of the screen', async ({ page }) => { + await page.setViewportSize({ width: 900, height: 500 }); + await setEditorContent(page, 'text\n'.repeat(10)); + await page.locator('#markdown-editor').pressSequentially('/'); + await expect.poll(() => slashMenuPlacement(page)).toMatchObject({ + aboveLine: true, withinViewport: true, scrollable: true + }); +}); + +test('recalculates the available menu height when the viewport changes', async ({ page }) => { + await page.setViewportSize({ width: 900, height: 800 }); + await setEditorContent(page, 'text\n\n/'); + await expect.poll(() => slashMenuPlacement(page)).toMatchObject({ belowLine: true, height: 420 }); + + await page.setViewportSize({ width: 900, height: 500 }); + await expect.poll(() => slashMenuPlacement(page)).toMatchObject({ + belowLine: true, withinViewport: true, scrollable: true + }); + expect((await slashMenuPlacement(page)).height).toBeLessThan(420); + + await page.setViewportSize({ width: 900, height: 800 }); + await expect.poll(() => slashMenuPlacement(page)).toMatchObject({ + belowLine: true, withinViewport: true, height: 420 + }); +}); diff --git a/wiki/Features.md b/wiki/Features.md index 76a3e4ef..e78448fc 100644 --- a/wiki/Features.md +++ b/wiki/Features.md @@ -125,7 +125,7 @@ View-only Share Snapshot tabs and view-only Live Share participant tabs block mu Type `/` at the start of an Editor line to open a searchable block-command menu. Continue typing to filter, use the arrow keys to move, press **Enter** to run the selected command, or press **Escape** to close it. Pointer selection is also supported. -The menu shares the application's compact dropdown typography, spacing, surfaces, and Lucide icons in light and dark themes. Heading symbols match the toolbar's text-style menu. +The menu shares the application's compact dropdown typography, spacing, surfaces, and Lucide icons in light and dark themes. Heading symbols match the toolbar's text-style menu. Its height adapts to the space above or below the typing line, keeping your text visible while the command list scrolls. Commands cover headings, paragraphs, lists, quotes, horizontal rules, code and terminal blocks, math, links, references, date/time, alerts, tables, diagrams, and images/media. Commands that need more information open the same alert, table, diagram, or media chooser used by the toolbar. From 1a56492c7a435d494a05269c90714e944fa7a311 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sat, 10 Oct 2026 16:52:42 +0530 Subject: [PATCH 3/5] fix(editor): complete slash commands and preserve upload targets --- desktop-app/resources/js/script.js | 80 +++++--- script.js | 80 +++++--- tests/e2e/slash-command-actions.spec.js | 231 ++++++++++++++++++++++++ tests/e2e/slash-command-menu.spec.js | 6 +- wiki/Features.md | 2 +- wiki/Usage-Guide.md | 2 +- 6 files changed, 340 insertions(+), 61 deletions(-) create mode 100644 tests/e2e/slash-command-actions.spec.js diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 95ea03a8..dd54b8e8 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -17724,7 +17724,8 @@ ${selector} .arrowheadPath { const targetTab = tabs.find(function(tab) { return tab.id === targetTabId; }); if (!targetTab || isTemporaryDocument(targetTab) || isPrivateStorageMode()) return true; if (liveCollaboration && liveCollaboration.tabId === targetTabId) return true; - saveCurrentTabState(); + if (targetTabId === secondarySplitTabId) saveSecondarySplitState(); + else if (targetTabId === activeTabId) saveCurrentTabState(); if (targetTab.workspaceId === SECRET_WORKSPACE_ID) { await flushSecretWorkspaceToStorage(); return true; @@ -17732,13 +17733,21 @@ ${selector} .arrowheadPath { return await _flushTabsToStorage(tabs, { changedIds: [targetTabId] }); } - function restoreEditorAfterFailedImageInsertion(targetTabId, originalValue, selectionStart, selectionEnd) { - if (activeTabId !== targetTabId) return; + function restoreEditorAfterFailedImageInsertion(targetTabId, originalValue, selectionStart, selectionEnd, editorOverride) { + const editor = editorOverride || markdownEditor; + if ((editor === documentSplitEditor ? secondarySplitTabId : activeTabId) !== targetTabId) return; + if (editor === documentSplitEditor) { + editor.value = originalValue; + editor.setSelectionRange(selectionStart, selectionEnd); + editor.dispatchEvent(new Event('input', { bubbles: true })); + return; + } const targetTab = tabs.find(function(tab) { return tab.id === targetTabId; }); markdownEditor.value = originalValue; markdownEditor.setSelectionRange(selectionStart, selectionEnd); lastPushedValue = originalValue; if (targetTab) targetTab.content = originalValue; + markdownEditor.dispatchEvent(new Event('input', { bubbles: true })); renderMarkdown({ reason: 'image-storage-rollback', force: true }); updateDocumentStats(); scheduleLineNumberUpdate({ force: true }); @@ -17746,13 +17755,14 @@ ${selector} .arrowheadPath { async function insertImageFilesIntoEditor(fileList, options) { const settings = options || {}; + const editor = settings.editor || markdownEditor; const suppliedFiles = Array.from(fileList || []); const mediaFiles = suppliedFiles.filter(isMediaFile); if (mediaFiles.length !== suppliedFiles.length) { showUnsupportedFileToast('Choose an image, GIF, MP4, WebM, or Ogg video file.'); } if (!mediaFiles.length) return 0; - if (!hasActiveOpenDocument() || !canMutateEditor()) { + if (!isMarkdownEditorEditable(editor)) { announceToScreenReader(getEditorReadOnlyMessage()); return 0; } @@ -17767,19 +17777,27 @@ ${selector} .arrowheadPath { showUnsupportedFileToast('This media file exceeds the supported limit: still images 25 MB before optimization, GIFs 5 MB, and videos 10 MB.'); } if (!acceptedFiles.length) return 0; + const targetTabId = editor === documentSplitEditor ? secondarySplitTabId : activeTabId; + const originalValue = editor.value; + const start = Number.isFinite(settings.selectionStart) ? settings.selectionStart : editor.selectionStart; + const end = Number.isFinite(settings.selectionEnd) ? settings.selectionEnd : editor.selectionEnd; + function targetIsUnchanged() { + return (editor === documentSplitEditor ? secondarySplitTabId : activeTabId) === targetTabId && + editor.value === originalValue && isMarkdownEditorEditable(editor); + } if (!(await requestManagedImageUploadConsent())) { announceToScreenReader('Media upload cancelled.'); return 0; } - - const targetTabId = activeTabId; - const originalValue = markdownEditor.value; - const start = Number.isFinite(settings.selectionStart) ? settings.selectionStart : markdownEditor.selectionStart; - const end = Number.isFinite(settings.selectionEnd) ? settings.selectionEnd : markdownEditor.selectionEnd; + if (!targetIsUnchanged()) { + announceToScreenReader('Media insertion was cancelled because the document changed.'); + return 0; + } const source = settings.source === 'clipboard' ? 'clipboard' : (settings.source === 'upload' ? 'upload' : 'drop'); let didInsert = false; + let insertedValue; let uploadedCount = 0; showMediaProgress(acceptedFiles.length); if (typeof settings.onStage === 'function') settings.onStage('preparing'); @@ -17798,7 +17816,7 @@ ${selector} .arrowheadPath { uploadedCount = index + 1; updateMediaProgress(uploadedCount, acceptedFiles.length, (file.name || 'Media file') + ' uploaded'); } - if (activeTabId !== targetTabId || markdownEditor.value !== originalValue || !canMutateEditor()) { + if (!targetIsUnchanged()) { finishMediaProgress(uploadedCount, acceptedFiles.length, { title: 'Media insertion cancelled', detail: 'The document changed during upload.' }); announceToScreenReader('Media insertion was cancelled because the document changed.'); return 0; @@ -17818,16 +17836,17 @@ ${selector} .arrowheadPath { return '![' + altText + '](' + managedItem.url + ')'; }).join('\n\n'); - replaceEditorRange(start, end, markdown, start + markdown.length, start + markdown.length); + replaceMarkdownEditorRange(editor, start, end, markdown, start + markdown.length, start + markdown.length); didInsert = true; + insertedValue = editor.value; const persisted = await persistImageInsertion(targetTabId); if (!persisted) throw new Error('The browser could not save the short media link.'); finishMediaProgress(acceptedFiles.length, acceptedFiles.length); announceToScreenReader(acceptedFiles.length + ' media file' + (acceptedFiles.length === 1 ? '' : 's') + ' uploaded and inserted with short links.'); return acceptedFiles.length; } catch (error) { - if (didInsert) { - restoreEditorAfterFailedImageInsertion(targetTabId, originalValue, start, end); + if (didInsert && editor.value === insertedValue) { + restoreEditorAfterFailedImageInsertion(targetTabId, originalValue, start, end, editor); } finishMediaProgress(uploadedCount, acceptedFiles.length); console.warn('Media insertion failed:', error); @@ -18238,6 +18257,14 @@ ${selector} .arrowheadPath { return true; } + function formatMarkdownDateTime() { + const now = new Date(); + const datePart = now.toLocaleDateString('en-CA'); + const timePart = now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + const dayName = now.toLocaleDateString('en-US', { weekday: 'long' }); + return `${datePart} ${timePart} ${dayName}`; + } + const SLASH_COMMANDS = Object.freeze([ { id: 'paragraph', label: 'Paragraph', description: 'Continue with plain text', icon: 'lucide-pilcrow', keywords: 'text normal body', insert: '' }, { id: 'heading-1', label: 'Heading 1', description: 'Large section heading', textIcon: 'H1', keywords: 'title h1', insert: '# ' }, @@ -18253,13 +18280,14 @@ ${selector} .arrowheadPath { { id: 'code-block', label: 'Code block', description: 'Insert a fenced code block', icon: 'lucide-square-code', keywords: 'fence programming', insert: '```\ncode\n```\n', select: [4, 8] }, { id: 'horizontal-rule', label: 'Horizontal rule', description: 'Separate sections', icon: 'lucide-minus', keywords: 'divider separator', insert: '---\n' }, { id: 'link', label: 'Link', description: 'Insert a Markdown link', icon: 'lucide-link-2', keywords: 'url hyperlink', insert: '[text](https://example.com)', select: [1, 5] }, + { id: 'reference', label: 'Reference', description: 'Insert a numbered reference link', icon: 'lucide-bookmark', keywords: 'reference numbered link', action: 'reference' }, { id: 'image', label: 'Image or video', description: 'Upload media or use a URL', icon: 'lucide-image', keywords: 'photo picture media upload url', action: 'image' }, { id: 'table', label: 'Table', description: 'Choose columns and rows', icon: 'lucide-grid-2x2', keywords: 'columns rows grid', action: 'table' }, { id: 'alert', label: 'Alert', description: 'Choose Note, Tip, Important, Warning, or Caution', icon: 'lucide-badge-alert', keywords: 'callout note tip important warning caution', action: 'alert' }, { id: 'math', label: 'Math block', description: 'Insert display math', icon: 'lucide-braces', keywords: 'latex equation', insert: '$$\nformula\n$$\n', select: [3, 10] }, { id: 'diagram', label: 'Diagram', description: 'Choose a diagram or visualization', icon: 'lucide-workflow', keywords: 'mermaid plantuml graphviz d2 markmap vega wavedrom map chart', action: 'diagram' }, { id: 'terminal-block', label: 'Terminal block', description: 'Insert a shell command block', icon: 'lucide-square-terminal', keywords: 'bash shell command console', insert: '```bash\nnpm run dev\n```\n', select: [8, 19] }, - { id: 'date', label: 'Date and time', description: 'Insert the current date and time', icon: 'lucide-clock-3', keywords: 'today timestamp time', build: function() { return new Date().toLocaleString(); } } + { id: 'date', label: 'Date and time', description: 'Insert the current date and time', icon: 'lucide-clock-3', keywords: 'today timestamp time', build: formatMarkdownDateTime } ]); let slashCommandState = null; let slashCommandMenu = null; @@ -18402,6 +18430,7 @@ ${selector} .arrowheadPath { else if (command.action === 'alert') openAlertModal(state.editor); else if (command.action === 'diagram') void openDiagramModal(null, state.editor); else if (command.action === 'image') insertMarkdownImage(state.editor); + else if (command.action === 'reference') insertMarkdownReference(state.editor); }); return; } @@ -21055,6 +21084,7 @@ ${selector} .arrowheadPath { if (isProcessing) return; setProcessing(true); const insertedCount = await insertImageFilesIntoEditor([file], { + editor: editor, source: 'upload', selectionStart: start, selectionEnd: end, @@ -21156,7 +21186,8 @@ ${selector} .arrowheadPath { updateMode(true); } - function insertMarkdownReference() { + function insertMarkdownReference(editorOverride) { + const editor = editorOverride || markdownEditor; const modal = document.getElementById('reference-modal'); const numberInput = document.getElementById('reference-modal-number'); const urlInput = document.getElementById('reference-modal-url'); @@ -21164,9 +21195,9 @@ ${selector} .arrowheadPath { const confirmBtn = document.getElementById('reference-modal-apply'); const cancelBtn = document.getElementById('reference-modal-cancel'); if (!modal || !numberInput || !urlInput || !titleInput || !confirmBtn || !cancelBtn) return; - const start = markdownEditor.selectionStart; - const end = markdownEditor.selectionEnd; - const currentValue = markdownEditor.value; + const start = editor.selectionStart; + const end = editor.selectionEnd; + const currentValue = editor.value; const used = getUsedReferenceNumbers(currentValue); const maxUsed = used.size ? Math.max(...used) : 0; referenceCounter = Math.max(1, maxUsed + 1); @@ -21177,7 +21208,7 @@ ${selector} .arrowheadPath { modal.style.display = 'flex'; function insertReference() { - const latestValue = markdownEditor.value; + const latestValue = editor.value; const usedNumbers = getUsedReferenceNumbers(latestValue); const parsed = parseInt(numberInput.value.replace(/[^\d]/g, ''), 10); const baseNumber = Number.isNaN(parsed) ? suggestedNumber : parsed; @@ -21194,11 +21225,8 @@ ${selector} .arrowheadPath { separator = '\n'; } const updatedValue = baseValue + separator + definition; - markdownEditor.value = updatedValue; - markdownEditor.focus(); const caret = start + inlineReference.length; - markdownEditor.setSelectionRange(caret, caret); - markdownEditor.dispatchEvent(new Event('input', { bubbles: true })); + replaceMarkdownEditorRange(editor, 0, latestValue.length, updatedValue, caret, caret); referenceCounter = Math.max(referenceCounter, finalNumber + 1); modal.style.display = 'none'; cleanup(); @@ -23398,11 +23426,7 @@ ${selector} .arrowheadPath { else if (action === 'code-block') insertMarkdownBlock('```js\n' + (markdownEditor.value.slice(markdownEditor.selectionStart, markdownEditor.selectionEnd) || 'console.log("Hello, Markdown!");') + '\n```\n'); else if (action === 'table') openTableModal(); else if (action === 'date-time') { - const now = new Date(); - const datePart = now.toLocaleDateString('en-CA'); - const timePart = now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); - const dayName = now.toLocaleDateString('en-US', { weekday: 'long' }); - const timestamp = `${datePart} ${timePart} ${dayName}`; + const timestamp = formatMarkdownDateTime(); replaceEditorRange(markdownEditor.selectionStart, markdownEditor.selectionEnd, timestamp, markdownEditor.selectionStart + timestamp.length, markdownEditor.selectionStart + timestamp.length); } else if (action === 'emoji') { openEmojiModal(); diff --git a/script.js b/script.js index 95ea03a8..dd54b8e8 100644 --- a/script.js +++ b/script.js @@ -17724,7 +17724,8 @@ ${selector} .arrowheadPath { const targetTab = tabs.find(function(tab) { return tab.id === targetTabId; }); if (!targetTab || isTemporaryDocument(targetTab) || isPrivateStorageMode()) return true; if (liveCollaboration && liveCollaboration.tabId === targetTabId) return true; - saveCurrentTabState(); + if (targetTabId === secondarySplitTabId) saveSecondarySplitState(); + else if (targetTabId === activeTabId) saveCurrentTabState(); if (targetTab.workspaceId === SECRET_WORKSPACE_ID) { await flushSecretWorkspaceToStorage(); return true; @@ -17732,13 +17733,21 @@ ${selector} .arrowheadPath { return await _flushTabsToStorage(tabs, { changedIds: [targetTabId] }); } - function restoreEditorAfterFailedImageInsertion(targetTabId, originalValue, selectionStart, selectionEnd) { - if (activeTabId !== targetTabId) return; + function restoreEditorAfterFailedImageInsertion(targetTabId, originalValue, selectionStart, selectionEnd, editorOverride) { + const editor = editorOverride || markdownEditor; + if ((editor === documentSplitEditor ? secondarySplitTabId : activeTabId) !== targetTabId) return; + if (editor === documentSplitEditor) { + editor.value = originalValue; + editor.setSelectionRange(selectionStart, selectionEnd); + editor.dispatchEvent(new Event('input', { bubbles: true })); + return; + } const targetTab = tabs.find(function(tab) { return tab.id === targetTabId; }); markdownEditor.value = originalValue; markdownEditor.setSelectionRange(selectionStart, selectionEnd); lastPushedValue = originalValue; if (targetTab) targetTab.content = originalValue; + markdownEditor.dispatchEvent(new Event('input', { bubbles: true })); renderMarkdown({ reason: 'image-storage-rollback', force: true }); updateDocumentStats(); scheduleLineNumberUpdate({ force: true }); @@ -17746,13 +17755,14 @@ ${selector} .arrowheadPath { async function insertImageFilesIntoEditor(fileList, options) { const settings = options || {}; + const editor = settings.editor || markdownEditor; const suppliedFiles = Array.from(fileList || []); const mediaFiles = suppliedFiles.filter(isMediaFile); if (mediaFiles.length !== suppliedFiles.length) { showUnsupportedFileToast('Choose an image, GIF, MP4, WebM, or Ogg video file.'); } if (!mediaFiles.length) return 0; - if (!hasActiveOpenDocument() || !canMutateEditor()) { + if (!isMarkdownEditorEditable(editor)) { announceToScreenReader(getEditorReadOnlyMessage()); return 0; } @@ -17767,19 +17777,27 @@ ${selector} .arrowheadPath { showUnsupportedFileToast('This media file exceeds the supported limit: still images 25 MB before optimization, GIFs 5 MB, and videos 10 MB.'); } if (!acceptedFiles.length) return 0; + const targetTabId = editor === documentSplitEditor ? secondarySplitTabId : activeTabId; + const originalValue = editor.value; + const start = Number.isFinite(settings.selectionStart) ? settings.selectionStart : editor.selectionStart; + const end = Number.isFinite(settings.selectionEnd) ? settings.selectionEnd : editor.selectionEnd; + function targetIsUnchanged() { + return (editor === documentSplitEditor ? secondarySplitTabId : activeTabId) === targetTabId && + editor.value === originalValue && isMarkdownEditorEditable(editor); + } if (!(await requestManagedImageUploadConsent())) { announceToScreenReader('Media upload cancelled.'); return 0; } - - const targetTabId = activeTabId; - const originalValue = markdownEditor.value; - const start = Number.isFinite(settings.selectionStart) ? settings.selectionStart : markdownEditor.selectionStart; - const end = Number.isFinite(settings.selectionEnd) ? settings.selectionEnd : markdownEditor.selectionEnd; + if (!targetIsUnchanged()) { + announceToScreenReader('Media insertion was cancelled because the document changed.'); + return 0; + } const source = settings.source === 'clipboard' ? 'clipboard' : (settings.source === 'upload' ? 'upload' : 'drop'); let didInsert = false; + let insertedValue; let uploadedCount = 0; showMediaProgress(acceptedFiles.length); if (typeof settings.onStage === 'function') settings.onStage('preparing'); @@ -17798,7 +17816,7 @@ ${selector} .arrowheadPath { uploadedCount = index + 1; updateMediaProgress(uploadedCount, acceptedFiles.length, (file.name || 'Media file') + ' uploaded'); } - if (activeTabId !== targetTabId || markdownEditor.value !== originalValue || !canMutateEditor()) { + if (!targetIsUnchanged()) { finishMediaProgress(uploadedCount, acceptedFiles.length, { title: 'Media insertion cancelled', detail: 'The document changed during upload.' }); announceToScreenReader('Media insertion was cancelled because the document changed.'); return 0; @@ -17818,16 +17836,17 @@ ${selector} .arrowheadPath { return '![' + altText + '](' + managedItem.url + ')'; }).join('\n\n'); - replaceEditorRange(start, end, markdown, start + markdown.length, start + markdown.length); + replaceMarkdownEditorRange(editor, start, end, markdown, start + markdown.length, start + markdown.length); didInsert = true; + insertedValue = editor.value; const persisted = await persistImageInsertion(targetTabId); if (!persisted) throw new Error('The browser could not save the short media link.'); finishMediaProgress(acceptedFiles.length, acceptedFiles.length); announceToScreenReader(acceptedFiles.length + ' media file' + (acceptedFiles.length === 1 ? '' : 's') + ' uploaded and inserted with short links.'); return acceptedFiles.length; } catch (error) { - if (didInsert) { - restoreEditorAfterFailedImageInsertion(targetTabId, originalValue, start, end); + if (didInsert && editor.value === insertedValue) { + restoreEditorAfterFailedImageInsertion(targetTabId, originalValue, start, end, editor); } finishMediaProgress(uploadedCount, acceptedFiles.length); console.warn('Media insertion failed:', error); @@ -18238,6 +18257,14 @@ ${selector} .arrowheadPath { return true; } + function formatMarkdownDateTime() { + const now = new Date(); + const datePart = now.toLocaleDateString('en-CA'); + const timePart = now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + const dayName = now.toLocaleDateString('en-US', { weekday: 'long' }); + return `${datePart} ${timePart} ${dayName}`; + } + const SLASH_COMMANDS = Object.freeze([ { id: 'paragraph', label: 'Paragraph', description: 'Continue with plain text', icon: 'lucide-pilcrow', keywords: 'text normal body', insert: '' }, { id: 'heading-1', label: 'Heading 1', description: 'Large section heading', textIcon: 'H1', keywords: 'title h1', insert: '# ' }, @@ -18253,13 +18280,14 @@ ${selector} .arrowheadPath { { id: 'code-block', label: 'Code block', description: 'Insert a fenced code block', icon: 'lucide-square-code', keywords: 'fence programming', insert: '```\ncode\n```\n', select: [4, 8] }, { id: 'horizontal-rule', label: 'Horizontal rule', description: 'Separate sections', icon: 'lucide-minus', keywords: 'divider separator', insert: '---\n' }, { id: 'link', label: 'Link', description: 'Insert a Markdown link', icon: 'lucide-link-2', keywords: 'url hyperlink', insert: '[text](https://example.com)', select: [1, 5] }, + { id: 'reference', label: 'Reference', description: 'Insert a numbered reference link', icon: 'lucide-bookmark', keywords: 'reference numbered link', action: 'reference' }, { id: 'image', label: 'Image or video', description: 'Upload media or use a URL', icon: 'lucide-image', keywords: 'photo picture media upload url', action: 'image' }, { id: 'table', label: 'Table', description: 'Choose columns and rows', icon: 'lucide-grid-2x2', keywords: 'columns rows grid', action: 'table' }, { id: 'alert', label: 'Alert', description: 'Choose Note, Tip, Important, Warning, or Caution', icon: 'lucide-badge-alert', keywords: 'callout note tip important warning caution', action: 'alert' }, { id: 'math', label: 'Math block', description: 'Insert display math', icon: 'lucide-braces', keywords: 'latex equation', insert: '$$\nformula\n$$\n', select: [3, 10] }, { id: 'diagram', label: 'Diagram', description: 'Choose a diagram or visualization', icon: 'lucide-workflow', keywords: 'mermaid plantuml graphviz d2 markmap vega wavedrom map chart', action: 'diagram' }, { id: 'terminal-block', label: 'Terminal block', description: 'Insert a shell command block', icon: 'lucide-square-terminal', keywords: 'bash shell command console', insert: '```bash\nnpm run dev\n```\n', select: [8, 19] }, - { id: 'date', label: 'Date and time', description: 'Insert the current date and time', icon: 'lucide-clock-3', keywords: 'today timestamp time', build: function() { return new Date().toLocaleString(); } } + { id: 'date', label: 'Date and time', description: 'Insert the current date and time', icon: 'lucide-clock-3', keywords: 'today timestamp time', build: formatMarkdownDateTime } ]); let slashCommandState = null; let slashCommandMenu = null; @@ -18402,6 +18430,7 @@ ${selector} .arrowheadPath { else if (command.action === 'alert') openAlertModal(state.editor); else if (command.action === 'diagram') void openDiagramModal(null, state.editor); else if (command.action === 'image') insertMarkdownImage(state.editor); + else if (command.action === 'reference') insertMarkdownReference(state.editor); }); return; } @@ -21055,6 +21084,7 @@ ${selector} .arrowheadPath { if (isProcessing) return; setProcessing(true); const insertedCount = await insertImageFilesIntoEditor([file], { + editor: editor, source: 'upload', selectionStart: start, selectionEnd: end, @@ -21156,7 +21186,8 @@ ${selector} .arrowheadPath { updateMode(true); } - function insertMarkdownReference() { + function insertMarkdownReference(editorOverride) { + const editor = editorOverride || markdownEditor; const modal = document.getElementById('reference-modal'); const numberInput = document.getElementById('reference-modal-number'); const urlInput = document.getElementById('reference-modal-url'); @@ -21164,9 +21195,9 @@ ${selector} .arrowheadPath { const confirmBtn = document.getElementById('reference-modal-apply'); const cancelBtn = document.getElementById('reference-modal-cancel'); if (!modal || !numberInput || !urlInput || !titleInput || !confirmBtn || !cancelBtn) return; - const start = markdownEditor.selectionStart; - const end = markdownEditor.selectionEnd; - const currentValue = markdownEditor.value; + const start = editor.selectionStart; + const end = editor.selectionEnd; + const currentValue = editor.value; const used = getUsedReferenceNumbers(currentValue); const maxUsed = used.size ? Math.max(...used) : 0; referenceCounter = Math.max(1, maxUsed + 1); @@ -21177,7 +21208,7 @@ ${selector} .arrowheadPath { modal.style.display = 'flex'; function insertReference() { - const latestValue = markdownEditor.value; + const latestValue = editor.value; const usedNumbers = getUsedReferenceNumbers(latestValue); const parsed = parseInt(numberInput.value.replace(/[^\d]/g, ''), 10); const baseNumber = Number.isNaN(parsed) ? suggestedNumber : parsed; @@ -21194,11 +21225,8 @@ ${selector} .arrowheadPath { separator = '\n'; } const updatedValue = baseValue + separator + definition; - markdownEditor.value = updatedValue; - markdownEditor.focus(); const caret = start + inlineReference.length; - markdownEditor.setSelectionRange(caret, caret); - markdownEditor.dispatchEvent(new Event('input', { bubbles: true })); + replaceMarkdownEditorRange(editor, 0, latestValue.length, updatedValue, caret, caret); referenceCounter = Math.max(referenceCounter, finalNumber + 1); modal.style.display = 'none'; cleanup(); @@ -23398,11 +23426,7 @@ ${selector} .arrowheadPath { else if (action === 'code-block') insertMarkdownBlock('```js\n' + (markdownEditor.value.slice(markdownEditor.selectionStart, markdownEditor.selectionEnd) || 'console.log("Hello, Markdown!");') + '\n```\n'); else if (action === 'table') openTableModal(); else if (action === 'date-time') { - const now = new Date(); - const datePart = now.toLocaleDateString('en-CA'); - const timePart = now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); - const dayName = now.toLocaleDateString('en-US', { weekday: 'long' }); - const timestamp = `${datePart} ${timePart} ${dayName}`; + const timestamp = formatMarkdownDateTime(); replaceEditorRange(markdownEditor.selectionStart, markdownEditor.selectionEnd, timestamp, markdownEditor.selectionStart + timestamp.length, markdownEditor.selectionStart + timestamp.length); } else if (action === 'emoji') { openEmojiModal(); diff --git a/tests/e2e/slash-command-actions.spec.js b/tests/e2e/slash-command-actions.spec.js new file mode 100644 index 00000000..26f37ef2 --- /dev/null +++ b/tests/e2e/slash-command-actions.spec.js @@ -0,0 +1,231 @@ +const { test, expect } = require('@playwright/test'); +const { openApp, setEditorContent, stubRemoteDiagramServices, stubLazyRendererLibraries, storedDocuments, waitForAppReady } = require('../helpers/app'); +test.use({ timezoneId: 'UTC' }); + +const mediaId = 'slashAuditMedia0000000001'; +const gif = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'); + +async function chooseUpload(page, editor) { + await enterCommand(page, editor, 'image', 'image'); + await page.locator('#image-source-upload').check(); + await page.locator('#image-modal-file').setInputFiles({ name: 'audit.gif', mimeType: 'image/gif', buffer: gif }); +} + +async function mockMediaDownload(page) { + await page.route('**/api/image/' + mediaId, route => route.fulfill({ contentType: 'image/gif', body: gif })); +} + +async function expectPersistedAfterReload(page, content) { + let saved; + await expect.poll(async () => { + saved = (await storedDocuments(page)).find(doc => doc.content === content); + return saved?.content; + }).toBe(content); + await page.reload(); + await waitForAppReady(page); + // The split layout is transient; verify the saved document independently of its pane. + await expect.poll(async () => (await storedDocuments(page)).find(doc => doc.id === saved.id)?.content).toBe(content); +} + +const prefix = 'Before\n\n'; +const suffix = '\n\nAfter'; +const direct = [ + ['paragraph', 'paragraph', ''], + ...Array.from({ length: 6 }, (_, i) => ['heading-' + (i + 1), 'h' + (i + 1), '#'.repeat(i + 1) + ' ']), + ['bulleted-list', 'bullet', '- '], ['numbered-list', 'number', '1. '], + ['task-list', 'checklist', '- [ ] '], ['blockquote', 'quote', '> '], + ['code-block', 'fence', '```\ncode\n```\n', 'code'], + ['horizontal-rule', 'divider', '---\n'], + ['link', 'hyperlink', '[text](https://example.com)', 'text'], + ['math', 'latex', '$$\nformula\n$$\n', 'formula'], + ['terminal-block', 'shell', '```bash\nnpm run dev\n```\n', 'npm run dev'], + ['date', 'today', null] +]; +async function enterCommand(page, editor, query, id) { + await expect(page.locator('.reset-modal-overlay:visible')).toHaveCount(0); + await editor.evaluate((node, value) => { + node.focus(); node.value = value; node.setSelectionRange(8, 8); + node.dispatchEvent(new Event('input', { bubbles: true })); + }, prefix + suffix); + await editor.pressSequentially('/' + query); + await expect(page.locator('#slash-command-' + id)).toHaveAttribute('aria-selected', 'true'); + await editor.press('Enter'); + await expect(page.locator('#slash-command-menu')).toBeHidden(); +} +for (const pane of ['primary', 'secondary']) { + test.describe(pane, () => { + let editor; + test.beforeEach(async ({ page }) => { + await stubRemoteDiagramServices(page); + await stubLazyRendererLibraries(page); + await openApp(page); + await page.getByRole('button', { name: 'Edit Markdown' }).click(); + if (pane === 'secondary') { + await setEditorContent(page, 'PRIMARY MUST STAY UNCHANGED'); + await page.locator('#tab-new-btn').click(); + const secondId = await page.locator('#tab-list .tab-item.active').getAttribute('data-tab-id'); + await page.locator('#tab-list .tab-item').first().click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Open in split view' }).click(); + await page.locator('#document-split-destination').selectOption(secondId); + await page.locator('#document-split-modal-confirm').click(); + } + editor = page.locator(pane === 'primary' ? '#markdown-editor' : '#document-split-editor'); + await expect(editor).toBeVisible(); + }); + test.afterEach(async ({ page }) => { + if (pane === 'secondary') await expect(page.locator('#markdown-editor')).toHaveValue('PRIMARY MUST STAY UNCHANGED'); + }); + test('all 17 direct commands preserve surrounding text and select the correct placeholder', async ({ page }) => { + await page.clock.setFixedTime(new Date('2026-10-10T10:30:00Z')); + for (const [id, query, insert, selected = ''] of direct) { + await test.step(id, async () => { + const expected = insert === null ? '2026-10-10 10:30 AM Saturday' : insert; + await enterCommand(page, editor, query, id); + await expect(editor).toHaveValue(prefix + expected + suffix); + const selection = await editor.evaluate(node => ({ text: node.value.slice(node.selectionStart, node.selectionEnd), start: node.selectionStart, end: node.selectionEnd })); + expect(selection.text).toBe(selected); + if (selected) { + await editor.pressSequentially('replacement'); + await expect(editor).toHaveValue(prefix + expected.replace(selected, 'replacement') + suffix); + } else expect(selection.start).toBe(prefix.length + expected.length); + }); + } + }); + test('table, all five alerts, diagram, image URL, and video URL insert into the correct editor', async ({ page }) => { + await enterCommand(page, editor, 'table', 'table'); + await page.locator('#table-modal-columns').fill('2'); + await page.locator('#table-modal-rows').fill('2'); + await page.locator('#table-modal-insert').click(); + await expect(editor).toHaveValue(prefix + '| Column 1 | Column 2 |\n| --- | --- |\n| Value | Value |\n' + suffix); + for (const type of ['note', 'tip', 'important', 'warning', 'caution']) { + await enterCommand(page, editor, 'alert', 'alert'); + await page.locator(`#alert-modal [data-alert-type="${type}"]`).click(); + await page.locator('#alert-modal-insert').click(); + await expect(editor).toHaveValue(prefix + `> [!${type.toUpperCase()}]\n> ${type[0].toUpperCase() + type.slice(1)} details go here.\n` + suffix); + } + await enterCommand(page, editor, 'diagram', 'diagram'); + await page.locator('#diagram-modal .diagram-card').first().click(); + const template = await page.locator('#diagram-modal-preview-code').inputValue(); + expect(template).toMatch(/^```/); + await page.locator('#diagram-modal-insert').click(); + await expect.poll(async () => (await editor.inputValue()).slice(prefix.length, -suffix.length).trim()).toBe(template.trim()); + for (const ext of ['png', 'mp4']) { + await enterCommand(page, editor, 'image', 'image'); + await page.locator('#image-modal-url').fill('https://example.com/audit.' + ext); + await page.locator('#image-modal-alt').fill('Audit media'); + await page.locator('#image-modal-insert').click(); + const expected = ext === 'png' ? '![Audit media](https://example.com/audit.png "Audit media")' : ''; + await expect(editor).toHaveValue(prefix + expected + suffix); + } + }); + test('media upload stays in the command target editor (mock upload API)', async ({ page }) => { + await page.route('**/api/image', route => route.fulfill({ json: { id: mediaId } })); + await mockMediaDownload(page); + await chooseUpload(page, editor); + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + await expect(page.locator('#image-modal')).toBeHidden(); + await expect(editor).toHaveValue(new RegExp('^Before\\n\\n!\\[alt text\\]\\(https?://[^)]+/api/image/' + mediaId + '\\)\\n\\nAfter$')); + const insertedValue = await editor.inputValue(); + await expectPersistedAfterReload(page, insertedValue); + }); + + test('reference chooser preserves text and avoids duplicate definition numbers', async ({ page }) => { + await enterCommand(page, editor, 'reference', 'reference'); + await expect(page.locator('#reference-modal-number')).toHaveValue('[1]'); + await page.locator('#reference-modal-url').fill('https://example.com/first'); + await page.locator('#reference-modal-title-input').fill('First source'); + await page.locator('#reference-modal-apply').click(); + const first = prefix + '[1]' + suffix + '\n[1]: https://example.com/first "First source"'; + await expect(editor).toHaveValue(first); + await expect(page.locator('.reset-modal-overlay:visible')).toHaveCount(0); + await editor.evaluate(node => { + node.focus(); + node.setSelectionRange(node.value.length, node.value.length); + }); + await editor.press('End'); + await editor.press('Enter'); + await editor.pressSequentially('/reference'); + await editor.press('Enter'); + await expect(page.locator('#reference-modal-number')).toHaveValue('[2]'); + await page.locator('#reference-modal-number').fill('[1]'); + await page.locator('#reference-modal-url').fill('https://example.com/second'); + await page.locator('#reference-modal-url').press('Enter'); + await expect(editor).toHaveValue(first + '\n[2]\n[2]: https://example.com/second'); + await expect(editor).toBeFocused(); + }); + + test('cancels insertion when the target changes during upload', async ({ page }) => { + let releaseUpload; + const pendingUpload = new Promise(resolve => { releaseUpload = resolve; }); + let uploadStarted = false; + await page.route('**/api/image', async route => { + uploadStarted = true; + await pendingUpload; + await route.fulfill({ json: { id: mediaId } }); + }); + await chooseUpload(page, editor); + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + await expect.poll(() => uploadStarted).toBe(true); + await editor.evaluate(node => { + node.value = 'Updated while upload was pending'; + node.dispatchEvent(new Event('input', { bubbles: true })); + }); + releaseUpload(); + await expect(page.getByText('Media insertion cancelled', { exact: true })).toBeVisible(); + await expect(editor).toHaveValue('Updated while upload was pending'); + await page.locator('#image-modal-cancel').click(); + }); + + test('cancels before uploading when the target changes during consent', async ({ page }) => { + let uploadCount = 0; + await page.route('**/api/image', route => { + uploadCount += 1; + return route.fulfill({ json: { id: mediaId } }); + }); + await chooseUpload(page, editor); + await expect(page.getByRole('button', { name: 'Continue', exact: true })).toBeVisible(); + await editor.evaluate(node => { + node.value = 'Updated while consent was pending'; + node.dispatchEvent(new Event('input', { bubbles: true })); + }); + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + await expect(page.locator('#image-modal-cancel')).toBeEnabled(); + await expect(editor).toHaveValue('Updated while consent was pending'); + expect(uploadCount).toBe(0); + await page.locator('#image-modal-cancel').click(); + }); + + test('restores only the target editor if saving the uploaded link fails', async ({ page }) => { + await page.route('**/api/image', route => route.fulfill({ json: { id: mediaId } })); + await mockMediaDownload(page); + await chooseUpload(page, editor); + await page.evaluate(() => { + const originalPut = IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put = function(value, ...args) { + if (this.name === 'contents' && value.content && value.content.includes('/api/image/')) { + throw new DOMException('Injected media persistence failure', 'QuotaExceededError'); + } + return originalPut.call(this, value, ...args); + }; + }); + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + await expect(page.getByText('The browser could not save the short media link.', { exact: true })).toBeVisible(); + await expect(editor).toHaveValue(prefix + suffix); + await expect(page.locator('#image-modal-cancel')).toBeEnabled(); + await page.locator('#image-modal-cancel').click(); + await expectPersistedAfterReload(page, prefix + suffix); + }); + test('all 22 entries have visible labels and icons; unsupported searches show no results', async ({ page }) => { + await editor.fill('/'); + const menu = page.locator('#slash-command-menu'); + await expect(menu.getByRole('option')).toHaveCount(22); + const entries = await menu.getByRole('option').evaluateAll(nodes => nodes.map(node => ({ label: node.querySelector('.app-menu-label').textContent, icon: node.querySelector('.lucide') ? getComputedStyle(node.querySelector('.lucide')).maskImage !== 'none' : !!node.querySelector('.slash-command-text-icon').textContent }))); + expect(entries.every(entry => entry.label && entry.icon)).toBe(true); + for (const query of ['emoji', 'symbols', 'bold', 'italic', 'comment']) { + await editor.fill('/' + query); + await expect(menu.getByRole('option')).toHaveCount(0); + await expect(menu).toContainText('No matching commands'); + } + }); + }); +} diff --git a/tests/e2e/slash-command-menu.spec.js b/tests/e2e/slash-command-menu.spec.js index 5efc8da9..47aa3b66 100644 --- a/tests/e2e/slash-command-menu.spec.js +++ b/tests/e2e/slash-command-menu.spec.js @@ -58,7 +58,7 @@ test('opens the full alert chooser from the slash menu', async ({ page }) => { test('uses available icons for every slash command', async ({ page }) => { await setEditorContent(page, '/'); const icons = page.locator('#slash-command-menu .slash-command-icon.lucide'); - await expect(icons).toHaveCount(15); + await expect(icons).toHaveCount(16); const missingIcons = await icons.evaluateAll(nodes => nodes .filter(node => getComputedStyle(node).maskImage === 'none' && getComputedStyle(node).webkitMaskImage === 'none') .map(node => node.className)); @@ -206,7 +206,7 @@ for (const width of [900, 375]) { const editor = page.locator('#markdown-editor'); const menu = page.locator('#slash-command-menu'); await editor.pressSequentially('/'); - await expect(menu.getByRole('option')).toHaveCount(21); + await expect(menu.getByRole('option')).toHaveCount(22); await expect.poll(() => slashMenuPlacement(page)).toMatchObject({ belowLine: true, withinViewport: true, scrollable: true }); @@ -219,7 +219,7 @@ for (const width of [900, 375]) { await editor.press('Backspace'); await editor.press('Backspace'); - await expect(menu.getByRole('option')).toHaveCount(21); + await expect(menu.getByRole('option')).toHaveCount(22); await expect.poll(() => slashMenuPlacement(page)).toMatchObject({ belowLine: true, withinViewport: true, scrollable: true }); diff --git a/wiki/Features.md b/wiki/Features.md index e78448fc..a15dd7e4 100644 --- a/wiki/Features.md +++ b/wiki/Features.md @@ -127,7 +127,7 @@ Type `/` at the start of an Editor line to open a searchable block-command menu. The menu shares the application's compact dropdown typography, spacing, surfaces, and Lucide icons in light and dark themes. Heading symbols match the toolbar's text-style menu. Its height adapts to the space above or below the typing line, keeping your text visible while the command list scrolls. -Commands cover headings, paragraphs, lists, quotes, horizontal rules, code and terminal blocks, math, links, references, date/time, alerts, tables, diagrams, and images/media. Commands that need more information open the same alert, table, diagram, or media chooser used by the toolbar. +Commands cover headings, paragraphs, lists, quotes, horizontal rules, code and terminal blocks, math, links, references, date/time, alerts, tables, diagrams, and images/media. Commands that need more information open the same reference, alert, table, diagram, or media chooser used by the toolbar. Date/time uses the toolbar format (date, 12-hour time, and weekday). Uploaded media is saved in the document where the command started; insertion is cancelled if that document changes during upload. The menu opens only for a literal slash at column zero. A slash after other text or indentation, inside a URL, during IME composition, or outside an Editor remains normal text. The menu follows the active primary or secondary Editor, closes when the document or focus context changes, exposes listbox state to assistive technology, and positions correctly for RTL documents. diff --git a/wiki/Usage-Guide.md b/wiki/Usage-Guide.md index f3ad23db..5e6692b5 100644 --- a/wiki/Usage-Guide.md +++ b/wiki/Usage-Guide.md @@ -101,7 +101,7 @@ With the Editor focused, **Page Up** and **Page Down** scroll smoothly without s Type `/` at the very start of an Editor line to open **Insert block**. Type a command name to filter the list, use **Arrow Up/Down** to choose, press **Enter** to run it, or press **Escape** to close the menu. You can also click or tap a command. -The menu includes headings, paragraphs, lists, quotes, horizontal rules, code and terminal blocks, math, links, references, date/time, alerts, tables, diagrams, and images/media. Alert, table, diagram, and media commands open their existing chooser so you can finish the insertion. Typing `/` after indentation or other text, inside a URL, or while composing text with an IME does not open the menu. Slash commands work in either Editor of the two-Document view and follow RTL placement. +The menu includes headings, paragraphs, lists, quotes, horizontal rules, code and terminal blocks, math, links, references, date/time, alerts, tables, diagrams, and images/media. Reference, alert, table, diagram, and media commands open their existing chooser so you can finish the insertion. Typing `/` after indentation or other text, inside a URL, or while composing text with an IME does not open the menu. Slash commands work in either Editor of the two-Document view and follow RTL placement. ### Copying a Document From 84d648912d34439367a24b59e856e24126916889 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Sat, 10 Oct 2026 17:23:53 +0530 Subject: [PATCH 4/5] fix(editor): align split document spacing and placeholders --- desktop-app/resources/index.html | 2 +- desktop-app/resources/styles.css | 11 ++++++++++- index.html | 2 +- styles.css | 11 ++++++++++- wiki/Features.md | 1 + 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index e4192e2f..6901db2b 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -1686,7 +1686,7 @@

Where your content goes