From b085836eb159f6855791a13533dbe453a97488a5 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Mon, 28 Sep 2026 13:36:49 +0300 Subject: [PATCH 1/2] feat(editor): add slash command menu --- desktop-app/resources/js/script.js | 298 +++++++++++++++++++++++++-- desktop-app/resources/styles.css | 86 ++++++++ script.js | 298 +++++++++++++++++++++++++-- styles.css | 86 ++++++++ tests/e2e/slash-command-menu.spec.js | 107 ++++++++++ 5 files changed, 841 insertions(+), 34 deletions(-) create mode 100644 tests/e2e/slash-command-menu.spec.js diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 133e6911..4f6dbcce 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -17886,6 +17886,265 @@ ${selector} .arrowheadPath { return true; } + 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: '# ' }, + { id: 'heading-2', label: 'Heading 2', description: 'Medium section heading', textIcon: 'H2', keywords: 'subtitle h2', insert: '## ' }, + { id: 'heading-3', label: 'Heading 3', description: 'Small section heading', textIcon: 'H3', keywords: 'subtitle h3', insert: '### ' }, + { id: 'heading-4', label: 'Heading 4', description: 'Fourth-level heading', textIcon: 'H4', keywords: 'subtitle h4', insert: '#### ' }, + { id: 'heading-5', label: 'Heading 5', description: 'Fifth-level heading', textIcon: 'H5', keywords: 'subtitle h5', insert: '##### ' }, + { id: 'heading-6', label: 'Heading 6', description: 'Sixth-level heading', textIcon: 'H6', keywords: 'subtitle h6', insert: '###### ' }, + { id: 'bulleted-list', label: 'Bulleted list', description: 'Create an unordered list', icon: 'lucide-list', keywords: 'unordered bullet', insert: '- ' }, + { id: 'numbered-list', label: 'Numbered list', description: 'Create an ordered list', icon: 'lucide-list-ordered', keywords: 'ordered number', insert: '1. ' }, + { id: 'task-list', label: 'Task list', description: 'Create a checklist item', icon: 'lucide-check-check', keywords: 'todo checkbox checklist', insert: '- [ ] ' }, + { id: 'blockquote', label: 'Blockquote', description: 'Insert a quoted paragraph', icon: 'lucide-quote', keywords: 'quote citation', insert: '> ' }, + { 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: '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(); } } + ]); + let slashCommandState = null; + let slashCommandMenu = null; + let slashCommandList = null; + + function ensureSlashCommandMenu() { + if (slashCommandMenu) return slashCommandMenu; + slashCommandMenu = document.createElement('div'); + slashCommandMenu.id = 'slash-command-menu'; + slashCommandMenu.className = 'slash-command-menu'; + slashCommandMenu.hidden = true; + slashCommandMenu.setAttribute('role', 'dialog'); + slashCommandMenu.setAttribute('aria-label', 'Insert Markdown block'); + const header = document.createElement('div'); + header.className = 'slash-command-header'; + header.textContent = 'Insert block'; + slashCommandList = document.createElement('div'); + slashCommandList.className = 'slash-command-list'; + slashCommandList.setAttribute('role', 'listbox'); + slashCommandList.setAttribute('aria-label', 'Markdown commands'); + slashCommandMenu.append(header, slashCommandList); + slashCommandMenu.addEventListener('mousedown', function(event) { event.preventDefault(); }); + document.body.appendChild(slashCommandMenu); + return slashCommandMenu; + } + + function getSlashCommandMatch(editor) { + if (!editor || editor.readOnly || editor.selectionStart !== editor.selectionEnd) return null; + const caret = editor.selectionStart; + const beforeCaret = editor.value.slice(0, caret); + const currentLine = beforeCaret.slice(beforeCaret.lastIndexOf('\n') + 1); + const match = currentLine.match(/^\/([^\s\/]*)$/); + if (!match) return null; + return { start: caret - match[1].length - 1, end: caret, query: match[1].trim().toLowerCase() }; + } + + function getSlashCommandMatches(query) { + const terms = String(query || '').split(/\s+/).filter(Boolean); + return SLASH_COMMANDS.filter(function(command) { + const haystack = (command.label + ' ' + command.keywords).toLowerCase(); + return terms.every(function(term) { return haystack.includes(term); }); + }); + } + + function getSlashMenuCaretPosition(editor) { + const style = window.getComputedStyle(editor); + const mirror = document.createElement('div'); + [ + 'boxSizing', 'width', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', + 'letterSpacing', 'lineHeight', 'paddingTop', 'paddingRight', 'paddingBottom', + 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', + 'borderLeftWidth', 'textAlign', 'textIndent', 'textTransform', 'tabSize' + ].forEach(function(property) { mirror.style[property] = style[property]; }); + mirror.className = 'slash-command-caret-mirror'; + mirror.style.whiteSpace = 'pre-wrap'; + mirror.style.overflowWrap = 'break-word'; + mirror.textContent = editor.value.slice(0, editor.selectionStart); + const marker = document.createElement('span'); + marker.textContent = '\u200b'; + mirror.appendChild(marker); + document.body.appendChild(mirror); + const editorRect = editor.getBoundingClientRect(); + const position = { + left: editorRect.left + marker.offsetLeft - editor.scrollLeft, + top: editorRect.top + marker.offsetTop - editor.scrollTop, + lineHeight: parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.4 || 20 + }; + mirror.remove(); + return position; + } + + function positionSlashCommandMenu(editor) { + 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 left = Math.max(margin, Math.min(caret.left, 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); + slashCommandMenu.style.left = left + 'px'; + slashCommandMenu.style.top = top + 'px'; + } + + function closeSlashCommandMenu(options) { + if (!slashCommandState) return; + const editor = slashCommandState.editor; + slashCommandState = null; + if (slashCommandMenu) slashCommandMenu.hidden = true; + if (editor) { + editor.removeAttribute('aria-controls'); + editor.removeAttribute('aria-activedescendant'); + editor.setAttribute('aria-expanded', 'false'); + if (options && options.focus) editor.focus(); + } + } + + function selectSlashCommand(index) { + if (!slashCommandState || !slashCommandState.commands.length) return; + const count = slashCommandState.commands.length; + slashCommandState.activeIndex = (index + count) % count; + Array.from(slashCommandList.querySelectorAll('.slash-command-item')).forEach(function(item, itemIndex) { + const selected = itemIndex === slashCommandState.activeIndex; + item.classList.toggle('is-active', selected); + item.setAttribute('aria-selected', String(selected)); + if (selected) { + slashCommandState.editor.setAttribute('aria-activedescendant', item.id); + item.scrollIntoView({ block: 'nearest' }); + } + }); + } + + function executeSlashCommand(command) { + if (!slashCommandState || !command) return; + const state = slashCommandState; + if (command.action) { + closeSlashCommandMenu(); + replaceMarkdownEditorRange(state.editor, state.start, state.end, '', state.start, state.start); + requestAnimationFrame(function() { + if (command.action === 'table') openTableModal(state.editor); + 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); + }); + return; + } + const replacement = typeof command.build === 'function' ? command.build() : command.insert; + const selection = command.select || [String(replacement).length, String(replacement).length]; + closeSlashCommandMenu(); + replaceMarkdownEditorRange( + state.editor, + state.start, + state.end, + replacement, + state.start + selection[0], + state.start + selection[1] + ); + } + + function renderSlashCommandMenu() { + if (!slashCommandState) return; + ensureSlashCommandMenu(); + const commands = slashCommandState.commands; + slashCommandList.textContent = ''; + if (!commands.length) { + slashCommandState.editor.removeAttribute('aria-activedescendant'); + const empty = document.createElement('div'); + empty.className = 'slash-command-empty'; + empty.textContent = 'No matching commands'; + slashCommandList.appendChild(empty); + } else { + commands.forEach(function(command, index) { + const item = document.createElement('button'); + item.type = 'button'; + item.id = 'slash-command-' + command.id; + item.className = 'slash-command-item'; + item.setAttribute('role', 'option'); + item.setAttribute('aria-selected', 'false'); + 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.addEventListener('mouseenter', function() { selectSlashCommand(index); }); + item.addEventListener('click', function() { executeSlashCommand(command); }); + slashCommandList.appendChild(item); + }); + } + slashCommandMenu.hidden = false; + slashCommandState.editor.setAttribute('aria-controls', slashCommandMenu.id); + slashCommandState.editor.setAttribute('aria-expanded', 'true'); + selectSlashCommand(Math.min(slashCommandState.activeIndex, Math.max(0, commands.length - 1))); + requestAnimationFrame(function() { + if (slashCommandState) positionSlashCommandMenu(slashCommandState.editor); + }); + } + + function updateSlashCommandMenu(editor) { + if (!isMarkdownEditorEditable(editor)) { + closeSlashCommandMenu(); + return; + } + const match = getSlashCommandMatch(editor); + if (!match) { + if (slashCommandState && slashCommandState.editor === editor) closeSlashCommandMenu(); + return; + } + slashCommandState = { + editor: editor, + start: match.start, + end: match.end, + commands: getSlashCommandMatches(match.query), + activeIndex: slashCommandState && slashCommandState.editor === editor ? slashCommandState.activeIndex : 0 + }; + renderSlashCommandMenu(); + } + + function handleSlashCommandKeydown(event) { + if (!slashCommandState || slashCommandState.editor !== event.currentTarget) return; + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + selectSlashCommand(slashCommandState.activeIndex + (event.key === 'ArrowDown' ? 1 : -1)); + } else if (event.key === 'Enter' && slashCommandState.commands.length) { + event.preventDefault(); + executeSlashCommand(slashCommandState.commands[slashCommandState.activeIndex]); + } else if (event.key === 'Escape') { + event.preventDefault(); + closeSlashCommandMenu({ focus: true }); + } + } + + function initSlashCommandMenu() { + ensureSlashCommandMenu(); + [markdownEditor, documentSplitEditor].forEach(function(editor) { + if (!editor) return; + editor.setAttribute('aria-haspopup', 'listbox'); + editor.setAttribute('aria-expanded', 'false'); + editor.addEventListener('input', function() { updateSlashCommandMenu(editor); }); + editor.addEventListener('click', function() { updateSlashCommandMenu(editor); }); + editor.addEventListener('keydown', handleSlashCommandKeydown); + editor.addEventListener('scroll', function() { + if (slashCommandState && slashCommandState.editor === editor) positionSlashCommandMenu(editor); + }); + }); + document.addEventListener('pointerdown', function(event) { + if (!slashCommandState || slashCommandMenu.contains(event.target) || event.target === slashCommandState.editor) return; + closeSlashCommandMenu(); + }); + window.addEventListener('resize', function() { + if (slashCommandState) positionSlashCommandMenu(slashCommandState.editor); + }); + } + function transformSelectionOrCurrentLine(transformer) { let start = markdownEditor.selectionStart; let end = markdownEditor.selectionEnd; @@ -18463,15 +18722,16 @@ ${selector} .arrowheadPath { }, 1200); } - function openTableModal() { + function openTableModal(editorOverride) { + const editor = editorOverride || markdownEditor; const modal = document.getElementById('table-modal'); const columnInput = document.getElementById('table-modal-columns'); const rowInput = document.getElementById('table-modal-rows'); const confirmBtn = document.getElementById('table-modal-insert'); const cancelBtn = document.getElementById('table-modal-cancel'); if (!modal || !columnInput || !rowInput || !confirmBtn || !cancelBtn) return; - const start = markdownEditor.selectionStart; - const end = markdownEditor.selectionEnd; + const start = editor.selectionStart; + const end = editor.selectionEnd; columnInput.value = '3'; rowInput.value = '3'; modal.style.display = 'flex'; @@ -18484,7 +18744,7 @@ ${selector} .arrowheadPath { modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); cleanup(); - insertMarkdownBlock(table, start, end); + insertMarkdownBlock(table, start, end, editor); } function closeModal() { @@ -18845,15 +19105,16 @@ ${selector} .arrowheadPath { requestAnimationFrame(() => searchInput.focus()); } - function openAlertModal() { + function openAlertModal(editorOverride) { + const editor = editorOverride || markdownEditor; const modal = document.getElementById('alert-modal'); const grid = document.getElementById('alert-modal-grid'); const confirmBtn = document.getElementById('alert-modal-insert'); const cancelBtn = document.getElementById('alert-modal-cancel'); const closeBtn = document.getElementById('alert-modal-close'); if (!modal || !grid || !confirmBtn || !cancelBtn || !closeBtn) return; - const start = markdownEditor.selectionStart; - const end = markdownEditor.selectionEnd; + const start = editor.selectionStart; + const end = editor.selectionEnd; // PERF-007: Clear elements using textContent grid.textContent = ''; @@ -18891,7 +19152,7 @@ ${selector} .arrowheadPath { const block = `> [!${type}]\n> ${body}\n`; cleanup(); closeAppModal(modal); - insertMarkdownBlock(block, start, end); + insertMarkdownBlock(block, start, end, editor); } function closeModal() { @@ -19090,7 +19351,8 @@ ${selector} .arrowheadPath { } } - async function openDiagramModal(opener) { + async function openDiagramModal(opener, editorOverride) { + const editor = editorOverride || markdownEditor; const modal = document.getElementById('diagram-modal'); const sidebar = modal.querySelector('.diagram-modal-sidebar'); const grid = document.getElementById('diagram-modal-grid'); @@ -19112,8 +19374,8 @@ ${selector} .arrowheadPath { } } - const start = markdownEditor.selectionStart; - const end = markdownEditor.selectionEnd; + const start = editor.selectionStart; + const end = editor.selectionEnd; // Clear and reset state searchInput.value = ''; @@ -20268,7 +20530,7 @@ ${selector} .arrowheadPath { if (!selectedTemplate) return; cleanup(); closeAppModal(modal); - insertMarkdownBlock(selectedTemplate.code, start, end); + insertMarkdownBlock(selectedTemplate.code, start, end, editor); } function closeModal() { @@ -20346,7 +20608,8 @@ ${selector} .arrowheadPath { }); } - function insertMarkdownImage() { + function insertMarkdownImage(editorOverride) { + const editor = editorOverride || markdownEditor; const modal = document.getElementById('image-modal'); const uploadOption = document.getElementById('image-source-upload'); const urlOption = document.getElementById('image-source-url'); @@ -20358,9 +20621,9 @@ ${selector} .arrowheadPath { const confirmBtn = document.getElementById('image-modal-insert'); const cancelBtn = document.getElementById('image-modal-cancel'); if (!modal || !uploadOption || !urlOption || !uploadFields || !urlFields || !fileInput || !urlInput || !altInput || !confirmBtn || !cancelBtn) return; - const start = markdownEditor.selectionStart; - const end = markdownEditor.selectionEnd; - const selected = markdownEditor.value.slice(start, end); + const start = editor.selectionStart; + const end = editor.selectionEnd; + const selected = editor.value.slice(start, end); urlInput.value = 'https://'; altInput.value = selected || ''; fileInput.value = ''; @@ -20387,7 +20650,7 @@ ${selector} .arrowheadPath { const replacement = buildMediaMarkdown(safeUrl); modal.style.display = 'none'; cleanup(); - replaceEditorRange(start, end, replacement, start + replacement.length, start + replacement.length); + replaceMarkdownEditorRange(editor, start, end, replacement, start + replacement.length, start + replacement.length); } function setProcessing(processing, stage) { @@ -23682,6 +23945,7 @@ ${selector} .arrowheadPath { // Editor key handlers for list continuation and indentation markdownEditor.addEventListener('keydown', handleMarkdownEditorStructureKeydown); if (documentSplitEditor) documentSplitEditor.addEventListener('keydown', handleMarkdownEditorStructureKeydown); + initSlashCommandMenu(); markdownEditor.addEventListener("scroll", function() { cachedScrollTop = this.scrollTop; diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index 9e1692fb..478aac7d 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -14383,3 +14383,89 @@ body.release-notes-active .mobile-stats-container { break-inside: avoid; } } +.slash-command-menu { + position: fixed; + z-index: 2200; + 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; +} + +.slash-command-list { + max-height: min(372px, calc(100vh - 72px)); + 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); +} + +.slash-command-text-icon { + 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; +} + +.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; + left: -100000px; + visibility: hidden; + min-height: 0; + overflow: hidden; +} diff --git a/script.js b/script.js index 133e6911..4f6dbcce 100644 --- a/script.js +++ b/script.js @@ -17886,6 +17886,265 @@ ${selector} .arrowheadPath { return true; } + 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: '# ' }, + { id: 'heading-2', label: 'Heading 2', description: 'Medium section heading', textIcon: 'H2', keywords: 'subtitle h2', insert: '## ' }, + { id: 'heading-3', label: 'Heading 3', description: 'Small section heading', textIcon: 'H3', keywords: 'subtitle h3', insert: '### ' }, + { id: 'heading-4', label: 'Heading 4', description: 'Fourth-level heading', textIcon: 'H4', keywords: 'subtitle h4', insert: '#### ' }, + { id: 'heading-5', label: 'Heading 5', description: 'Fifth-level heading', textIcon: 'H5', keywords: 'subtitle h5', insert: '##### ' }, + { id: 'heading-6', label: 'Heading 6', description: 'Sixth-level heading', textIcon: 'H6', keywords: 'subtitle h6', insert: '###### ' }, + { id: 'bulleted-list', label: 'Bulleted list', description: 'Create an unordered list', icon: 'lucide-list', keywords: 'unordered bullet', insert: '- ' }, + { id: 'numbered-list', label: 'Numbered list', description: 'Create an ordered list', icon: 'lucide-list-ordered', keywords: 'ordered number', insert: '1. ' }, + { id: 'task-list', label: 'Task list', description: 'Create a checklist item', icon: 'lucide-check-check', keywords: 'todo checkbox checklist', insert: '- [ ] ' }, + { id: 'blockquote', label: 'Blockquote', description: 'Insert a quoted paragraph', icon: 'lucide-quote', keywords: 'quote citation', insert: '> ' }, + { 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: '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(); } } + ]); + let slashCommandState = null; + let slashCommandMenu = null; + let slashCommandList = null; + + function ensureSlashCommandMenu() { + if (slashCommandMenu) return slashCommandMenu; + slashCommandMenu = document.createElement('div'); + slashCommandMenu.id = 'slash-command-menu'; + slashCommandMenu.className = 'slash-command-menu'; + slashCommandMenu.hidden = true; + slashCommandMenu.setAttribute('role', 'dialog'); + slashCommandMenu.setAttribute('aria-label', 'Insert Markdown block'); + const header = document.createElement('div'); + header.className = 'slash-command-header'; + header.textContent = 'Insert block'; + slashCommandList = document.createElement('div'); + slashCommandList.className = 'slash-command-list'; + slashCommandList.setAttribute('role', 'listbox'); + slashCommandList.setAttribute('aria-label', 'Markdown commands'); + slashCommandMenu.append(header, slashCommandList); + slashCommandMenu.addEventListener('mousedown', function(event) { event.preventDefault(); }); + document.body.appendChild(slashCommandMenu); + return slashCommandMenu; + } + + function getSlashCommandMatch(editor) { + if (!editor || editor.readOnly || editor.selectionStart !== editor.selectionEnd) return null; + const caret = editor.selectionStart; + const beforeCaret = editor.value.slice(0, caret); + const currentLine = beforeCaret.slice(beforeCaret.lastIndexOf('\n') + 1); + const match = currentLine.match(/^\/([^\s\/]*)$/); + if (!match) return null; + return { start: caret - match[1].length - 1, end: caret, query: match[1].trim().toLowerCase() }; + } + + function getSlashCommandMatches(query) { + const terms = String(query || '').split(/\s+/).filter(Boolean); + return SLASH_COMMANDS.filter(function(command) { + const haystack = (command.label + ' ' + command.keywords).toLowerCase(); + return terms.every(function(term) { return haystack.includes(term); }); + }); + } + + function getSlashMenuCaretPosition(editor) { + const style = window.getComputedStyle(editor); + const mirror = document.createElement('div'); + [ + 'boxSizing', 'width', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', + 'letterSpacing', 'lineHeight', 'paddingTop', 'paddingRight', 'paddingBottom', + 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', + 'borderLeftWidth', 'textAlign', 'textIndent', 'textTransform', 'tabSize' + ].forEach(function(property) { mirror.style[property] = style[property]; }); + mirror.className = 'slash-command-caret-mirror'; + mirror.style.whiteSpace = 'pre-wrap'; + mirror.style.overflowWrap = 'break-word'; + mirror.textContent = editor.value.slice(0, editor.selectionStart); + const marker = document.createElement('span'); + marker.textContent = '\u200b'; + mirror.appendChild(marker); + document.body.appendChild(mirror); + const editorRect = editor.getBoundingClientRect(); + const position = { + left: editorRect.left + marker.offsetLeft - editor.scrollLeft, + top: editorRect.top + marker.offsetTop - editor.scrollTop, + lineHeight: parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.4 || 20 + }; + mirror.remove(); + return position; + } + + function positionSlashCommandMenu(editor) { + 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 left = Math.max(margin, Math.min(caret.left, 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); + slashCommandMenu.style.left = left + 'px'; + slashCommandMenu.style.top = top + 'px'; + } + + function closeSlashCommandMenu(options) { + if (!slashCommandState) return; + const editor = slashCommandState.editor; + slashCommandState = null; + if (slashCommandMenu) slashCommandMenu.hidden = true; + if (editor) { + editor.removeAttribute('aria-controls'); + editor.removeAttribute('aria-activedescendant'); + editor.setAttribute('aria-expanded', 'false'); + if (options && options.focus) editor.focus(); + } + } + + function selectSlashCommand(index) { + if (!slashCommandState || !slashCommandState.commands.length) return; + const count = slashCommandState.commands.length; + slashCommandState.activeIndex = (index + count) % count; + Array.from(slashCommandList.querySelectorAll('.slash-command-item')).forEach(function(item, itemIndex) { + const selected = itemIndex === slashCommandState.activeIndex; + item.classList.toggle('is-active', selected); + item.setAttribute('aria-selected', String(selected)); + if (selected) { + slashCommandState.editor.setAttribute('aria-activedescendant', item.id); + item.scrollIntoView({ block: 'nearest' }); + } + }); + } + + function executeSlashCommand(command) { + if (!slashCommandState || !command) return; + const state = slashCommandState; + if (command.action) { + closeSlashCommandMenu(); + replaceMarkdownEditorRange(state.editor, state.start, state.end, '', state.start, state.start); + requestAnimationFrame(function() { + if (command.action === 'table') openTableModal(state.editor); + 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); + }); + return; + } + const replacement = typeof command.build === 'function' ? command.build() : command.insert; + const selection = command.select || [String(replacement).length, String(replacement).length]; + closeSlashCommandMenu(); + replaceMarkdownEditorRange( + state.editor, + state.start, + state.end, + replacement, + state.start + selection[0], + state.start + selection[1] + ); + } + + function renderSlashCommandMenu() { + if (!slashCommandState) return; + ensureSlashCommandMenu(); + const commands = slashCommandState.commands; + slashCommandList.textContent = ''; + if (!commands.length) { + slashCommandState.editor.removeAttribute('aria-activedescendant'); + const empty = document.createElement('div'); + empty.className = 'slash-command-empty'; + empty.textContent = 'No matching commands'; + slashCommandList.appendChild(empty); + } else { + commands.forEach(function(command, index) { + const item = document.createElement('button'); + item.type = 'button'; + item.id = 'slash-command-' + command.id; + item.className = 'slash-command-item'; + item.setAttribute('role', 'option'); + item.setAttribute('aria-selected', 'false'); + 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.addEventListener('mouseenter', function() { selectSlashCommand(index); }); + item.addEventListener('click', function() { executeSlashCommand(command); }); + slashCommandList.appendChild(item); + }); + } + slashCommandMenu.hidden = false; + slashCommandState.editor.setAttribute('aria-controls', slashCommandMenu.id); + slashCommandState.editor.setAttribute('aria-expanded', 'true'); + selectSlashCommand(Math.min(slashCommandState.activeIndex, Math.max(0, commands.length - 1))); + requestAnimationFrame(function() { + if (slashCommandState) positionSlashCommandMenu(slashCommandState.editor); + }); + } + + function updateSlashCommandMenu(editor) { + if (!isMarkdownEditorEditable(editor)) { + closeSlashCommandMenu(); + return; + } + const match = getSlashCommandMatch(editor); + if (!match) { + if (slashCommandState && slashCommandState.editor === editor) closeSlashCommandMenu(); + return; + } + slashCommandState = { + editor: editor, + start: match.start, + end: match.end, + commands: getSlashCommandMatches(match.query), + activeIndex: slashCommandState && slashCommandState.editor === editor ? slashCommandState.activeIndex : 0 + }; + renderSlashCommandMenu(); + } + + function handleSlashCommandKeydown(event) { + if (!slashCommandState || slashCommandState.editor !== event.currentTarget) return; + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + selectSlashCommand(slashCommandState.activeIndex + (event.key === 'ArrowDown' ? 1 : -1)); + } else if (event.key === 'Enter' && slashCommandState.commands.length) { + event.preventDefault(); + executeSlashCommand(slashCommandState.commands[slashCommandState.activeIndex]); + } else if (event.key === 'Escape') { + event.preventDefault(); + closeSlashCommandMenu({ focus: true }); + } + } + + function initSlashCommandMenu() { + ensureSlashCommandMenu(); + [markdownEditor, documentSplitEditor].forEach(function(editor) { + if (!editor) return; + editor.setAttribute('aria-haspopup', 'listbox'); + editor.setAttribute('aria-expanded', 'false'); + editor.addEventListener('input', function() { updateSlashCommandMenu(editor); }); + editor.addEventListener('click', function() { updateSlashCommandMenu(editor); }); + editor.addEventListener('keydown', handleSlashCommandKeydown); + editor.addEventListener('scroll', function() { + if (slashCommandState && slashCommandState.editor === editor) positionSlashCommandMenu(editor); + }); + }); + document.addEventListener('pointerdown', function(event) { + if (!slashCommandState || slashCommandMenu.contains(event.target) || event.target === slashCommandState.editor) return; + closeSlashCommandMenu(); + }); + window.addEventListener('resize', function() { + if (slashCommandState) positionSlashCommandMenu(slashCommandState.editor); + }); + } + function transformSelectionOrCurrentLine(transformer) { let start = markdownEditor.selectionStart; let end = markdownEditor.selectionEnd; @@ -18463,15 +18722,16 @@ ${selector} .arrowheadPath { }, 1200); } - function openTableModal() { + function openTableModal(editorOverride) { + const editor = editorOverride || markdownEditor; const modal = document.getElementById('table-modal'); const columnInput = document.getElementById('table-modal-columns'); const rowInput = document.getElementById('table-modal-rows'); const confirmBtn = document.getElementById('table-modal-insert'); const cancelBtn = document.getElementById('table-modal-cancel'); if (!modal || !columnInput || !rowInput || !confirmBtn || !cancelBtn) return; - const start = markdownEditor.selectionStart; - const end = markdownEditor.selectionEnd; + const start = editor.selectionStart; + const end = editor.selectionEnd; columnInput.value = '3'; rowInput.value = '3'; modal.style.display = 'flex'; @@ -18484,7 +18744,7 @@ ${selector} .arrowheadPath { modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); cleanup(); - insertMarkdownBlock(table, start, end); + insertMarkdownBlock(table, start, end, editor); } function closeModal() { @@ -18845,15 +19105,16 @@ ${selector} .arrowheadPath { requestAnimationFrame(() => searchInput.focus()); } - function openAlertModal() { + function openAlertModal(editorOverride) { + const editor = editorOverride || markdownEditor; const modal = document.getElementById('alert-modal'); const grid = document.getElementById('alert-modal-grid'); const confirmBtn = document.getElementById('alert-modal-insert'); const cancelBtn = document.getElementById('alert-modal-cancel'); const closeBtn = document.getElementById('alert-modal-close'); if (!modal || !grid || !confirmBtn || !cancelBtn || !closeBtn) return; - const start = markdownEditor.selectionStart; - const end = markdownEditor.selectionEnd; + const start = editor.selectionStart; + const end = editor.selectionEnd; // PERF-007: Clear elements using textContent grid.textContent = ''; @@ -18891,7 +19152,7 @@ ${selector} .arrowheadPath { const block = `> [!${type}]\n> ${body}\n`; cleanup(); closeAppModal(modal); - insertMarkdownBlock(block, start, end); + insertMarkdownBlock(block, start, end, editor); } function closeModal() { @@ -19090,7 +19351,8 @@ ${selector} .arrowheadPath { } } - async function openDiagramModal(opener) { + async function openDiagramModal(opener, editorOverride) { + const editor = editorOverride || markdownEditor; const modal = document.getElementById('diagram-modal'); const sidebar = modal.querySelector('.diagram-modal-sidebar'); const grid = document.getElementById('diagram-modal-grid'); @@ -19112,8 +19374,8 @@ ${selector} .arrowheadPath { } } - const start = markdownEditor.selectionStart; - const end = markdownEditor.selectionEnd; + const start = editor.selectionStart; + const end = editor.selectionEnd; // Clear and reset state searchInput.value = ''; @@ -20268,7 +20530,7 @@ ${selector} .arrowheadPath { if (!selectedTemplate) return; cleanup(); closeAppModal(modal); - insertMarkdownBlock(selectedTemplate.code, start, end); + insertMarkdownBlock(selectedTemplate.code, start, end, editor); } function closeModal() { @@ -20346,7 +20608,8 @@ ${selector} .arrowheadPath { }); } - function insertMarkdownImage() { + function insertMarkdownImage(editorOverride) { + const editor = editorOverride || markdownEditor; const modal = document.getElementById('image-modal'); const uploadOption = document.getElementById('image-source-upload'); const urlOption = document.getElementById('image-source-url'); @@ -20358,9 +20621,9 @@ ${selector} .arrowheadPath { const confirmBtn = document.getElementById('image-modal-insert'); const cancelBtn = document.getElementById('image-modal-cancel'); if (!modal || !uploadOption || !urlOption || !uploadFields || !urlFields || !fileInput || !urlInput || !altInput || !confirmBtn || !cancelBtn) return; - const start = markdownEditor.selectionStart; - const end = markdownEditor.selectionEnd; - const selected = markdownEditor.value.slice(start, end); + const start = editor.selectionStart; + const end = editor.selectionEnd; + const selected = editor.value.slice(start, end); urlInput.value = 'https://'; altInput.value = selected || ''; fileInput.value = ''; @@ -20387,7 +20650,7 @@ ${selector} .arrowheadPath { const replacement = buildMediaMarkdown(safeUrl); modal.style.display = 'none'; cleanup(); - replaceEditorRange(start, end, replacement, start + replacement.length, start + replacement.length); + replaceMarkdownEditorRange(editor, start, end, replacement, start + replacement.length, start + replacement.length); } function setProcessing(processing, stage) { @@ -23682,6 +23945,7 @@ ${selector} .arrowheadPath { // Editor key handlers for list continuation and indentation markdownEditor.addEventListener('keydown', handleMarkdownEditorStructureKeydown); if (documentSplitEditor) documentSplitEditor.addEventListener('keydown', handleMarkdownEditorStructureKeydown); + initSlashCommandMenu(); markdownEditor.addEventListener("scroll", function() { cachedScrollTop = this.scrollTop; diff --git a/styles.css b/styles.css index 9e1692fb..478aac7d 100644 --- a/styles.css +++ b/styles.css @@ -14383,3 +14383,89 @@ body.release-notes-active .mobile-stats-container { break-inside: avoid; } } +.slash-command-menu { + position: fixed; + z-index: 2200; + 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; +} + +.slash-command-list { + max-height: min(372px, calc(100vh - 72px)); + 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); +} + +.slash-command-text-icon { + 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; +} + +.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; + left: -100000px; + visibility: hidden; + min-height: 0; + overflow: hidden; +} diff --git a/tests/e2e/slash-command-menu.spec.js b/tests/e2e/slash-command-menu.spec.js new file mode 100644 index 00000000..5cc726fa --- /dev/null +++ b/tests/e2e/slash-command-menu.spec.js @@ -0,0 +1,107 @@ +const { test, expect } = require('@playwright/test'); +const { openApp, setEditorContent, editorValue } = require('../helpers/app'); + +test.beforeEach(async ({ page }) => { + await openApp(page); + await page.getByRole('button', { name: 'Edit Markdown' }).click(); +}); + +test('opens at a line slash and filters commands while typing', async ({ page }) => { + const pageErrors = []; + page.on('pageerror', error => pageErrors.push(error.stack || error.message)); + await setEditorContent(page, ''); + const editor = page.locator('#markdown-editor'); + await editor.pressSequentially('/hea'); + + const menu = page.locator('#slash-command-menu'); + await expect(menu).toBeVisible(); + await expect(menu.getByRole('option')).toHaveCount(6); + await expect(menu.getByRole('option').first()).toContainText('Heading 1'); + await expect(menu.locator('.slash-command-text-icon')).toHaveCount(6); + await expect(editor).toHaveAttribute('aria-expanded', 'true'); + await expect.poll(() => pageErrors.filter(error => error.includes('positionSlashCommandMenu'))).toEqual([]); +}); + +test('opens the full alert chooser from the slash menu', async ({ page }) => { + await setEditorContent(page, ''); + const editor = page.locator('#markdown-editor'); + await editor.pressSequentially('/alert'); + await editor.press('Enter'); + + await expect(page.locator('#alert-modal')).toBeVisible(); + await expect(page.locator('#alert-modal .alert-option')).toHaveCount(5); + await expect(page.locator('#alert-modal .alert-option[data-alert-type="caution"]')).toBeVisible(); + await expect.poll(() => editorValue(page)).toBe(''); +}); + +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 + .filter(node => getComputedStyle(node).maskImage === 'none' && getComputedStyle(node).webkitMaskImage === 'none') + .map(node => node.className)); + expect(missingIcons).toEqual([]); +}); + +test('opens table, diagram, and media choosers from slash commands', async ({ page }) => { + const editor = page.locator('#markdown-editor'); + await setEditorContent(page, '/table'); + await editor.press('Enter'); + await expect(page.locator('#table-modal')).toBeVisible(); + await page.locator('#table-modal-cancel').click(); + + await setEditorContent(page, '/diagram'); + await editor.press('Enter'); + await expect(page.locator('#diagram-modal')).toBeVisible(); + await page.locator('#diagram-modal-cancel').click(); + + await setEditorContent(page, '/image'); + await editor.press('Enter'); + await expect(page.locator('#image-modal')).toBeVisible(); +}); + +test('inserts the selected command with the keyboard', async ({ page }) => { + await setEditorContent(page, 'Intro\n'); + const editor = page.locator('#markdown-editor'); + await editor.pressSequentially('/task'); + await expect(page.locator('#slash-command-menu')).toBeVisible(); + await editor.press('Enter'); + + await expect.poll(() => editorValue(page)).toBe('Intro\n- [ ] '); + await expect(page.locator('#slash-command-menu')).toBeHidden(); + await expect(editor).toHaveAttribute('aria-expanded', 'false'); +}); + +test('supports arrow navigation and escape without changing text', async ({ page }) => { + await setEditorContent(page, ''); + const editor = page.locator('#markdown-editor'); + await editor.pressSequentially('/'); + await editor.press('ArrowDown'); + await editor.press('Enter'); + + await expect.poll(() => editorValue(page)).toBe('# '); + + await setEditorContent(page, '/quo'); + await expect(page.locator('#slash-command-menu')).toBeVisible(); + await editor.press('Escape'); + await expect(page.locator('#slash-command-menu')).toBeHidden(); + await expect.poll(() => editorValue(page)).toBe('/quo'); +}); + +test('does not open for slashes inside normal text or URLs', async ({ page }) => { + await setEditorContent(page, 'Visit https://example.com/'); + await expect(page.locator('#slash-command-menu')).toBeHidden(); + + const editor = page.locator('#markdown-editor'); + await editor.pressSequentially('docs'); + await expect(page.locator('#slash-command-menu')).toBeHidden(); +}); + +test('does not open after text or indentation on the current line', async ({ page }) => { + await setEditorContent(page, 'Insert '); + const editor = page.locator('#markdown-editor'); + await editor.pressSequentially('/hea'); + await expect(page.locator('#slash-command-menu')).toBeHidden(); + + await setEditorContent(page, ' /hea'); + await expect(page.locator('#slash-command-menu')).toBeHidden(); +}); From 7ebb35a5b3b23762b30350e20e7c02fc77a00612 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Tue, 29 Sep 2026 14:34:12 +0300 Subject: [PATCH 2/2] fix(editor): harden slash menu lifecycle and accessibility --- desktop-app/resources/js/script.js | 34 +++++++++++--- script.js | 34 +++++++++++--- tests/e2e/slash-command-menu.spec.js | 70 ++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 12 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 4f6dbcce..0a10b92e 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -12243,6 +12243,7 @@ document.addEventListener("DOMContentLoaded", async function () { } catch (_) { return; } + closeSlashCommandMenu(); tab.isOpen = true; const previousActiveTabId = activeTabId; const swapSplitPanes = tabId === secondarySplitTabId; @@ -12296,6 +12297,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function newTab(content, title, location) { + closeSlashCommandMenu(); if (content === undefined) content = ''; const targetLocation = location || (documentOrganization ? getPreferredDocumentLocation() : { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); if (targetLocation.workspaceId === SECRET_WORKSPACE_ID && !isSecretWorkspaceUnlocked()) { @@ -17912,6 +17914,7 @@ ${selector} .arrowheadPath { let slashCommandState = null; let slashCommandMenu = null; let slashCommandList = null; + const slashComposingEditors = new WeakSet(); function ensureSlashCommandMenu() { if (slashCommandMenu) return slashCommandMenu; @@ -17919,12 +17922,11 @@ ${selector} .arrowheadPath { slashCommandMenu.id = 'slash-command-menu'; slashCommandMenu.className = 'slash-command-menu'; slashCommandMenu.hidden = true; - slashCommandMenu.setAttribute('role', 'dialog'); - slashCommandMenu.setAttribute('aria-label', 'Insert Markdown block'); const header = document.createElement('div'); header.className = 'slash-command-header'; header.textContent = 'Insert block'; slashCommandList = document.createElement('div'); + slashCommandList.id = 'slash-command-list'; slashCommandList.className = 'slash-command-list'; slashCommandList.setAttribute('role', 'listbox'); slashCommandList.setAttribute('aria-label', 'Markdown commands'); @@ -17959,7 +17961,7 @@ ${selector} .arrowheadPath { 'boxSizing', 'width', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'letterSpacing', 'lineHeight', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', - 'borderLeftWidth', 'textAlign', 'textIndent', 'textTransform', 'tabSize' + 'borderLeftWidth', 'direction', 'textAlign', 'textIndent', 'textTransform', 'tabSize' ].forEach(function(property) { mirror.style[property] = style[property]; }); mirror.className = 'slash-command-caret-mirror'; mirror.style.whiteSpace = 'pre-wrap'; @@ -17985,7 +17987,8 @@ ${selector} .arrowheadPath { const margin = 8; const width = Math.min(340, window.innerWidth - margin * 2); slashCommandMenu.style.width = width + 'px'; - const left = Math.max(margin, Math.min(caret.left, window.innerWidth - width - margin)); + 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); @@ -18024,6 +18027,12 @@ ${selector} .arrowheadPath { function executeSlashCommand(command) { if (!slashCommandState || !command) return; const state = slashCommandState; + const match = getSlashCommandMatch(state.editor); + if (document.activeElement !== state.editor || !isMarkdownEditorEditable(state.editor) || + !match || match.start !== state.start || match.end !== state.end) { + closeSlashCommandMenu(); + return; + } if (command.action) { closeSlashCommandMenu(); replaceMarkdownEditorRange(state.editor, state.start, state.end, '', state.start, state.start); @@ -18065,6 +18074,7 @@ ${selector} .arrowheadPath { item.type = 'button'; item.id = 'slash-command-' + command.id; item.className = 'slash-command-item'; + item.tabIndex = -1; item.setAttribute('role', 'option'); item.setAttribute('aria-selected', 'false'); item.innerHTML = '' + (command.textIcon @@ -18081,7 +18091,7 @@ ${selector} .arrowheadPath { }); } slashCommandMenu.hidden = false; - slashCommandState.editor.setAttribute('aria-controls', slashCommandMenu.id); + slashCommandState.editor.setAttribute('aria-controls', slashCommandList.id); slashCommandState.editor.setAttribute('aria-expanded', 'true'); selectSlashCommand(Math.min(slashCommandState.activeIndex, Math.max(0, commands.length - 1))); requestAnimationFrame(function() { @@ -18090,7 +18100,7 @@ ${selector} .arrowheadPath { } function updateSlashCommandMenu(editor) { - if (!isMarkdownEditorEditable(editor)) { + if (document.activeElement !== editor || slashComposingEditors.has(editor) || !isMarkdownEditorEditable(editor)) { closeSlashCommandMenu(); return; } @@ -18111,6 +18121,7 @@ ${selector} .arrowheadPath { function handleSlashCommandKeydown(event) { if (!slashCommandState || slashCommandState.editor !== event.currentTarget) return; + if (event.isComposing || event.keyCode === 229 || slashComposingEditors.has(event.currentTarget)) return; if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); selectSlashCommand(slashCommandState.activeIndex + (event.key === 'ArrowDown' ? 1 : -1)); @@ -18132,6 +18143,17 @@ ${selector} .arrowheadPath { editor.addEventListener('input', function() { updateSlashCommandMenu(editor); }); editor.addEventListener('click', function() { updateSlashCommandMenu(editor); }); editor.addEventListener('keydown', handleSlashCommandKeydown); + editor.addEventListener('compositionstart', function() { + slashComposingEditors.add(editor); + if (slashCommandState && slashCommandState.editor === editor) closeSlashCommandMenu(); + }); + editor.addEventListener('compositionend', function() { + slashComposingEditors.delete(editor); + updateSlashCommandMenu(editor); + }); + editor.addEventListener('blur', function() { + if (slashCommandState && slashCommandState.editor === editor) closeSlashCommandMenu(); + }); editor.addEventListener('scroll', function() { if (slashCommandState && slashCommandState.editor === editor) positionSlashCommandMenu(editor); }); diff --git a/script.js b/script.js index 4f6dbcce..0a10b92e 100644 --- a/script.js +++ b/script.js @@ -12243,6 +12243,7 @@ document.addEventListener("DOMContentLoaded", async function () { } catch (_) { return; } + closeSlashCommandMenu(); tab.isOpen = true; const previousActiveTabId = activeTabId; const swapSplitPanes = tabId === secondarySplitTabId; @@ -12296,6 +12297,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function newTab(content, title, location) { + closeSlashCommandMenu(); if (content === undefined) content = ''; const targetLocation = location || (documentOrganization ? getPreferredDocumentLocation() : { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); if (targetLocation.workspaceId === SECRET_WORKSPACE_ID && !isSecretWorkspaceUnlocked()) { @@ -17912,6 +17914,7 @@ ${selector} .arrowheadPath { let slashCommandState = null; let slashCommandMenu = null; let slashCommandList = null; + const slashComposingEditors = new WeakSet(); function ensureSlashCommandMenu() { if (slashCommandMenu) return slashCommandMenu; @@ -17919,12 +17922,11 @@ ${selector} .arrowheadPath { slashCommandMenu.id = 'slash-command-menu'; slashCommandMenu.className = 'slash-command-menu'; slashCommandMenu.hidden = true; - slashCommandMenu.setAttribute('role', 'dialog'); - slashCommandMenu.setAttribute('aria-label', 'Insert Markdown block'); const header = document.createElement('div'); header.className = 'slash-command-header'; header.textContent = 'Insert block'; slashCommandList = document.createElement('div'); + slashCommandList.id = 'slash-command-list'; slashCommandList.className = 'slash-command-list'; slashCommandList.setAttribute('role', 'listbox'); slashCommandList.setAttribute('aria-label', 'Markdown commands'); @@ -17959,7 +17961,7 @@ ${selector} .arrowheadPath { 'boxSizing', 'width', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'letterSpacing', 'lineHeight', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', - 'borderLeftWidth', 'textAlign', 'textIndent', 'textTransform', 'tabSize' + 'borderLeftWidth', 'direction', 'textAlign', 'textIndent', 'textTransform', 'tabSize' ].forEach(function(property) { mirror.style[property] = style[property]; }); mirror.className = 'slash-command-caret-mirror'; mirror.style.whiteSpace = 'pre-wrap'; @@ -17985,7 +17987,8 @@ ${selector} .arrowheadPath { const margin = 8; const width = Math.min(340, window.innerWidth - margin * 2); slashCommandMenu.style.width = width + 'px'; - const left = Math.max(margin, Math.min(caret.left, window.innerWidth - width - margin)); + 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); @@ -18024,6 +18027,12 @@ ${selector} .arrowheadPath { function executeSlashCommand(command) { if (!slashCommandState || !command) return; const state = slashCommandState; + const match = getSlashCommandMatch(state.editor); + if (document.activeElement !== state.editor || !isMarkdownEditorEditable(state.editor) || + !match || match.start !== state.start || match.end !== state.end) { + closeSlashCommandMenu(); + return; + } if (command.action) { closeSlashCommandMenu(); replaceMarkdownEditorRange(state.editor, state.start, state.end, '', state.start, state.start); @@ -18065,6 +18074,7 @@ ${selector} .arrowheadPath { item.type = 'button'; item.id = 'slash-command-' + command.id; item.className = 'slash-command-item'; + item.tabIndex = -1; item.setAttribute('role', 'option'); item.setAttribute('aria-selected', 'false'); item.innerHTML = '' + (command.textIcon @@ -18081,7 +18091,7 @@ ${selector} .arrowheadPath { }); } slashCommandMenu.hidden = false; - slashCommandState.editor.setAttribute('aria-controls', slashCommandMenu.id); + slashCommandState.editor.setAttribute('aria-controls', slashCommandList.id); slashCommandState.editor.setAttribute('aria-expanded', 'true'); selectSlashCommand(Math.min(slashCommandState.activeIndex, Math.max(0, commands.length - 1))); requestAnimationFrame(function() { @@ -18090,7 +18100,7 @@ ${selector} .arrowheadPath { } function updateSlashCommandMenu(editor) { - if (!isMarkdownEditorEditable(editor)) { + if (document.activeElement !== editor || slashComposingEditors.has(editor) || !isMarkdownEditorEditable(editor)) { closeSlashCommandMenu(); return; } @@ -18111,6 +18121,7 @@ ${selector} .arrowheadPath { function handleSlashCommandKeydown(event) { if (!slashCommandState || slashCommandState.editor !== event.currentTarget) return; + if (event.isComposing || event.keyCode === 229 || slashComposingEditors.has(event.currentTarget)) return; if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); selectSlashCommand(slashCommandState.activeIndex + (event.key === 'ArrowDown' ? 1 : -1)); @@ -18132,6 +18143,17 @@ ${selector} .arrowheadPath { editor.addEventListener('input', function() { updateSlashCommandMenu(editor); }); editor.addEventListener('click', function() { updateSlashCommandMenu(editor); }); editor.addEventListener('keydown', handleSlashCommandKeydown); + editor.addEventListener('compositionstart', function() { + slashComposingEditors.add(editor); + if (slashCommandState && slashCommandState.editor === editor) closeSlashCommandMenu(); + }); + editor.addEventListener('compositionend', function() { + slashComposingEditors.delete(editor); + updateSlashCommandMenu(editor); + }); + editor.addEventListener('blur', function() { + if (slashCommandState && slashCommandState.editor === editor) closeSlashCommandMenu(); + }); editor.addEventListener('scroll', function() { if (slashCommandState && slashCommandState.editor === editor) positionSlashCommandMenu(editor); }); diff --git a/tests/e2e/slash-command-menu.spec.js b/tests/e2e/slash-command-menu.spec.js index 5cc726fa..25ac93d7 100644 --- a/tests/e2e/slash-command-menu.spec.js +++ b/tests/e2e/slash-command-menu.spec.js @@ -105,3 +105,73 @@ test('does not open after text or indentation on the current line', async ({ pag await setEditorContent(page, ' /hea'); await expect(page.locator('#slash-command-menu')).toBeHidden(); }); + +test('closes when a keyboard shortcut creates another document', async ({ page }) => { + await setEditorContent(page, '/'); + const editor = page.locator('#markdown-editor'); + await expect(page.locator('#slash-command-menu')).toBeVisible(); + + await editor.press('Alt+Shift+t'); + await expect(page.locator('#tab-list .tab-item')).toHaveCount(2); + await expect(page.locator('#slash-command-menu')).toBeHidden(); + await expect(editor).toHaveAttribute('aria-expanded', 'false'); + await expect.poll(() => editorValue(page)).toBe(''); +}); + +test('does not execute commands during IME composition', async ({ page }) => { + await setEditorContent(page, '/'); + const editor = page.locator('#markdown-editor'); + await expect(page.locator('#slash-command-menu')).toBeVisible(); + + await editor.evaluate(node => { + node.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', isComposing: true, bubbles: true, cancelable: true + })); + }); + await expect.poll(() => editorValue(page)).toBe('/'); + await expect(page.locator('#slash-command-menu')).toBeVisible(); + + await editor.dispatchEvent('compositionstart'); + await expect(page.locator('#slash-command-menu')).toBeHidden(); + await editor.dispatchEvent('compositionend'); + await expect(page.locator('#slash-command-menu')).toBeVisible(); +}); + +test('closes when Find takes focus from the editor', async ({ page }) => { + await setEditorContent(page, '/'); + const editor = page.locator('#markdown-editor'); + await expect(page.locator('#slash-command-menu')).toBeVisible(); + + await editor.press('Control+f'); + await expect(page.locator('#find-replace-input')).toBeFocused(); + await expect(page.locator('#slash-command-menu')).toBeHidden(); + await expect(editor).toHaveAttribute('aria-expanded', 'false'); +}); + +test('exposes the listbox to assistive technology and anchors it inside an RTL editor', async ({ page }) => { + await page.evaluate(() => { document.documentElement.dir = 'rtl'; }); + await setEditorContent(page, '/'); + const editor = page.locator('#markdown-editor'); + const menu = page.locator('#slash-command-menu'); + await expect(menu).toBeVisible(); + await expect(editor).toHaveAttribute('aria-controls', 'slash-command-list'); + await expect(page.locator('#slash-command-list')).toHaveAttribute('role', 'listbox'); + const activeId = await editor.getAttribute('aria-activedescendant'); + expect(activeId).toBeTruthy(); + await expect(page.locator('#slash-command-list').locator('#' + activeId)).toHaveAttribute('role', 'option'); + await expect(menu.getByRole('option').first()).toHaveAttribute('tabindex', '-1'); + + const position = await page.evaluate(() => { + const editorRect = document.getElementById('markdown-editor').getBoundingClientRect(); + const menuRect = document.getElementById('slash-command-menu').getBoundingClientRect(); + return { editorRight: editorRect.right, menuRight: menuRect.right, menuLeft: menuRect.left }; + }); + expect(position.menuRight).toBeLessThanOrEqual(position.editorRight + 8); + expect(position.menuLeft).toBeGreaterThanOrEqual(8); +}); + +test('still inserts a command selected with the mouse', async ({ page }) => { + await setEditorContent(page, '/heading'); + await page.locator('#slash-command-menu').getByRole('option', { name: /Heading 2/ }).click(); + await expect.poll(() => editorValue(page)).toBe('## '); +});