diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index e9e3e0f3..5f95112d 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -6608,35 +6608,150 @@ document.addEventListener("DOMContentLoaded", async function () { return true; } + const CLIPBOARD_INHERITED_STYLES = [ + 'font-family', 'font-size', 'font-style', 'font-weight', 'line-height', + 'color', 'text-align', 'white-space', 'word-break', 'overflow-wrap', + 'direction', 'list-style-type', 'letter-spacing', 'visibility' + ]; + const CLIPBOARD_EXCLUDED_ELEMENTS = + '.diagram-status, .diagram-toolbar, .mermaid-toolbar, .abc-toolbar, ' + + '.plantuml-toolbar, .d2-toolbar, .graphviz-toolbar, .stl-toolbar, .review-target-actions, ' + + '.code-preview-toolbar, .review-pins-layer, button, [role="toolbar"], script, style, mjx-assistive-mml'; + function getPreviewSelectionClipboardContent(surface, selection) { if (!selectionBelongsToElement(selection, surface) || selection.isCollapsed || !selection.rangeCount) return null; const range = selection.getRangeAt(0); + const styles = new WeakMap(); + const sources = new WeakMap(); + function readStyle(source) { + if (!styles.has(source)) { + const computed = window.getComputedStyle(source); + const inherited = {}; + CLIPBOARD_INHERITED_STYLES.forEach(function(property) { + inherited[property] = computed.getPropertyValue(property); + }); + styles.set(source, { computed: computed, inherited: inherited }); + } + return styles.get(source); + } + function copyElement(source, copy) { + // Read only the live Preview, whose selectors, theme and ancestors are + // intact. Writes go to detached copies, avoiding repeated style/layout + // invalidation and changes to a child's inherited computed values. + if (!copy) copy = source === surface ? document.createElement('div') : source.cloneNode(false); + sources.set(copy, source); + const current = readStyle(source); + const parent = source === surface ? null : readStyle(source.parentElement).inherited; + const css = copy.style; + CLIPBOARD_INHERITED_STYLES.forEach(function(property) { + const value = current.inherited[property]; + // GitHub's semibold emphasis is represented by strong/b. Serializing + // weight 600 makes Chromium paste replace that semantic tag with a + // styled span. Let receiving editors apply their native bold weight. + if (property === 'font-weight' && /^(STRONG|B)$/.test(source.tagName) && (value === '600' || value === '700')) { + css.removeProperty(property); + return; + } + // Some semantic elements have UA defaults instead of inheriting. + const semanticDefault = + (property === 'font-size' && /^(H[1-6]|SMALL|SUB|SUP|CODE|KBD|SAMP)$/.test(source.tagName)) || + (property === 'font-family' && /^(PRE|CODE|KBD|SAMP)$/.test(source.tagName)) || + (property === 'font-weight' && /^(H[1-6]|STRONG|B|TH|DT)$/.test(source.tagName)) || + (property === 'font-style' && /^(EM|I|CITE|VAR)$/.test(source.tagName)) || + (property === 'white-space' && source.tagName === 'PRE') || + (property === 'text-align' && source.tagName === 'TH') || + // Tables reset typography in some receiving documents/quirks mode. + (source.tagName === 'TABLE' && /^(font-|color$|line-height$)/.test(property)); + if (value && (!parent || value !== parent[property] || semanticDefault || css.getPropertyValue(property))) { + css.setProperty(property, value); + } + }); + const computed = current.computed; + if (source === surface || computed.backgroundColor !== 'rgba(0, 0, 0, 0)' || css.background) { + css.backgroundColor = computed.backgroundColor; + } + if (source !== surface) { + // Preserve zeroes where they reset UA spacing, without adding the same + // zero margin/padding to every span, table row and wrapper. + if (computed.margin !== '0px' || css.margin || /^(H[1-6]|P|PRE|BLOCKQUOTE|UL|OL|DL|DD|FIGURE|HR)$/.test(source.tagName)) css.margin = computed.margin; + if (computed.padding !== '0px' || css.padding || /^(UL|OL|TD|TH)$/.test(source.tagName)) css.padding = computed.padding; + const border = computed.border; + css.removeProperty('border'); + if (border) { + if (computed.borderTopWidth !== '0px' || source.tagName === 'HR') css.border = border; + } else { + // The border shorthand is empty when sides differ (quotes/headings). + if (source.tagName === 'HR') css.border = '0'; + ['top', 'right', 'bottom', 'left'].forEach(function(side) { + if (computed.getPropertyValue('border-' + side + '-width') !== '0px') css.setProperty('border-' + side, computed.getPropertyValue('border-' + side)); + }); + } + if (computed.borderRadius !== '0px') css.borderRadius = computed.borderRadius; + if (source.tagName === 'TABLE') { + css.borderCollapse = computed.borderCollapse; + css.borderSpacing = computed.borderSpacing; + } + if (computed.verticalAlign !== 'baseline') css.verticalAlign = computed.verticalAlign; + if (source.tagName === 'A' || computed.textDecorationLine !== 'none') css.textDecoration = computed.textDecoration; + } + return copy; + } + function preserveListStart(source, copy) { + if (source.tagName !== 'OL') return; + const first = Array.from(copy.children).find(function(child) { return child.tagName === 'LI'; }); + if (!first) return; + const items = Array.from(source.children).filter(function(child) { return child.tagName === 'LI'; }); + let ordinal = source.hasAttribute('start') ? source.start : source.reversed ? items.length : 1; + for (const item of items) { + if (item.hasAttribute('value')) ordinal = item.value; + if (item === sources.get(first)) { + copy.start = ordinal; + break; + } + ordinal += source.reversed ? -1 : 1; + } + } + // Let the browser clone exact text/element boundaries in one native pass. + // Its descendant elements have the same preorder as intersecting source + // descendants; pair them before removing any controls or hidden content. let fragment = range.cloneContents(); - // cloneContents omits shared ancestors, including the bold, heading, list, - // or table cell that gives a partial selection its formatting. - let ancestor = range.commonAncestorContainer; - if (ancestor.nodeType !== Node.ELEMENT_NODE) ancestor = ancestor.parentElement; - while (ancestor && ancestor !== surface) { - const wrapper = ancestor.cloneNode(false); + const walker = document.createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_ELEMENT, { + acceptNode(node) { return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } + }); + const copies = Array.from(fragment.querySelectorAll('*')); + for (const copy of copies) sources.set(copy, walker.nextNode()); + for (const copy of copies) { + if (!fragment.contains(copy)) continue; + const original = sources.get(copy); + if (original.matches(CLIPBOARD_EXCLUDED_ELEMENTS) || readStyle(original).computed.display === 'none') copy.remove(); + else copyElement(original, copy); + } + for (const copy of copies) { + if (fragment.contains(copy)) preserveListStart(sources.get(copy), copy); + } + // Include shared ancestors for partial words, headings, code and cells, + // plus one surface wrapper for inherited typography and theme background. + let source = range.commonAncestorContainer; + if (source.nodeType !== Node.ELEMENT_NODE) source = source.parentElement; + while (source) { + if (!source || source.matches(CLIPBOARD_EXCLUDED_ELEMENTS) || readStyle(source).computed.display === 'none') return null; + const wrapper = copyElement(source); wrapper.appendChild(fragment); + preserveListStart(source, wrapper); fragment = wrapper; - ancestor = ancestor.parentElement; + if (source === surface) break; + source = source.parentElement; } const container = document.createElement('div'); - container.appendChild(fragment); - removeExportOnlyControls(container); - container.querySelectorAll( - '.code-preview-toolbar, .review-pins-layer, button, [role="toolbar"], ' + - 'script, style, mjx-assistive-mml' - ).forEach(function(element) { element.remove(); }); - const html = DOMPurify.sanitize(container.innerHTML, { + container.appendChild(DOMPurify.sanitize(fragment, { + RETURN_DOM_FRAGMENT: true, ALLOW_DATA_ATTR: false, FORBID_ATTR: ['id', 'class', 'contenteditable', 'tabindex'], ALLOWED_URI_REGEXP: SAFE_MARKDOWN_URI_REGEXP - }); - container.innerHTML = html; - // innerText needs a rendered node to retain paragraph, table, and code line - // breaks. Keep this temporary copy out of view and the accessibility tree. + })); + const html = container.innerHTML; + // Only sanitized content is attached, once, for native innerText line and + // table separators. No style measurements or per-node writes happen here. container.setAttribute('aria-hidden', 'true'); container.style.cssText = 'position:fixed;left:-10000px;top:0;pointer-events:none;'; document.body.appendChild(container); @@ -6654,7 +6769,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (!tab) return null; const editor = surface === markdownEditor || surface === documentSplitEditor ? surface : null; let selectedText = ''; - let selectedHtml = ''; + let previewSelection = null; let selectionStart = null; let selectionEnd = null; if (editor) { @@ -6662,18 +6777,16 @@ document.addEventListener("DOMContentLoaded", async function () { selectionEnd = editor.selectionEnd; selectedText = editor.value.slice(selectionStart, selectionEnd); } else { - const content = getPreviewSelectionClipboardContent(surface, window.getSelection()); - if (content) { - selectedText = content.text; - selectedHtml = content.html; - } + // Opening a menu must not serialize/style the entire selection. Capture + // its range now and generate the payload only when Copy is activated. + previewSelection = captureDocumentSurfaceSelection(surface); } return { tab: tab, tabId: tabId, editor: editor, selectedText: selectedText, - selectedHtml: selectedHtml, + previewSelection: previewSelection, selectionStart: selectionStart, selectionEnd: selectionEnd, editable: Boolean(editor && !editor.readOnly && (isSecondary || canMutateEditor())) @@ -6694,10 +6807,13 @@ document.addEventListener("DOMContentLoaded", async function () { } async function copyDocumentSurfaceSelection(context, cut) { - if (!context || (!context.selectedText && !context.selectedHtml)) return; + if (!context || (!context.selectedText && !context.previewSelection)) return; try { - if (context.selectedHtml && !cut) { - await copyHtmlToClipboard(context.selectedText, context.selectedHtml); + if (context.previewSelection && !cut) { + if (!restoreDocumentSurfaceSelection(context.previewSelection, false)) return; + const content = getPreviewSelectionClipboardContent(context.previewSelection.surface, window.getSelection()); + if (!content) return; + await copyHtmlToClipboard(content.text, content.html); } else { await copyTextToClipboard(context.selectedText); } @@ -6755,7 +6871,7 @@ document.addEventListener("DOMContentLoaded", async function () { { id: 'new-document', icon: 'lucide-file-plus-2', label: 'New file', run: function() { newTab(); } }, { separator: true }, { id: 'cut', icon: 'lucide-square-pen', label: 'Cut', disabled: !context.editable || !context.selectedText, run: function() { copyDocumentSurfaceSelection(context, true); } }, - { id: 'copy', icon: 'lucide-copy', label: 'Copy', disabled: !context.selectedText && !context.selectedHtml, run: function() { copyDocumentSurfaceSelection(context, false); } }, + { id: 'copy', icon: 'lucide-copy', label: 'Copy', disabled: !context.selectedText && !context.previewSelection, run: function() { copyDocumentSurfaceSelection(context, false); } }, { id: 'paste', icon: 'lucide-file-plus-2', label: 'Paste', disabled: !context.editable, run: function() { pasteIntoDocumentSurface(context); } }, { id: 'select-all', icon: 'lucide-scan-text', label: 'Select All', keepOpen: keepSelectionMenuOpen, run: function() { selectAllDocumentSurface(surface); @@ -6763,7 +6879,7 @@ document.addEventListener("DOMContentLoaded", async function () { // Touch selection actions stay available so the next tap can copy. context = getDocumentSurfaceContext(surface); menu._touchSelection = captureDocumentSurfaceSelection(surface); - menu.querySelector('[data-action="copy"]').disabled = !context || (!context.selectedText && !context.selectedHtml); + menu.querySelector('[data-action="copy"]').disabled = !context || (!context.selectedText && !context.previewSelection); menu.querySelector('[data-action="cut"]').disabled = !context || !context.editable || !context.selectedText; } } }, diff --git a/script.js b/script.js index e9e3e0f3..5f95112d 100644 --- a/script.js +++ b/script.js @@ -6608,35 +6608,150 @@ document.addEventListener("DOMContentLoaded", async function () { return true; } + const CLIPBOARD_INHERITED_STYLES = [ + 'font-family', 'font-size', 'font-style', 'font-weight', 'line-height', + 'color', 'text-align', 'white-space', 'word-break', 'overflow-wrap', + 'direction', 'list-style-type', 'letter-spacing', 'visibility' + ]; + const CLIPBOARD_EXCLUDED_ELEMENTS = + '.diagram-status, .diagram-toolbar, .mermaid-toolbar, .abc-toolbar, ' + + '.plantuml-toolbar, .d2-toolbar, .graphviz-toolbar, .stl-toolbar, .review-target-actions, ' + + '.code-preview-toolbar, .review-pins-layer, button, [role="toolbar"], script, style, mjx-assistive-mml'; + function getPreviewSelectionClipboardContent(surface, selection) { if (!selectionBelongsToElement(selection, surface) || selection.isCollapsed || !selection.rangeCount) return null; const range = selection.getRangeAt(0); + const styles = new WeakMap(); + const sources = new WeakMap(); + function readStyle(source) { + if (!styles.has(source)) { + const computed = window.getComputedStyle(source); + const inherited = {}; + CLIPBOARD_INHERITED_STYLES.forEach(function(property) { + inherited[property] = computed.getPropertyValue(property); + }); + styles.set(source, { computed: computed, inherited: inherited }); + } + return styles.get(source); + } + function copyElement(source, copy) { + // Read only the live Preview, whose selectors, theme and ancestors are + // intact. Writes go to detached copies, avoiding repeated style/layout + // invalidation and changes to a child's inherited computed values. + if (!copy) copy = source === surface ? document.createElement('div') : source.cloneNode(false); + sources.set(copy, source); + const current = readStyle(source); + const parent = source === surface ? null : readStyle(source.parentElement).inherited; + const css = copy.style; + CLIPBOARD_INHERITED_STYLES.forEach(function(property) { + const value = current.inherited[property]; + // GitHub's semibold emphasis is represented by strong/b. Serializing + // weight 600 makes Chromium paste replace that semantic tag with a + // styled span. Let receiving editors apply their native bold weight. + if (property === 'font-weight' && /^(STRONG|B)$/.test(source.tagName) && (value === '600' || value === '700')) { + css.removeProperty(property); + return; + } + // Some semantic elements have UA defaults instead of inheriting. + const semanticDefault = + (property === 'font-size' && /^(H[1-6]|SMALL|SUB|SUP|CODE|KBD|SAMP)$/.test(source.tagName)) || + (property === 'font-family' && /^(PRE|CODE|KBD|SAMP)$/.test(source.tagName)) || + (property === 'font-weight' && /^(H[1-6]|STRONG|B|TH|DT)$/.test(source.tagName)) || + (property === 'font-style' && /^(EM|I|CITE|VAR)$/.test(source.tagName)) || + (property === 'white-space' && source.tagName === 'PRE') || + (property === 'text-align' && source.tagName === 'TH') || + // Tables reset typography in some receiving documents/quirks mode. + (source.tagName === 'TABLE' && /^(font-|color$|line-height$)/.test(property)); + if (value && (!parent || value !== parent[property] || semanticDefault || css.getPropertyValue(property))) { + css.setProperty(property, value); + } + }); + const computed = current.computed; + if (source === surface || computed.backgroundColor !== 'rgba(0, 0, 0, 0)' || css.background) { + css.backgroundColor = computed.backgroundColor; + } + if (source !== surface) { + // Preserve zeroes where they reset UA spacing, without adding the same + // zero margin/padding to every span, table row and wrapper. + if (computed.margin !== '0px' || css.margin || /^(H[1-6]|P|PRE|BLOCKQUOTE|UL|OL|DL|DD|FIGURE|HR)$/.test(source.tagName)) css.margin = computed.margin; + if (computed.padding !== '0px' || css.padding || /^(UL|OL|TD|TH)$/.test(source.tagName)) css.padding = computed.padding; + const border = computed.border; + css.removeProperty('border'); + if (border) { + if (computed.borderTopWidth !== '0px' || source.tagName === 'HR') css.border = border; + } else { + // The border shorthand is empty when sides differ (quotes/headings). + if (source.tagName === 'HR') css.border = '0'; + ['top', 'right', 'bottom', 'left'].forEach(function(side) { + if (computed.getPropertyValue('border-' + side + '-width') !== '0px') css.setProperty('border-' + side, computed.getPropertyValue('border-' + side)); + }); + } + if (computed.borderRadius !== '0px') css.borderRadius = computed.borderRadius; + if (source.tagName === 'TABLE') { + css.borderCollapse = computed.borderCollapse; + css.borderSpacing = computed.borderSpacing; + } + if (computed.verticalAlign !== 'baseline') css.verticalAlign = computed.verticalAlign; + if (source.tagName === 'A' || computed.textDecorationLine !== 'none') css.textDecoration = computed.textDecoration; + } + return copy; + } + function preserveListStart(source, copy) { + if (source.tagName !== 'OL') return; + const first = Array.from(copy.children).find(function(child) { return child.tagName === 'LI'; }); + if (!first) return; + const items = Array.from(source.children).filter(function(child) { return child.tagName === 'LI'; }); + let ordinal = source.hasAttribute('start') ? source.start : source.reversed ? items.length : 1; + for (const item of items) { + if (item.hasAttribute('value')) ordinal = item.value; + if (item === sources.get(first)) { + copy.start = ordinal; + break; + } + ordinal += source.reversed ? -1 : 1; + } + } + // Let the browser clone exact text/element boundaries in one native pass. + // Its descendant elements have the same preorder as intersecting source + // descendants; pair them before removing any controls or hidden content. let fragment = range.cloneContents(); - // cloneContents omits shared ancestors, including the bold, heading, list, - // or table cell that gives a partial selection its formatting. - let ancestor = range.commonAncestorContainer; - if (ancestor.nodeType !== Node.ELEMENT_NODE) ancestor = ancestor.parentElement; - while (ancestor && ancestor !== surface) { - const wrapper = ancestor.cloneNode(false); + const walker = document.createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_ELEMENT, { + acceptNode(node) { return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } + }); + const copies = Array.from(fragment.querySelectorAll('*')); + for (const copy of copies) sources.set(copy, walker.nextNode()); + for (const copy of copies) { + if (!fragment.contains(copy)) continue; + const original = sources.get(copy); + if (original.matches(CLIPBOARD_EXCLUDED_ELEMENTS) || readStyle(original).computed.display === 'none') copy.remove(); + else copyElement(original, copy); + } + for (const copy of copies) { + if (fragment.contains(copy)) preserveListStart(sources.get(copy), copy); + } + // Include shared ancestors for partial words, headings, code and cells, + // plus one surface wrapper for inherited typography and theme background. + let source = range.commonAncestorContainer; + if (source.nodeType !== Node.ELEMENT_NODE) source = source.parentElement; + while (source) { + if (!source || source.matches(CLIPBOARD_EXCLUDED_ELEMENTS) || readStyle(source).computed.display === 'none') return null; + const wrapper = copyElement(source); wrapper.appendChild(fragment); + preserveListStart(source, wrapper); fragment = wrapper; - ancestor = ancestor.parentElement; + if (source === surface) break; + source = source.parentElement; } const container = document.createElement('div'); - container.appendChild(fragment); - removeExportOnlyControls(container); - container.querySelectorAll( - '.code-preview-toolbar, .review-pins-layer, button, [role="toolbar"], ' + - 'script, style, mjx-assistive-mml' - ).forEach(function(element) { element.remove(); }); - const html = DOMPurify.sanitize(container.innerHTML, { + container.appendChild(DOMPurify.sanitize(fragment, { + RETURN_DOM_FRAGMENT: true, ALLOW_DATA_ATTR: false, FORBID_ATTR: ['id', 'class', 'contenteditable', 'tabindex'], ALLOWED_URI_REGEXP: SAFE_MARKDOWN_URI_REGEXP - }); - container.innerHTML = html; - // innerText needs a rendered node to retain paragraph, table, and code line - // breaks. Keep this temporary copy out of view and the accessibility tree. + })); + const html = container.innerHTML; + // Only sanitized content is attached, once, for native innerText line and + // table separators. No style measurements or per-node writes happen here. container.setAttribute('aria-hidden', 'true'); container.style.cssText = 'position:fixed;left:-10000px;top:0;pointer-events:none;'; document.body.appendChild(container); @@ -6654,7 +6769,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (!tab) return null; const editor = surface === markdownEditor || surface === documentSplitEditor ? surface : null; let selectedText = ''; - let selectedHtml = ''; + let previewSelection = null; let selectionStart = null; let selectionEnd = null; if (editor) { @@ -6662,18 +6777,16 @@ document.addEventListener("DOMContentLoaded", async function () { selectionEnd = editor.selectionEnd; selectedText = editor.value.slice(selectionStart, selectionEnd); } else { - const content = getPreviewSelectionClipboardContent(surface, window.getSelection()); - if (content) { - selectedText = content.text; - selectedHtml = content.html; - } + // Opening a menu must not serialize/style the entire selection. Capture + // its range now and generate the payload only when Copy is activated. + previewSelection = captureDocumentSurfaceSelection(surface); } return { tab: tab, tabId: tabId, editor: editor, selectedText: selectedText, - selectedHtml: selectedHtml, + previewSelection: previewSelection, selectionStart: selectionStart, selectionEnd: selectionEnd, editable: Boolean(editor && !editor.readOnly && (isSecondary || canMutateEditor())) @@ -6694,10 +6807,13 @@ document.addEventListener("DOMContentLoaded", async function () { } async function copyDocumentSurfaceSelection(context, cut) { - if (!context || (!context.selectedText && !context.selectedHtml)) return; + if (!context || (!context.selectedText && !context.previewSelection)) return; try { - if (context.selectedHtml && !cut) { - await copyHtmlToClipboard(context.selectedText, context.selectedHtml); + if (context.previewSelection && !cut) { + if (!restoreDocumentSurfaceSelection(context.previewSelection, false)) return; + const content = getPreviewSelectionClipboardContent(context.previewSelection.surface, window.getSelection()); + if (!content) return; + await copyHtmlToClipboard(content.text, content.html); } else { await copyTextToClipboard(context.selectedText); } @@ -6755,7 +6871,7 @@ document.addEventListener("DOMContentLoaded", async function () { { id: 'new-document', icon: 'lucide-file-plus-2', label: 'New file', run: function() { newTab(); } }, { separator: true }, { id: 'cut', icon: 'lucide-square-pen', label: 'Cut', disabled: !context.editable || !context.selectedText, run: function() { copyDocumentSurfaceSelection(context, true); } }, - { id: 'copy', icon: 'lucide-copy', label: 'Copy', disabled: !context.selectedText && !context.selectedHtml, run: function() { copyDocumentSurfaceSelection(context, false); } }, + { id: 'copy', icon: 'lucide-copy', label: 'Copy', disabled: !context.selectedText && !context.previewSelection, run: function() { copyDocumentSurfaceSelection(context, false); } }, { id: 'paste', icon: 'lucide-file-plus-2', label: 'Paste', disabled: !context.editable, run: function() { pasteIntoDocumentSurface(context); } }, { id: 'select-all', icon: 'lucide-scan-text', label: 'Select All', keepOpen: keepSelectionMenuOpen, run: function() { selectAllDocumentSurface(surface); @@ -6763,7 +6879,7 @@ document.addEventListener("DOMContentLoaded", async function () { // Touch selection actions stay available so the next tap can copy. context = getDocumentSurfaceContext(surface); menu._touchSelection = captureDocumentSurfaceSelection(surface); - menu.querySelector('[data-action="copy"]').disabled = !context || (!context.selectedText && !context.selectedHtml); + menu.querySelector('[data-action="copy"]').disabled = !context || (!context.selectedText && !context.previewSelection); menu.querySelector('[data-action="cut"]').disabled = !context || !context.editable || !context.selectedText; } } }, diff --git a/tests/README.md b/tests/README.md index 986a29b1..352a12ee 100644 --- a/tests/README.md +++ b/tests/README.md @@ -52,6 +52,7 @@ Math font lifecycle tests use the real MathJax bundle, Modern, STIX Two, and Asa - Mermaid, PlantUML, Graphviz/DOT, D2, Vega-Lite, WaveDrom, Markmap, math, ABC notation, maps, and STL surfaces - Share Snapshot and locally mocked Live Share access behavior - Markdown, HTML, PNG, and browser-print PDF export paths +- Preview clipboard styles in independent paste documents across light/dark themes, primary/secondary panes, native/menu Copy and async/legacy fallbacks; partial selections, numbered/reversed lists, sanitization, semantic emphasis, and large-selection style-recalculation/payload bounds (`clipboard.spec.js`). Real clipboard checks must run serially because they share the system clipboard. - Workspace storage, migration, backup/restore, Private mode, and responsive behavior - Explorer, toolbar, dialogs, keyboard interaction, accessible names, and focus management - Per-document scroll restoration across large-document loading placeholders, hidden Editor/Preview panes, interrupted commits, forced refreshes, workspace reload, worker rendering/fallback, content shrinkage, and delayed image loading/failure/user input (`preview-scroll-lifecycle.spec.js`). Keep these regressions alongside the tab switching, reordering, closing/reopening, and Sync on/off coverage in `tab-split-sidebar-update.spec.js`. diff --git a/tests/e2e/clipboard.spec.js b/tests/e2e/clipboard.spec.js index 1c908dff..6d4d7038 100644 --- a/tests/e2e/clipboard.spec.js +++ b/tests/e2e/clipboard.spec.js @@ -1,4 +1,5 @@ const { test, expect } = require('@playwright/test'); +const path = require('node:path'); const { openApp, setEditorContent } = require('../helpers/app'); const markdown = `# Clipboard document @@ -73,6 +74,10 @@ async function openSecondDocument(page, secondaryMarkdown) { } test.beforeEach(async ({ page }) => { + await page.route(/^https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/highlight\.js\/11\.9\.0\/styles\/(.+)$/, route => { + const filename = new URL(route.request().url()).pathname.split('/styles/')[1]; + return route.fulfill({ path: path.join(__dirname, '../../node_modules/@highlightjs/cdn-assets/styles', filename), contentType: 'text/css' }); + }); await openApp(page); await setEditorContent(page, markdown); await expect(page.locator('#markdown-preview h1')).toHaveText('Clipboard document'); @@ -87,13 +92,13 @@ test('preview Copy includes rich HTML and readable text without preview controls await expect.poll(() => page.evaluate(() => Object.keys(window.__clipboard || {}))).toContain('text/html'); const copied = await page.evaluate(() => window.__clipboard); expect(copied['text/html']).toMatch(/]*>Clipboard document<\/h1>/); - expect(copied['text/html']).toContain('bold phrase'); - expect(copied['text/html']).toContain('italic text'); + expect(copied['text/html']).toMatch(/]*>bold phrase<\/strong>/); + expect(copied['text/html']).toMatch(/]*>italic text<\/em>/); expect(copied['text/html']).toContain('href="https://example.com"'); - expect(copied['text/html']).toMatch(/
    [\s\S]*
  • First item<\/li>/); - expect(copied['text/html']).toContain('
    '); - expect(copied['text/html']).toContain(''); - expect(copied['text/html']).toContain('
    ');
    +  expect(copied['text/html']).toMatch(/]*>[\s\S]*]*>First item<\/li>/);
    +  expect(copied['text/html']).toMatch(/]*>/);
    +  expect(copied['text/html']).toMatch(/]*>/);
    +  expect(copied['text/html']).toMatch(/]*>/);
       expect(copied['text/html']).not.toMatch(/ page.evaluate(() => window.__clipboard?.['text/html'])).toContain('bold phrase');
    +  await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toMatch(/]*>bold phrase<\/strong>/);
       const text = await page.evaluate(() => window.__clipboard['text/plain']);
       expect(text).toContain('Clipboard document');
       expect(text).toContain('const answer = 42;');
    @@ -151,7 +156,7 @@ test('copying part of a bold phrase preserves formatting without copying surroun
       await selectPreview(page, '#markdown-preview strong', 5, 11);
       const menu = await openSurfaceMenu(page, '#markdown-preview');
       await menu.getByRole('menuitem', { name: 'Copy', exact: true }).click();
    -  await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toContain('phrase');
    +  await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toMatch(/]*>phrase<\/strong>/);
       expect(await page.evaluate(() => window.__clipboard['text/plain'])).toBe('phrase');
       expect(await page.evaluate(() => window.__clipboard['text/html'])).not.toMatch(/Clipboard document|italic text|bold /);
     });
    @@ -164,7 +169,9 @@ test('native preview Copy supplies rich data and leaves editor Copy untouched',
         document.querySelector('#markdown-preview strong').dispatchEvent(event);
         return { html: event.clipboardData.getData('text/html'), text: event.clipboardData.getData('text/plain'), prevented: event.defaultPrevented };
       });
    -  expect(copied).toEqual({ html: '

    bold phrase

    ', text: 'bold phrase', prevented: true }); + expect(copied.prevented).toBe(true); + expect(copied.text).toBe('bold phrase'); + expect(copied.html).toMatch(/]*>]*>bold phrase<\/strong><\/p>/); const editorCopy = await page.locator('#markdown-editor').evaluate(editor => { editor.focus(); editor.select(); @@ -207,7 +214,7 @@ test('Ctrl/Cmd+A follows the focused editor or preview without selecting the app await expect(editor).toHaveValue(markdown); const menu = await openSurfaceMenu(page, '#markdown-preview'); await menu.getByRole('menuitem', { name: 'Copy', exact: true }).click(); - await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toContain('bold phrase'); + await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toMatch(/]*>bold phrase<\/strong>/); expect(await page.evaluate(() => window.__clipboard['text/plain'])).not.toMatch(/Explorer|Select All|New file/); }); @@ -308,7 +315,7 @@ test('Select All and Copy are scoped to the secondary editor and preview', async await page.keyboard.press('ControlOrMeta+A'); menu = await openSurfaceMenu(page, '#document-split-preview'); await menu.getByRole('menuitem', { name: 'Copy', exact: true }).click(); - await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toContain('শিক্ষা'); + await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toMatch(/]*>শিক্ষা<\/strong>/); expect(await page.evaluate(() => window.__clipboard['text/plain'])).not.toContain('Clipboard document'); }); @@ -481,7 +488,7 @@ test.describe('Android-sized touch layout', () => { await menu.getByRole('menuitem', { name: 'Copy', exact: true }).tap(); await expect(menu).toHaveCount(0); if (pane === 'preview') { - await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toContain('bold phrase'); + await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toMatch(/]*>bold phrase<\/strong>/); } else { await expect.poll(() => page.evaluate(() => window.__clipboard)).toEqual({ 'text/plain': markdown }); } @@ -513,7 +520,7 @@ test.describe('Android-sized touch layout', () => { await menu.getByRole('menuitem', { name: 'Copy', exact: true }).tap(); await expect(menu).toHaveCount(0); if (pane === 'preview') { - await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toContain('bold phrase'); + await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toMatch(/]*>bold phrase<\/strong>/); expect(await page.evaluate(() => window.__clipboard['text/plain'])).toContain('Clipboard document'); } else { await expect.poll(() => page.evaluate(() => window.__clipboard)).toEqual({ 'text/plain': markdown }); @@ -564,7 +571,7 @@ test.describe('Android-sized touch layout', () => { await page.touchscreen.tap(point.x, point.y); await expect(menu).toBeVisible(); await menu.getByRole('menuitem', { name: 'Copy', exact: true }).tap(); - await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toContain('phrase'); + await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toMatch(/]*>phrase<\/strong>/); expect(await page.evaluate(() => window.__clipboard['text/plain'])).toBe('phrase'); await page.locator('#markdown-preview h1').tap(); await expect(menu).toHaveCount(0); @@ -588,7 +595,7 @@ test.describe('Android-sized touch layout', () => { await expect(menu).toBeVisible(); await menu.getByRole('menuitem', { name: 'Copy', exact: true }).tap(); if (pane === 'preview') { - await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toContain('Secondary content'); + await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toMatch(/]*>Secondary content<\/strong>/); expect(await page.evaluate(() => window.__clipboard['text/plain'])).not.toContain('Clipboard document'); } else { await expect.poll(() => page.evaluate(() => window.__clipboard)).toEqual({ 'text/plain': secondary }); @@ -635,7 +642,7 @@ test.describe('Android-sized touch layout', () => { expect(await page.evaluate(() => window.getSelection().toString())).toContain('Clipboard document'); await menu.getByRole('menuitem', { name: 'Copy', exact: true }).tap(); await expect(menu).toHaveCount(0); - await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toContain('bold phrase'); + await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toMatch(/]*>bold phrase<\/strong>/); }); test('mobile Copy Markdown copies the entire raw document from a preview selection', async ({ page }) => { @@ -646,3 +653,272 @@ test.describe('Android-sized touch layout', () => { await expect.poll(() => page.evaluate(() => window.__clipboard)).toEqual({ 'text/plain': markdown }); }); }); + +// A neutral document is essential: application CSS can mask missing clipboard +// styles when the paste target lives in the app's own DOM. +async function readClipboardAppearance(page, surface, html) { + return page.evaluate(({ surface, html }) => { + let frame; + let root = document.querySelector(surface); + if (html !== undefined) { + frame = document.createElement('iframe'); + document.body.appendChild(frame); + root = frame.contentDocument.body; + root.innerHTML = html; + } + const css = element => element.ownerDocument.defaultView.getComputedStyle(element); + const property = (selector, name) => css(root.querySelector(selector)).getPropertyValue(name); + const background = element => { + for (let current = element; current; current = current.parentElement) { + const color = css(current).backgroundColor; + if (color !== 'rgba(0, 0, 0, 0)') return color; + } + return 'rgb(255, 255, 255)'; + }; + const token = Array.from(root.querySelectorAll('pre span')).find(element => element.textContent === 'const'); + const appearance = { + headingSize: property('h1', 'font-size'), + headingSpacing: property('h1', 'margin'), + headingRule: property('h1', 'border-bottom'), + paragraphFont: property('p', 'font-family'), + paragraphColor: property('p', 'color'), + paragraphLineHeight: property('p', 'line-height'), + paragraphBackground: background(root.querySelector('p')), + quoteBorder: property('blockquote', 'border-left'), + quotePadding: property('blockquote', 'padding'), + cellBorder: property('td', 'border-left'), + cellPadding: property('td', 'padding'), + cellColor: property('td', 'color'), + cellLineHeight: property('td', 'line-height'), + tableCollapse: property('table', 'border-collapse'), + codeFont: property('pre code', 'font-family'), + codeSize: property('pre code', 'font-size'), + codeLineHeight: property('pre code', 'line-height'), + codePadding: property('pre', 'padding'), + codeBackground: background(root.querySelector('pre code')), + tokenColor: token ? css(token).color : null + }; + if (frame) frame.remove(); + return appearance; + }, { surface, html }); +} + +async function syntheticPreviewCopy(page, surface) { + return page.locator(surface).evaluate(element => { + const event = new ClipboardEvent('copy', { bubbles: true, cancelable: true, clipboardData: new DataTransfer() }); + element.dispatchEvent(event); + return { html: event.clipboardData.getData('text/html'), text: event.clipboardData.getData('text/plain'), prevented: event.defaultPrevented }; + }); +} + +async function prepareStyledPreview(page, theme, pane) { + await page.evaluate(theme => { + if (document.documentElement.dataset.theme !== theme) document.querySelector('#theme-toggle').click(); + }, theme); + // Theme changes load a stylesheet; wait for its local response and activation. + await expect.poll(() => page.locator('#markdown-preview .hljs-keyword').evaluate(element => getComputedStyle(element).color)) + .toBe(theme === 'dark' ? 'rgb(255, 123, 114)' : 'rgb(207, 34, 46)'); + if (pane === 'secondary') { + await openSecondDocument(page, markdown); + await page.locator('.view-toolbar [data-view-mode="preview"]').click(); + await expect(page.locator('#document-split-preview h1')).toHaveText('Clipboard document'); + } + return pane === 'secondary' ? '#document-split-preview' : '#markdown-preview'; +} + +for (const theme of ['light', 'dark']) { + for (const pane of ['primary', 'secondary']) { + test(`clipboard HTML preserves rendered ${theme} styles from the ${pane} Preview`, async ({ page }) => { + const surface = await prepareStyledPreview(page, theme, pane); + const before = await readClipboardAppearance(page, surface); + const sourceHtml = await page.locator(surface).innerHTML(); + await selectPreview(page, surface); + const copied = await syntheticPreviewCopy(page, surface); + expect(copied.prevented).toBe(true); + expect(before.cellBorder).toMatch(/^1px solid/); + expect(before.quoteBorder).toMatch(/^4px solid/); + expect(before.tokenColor).not.toBeNull(); + expect(await readClipboardAppearance(page, surface, copied.html)).toEqual(before); + expect(copied.text).not.toMatch(/Copy|\bJS\b/); + expect(await page.locator(surface).innerHTML()).toBe(sourceHtml); + const wrapper = await page.evaluate(html => { + const root = document.createElement('div'); root.innerHTML = html; + const code = root.querySelector('pre').parentElement; + return { background: code.style.backgroundColor, border: code.style.borderLeftWidth }; + }, copied.html); + expect(wrapper.border).toBe('1px'); + expect(wrapper.background).not.toBe(''); + }); + + for (const mode of ['native', 'async', 'legacy', 'rejected']) { + test(`${theme} ${pane} Preview styles survive real paste via ${mode} Copy`, async ({ page, browserName }) => { + test.skip(browserName !== 'chromium', 'Clipboard read permissions are Chromium-specific.'); + const surface = await prepareStyledPreview(page, theme, pane); + const before = await readClipboardAppearance(page, surface); + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + await page.locator(surface).focus(); + await page.evaluate(async mode => { + navigator.clipboard.write = window.__writeClipboard; + navigator.clipboard.writeText = window.__writeClipboardText; + await navigator.clipboard.writeText('Clipboard sentinel'); + if (mode === 'legacy') navigator.clipboard.write = undefined; + if (mode === 'rejected') navigator.clipboard.write = async () => { throw new Error('HTML write unavailable'); }; + }, mode); + await selectPreview(page, surface); + if (mode === 'native') await page.keyboard.press('ControlOrMeta+C'); + else { + const menu = await openSurfaceMenu(page, surface); + await menu.getByRole('menuitem', { name: 'Copy', exact: true }).click(); + } + await expect.poll(() => page.evaluate(async () => (await navigator.clipboard.read())[0].types)).toContain('text/html'); + await page.evaluate(() => { + const frame = document.createElement('iframe'); frame.id = 'neutral-paste'; + frame.style.cssText = 'position:fixed;inset:20px;width:800px;height:700px;z-index:999999;'; + document.body.appendChild(frame); + frame.contentDocument.body.contentEditable = 'true'; + frame.contentDocument.body.focus(); + }); + await page.keyboard.press('ControlOrMeta+V'); + const target = page.frameLocator('#neutral-paste').locator('body'); + await expect(target.locator('h1')).toHaveText('Clipboard document'); + await expect(target.locator('strong')).toHaveText('bold phrase'); + await expect(target.locator('em')).toHaveText('italic text'); + await expect(target.locator('a')).toHaveAttribute('href', /^https:\/\/example\.com\/?$/); + expect(await readClipboardAppearance(page, surface, await target.innerHTML())).toEqual(before); + }); + } + } +} + +test('partial copies retain inherited typography, syntax colors and inline-code line height', async ({ page }) => { + await setEditorContent(page, markdown + '\n\nInline `example` code.'); + await expect(page.locator('#markdown-preview p > code')).toHaveText('example'); + // Also exercise source-specific styling which an offscreen .markdown-body + // clone cannot resolve, and parent-relative line heights. + await page.addStyleTag({ content: '#markdown-preview { font-family: serif; font-size: 18px; line-height: 1.6; }' }); + for (const selector of ['h1', 'blockquote p', 'td:first-child', 'pre code .hljs-keyword', 'p > code', 'strong']) { + const source = page.locator('#markdown-preview ' + selector).first(); + const before = await source.evaluate(element => { + const style = getComputedStyle(element); + return { text: element.textContent, font: style.fontFamily, size: style.fontSize, line: style.lineHeight, color: style.color }; + }); + await selectPreview(page, '#markdown-preview ' + selector, 0, Math.min(3, before.text.length)); + const copied = await syntheticPreviewCopy(page, '#markdown-preview'); + expect(copied.text.trim()).toBe(before.text.slice(0, 3)); + const after = await page.evaluate(html => { + const frame = document.createElement('iframe'); document.body.appendChild(frame); + frame.contentDocument.body.innerHTML = html; + const walker = frame.contentDocument.createTreeWalker(frame.contentDocument.body, NodeFilter.SHOW_TEXT); + let node; while ((node = walker.nextNode()) && !node.textContent.trim()) {} + const style = frame.contentWindow.getComputedStyle(node.parentElement); + const result = { font: style.fontFamily, size: style.fontSize, line: style.lineHeight, color: style.color }; + frame.remove(); return result; + }, copied.html); + expect(after, selector).toEqual({ font: before.font, size: before.size, line: before.line, color: before.color }); + } +}); + +test('partial ordered lists retain their numbers, explicit values and reversed counters', async ({ page }) => { + const cases = [ + ['
    1. Third
    2. Fourth
    3. Fifth
    ', 4], + ['
    1. Third
    2. Second
    3. First
    ', 2], + ['
    1. Eighth
    2. Ninth
    3. Tenth
    ', 9], + ['
    1. Seventh
    2. Sixth
    3. Fifth
    ', 6] + ]; + for (const [html, expectedStart] of cases) { + await setEditorContent(page, html); + await expect(page.locator('#markdown-preview ol')).toHaveCount(1); + await expect(page.locator('#markdown-preview')).toContainText(html.includes('Ninth') ? 'Ninth' : html.includes('Sixth') ? 'Sixth' : html.includes('Fourth') ? 'Fourth' : 'Second'); + await selectPreview(page, '#markdown-preview li:nth-child(2)'); + const copied = await syntheticPreviewCopy(page, '#markdown-preview'); + const list = await page.evaluate(html => { + const root = document.createElement('div'); root.innerHTML = html; + return { start: root.querySelector('ol').start, items: root.querySelectorAll('li').length, reversed: root.querySelector('ol').reversed }; + }, copied.html); + expect(list).toEqual({ start: expectedStart, items: 1, reversed: html.includes('reversed') }); + } +}); + +test('cross-element selections keep boundaries and nested ordered-list positions', async ({ page }) => { + await setEditorContent(page, '3. Third item\n4. Fourth **bold** item\n 7. Nested seven\n 8. Nested eight\n5. Fifth item'); + await expect(page.locator('#markdown-preview ol ol li')).toHaveCount(2); + await page.evaluate(() => { + document.activeElement.blur(); + const start = document.querySelector('#markdown-preview > ol > li:nth-child(2)').firstChild; + const end = document.querySelector('#markdown-preview ol ol li:nth-child(2)').firstChild; + const range = document.createRange(); range.setStart(start, 2); range.setEnd(end, 6); + getSelection().removeAllRanges(); getSelection().addRange(range); + }); + const copied = await syntheticPreviewCopy(page, '#markdown-preview'); + expect(copied.text).toContain('urth bold item'); + expect(copied.text.trim()).toMatch(/Nested$/); + expect(copied.text).not.toMatch(/Third|Fifth|eight/); + expect(await page.evaluate(html => { + const root = document.createElement('div'); root.innerHTML = html; + return Array.from(root.querySelectorAll('ol')).map(list => list.start); + }, copied.html)).toEqual([4, 7]); +}); + +test('clipboard sanitization removes controls and unsafe attributes without mutating Preview', async ({ page }) => { + await page.locator('#markdown-preview').evaluate(preview => { + const extra = document.createElement('div'); + extra.innerHTML = '
    Diagram control

    Hidden paragraph

    Safe link text'; + preview.appendChild(extra); + }); + const before = await page.locator('#markdown-preview').innerHTML(); + await selectPreview(page); + const copied = await syntheticPreviewCopy(page, '#markdown-preview'); + expect(copied.html).not.toMatch(/javascript:|onclick=|\bid=|\bclass=| { + test.skip(browserName !== 'chromium', 'Style recalculation metrics use the Chromium protocol.'); + const content = '# Copy performance\n\n' + Array.from({ length: 80 }, (_, index) => + `## Section ${index}\n\nParagraph **bold** and *italic*.\n\n> Quotation\n\n| Name | Value |\n| --- | --- |\n| Sample | ${index} |`).join('\n\n') + '\n\n```js\nconst answer = 42;\n```'; + await setEditorContent(page, content); + await expect(page.locator('#markdown-preview table')).toHaveCount(80); + await selectPreview(page); + const session = await page.context().newCDPSession(page); + await session.send('Performance.enable'); + const metrics = async () => Object.fromEntries((await session.send('Performance.getMetrics')).metrics.map(metric => [metric.name, metric.value])); + try { + const beforeMenu = await metrics(); + const menu = await openSurfaceMenu(page, '#markdown-preview'); + const afterMenu = await metrics(); + expect(afterMenu.RecalcStyleCount - beforeMenu.RecalcStyleCount).toBeLessThan(20); + expect(await page.evaluate(() => window.__clipboard)).toBeNull(); + const sourceBytes = await page.locator('#markdown-preview').evaluate(element => element.innerHTML.length); + await menu.getByRole('menuitem', { name: 'Copy', exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toContain('Copy performance'); + const afterCopy = await metrics(); + expect(afterCopy.RecalcStyleCount - afterMenu.RecalcStyleCount).toBeLessThan(20); + const html = await page.evaluate(() => window.__clipboard['text/html']); + expect(html.length).toBeLessThan(sourceBytes * 8); + expect(await readClipboardAppearance(page, '#markdown-preview', html)).toEqual(await readClipboardAppearance(page, '#markdown-preview')); + } finally { await session.detach(); } +}); + +test('an image-only Preview selection keeps menu Copy enabled', async ({ page }) => { + await setEditorContent(page, '

    Copy image

    '); + await expect(page.locator('#markdown-preview img')).toHaveAttribute('alt', 'Copy image'); + await page.locator('#markdown-preview img').evaluate(image => { + document.activeElement.blur(); + const range = document.createRange(); range.selectNode(image); + getSelection().removeAllRanges(); getSelection().addRange(range); + }); + const menu = await openSurfaceMenu(page, '#markdown-preview'); + await expect(menu.getByRole('menuitem', { name: 'Copy', exact: true })).toBeEnabled(); + await menu.getByRole('menuitem', { name: 'Copy', exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.__clipboard?.['text/html'])).toContain('alt="Copy image"'); +}); + +test('a Preview replaced after opening its menu cannot copy a stale selection', async ({ page }) => { + await selectPreview(page, '#markdown-preview strong'); + const menu = await openSurfaceMenu(page, '#markdown-preview'); + await page.locator('#markdown-preview').evaluate(preview => { preview.textContent = 'Replacement content'; }); + await menu.getByRole('menuitem', { name: 'Copy', exact: true }).click(); + await expect(menu).toHaveCount(0); + expect(await page.evaluate(() => window.__clipboard)).toBeNull(); +}); diff --git a/wiki/Features.md b/wiki/Features.md index 7d1e33d0..e64522d4 100644 --- a/wiki/Features.md +++ b/wiki/Features.md @@ -438,7 +438,7 @@ Privacy implications: - **Copy Markdown** in the navbar or mobile menu, and **Ctrl/Cmd+Shift+C**, copy the entire active document as raw Markdown in every view, even when only part of it is selected. In a two-Document view, they use the document whose Editor or Preview was last focused; clicking toolbar controls keeps that document as the target. - The Editor and Preview context menus include **Select All** for that pane's complete document, including either side of a two-Document view. With a mouse or keyboard, the menu closes after selection and focus returns to the pane. With touch, Select All keeps the menu available for the next tap on **Copy**; Copy closes it. **Ctrl/Cmd+A** also selects within the focused pane. Read-only documents support selection and copying. - On touch devices, dismissing the menu onto the surrounding background preserves the selection. Tapping highlighted text reopens the menu, including for partial selections and secondary document panes. Swiping scrolls, tapping unselected text keeps normal selection/cursor behavior, and touching selected text does not start a text drag. -- Preview Copy provides formatted HTML and plain text for pasting into apps such as Google Docs. Headings, emphasis, links, lists, tables, and code are retained where the receiving app supports them. Partial selections and native Copy use the same rich content; preview controls are excluded. Editor Copy remains raw Markdown. +- Preview Copy provides formatted HTML and plain text for pasting into apps such as Google Docs. Headings, emphasis, links, lists, tables, and code retain their supported fonts, spacing, borders, backgrounds, and syntax colors. Copies include the current Preview theme's text and background colors together. Partial selections retain surrounding formatting and ordered-list positions, including reversed lists. Native Copy uses the same rich content; preview controls are excluded. The receiving app determines which formatting it accepts. Editor Copy remains raw Markdown. - **Copy** and **Ctrl/Cmd+C** copy only the selection in its original context: raw source in an Editor, formatted content in a Preview, and native text in other controls. With no selection, ordinary Copy leaves the clipboard unchanged. - Editor **Paste** and **Ctrl/Cmd+V** insert clipboard text at the cursor or replace the selection; they do not automatically convert rich text to Markdown. Preview supports selection and copying without allowing Paste, Cut, or typing to modify the document. Existing image-paste actions in the Editor are unchanged. - Diagram and ABC copy actions attempt to write PNG image data to the clipboard. diff --git a/wiki/Usage-Guide.md b/wiki/Usage-Guide.md index 37aa50a6..195419a0 100644 --- a/wiki/Usage-Guide.md +++ b/wiki/Usage-Guide.md @@ -124,7 +124,7 @@ To copy a selection, right-click an Editor or Preview, or open its context menu On touch devices, tapping the surrounding background dismisses the menu while keeping the text selected. Tap the highlighted text to reopen the menu and choose **Copy**. This also works with partial selections. Swiping still scrolls, and tapping unselected text lets you change the selection or position the Editor cursor normally. Touch presses on selected text do not drag the document text. - **Editor:** Copy provides the selected raw Markdown, including Markdown syntax. Paste inserts clipboard text at the cursor or replaces the selection; rich text is not automatically converted into Markdown. -- **Preview:** Copy provides formatted HTML and a plain-text alternative. Paste into a rich-text app such as Google Docs to retain supported headings, emphasis, links, lists, tables, and code blocks. Partial selections and the browser's native Copy command also retain formatting. Preview toolbars and action buttons are excluded. +- **Preview:** Copy provides formatted HTML and a plain-text alternative. Paste into a rich-text app such as Google Docs to retain supported headings, emphasis, links, lists, tables, and code blocks, including their fonts, spacing, borders, backgrounds, and syntax colors. Copies use the current Preview theme, with its text and background colors together. Partial selections retain their surrounding formatting and ordered-list numbers, including reversed lists. The browser's native Copy command uses the same formatting; Preview toolbars and action buttons are excluded. Ordinary **Copy** or **Ctrl/Cmd+C** with no selection leaves the clipboard unchanged. Preview is read only: it supports selection and copying, but Paste, Cut, and typing do not change the document. @@ -291,7 +291,7 @@ Markdown Viewer follows familiar Typora-style formatting shortcuts and Obsidian- | Quick open: show Explorer and focus file search | `Ctrl+P` / `Cmd+P` | | Import/open Markdown | `Ctrl+O` / `Cmd+O` | | Save/export Markdown | `Ctrl+S` / `Cmd+S` | -| Copy selected text, or whole Markdown when nothing is selected | `Ctrl+C` / `Cmd+C` | +| Copy the selection from the focused Editor or Preview | `Ctrl+C` / `Cmd+C` | | Copy raw Markdown directly | `Ctrl+Shift+C` / `Cmd+Shift+C` | | Toggle Edit/Preview | `Ctrl+E` / `Cmd+E` | | Toggle Explorer | `Ctrl+Shift+L` / `Cmd+Shift+L` |