From 316b87af34fac92b92f6e5aedd8baee8728cef49 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:04:54 +0000 Subject: [PATCH 1/8] Initial plan From 48db9d156e47259ed22666a4a4aceb3a30f87a47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:22:09 +0000 Subject: [PATCH 2/8] test(e2e): add failing regression test for copy with Select All (#3993) Co-authored-by: BayuAri <8419585+BayuAri@users.noreply.github.com> --- src/e2e/puppeteer/__tests__/multiselect.ts | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/e2e/puppeteer/__tests__/multiselect.ts b/src/e2e/puppeteer/__tests__/multiselect.ts index 5860b9b2dc6..f9e2ad8bfc9 100644 --- a/src/e2e/puppeteer/__tests__/multiselect.ts +++ b/src/e2e/puppeteer/__tests__/multiselect.ts @@ -1,4 +1,6 @@ import { KnownDevices } from 'puppeteer' +import clickThought from '../helpers/clickThought' +import command from '../helpers/command' import emulate from '../helpers/emulate' import longPressThought from '../helpers/longPressThought' import multiselectThoughts from '../helpers/multiselectThoughts' @@ -23,6 +25,44 @@ describe('multiselect', () => { expect(highlightedBullets.length).toBe(2) expect(alertContent).toContain('2 thoughts selected') }) + + // Regression test for https://github.com/cybersemics/em/issues/3993 + // When Select All is active, the native copy handler must copy all selected thoughts, not just the focused cursor. + // .skip keeps normal CI green while the test is red; remove the .skip when the fix lands. + it.skip('copies all selected thoughts when Select All is active', async () => { + await paste(` + - a + - b + - c + `) + + // place the cursor on b, then select all thoughts at the current level + await clickThought('b') + await command('selectAll') + + // The native copy event fires on the focused editable (permitDefault on copyCursor). Capture the + // clipboard data it writes. With a multicursor active it must export all selected thoughts. + const copiedText = await page.evaluate(() => { + const editable = [...document.querySelectorAll('[data-editable]')].find( + el => el.textContent === 'b', + ) as HTMLElement + editable.focus() + // collapse the caret inside b, mirroring the real desktop copy where the edited thought keeps focus + const range = document.createRange() + range.selectNodeContents(editable) + range.collapse(true) + const sel = window.getSelection()! + sel.removeAllRanges() + sel.addRange(range) + const clipboardData = new DataTransfer() + editable.dispatchEvent(new ClipboardEvent('copy', { clipboardData, bubbles: true, cancelable: true })) + return clipboardData.getData('text/plain') + }) + + expect(copiedText).toContain('a') + expect(copiedText).toContain('b') + expect(copiedText).toContain('c') + }) }) describe('mobile only', () => { From afabe81ae386fe52e231f7c2d5627382ff84d2be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:24:40 +0000 Subject: [PATCH 3/8] test(e2e): add failing regression test for copy with Select All (#3993) Co-authored-by: BayuAri <8419585+BayuAri@users.noreply.github.com> --- src/e2e/puppeteer/__tests__/multiselect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/e2e/puppeteer/__tests__/multiselect.ts b/src/e2e/puppeteer/__tests__/multiselect.ts index f9e2ad8bfc9..5dbdfdb83b2 100644 --- a/src/e2e/puppeteer/__tests__/multiselect.ts +++ b/src/e2e/puppeteer/__tests__/multiselect.ts @@ -43,7 +43,7 @@ describe('multiselect', () => { // The native copy event fires on the focused editable (permitDefault on copyCursor). Capture the // clipboard data it writes. With a multicursor active it must export all selected thoughts. const copiedText = await page.evaluate(() => { - const editable = [...document.querySelectorAll('[data-editable]')].find( + const editable = Array.from(document.querySelectorAll('[data-editable]')).find( el => el.textContent === 'b', ) as HTMLElement editable.focus() From 9f048776ea91b8110944c30328fffbc710c2dd43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:33:28 +0000 Subject: [PATCH 4/8] fix(copy): copy all selected thoughts when Select All is active (#3993) Co-authored-by: BayuAri <8419585+BayuAri@users.noreply.github.com> --- src/commands/copyCursor.ts | 16 ++++------------ src/components/Editable/useOnCopy.ts | 20 +++++++++++++++++++- src/e2e/puppeteer/__tests__/multiselect.ts | 2 +- src/selectors/getMulticursorThoughtIds.ts | 20 ++++++++++++++++++++ 4 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 src/selectors/getMulticursorThoughtIds.ts diff --git a/src/commands/copyCursor.ts b/src/commands/copyCursor.ts index 7c90fff0e5a..58224496590 100644 --- a/src/commands/copyCursor.ts +++ b/src/commands/copyCursor.ts @@ -1,7 +1,6 @@ import pluralize from 'pluralize' import Command from '../@types/Command' import Dispatch from '../@types/Dispatch' -import Path from '../@types/Path' import State from '../@types/State' import ThoughtId from '../@types/ThoughtId' import { alertActionCreator as alert } from '../actions/alert' @@ -10,6 +9,7 @@ import SettingsIcon from '../components/icons/SettingsIcon' import copy from '../device/copy' import * as selection from '../device/selection' import exportContext from '../selectors/exportContext' +import getMulticursorThoughtIds from '../selectors/getMulticursorThoughtIds' import getThoughtById from '../selectors/getThoughtById' import hasMulticursor from '../selectors/hasMulticursor' import isPending from '../selectors/isPending' @@ -52,19 +52,11 @@ const copyCursorCommand: Command = { keyboard: { key: 'c', meta: true }, multicursor: { execMulticursor: async (cursors, dispatch, getState) => { - const filteredCursors = cursors.reduce((acc, cur) => { - const hasAncestor = acc.some(p => cur.includes(head(p))) - if (hasAncestor) return acc - return [...acc.filter(p => !p.includes(head(cur))), cur] - }, []) + const ids = getMulticursorThoughtIds(getState()) - const exportedVisible = await copyThoughts( - filteredCursors.map(cursor => head(cursor)), - dispatch, - getState, - ) + const exportedVisible = await copyThoughts(ids, dispatch, getState) - const numThoughts = filteredCursors.length + const numThoughts = ids.length const numDescendants = exportedVisible.split('\n').length - numThoughts dispatch( diff --git a/src/components/Editable/useOnCopy.ts b/src/components/Editable/useOnCopy.ts index d04b69945f7..0652bca6af0 100644 --- a/src/components/Editable/useOnCopy.ts +++ b/src/components/Editable/useOnCopy.ts @@ -2,7 +2,10 @@ import React, { useCallback } from 'react' import ThoughtId from '../../@types/ThoughtId' import * as selection from '../../device/selection' import exportContext from '../../selectors/exportContext' +import getMulticursorThoughtIds from '../../selectors/getMulticursorThoughtIds' +import hasMulticursor from '../../selectors/hasMulticursor' import store from '../../stores/app' +import strip from '../../util/strip' import trimBullet from '../../util/trimBullet' /** Returns an onCopy handler that copies the selection text and sets a text/em flag in the clipboard data to detect the source is 'em' on paste. */ @@ -12,9 +15,24 @@ const useOnCopy = ({ thoughtId }: { thoughtId: ThoughtId }) => { event.preventDefault() const state = store.getState() + const clipboardData = event.clipboardData + + // When a multicursor is active, copy all selected thoughts, not just the focused one. + // The Copy Cursor command also writes the full selection to the clipboard, but copyCursor uses + // permitDefault, so this native copy event fires afterward and would otherwise clobber it with + // only the focused thought (#3993). + if (hasMulticursor(state)) { + const ids = getMulticursorThoughtIds(state) + const plainText = trimBullet(ids.map(id => strip(exportContext(state, id, 'text/plain'))).join('\n')) + const htmlText = ids.map(id => exportContext(state, id, 'text/html')).join('\n') + clipboardData.setData('text/plain', plainText) + clipboardData.setData('text/html', htmlText) + clipboardData.setData('text/em', 'true') + return + } + const currentText = selection.text() const currentHtml = selection.html() - const clipboardData = event.clipboardData clipboardData.setData('text/plain', currentText!) clipboardData.setData('text/html', currentHtml!) diff --git a/src/e2e/puppeteer/__tests__/multiselect.ts b/src/e2e/puppeteer/__tests__/multiselect.ts index 5dbdfdb83b2..85c571c2523 100644 --- a/src/e2e/puppeteer/__tests__/multiselect.ts +++ b/src/e2e/puppeteer/__tests__/multiselect.ts @@ -29,7 +29,7 @@ describe('multiselect', () => { // Regression test for https://github.com/cybersemics/em/issues/3993 // When Select All is active, the native copy handler must copy all selected thoughts, not just the focused cursor. // .skip keeps normal CI green while the test is red; remove the .skip when the fix lands. - it.skip('copies all selected thoughts when Select All is active', async () => { + it('copies all selected thoughts when Select All is active', async () => { await paste(` - a - b diff --git a/src/selectors/getMulticursorThoughtIds.ts b/src/selectors/getMulticursorThoughtIds.ts new file mode 100644 index 00000000000..aacf3baa81b --- /dev/null +++ b/src/selectors/getMulticursorThoughtIds.ts @@ -0,0 +1,20 @@ +import Path from '../@types/Path' +import State from '../@types/State' +import ThoughtId from '../@types/ThoughtId' +import head from '../util/head' +import documentSort from './documentSort' + +/** Returns the thought ids of the current multicursor selection in document order. When both an ancestor and one of its descendants are selected, only the ancestor is included (the descendant is already copied as part of the ancestor's subtree). */ +const getMulticursorThoughtIds = (state: State): ThoughtId[] => { + const paths = documentSort(state, Object.values(state.multicursors)) + + const filteredPaths = paths.reduce((acc, cur) => { + const hasAncestor = acc.some(p => cur.includes(head(p))) + if (hasAncestor) return acc + return [...acc.filter(p => !p.includes(head(cur))), cur] + }, []) + + return filteredPaths.map(path => head(path)) +} + +export default getMulticursorThoughtIds From 41ee407684ddbc247cf55114e5f894682ab42c6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:08:14 +0000 Subject: [PATCH 5/8] fix(copy): write html + em marker to clipboard for Safari multicursor copy (#3993) Co-authored-by: BayuAri <8419585+BayuAri@users.noreply.github.com> --- src/commands/__tests__/copyCursor.ts | 8 +++-- src/commands/copyCursor.ts | 5 ++- src/device/copy.ts | 34 +++++++++++++++--- src/e2e/puppeteer/__tests__/multiselect.ts | 40 ++++++++++++++++++++++ 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/src/commands/__tests__/copyCursor.ts b/src/commands/__tests__/copyCursor.ts index b388c0ff205..ae093e76fcd 100644 --- a/src/commands/__tests__/copyCursor.ts +++ b/src/commands/__tests__/copyCursor.ts @@ -37,6 +37,7 @@ describe('copyCursor', () => { `- a - a1 - a2`, + expect.objectContaining({ html: expect.any(String) }), ) }) @@ -52,7 +53,7 @@ describe('copyCursor', () => { executeCommandWithMulticursor(copyCursorCommand, { store }) - expect(copyModule.default).toHaveBeenCalledWith('a') + expect(copyModule.default).toHaveBeenCalledWith('a', expect.objectContaining({ html: expect.any(String) })) }) describe('multicursor', () => { @@ -85,6 +86,7 @@ describe('copyCursor', () => { - c - c1 - c2`, + expect.objectContaining({ html: expect.any(String) }), ) }) @@ -103,7 +105,7 @@ describe('copyCursor', () => { executeCommandWithMulticursor(copyCursorCommand, { store }) - expect(copyModule.default).toHaveBeenCalledWith('a') + expect(copyModule.default).toHaveBeenCalledWith('a', expect.objectContaining({ html: expect.any(String) })) }) it('only copies ancestors when both ancestor and descendant are selected', async () => { @@ -131,6 +133,7 @@ describe('copyCursor', () => { - a1 - a1a - a2`, + expect.objectContaining({ html: expect.any(String) }), ) }) @@ -168,6 +171,7 @@ describe('copyCursor', () => { - c - c1 - c2`, + expect.objectContaining({ html: expect.any(String) }), ) }) }) diff --git a/src/commands/copyCursor.ts b/src/commands/copyCursor.ts index 58224496590..6ce50b703ae 100644 --- a/src/commands/copyCursor.ts +++ b/src/commands/copyCursor.ts @@ -36,11 +36,14 @@ const copyThoughts = async (ids: ThoughtId[], dispatch: Dispatch, getState: () = const stateAfterPull = getState() const exported = ids.map(id => strip(exportContext(stateAfterPull, id, 'text/plain'))).join('\n') + const exportedHtml = ids.map(id => exportContext(stateAfterPull, id, 'text/html')).join('\n') const exportedVisible = ids .map(id => exportContext(stateAfterPull, id, 'text/plain', { excludeMeta: true })) .join('\n') - copy(trimBullet(exported)) + // Write text/html and the text/em marker alongside the plain text so structured paste works even when + // the browser does not fire a native copy event for the collapsed selection (e.g. Safari) (#3993). + copy(trimBullet(exported), { html: exportedHtml }) return exportedVisible } diff --git a/src/device/copy.ts b/src/device/copy.ts index 0678e489d63..50e782ac509 100644 --- a/src/device/copy.ts +++ b/src/device/copy.ts @@ -3,19 +3,45 @@ import { Capacitor } from '@capacitor/core' import ClipboardJS from 'clipboard' import * as selection from './selection' -/** Copies a string directly to the clipboard by simulating a button click with ClipboadJS. */ -const copy = (s: string): void => { +interface CopyOptions { + /** Rich text/html representation written alongside the plain text. When provided, the clipboard is written deterministically (text/plain + text/html + text/em) rather than relying on the browser's native copy event. */ + html?: string +} + +/** Copies text, html, and the text/em source marker to the clipboard in a single synchronous copy event. ClipboardJS provides the selection and triggers execCommand('copy'); the listener enriches the event with the additional formats. Unlike relying on the native copy event of the focused editable, this fires reliably even when the browser does not emit a copy event for a collapsed contenteditable selection (e.g. Safari). */ +const copyRich = (text: string, html: string): void => { + /** Writes the plain text, html, and text/em marker onto the clipboard event. */ + const onCopy = (e: ClipboardEvent) => { + e.preventDefault() + e.clipboardData?.setData('text/plain', text) + e.clipboardData?.setData('text/html', html) + // Mark the source as 'em' so the paste handler preserves formatting and skips the browser's synthesized html. + e.clipboardData?.setData('text/em', 'true') + } + + document.addEventListener('copy', onCopy) + const dummyButton = document.createElement('button') + const clipboard = new ClipboardJS(dummyButton, { text: () => text }) + dummyButton.click() + clipboard.destroy() + document.removeEventListener('copy', onCopy) +} + +/** Copies a string to the clipboard. When html is provided, also writes text/html and the text/em source marker so that structured content pastes correctly even on browsers that do not fire a native copy event for a collapsed selection. */ +const copy = (text: string, { html }: CopyOptions = {}): void => { // save selection const selectionState = selection.save() if (Capacitor.isNativePlatform()) { Clipboard.write({ - string: s, + string: text, }) + } else if (html != null) { + copyRich(text, html) } else { // copy from dummy element using ClipboardJS const dummyButton = document.createElement('button') - const clipboard = new ClipboardJS(dummyButton, { text: () => s }) + const clipboard = new ClipboardJS(dummyButton, { text: () => text }) dummyButton.click() clipboard.destroy() } diff --git a/src/e2e/puppeteer/__tests__/multiselect.ts b/src/e2e/puppeteer/__tests__/multiselect.ts index 85c571c2523..62043637799 100644 --- a/src/e2e/puppeteer/__tests__/multiselect.ts +++ b/src/e2e/puppeteer/__tests__/multiselect.ts @@ -5,6 +5,7 @@ import emulate from '../helpers/emulate' import longPressThought from '../helpers/longPressThought' import multiselectThoughts from '../helpers/multiselectThoughts' import paste from '../helpers/paste' +import press from '../helpers/press' import waitForEditable from '../helpers/waitForEditable' import { page } from '../session' @@ -63,6 +64,45 @@ describe('multiselect', () => { expect(copiedText).toContain('b') expect(copiedText).toContain('c') }) + + // Regression test for https://github.com/cybersemics/em/issues/3993 (Desktop Safari) + // The copy command must write text/html and the text/em marker to the clipboard itself, rather than + // relying on the native copy event of the focused editable. Safari (like headless Chrome) does not fire + // a copy event for a collapsed contenteditable selection, so without an explicit text/html the browser + // synthesizes its own html on paste, which shadows the plain text and breaks structured paste. + it('writes html and the em marker to the clipboard when Select All is active', async () => { + await paste(` + - a + - b + - c + `) + + // intercept clipboardData.setData so we can observe what the copy command writes, regardless of + // whether a native copy event fires (it does not for a collapsed selection in headless Chrome) + await page.evaluate(() => { + const win = window as typeof window & { __copied: Record } + win.__copied = {} + const original = DataTransfer.prototype.setData + DataTransfer.prototype.setData = function (type: string, data: string) { + win.__copied[type] = data + return original.call(this, type, data) + } + }) + + // place the cursor on b, then select all thoughts at the current level and copy + await clickThought('b') + await command('selectAll') + await press('c', { meta: true }) + + const copied = await page.evaluate(() => (window as typeof window & { __copied: Record }).__copied) + + // the em marker must be present so importData treats the html as em-structured content + expect(copied['text/em']).toBeDefined() + // the html must contain all selected thoughts so structured paste reconstructs the full selection + expect(copied['text/html']).toContain('a') + expect(copied['text/html']).toContain('b') + expect(copied['text/html']).toContain('c') + }) }) describe('mobile only', () => { From 22b72ffc9dbc54e19aff4cfe78fab32f2216b5a7 Mon Sep 17 00:00:00 2001 From: Bayu Ari Date: Wed, 24 Jun 2026 21:54:33 +0700 Subject: [PATCH 6/8] fix(copy): make Safari multicursor copy/paste reliable (#3993) Safari only honors clipboard setData() in a genuine user-initiated copy event, and dispatches the Cmd+C copy event on a later task than setTimeout(0). With a multicursor no editable is focused, so the copy event targets and useOnCopy never runs; the browser then serializes the empty collapsed selection and the previous async write clobbered it, leaving an empty clipboard. Register a capture-phase document copy listener that intercepts the late user-initiated copy event regardless of focus target, preventDefault()s the empty serialization, and writes text/plain + text/html + text/em via setData (honored by Safari). The listener self-removes on the copy event and is cleaned up after a generous window otherwise. Non-Safari browsers keep the proven execCommand+setData path used by the e2e suite. Add a selectNode() helper used by the non-Safari rich copy path. --- src/device/copy.ts | 117 ++++++++++++++++++++++++++++++++++------ src/device/selection.ts | 10 ++++ 2 files changed, 112 insertions(+), 15 deletions(-) diff --git a/src/device/copy.ts b/src/device/copy.ts index 50e782ac509..3d6336b3a24 100644 --- a/src/device/copy.ts +++ b/src/device/copy.ts @@ -1,6 +1,7 @@ import { Clipboard } from '@capacitor/clipboard' import { Capacitor } from '@capacitor/core' import ClipboardJS from 'clipboard' +import { isSafari } from '../browser' import * as selection from './selection' interface CopyOptions { @@ -8,9 +9,15 @@ interface CopyOptions { html?: string } -/** Copies text, html, and the text/em source marker to the clipboard in a single synchronous copy event. ClipboardJS provides the selection and triggers execCommand('copy'); the listener enriches the event with the additional formats. Unlike relying on the native copy event of the focused editable, this fires reliably even when the browser does not emit a copy event for a collapsed contenteditable selection (e.g. Safari). */ -const copyRich = (text: string, html: string): void => { - /** Writes the plain text, html, and text/em marker onto the clipboard event. */ +/** Copies text and html (plus the text/em source marker) to the clipboard using a focused, visually-hidden + * contenteditable and a programmatic execCommand('copy'). The setData() calls write the exact text/plain, + * text/html, and text/em marker. Used on every non-Safari browser: Chrome (and Chromium, including the + * Puppeteer automation that the e2e suite relies on) honors setData() during a programmatic copy. + */ +const copyRichExecCommand = (text: string, html: string): void => { + const selectionState = selection.save() + + /** Writes the plain text, html, and text/em marker onto the copy event. */ const onCopy = (e: ClipboardEvent) => { e.preventDefault() e.clipboardData?.setData('text/plain', text) @@ -19,35 +26,115 @@ const copyRich = (text: string, html: string): void => { e.clipboardData?.setData('text/em', 'true') } - document.addEventListener('copy', onCopy) - const dummyButton = document.createElement('button') - const clipboard = new ClipboardJS(dummyButton, { text: () => text }) - dummyButton.click() - clipboard.destroy() - document.removeEventListener('copy', onCopy) + // Render the html into a focused, visually-hidden contenteditable so the browser copies genuine rich content. + const container = document.createElement('div') + container.setAttribute('contenteditable', 'true') + container.innerHTML = html + container.style.position = 'fixed' + container.style.top = '0' + container.style.left = '0' + container.style.width = '1px' + container.style.height = '1px' + container.style.opacity = '0' + container.style.overflow = 'hidden' + container.style.pointerEvents = 'none' + container.style.whiteSpace = 'pre-wrap' + container.setAttribute('aria-hidden', 'true') + document.body.appendChild(container) + container.focus() + selection.selectNode(container) + + document.addEventListener('copy', onCopy, true) + document.execCommand('copy') + document.removeEventListener('copy', onCopy, true) + + if (container.parentNode) document.body.removeChild(container) + selection.restore(selectionState) +} + +/** Copies text and html to the clipboard on Safari/WebKit. + * + * Safari ignores setData() during a programmatic execCommand('copy') and sanitizes text/html written via the + * async Clipboard API down to an empty document, so the rich path used on Chrome does not work here. However, + * Copy Cursor runs on the Cmd+C keydown with permitDefault, so the browser fires its own genuine, user- + * initiated copy event — and Safari *does* honor setData() in that event. The Editable's onCopy handler + * (useOnCopy) handles it when an editable is focused, but with a multicursor the copy event targets + * (no editable is focused), so useOnCopy never runs and the browser serializes the empty collapsed selection + * to an empty document — the root cause of #3993 on Safari. + * + * To handle the copy regardless of which element is focused, we register a capture-phase document listener + * that intercepts the user-initiated copy event, preventDefault()s the empty serialization, and writes the + * exact text/plain, text/html, and text/em marker — all honored by Safari because the event is user-initiated. + * + * Crucially, Safari dispatches the Cmd+C copy event on a later task than setTimeout(0), so the listener must + * stay registered well beyond the current tick. It removes itself as soon as the copy event fires, and a + * generous timeout removes it otherwise (e.g. the command was triggered from the command palette, which fires + * no copy event) so it cannot affect an unrelated later copy. + */ +const copyRichSafari = (text: string, html: string): void => { + let done = false + + /** Writes the plain text, html, and text/em marker onto the user-initiated copy event. */ + const onCopy = (e: ClipboardEvent) => { + if (done) return + done = true + document.removeEventListener('copy', onCopy, true) + e.preventDefault() + e.clipboardData?.setData('text/plain', text) + e.clipboardData?.setData('text/html', html) + // Mark the source as 'em' so the paste handler preserves formatting and skips the browser's synthesized html. + e.clipboardData?.setData('text/em', 'true') + } + + // Capture phase so the listener runs even when the copy event targets (multicursor has no focused editable). + document.addEventListener('copy', onCopy, true) + + // Safari dispatches the Cmd+C copy event on a later task, so keep the listener alive for a generous window. + // onCopy self-removes on the first copy event (setting done); this timeout only removes the listener if no + // copy event ever arrives (e.g. the command was triggered from the command palette) so it cannot affect an + // unrelated later copy. + setTimeout(() => { + if (done) return + done = true + document.removeEventListener('copy', onCopy, true) + }, 1000) +} + +/** Copies text and html (plus the text/em source marker) to the clipboard for a rich (structured) copy. + * Dispatches to a Safari-specific path because Safari does not honor setData() during a programmatic copy. + */ +const copyRich = (text: string, html: string): void => { + if (isSafari()) { + copyRichSafari(text, html) + } else { + copyRichExecCommand(text, html) + } } /** Copies a string to the clipboard. When html is provided, also writes text/html and the text/em source marker so that structured content pastes correctly even on browsers that do not fire a native copy event for a collapsed selection. */ const copy = (text: string, { html }: CopyOptions = {}): void => { - // save selection - const selectionState = selection.save() - if (Capacitor.isNativePlatform()) { + // save selection + const selectionState = selection.save() Clipboard.write({ string: text, }) + // restore selection + selection.restore(selectionState) } else if (html != null) { + // copyRich manages its own selection lifecycle, so it must not be wrapped in save/restore here. copyRich(text, html) } else { + // save selection + const selectionState = selection.save() // copy from dummy element using ClipboardJS const dummyButton = document.createElement('button') const clipboard = new ClipboardJS(dummyButton, { text: () => text }) dummyButton.click() clipboard.destroy() + // restore selection + selection.restore(selectionState) } - - // restore selection - selection.restore(selectionState) } export default copy diff --git a/src/device/selection.ts b/src/device/selection.ts index 9ba8a3adfb5..b21ef1ed9cc 100644 --- a/src/device/selection.ts +++ b/src/device/selection.ts @@ -51,6 +51,16 @@ export const clear = (): void => { } } +/** Selects the entire contents of the given node. Used to stage rich content for a programmatic copy. */ +export const selectNode = (node: Node): void => { + const sel = window.getSelection() + if (!sel) return + const range = document.createRange() + range.selectNodeContents(node) + sel.removeAllRanges() + sel.addRange(range) +} + /** Returns true if the selection is a collapsed caret, i.e. the beginning and end of the selection are the same. Returns undefined if there is no selection. */ export const isCollapsed = (): boolean => !!window.getSelection()?.isCollapsed From 4b3d61bea26df8d0d0340ee12e9b93c162b80758 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:56:20 +0000 Subject: [PATCH 7/8] fix(copy): route mobile Safari multicursor copy to plain-text path (#3993) Co-authored-by: BayuAri <8419585+BayuAri@users.noreply.github.com> --- src/device/copy.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/device/copy.ts b/src/device/copy.ts index 3d6336b3a24..776699408b0 100644 --- a/src/device/copy.ts +++ b/src/device/copy.ts @@ -1,7 +1,7 @@ import { Clipboard } from '@capacitor/clipboard' import { Capacitor } from '@capacitor/core' import ClipboardJS from 'clipboard' -import { isSafari } from '../browser' +import { isSafari, isTouch } from '../browser' import * as selection from './selection' interface CopyOptions { @@ -121,10 +121,17 @@ const copy = (text: string, { html }: CopyOptions = {}): void => { }) // restore selection selection.restore(selectionState) - } else if (html != null) { + } else if (html != null && !(isSafari() && isTouch)) { // copyRich manages its own selection lifecycle, so it must not be wrapped in save/restore here. copyRich(text, html) } else { + // Plain-text copy via a programmatic ClipboardJS execCommand('copy'). + // + // This is also the path for mobile Safari (isSafari() && isTouch). There, the rich copy cannot run: the + // copy is triggered by a Command Center tap rather than Cmd+C, so no native copy event fires, and Safari + // does not honor setData() during a programmatic copy. The full multicursor selection is still copied as + // indented plain text, which em reconstructs into the thought tree on paste (the pre-#3993 mobile behavior). + // // save selection const selectionState = selection.save() // copy from dummy element using ClipboardJS From a1863368a847e8d9570b7dcf8d15016352331734 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:52:14 +0000 Subject: [PATCH 8/8] refactor(copy): funnel useOnCopy into single return; cancel pending Safari copy timeout (#3993) Co-authored-by: raineorshine <750276+raineorshine@users.noreply.github.com> --- src/components/Editable/useOnCopy.ts | 32 +++++++++++-------------- src/device/copy.ts | 35 +++++++++++++++++----------- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/src/components/Editable/useOnCopy.ts b/src/components/Editable/useOnCopy.ts index 0652bca6af0..36f0adb1015 100644 --- a/src/components/Editable/useOnCopy.ts +++ b/src/components/Editable/useOnCopy.ts @@ -21,27 +21,21 @@ const useOnCopy = ({ thoughtId }: { thoughtId: ThoughtId }) => { // The Copy Cursor command also writes the full selection to the clipboard, but copyCursor uses // permitDefault, so this native copy event fires afterward and would otherwise clobber it with // only the focused thought (#3993). - if (hasMulticursor(state)) { - const ids = getMulticursorThoughtIds(state) - const plainText = trimBullet(ids.map(id => strip(exportContext(state, id, 'text/plain'))).join('\n')) - const htmlText = ids.map(id => exportContext(state, id, 'text/html')).join('\n') - clipboardData.setData('text/plain', plainText) - clipboardData.setData('text/html', htmlText) - clipboardData.setData('text/em', 'true') - return - } - + const ids = hasMulticursor(state) ? getMulticursorThoughtIds(state) : null const currentText = selection.text() - const currentHtml = selection.html() - clipboardData.setData('text/plain', currentText!) - clipboardData.setData('text/html', currentHtml!) - if (!currentText) { - const thoughtHtml = exportContext(state, thoughtId, 'text/html') - const thoughtText = exportContext(state, thoughtId, 'text/plain') - clipboardData.setData('text/plain', trimBullet(thoughtText)) - clipboardData.setData('text/html', thoughtHtml) - } + // multicursor: all selected thoughts; else the current selection, falling back to the focused thought when the selection is empty. + const plainText = ids + ? trimBullet(ids.map(id => strip(exportContext(state, id, 'text/plain'))).join('\n')) + : currentText || trimBullet(exportContext(state, thoughtId, 'text/plain')) + const htmlText = ids + ? ids.map(id => exportContext(state, id, 'text/html')).join('\n') + : currentText + ? selection.html()! + : exportContext(state, thoughtId, 'text/html') + + clipboardData.setData('text/plain', plainText) + clipboardData.setData('text/html', htmlText) clipboardData.setData('text/em', 'true') }, [thoughtId], diff --git a/src/device/copy.ts b/src/device/copy.ts index 776699408b0..8d23859aba9 100644 --- a/src/device/copy.ts +++ b/src/device/copy.ts @@ -69,16 +69,29 @@ const copyRichExecCommand = (text: string, html: string): void => { * Crucially, Safari dispatches the Cmd+C copy event on a later task than setTimeout(0), so the listener must * stay registered well beyond the current tick. It removes itself as soon as the copy event fires, and a * generous timeout removes it otherwise (e.g. the command was triggered from the command palette, which fires - * no copy event) so it cannot affect an unrelated later copy. + * no copy event) so it cannot affect an unrelated later copy. If copyRichSafari is called again before the + * previous listener has fired or timed out, the previous listener and its timeout are cancelled first so that + * only the latest text is written. */ +// Tracks the pending Safari copy listener/timeout so a later copyRichSafari call can cancel it before registering a new one. +let pendingSafariCopy: { onCopy: (e: ClipboardEvent) => void; timeoutId: ReturnType } | null = null + +/** Removes the pending Safari copy listener and clears its fallback timeout. Idempotent. */ +const clearPendingSafariCopy = (): void => { + if (!pendingSafariCopy) return + clearTimeout(pendingSafariCopy.timeoutId) + document.removeEventListener('copy', pendingSafariCopy.onCopy, true) + pendingSafariCopy = null +} + +/** Copies text and html to the clipboard on Safari/WebKit via a capture-phase document copy listener (see comment above). */ const copyRichSafari = (text: string, html: string): void => { - let done = false + // Cancel any pending listener/timeout from a previous invocation so only the latest copy is written. + clearPendingSafariCopy() /** Writes the plain text, html, and text/em marker onto the user-initiated copy event. */ const onCopy = (e: ClipboardEvent) => { - if (done) return - done = true - document.removeEventListener('copy', onCopy, true) + clearPendingSafariCopy() e.preventDefault() e.clipboardData?.setData('text/plain', text) e.clipboardData?.setData('text/html', html) @@ -90,14 +103,10 @@ const copyRichSafari = (text: string, html: string): void => { document.addEventListener('copy', onCopy, true) // Safari dispatches the Cmd+C copy event on a later task, so keep the listener alive for a generous window. - // onCopy self-removes on the first copy event (setting done); this timeout only removes the listener if no - // copy event ever arrives (e.g. the command was triggered from the command palette) so it cannot affect an - // unrelated later copy. - setTimeout(() => { - if (done) return - done = true - document.removeEventListener('copy', onCopy, true) - }, 1000) + // onCopy self-cancels on the first copy event; this timeout only clears the listener if no copy event ever + // arrives (e.g. the command was triggered from the command palette) so it cannot affect an unrelated later copy. + const timeoutId = setTimeout(clearPendingSafariCopy, 1000) + pendingSafariCopy = { onCopy, timeoutId } } /** Copies text and html (plus the text/em source marker) to the clipboard for a rich (structured) copy.