Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/cursor-and-caret.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ The two reads worth calling out:

Caret position is set via [`Editable`](../src/components/Editable.tsx)'s use of `selection.set`. The hook that actually decides *when* to set the selection is [`useEditMode`](#useeditmode), described below.

Notes own a separate contenteditable in [`Note`](../src/components/Note.tsx), so they place their caret directly instead of using `useEditMode`. Each note edit captures the post-edit character offset relative to the note root with `offsetFromNode()` and carries it on the note's edit action (`editThought` for a literal note or `editNotePath` for a path-based note). The undo enhancer uses the edit's plain-text length change to retain the corresponding pre-edit offset. Undo and redo force the note to render, then `Note` reads the requested `state.noteOffset` non-reactively, places the caret, and clears that one-shot request with a `setNoteFocus` that `undoRedoEnhancer` keeps out of the undo history. Placing the caret also focuses the note, which is why `onFocus` bails when state already has the caret on this note: that focus is the app's own, so there is no cursor to move, and the `setCursor` it would otherwise dispatch is an undoable navigation action — it would clear the offset the effect is in the middle of honoring, recompute `cursorOffset`, and discard the redo stack the moment an undo restores a note caret, leaving the note edit that was just undone with no redo step. Keeping the offset non-reactive prevents an ordinary click or text selection from being overwritten by a render.
Notes own a separate contenteditable in [`Note`](../src/components/Note.tsx), so they place their caret directly instead of using `useEditMode`. Each note edit captures the post-edit character offset relative to the note root with `offsetFromNode()` and carries it on the note's edit action (`editThought` for a literal note or `editNotePath` for a path-based note). The undo enhancer uses the edit's plain-text length change to retain the corresponding pre-edit offset. Undo and redo force the note to render, then `Note` reads the requested `state.noteOffset` non-reactively, places the caret, and clears that one-shot request with a `setNoteFocus` that `undoRedoEnhancer` keeps out of the undo history. Placing the caret also focuses the note, which is why `onFocus` bails when state already has the caret on this note: that focus is the app's own, so there is no cursor to move, and the `setCursor` it would otherwise dispatch is an undoable navigation action — it would clear the offset the effect is in the middle of honoring, recompute `cursorOffset`, and discard the redo stack the moment an undo restores a note caret, leaving the note edit that was just undone with no redo step. Keeping the offset non-reactive prevents an ordinary click or text selection from being overwritten by a render. A paste into a note inserts the clipboard's plain text with `execCommand('insertText')` instead of the browser's own paste, which on WebKit wraps the text in a styled span and a trailing newline: the caret stays after the pasted text, and the value the note saves matches its DOM, so the note does not re-render and lose the caret ([issue #3828](https://github.com/cybersemics/em/issues/3828)).

Notes use `white-space: pre-wrap`, like thoughts, so the browser preserves typed spaces without converting them to HTML entities. They preserve boundary spaces while typing and trim them when editing ends using the same `trimHtml` utility as thoughts. Blur normalizes the latest saved values through the existing edit actions, including each child of a path-based note, before clearing the draft and focus state. Formatting and interior whitespace are preserved; temporary blurs during suppressed editing leave the note untouched. A path note draft belongs to the `editableNonce` at which it was entered. An editor refresh, such as undo/redo, invalidates the draft during render so the restored value reaches the DOM before caret placement.

Expand Down
2 changes: 1 addition & 1 deletion docs/layout-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ When `b` is positioned, `yaccum` is *not* incremented; `b1` is placed at `(x = f

## `useLayoutTreeTop`

A small effect that writes the LayoutTree's top y (offset + autocrop) into `viewportStore.layoutTreeTop`. Used by `scrollCursorIntoView` to figure out where thoughts actually start on the page (vs. the toolbar above).
A small layout effect that writes the LayoutTree's top y (offset + autocrop) into `viewportStore.layoutTreeTop`. Used by `scrollCursorIntoView` to figure out where thoughts actually start on the page (vs. the toolbar above). It is a layout effect so that the value is set before the cursor overlay, a child of LayoutTree, checks the cursor on mount: React runs a child's passive effects before its parent's, so a passive effect here would leave `layoutTreeTop` at 0 for that check, and a cursor in plain view would be measured as behind the toolbar and scrolled.

## Scrolling the cursor into view

Expand Down
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,7 @@ The most important helper is [`exportThoughts`](../src/e2e/puppeteer/helpers/exp

The iOS suite has a separate driver vocabulary in [`../src/e2e/iOS/helpers/`](../src/e2e/iOS/helpers): [`tap`](../src/e2e/iOS/helpers/tap.ts) emits a W3C pointer action, [`keyboard.type`](../src/e2e/iOS/helpers/keyboard.ts) uses WDIO `sendKeys`, and [`gesture`](../src/e2e/iOS/helpers/gesture.ts) emits a touch pointer path. Helpers such as [`tapReturnKey`](../src/e2e/iOS/helpers/tapReturnKey.ts), [`hideKeyboardByTappingDone`](../src/e2e/iOS/helpers/hideKeyboardByTappingDone.ts), and [`showEditMenu`](../src/e2e/iOS/helpers/showEditMenu.ts) cross into native iOS UI when Web content APIs are insufficient.

**Element rects are viewport coordinates, but `performActions` delivers touches in screen coordinates.** [`getScreenOffsetY`](../src/e2e/iOS/helpers/getScreenOffsetY.ts) returns the difference for the current session: 60px of Safari chrome in mobile Safari (measured, and constant across keyboard states), and 0 in the Capacitor app, whose web view starts at the top of the screen. The same helpers drive both — the wdio suite runs Safari, the `browser-control-ios` bridge runs the app — so the platform is read from the page rather than assumed. `tap` adds the offset, so its `y` option means an offset *beyond* the element (to aim at the empty space below it, say) rather than the conversion itself. [`getElementRectByScreen`](../src/e2e/iOS/helpers/getElementRectByScreen.ts) adds it too, for the raw `performActions` and `gesture` calls that need screen coordinates directly. Never apply it twice.
**Element rects are viewport coordinates, but `performActions` delivers touches in screen coordinates.** [`getScreenOffsetY`](../src/e2e/iOS/helpers/getScreenOffsetY.ts) returns the difference for the current session: 60px of Safari chrome in mobile Safari (measured, and constant across keyboard states), and 0 in the Capacitor app, whose web view starts at the top of the screen. The same helpers drive both — the wdio suite runs Safari, the `browser-control-ios` bridge runs the app — so the platform is read from the page rather than assumed. `tap` adds the offset, so its `y` option means an offset *beyond* the element (to aim at the empty space below it, say) rather than the conversion itself. [`getElementRectByScreen`](../src/e2e/iOS/helpers/getElementRectByScreen.ts) adds it too, for the raw `performActions` and `gesture` calls that need screen coordinates directly, and [`showEditMenu`](../src/e2e/iOS/helpers/showEditMenu.ts) adds it to the selection rect before its native `mobile: tap`. Never apply it twice.

Do not call `window.scrollTo(0, 0)` while the keyboard is up. Safari keeps the document scroll in step with the visual viewport while the keyboard is open, and forcing the scroll back to the top breaks that: the conversion is then wrong by `visualViewport.offsetTop`, and the touch lands that far below its target. This was measured on a device but nothing asserts it, so treat it as a measurement rather than a guarantee. The symptom is a touch that quietly lands a row away from where it was aimed.

Expand Down
5 changes: 4 additions & 1 deletion src/components/LayoutTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,10 @@ const useLayoutTreeTop = (
/** The amount that the layout tree is shifted up is needed to produce the correct layoutTreeTop value. See: useAutocrop. */
autocrop: number,
) => {
useEffect(() => {
// A layout effect, so that layoutTreeTop is set before the cursor overlay's scrollCursorIntoView effect runs on mount.
// Child effects run before their parent's, so a passive effect here would leave layoutTreeTop at 0 for that first
// check, and a cursor in plain view would be measured as behind the toolbar and scrolled.
useLayoutEffect(() => {
if (!ref.current) return
viewportStore.update({ layoutTreeTop: (ref.current?.offsetTop || 0) + autocrop })
}, [ref, autocrop])
Expand Down
22 changes: 18 additions & 4 deletions src/components/Note.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -358,10 +358,24 @@ const Note = React.memo(
// Text copied from a note and pasted on a thought should not bring along the note's default color and italicization. (#3779)
onCopy={onCopy}
onCut={onCut}
onPaste={() => {
// set justPasted so onChange can strip HTML from the new value
// the default onPaste behavior is maintained for easier caret and selection management
setJustPasted(true)
onPaste={(e: React.ClipboardEvent) => {
// mobile Safari copies URLs as 'text/uri-list' when the share button is used
const text = e.clipboardData.getData('text/plain') || e.clipboardData.getData('text/uri-list')

// Pasting from mobile copy (e.g. Choose "Share" in Twitter and select "Copy") can result in a blank clipboard
// that the browser still pastes, so fall back to the default paste and strip the HTML it inserts in onChange.
// See: https://github.com/cybersemics/em/issues/286
if (!text) {
setJustPasted(true)
return
}

// Insert the plain text rather than letting the browser paste HTML. WebKit wraps pasted text in a span with
// inline styles and a trailing newline, and stripping them in onChange leaves a value that no longer matches
// the DOM, so the note re-renders and the caret jumps to the end on iOS and is dropped on Android (#3828).
// insertText leaves the caret after the pasted text and the DOM in sync with the value.
e.preventDefault()
document.execCommand('insertText', false, text)
}}
onBlur={onBlur}
onFocus={onFocus}
Expand Down
53 changes: 47 additions & 6 deletions src/e2e/iOS/__tests__/caret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,28 @@ import newThought from '../helpers/newThought'
import paste from '../helpers/paste'
import scrubSpaceBar from '../helpers/scrubSpaceBar'
import setFontSize from '../helpers/setFontSize'
import setSelection from '../helpers/setSelection'
import showEditMenu from '../helpers/showEditMenu'
import tap from '../helpers/tap'
import tapEditMenuItem from '../helpers/tapEditMenuItem'
import waitForEditable from '../helpers/waitForEditable'
import waitForElement from '../helpers/waitForElement'
import waitUntil from '../helpers/waitUntil'

/** Returns the caret's plain-text offset within the element, counted across any nested nodes, or null if the caret is not in it. Mirrors selection.offsetFromNode, which browser.execute cannot import. */
const getCaretOffsetIn = (element: WebdriverIO.Element) =>
browser.execute(el => {
const selection = window.getSelection()
if (!selection?.focusNode || !el.contains(selection.focusNode)) return null
const range = document.createRange()
range.selectNodeContents(el)
range.setEnd(selection.focusNode, selection.focusOffset)
return range.toString().length
}, element)

/** Returns the element's text content. Read in the page, since Safari's WebDriver does not implement getProperty. */
const getTextContent = (element: WebdriverIO.Element) => browser.execute(el => el.textContent, element)

// tests succeeds individually, but fails when there are too many tests running in parallel
// https://github.com/cybersemics/em/issues/1475
// https://github.com/cybersemics/em/issues/1523
Expand Down Expand Up @@ -171,14 +188,12 @@ describe('Caret', () => {
it('a caret scrubbed in a note stays in the note (#3276)', async () => {
// The note slides into place after the paste, and a tap aimed at it mid-slide lands on the parent thought.
await disableTransitions()
await newThought()
await paste(
[''],
`
// Import the thoughts before the keyboard opens. Opening it first, e.g. with newThought, lets Safari scroll the page,
// so the cursor scroll that follows the import moves the note while it is being tapped.
await paste(`
- a
- =note
- A`,
)
- A`)

const note = await waitForElement('[aria-label="note-editable"]')
await tap(note)
Expand All @@ -193,6 +208,32 @@ describe('Caret', () => {
expect(await isKeyboardShown()).toBeTruthy()
})

// https://github.com/cybersemics/em/issues/3828
it('a caret pasted into the middle of a note stays after the pasted text', async () => {
await disableTransitions()
await paste(`
- Lorem Ipsum Dolor Sit Amet
- =note
- Sed tortor nunc`)

await tap(await waitForEditable('Lorem Ipsum Dolor Sit Amet'), { pointerType: 'touch' })
await setSelection('Lorem '.length, 'Lorem Ipsum'.length)
await showEditMenu()
await tapEditMenuItem('Copy')

// The first tap places the caret between "Sed " and "tortor", the second raises the edit menu on it.
const note = await waitForElement('[aria-label="note-editable"]')
await tap(note, { pointerType: 'touch', offset: 'Sed '.length })
await waitUntil(isKeyboardShown)
await tap(note, { pointerType: 'touch', offset: 'Sed '.length })
await tapEditMenuItem('Paste')

await waitUntil(async () => (await getTextContent(note)) !== 'Sed tortor nunc')
expect(await getTextContent(note)).toBe('Sed Ipsumtortor nunc')
// measure the caret relative to the whole note, since the paste may leave the caret in a text node nested in it
expect(await getCaretOffsetIn(note)).toBe('Sed Ipsum'.length)
})

it.skip('Tap empty content while keyboard up', async () => {
const importText = `
- a
Expand Down
30 changes: 2 additions & 28 deletions src/e2e/iOS/helpers/pasteFromEditMenu.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,15 @@
import { browser } from '@wdio/globals'
import tap from './tap'
import tapEditMenuItem from './tapEditMenuItem'
import waitForElement from './waitForElement'

/** The thought em is currently editing. */
const EDITING = '[data-editing=true] [data-editable]'

/** Menu items iOS may offer, probed only to describe a failure. */
const MENU_ITEMS = ['Cut', 'Copy', 'Paste', 'Replace…', 'Look Up', 'Select All']

/** Pastes the system pasteboard into the thought being edited, via the native iOS edit menu. */
const pasteFromEditMenu = async (): Promise<void> => {
// One tap raises the menu on a thought already in edit mode; a second tap would dismiss it.
await tap(await waitForElement(EDITING), { pointerType: 'touch' })

const context = ((await browser.getContext()) as string) || 'NATIVE_APP'
await browser.switchContext('NATIVE_APP')
try {
// The native edit menu exposes more than one element named Paste, so count matches with $$ rather than WebdriverIO's
// strict $, which throws on more than one.
const paste = '//*[@name="Paste"]'
try {
await browser.waitUntil(async () => (await browser.$$(paste).length) > 0, { timeout: 8000 })
} catch {
const shown = (
await Promise.all(
MENU_ITEMS.map(async name => ((await browser.$$(`//*[@name="${name}"]`).length) > 0 ? name : null)),
)
).filter(Boolean)
throw new Error(
shown.length ? `the native edit menu offered ${shown.join(', ')} but no Paste` : 'no native edit menu appeared',
)
}
// The first in document order is the one that takes the tap.
await browser.$$(paste)[0].click()
} finally {
await browser.switchContext(context)
}
await tapEditMenuItem('Paste')
}

export default pasteFromEditMenu
5 changes: 4 additions & 1 deletion src/e2e/iOS/helpers/showEditMenu.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import getScreenOffsetY from './getScreenOffsetY.js'

interface ShowEditMenuOptions {
/**
* Milliseconds to let WebKit lay out the selection before tapping. A freshly-changed selection isn't
Expand Down Expand Up @@ -33,7 +35,8 @@ const showEditMenu = async ({ settleMs = 500 }: ShowEditMenuOptions = {}): Promi
if (target.error) throw new Error(target.error)

await browser.pause(settleMs)
await browser.execute('mobile: tap', { x: target.cx, y: target.cy })
// the selection rect is viewport-relative while touches are delivered in screen coordinates
await browser.execute('mobile: tap', { x: target.cx, y: target.cy + (await getScreenOffsetY()) })
}

export default showEditMenu
Expand Down
35 changes: 35 additions & 0 deletions src/e2e/iOS/helpers/tapEditMenuItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/** Menu items iOS may offer, probed only to describe a failure. */
const MENU_ITEMS = ['Cut', 'Copy', 'Paste', 'Replace…', 'Look Up', 'Select All']

/** Taps an item, such as Copy or Paste, in the native iOS edit menu, which must already be shown. */
const tapEditMenuItem = async (name: string): Promise<void> => {
const context = ((await browser.getContext()) as string) || 'NATIVE_APP'
await browser.switchContext('NATIVE_APP')
try {
// The native edit menu exposes more than one element with the same name, so count matches with $$ rather than
// WebdriverIO's strict $, which throws on more than one.
const item = `//*[@name="${name}"]`
try {
await browser.waitUntil(async () => (await browser.$$(item).length) > 0, { timeout: 8000 })
} catch {
const shown = (
await Promise.all(
MENU_ITEMS.map(async menuItem =>
(await browser.$$(`//*[@name="${menuItem}"]`).length) > 0 ? menuItem : null,
),
)
).filter(Boolean)
throw new Error(
shown.length
? `the native edit menu offered ${shown.join(', ')} but no ${name}`
: 'no native edit menu appeared',
)
}
// The first in document order is the one that takes the tap.
await browser.$$(item)[0].click()
} finally {
await browser.switchContext(context)
}
}

export default tapEditMenuItem
Loading