Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
20 changes: 19 additions & 1 deletion 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,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!)

Expand Down
136 changes: 128 additions & 8 deletions src/device/copy.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,147 @@
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.
*/
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 <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-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 => {
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