Skip to content
8 changes: 6 additions & 2 deletions src/commands/__tests__/copyCursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe('copyCursor', () => {
`- a
- a1
- a2`,
expect.objectContaining({ html: expect.any(String) }),
)
})

Expand All @@ -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', () => {
Expand Down Expand Up @@ -85,6 +86,7 @@ describe('copyCursor', () => {
- c
- c1
- c2`,
expect.objectContaining({ html: expect.any(String) }),
)
})

Expand All @@ -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 () => {
Expand Down Expand Up @@ -131,6 +133,7 @@ describe('copyCursor', () => {
- a1
- a1a
- a2`,
expect.objectContaining({ html: expect.any(String) }),
)
})

Expand Down Expand Up @@ -168,6 +171,7 @@ describe('copyCursor', () => {
- c
- c1
- c2`,
expect.objectContaining({ html: expect.any(String) }),
)
})
})
Expand Down
21 changes: 8 additions & 13 deletions src/commands/copyCursor.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand All @@ -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
}
Expand All @@ -52,19 +55,11 @@ const copyCursorCommand: Command = {
keyboard: { key: 'c', meta: true },
multicursor: {
execMulticursor: async (cursors, dispatch, getState) => {
const filteredCursors = cursors.reduce<Path[]>((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(
Expand Down
32 changes: 22 additions & 10 deletions src/components/Editable/useOnCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -12,18 +15,27 @@ const useOnCopy = ({ thoughtId }: { thoughtId: ThoughtId }) => {
event.preventDefault()

const state = store.getState()
const currentText = selection.text()
const currentHtml = selection.html()
const clipboardData = event.clipboardData
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)
}
// 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).
const ids = hasMulticursor(state) ? getMulticursorThoughtIds(state) : null
const currentText = selection.text()

// 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],
Expand Down
145 changes: 137 additions & 8 deletions src/device/copy.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,156 @@
import { Clipboard } from '@capacitor/clipboard'
import { Capacitor } from '@capacitor/core'
import ClipboardJS from 'clipboard'
import { isSafari, isTouch } from '../browser'
import * as selection from './selection'

/** Copies a string directly to the clipboard by simulating a button click with ClipboadJS. */
const copy = (s: string): void => {
// save selection
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 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)
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')
}

// 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 <body>
* (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. 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<typeof setTimeout> } | 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 => {
// 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) => {
clearPendingSafariCopy()
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 <body> (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-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.
* 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 => {
if (Capacitor.isNativePlatform()) {
// save selection
const selectionState = selection.save()
Clipboard.write({
string: s,
string: text,
})
// restore selection
selection.restore(selectionState)
} 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
const dummyButton = document.createElement('button')
const clipboard = new ClipboardJS(dummyButton, { text: () => s })
const clipboard = new ClipboardJS(dummyButton, { text: () => text })
dummyButton.click()
clipboard.destroy()
// restore selection
selection.restore(selectionState)
}

// restore selection
selection.restore(selectionState)
}

export default copy
10 changes: 10 additions & 0 deletions src/device/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading