fix(mobile): the caret survives the host's URL dialog, so Link and Image insert

Measured in the render check, on both engines: the Link and Image commands opened
the modal, took the URL, and inserted nothing. The dialog is what takes the
caret — the modal focuses its own field — and `execCommand` on a document that
does not hold the selection does nothing at all. So the page had swapped one
silent failure for another: `window.prompt` returning null on the phone, and a
command with no selection on the page.

Two halves. The document remembers its caret before it waits and puts it back
after (`restoreRememberedSelection`, unconditional where `restoreSelectionOrEnd`
needs a flag, because the wait itself is the blur); if the host replaced the
content while the dialog was open, the remembered range is gone from the document
and the caret goes to the end instead. And the component answers the promise from
the drawer's `onAfterClose` rather than from the submit, because WebKit would not
take the focus back while the field still held it — with the answer released on
submit, chromium inserted and WebKit did not.

`TextInputModal` forwards `onAfterClose` for that, which is the one thing it did
not already pass through to `BottomDrawer`.

Red first, `editor-selection.test.ts` against the previous `editor-commands.ts`:
2 failed, 7 passed — the caret was left in the dialog's field, and a replaced
document did not fall back to the end. The render check's Link/Image case went
from failing on both engines to inserting on both, with the inserted image's
`naturalWidth` above zero under the shipped policy.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-21 18:33:03 -04:00
parent 00770bb2aa
commit f7652bc72e
5 changed files with 112 additions and 7 deletions
@@ -69,6 +69,10 @@ function MobileRichMarkdownEditorWebInner(
// The modal's half of `promptForUrl`: the command is waiting on this, and it is answered once,
// by a submit, a cancel, or the unmount below.
const pendingUrlRef = useRef<((url: string | null) => void) | null>(null)
// What the user typed, held until the drawer has gone. Measured in WebKit: answering while the
// field still had the focus left `execCommand` acting on a document that did not hold the
// selection, and Link and Image inserted nothing at all.
const answeredUrlRef = useRef<string | null>(null)
const send = useCallback((call: (api: RichMarkdownEditorApi) => void) => {
const mounted = documentRef.current
@@ -135,10 +139,18 @@ function MobileRichMarkdownEditorWebInner(
receiveRef.current = handleMessage
}, [handleMessage])
/** Closes the modal, keeping the answer for the moment the field no longer has the focus. */
const answerUrlPrompt = useCallback((url: string | null) => {
const pending = pendingUrlRef.current
pendingUrlRef.current = null
answeredUrlRef.current = url
setUrlPromptKind(null)
}, [])
/** The drawer has gone: the document may have its caret back, and its command may run. */
const releaseUrlPrompt = useCallback(() => {
const pending = pendingUrlRef.current
const url = answeredUrlRef.current
pendingUrlRef.current = null
answeredUrlRef.current = null
pending?.(url)
}, [])
@@ -148,6 +160,7 @@ function MobileRichMarkdownEditorWebInner(
// A second ask while one is open cancels the first, so no command is left awaiting a modal
// that has been replaced.
pendingUrlRef.current?.(null)
answeredUrlRef.current = null
pendingUrlRef.current = resolve
setUrlPromptKind(kind)
}),
@@ -194,6 +207,7 @@ function MobileRichMarkdownEditorWebInner(
keyboardType="url"
onSubmit={answerUrlPrompt}
onCancel={() => answerUrlPrompt(null)}
onAfterClose={releaseUrlPrompt}
/>
</View>
)
+5 -2
View File
@@ -24,6 +24,8 @@ type Props = {
keyboardType?: KeyboardTypeOptions
onSubmit: (value: string) => void
onCancel: () => void
/** Called once the drawer has gone, which is when the field stops holding the focus. */
onAfterClose?: () => void
}
export function TextInputModal({
@@ -37,7 +39,8 @@ export function TextInputModal({
allowEmpty = false,
keyboardType,
onSubmit,
onCancel
onCancel,
onAfterClose
}: Props) {
const [value, setValue] = useState(defaultValue)
const [previousVisible, setPreviousVisible] = useState(visible)
@@ -64,7 +67,7 @@ export function TextInputModal({
const canSubmit = allowEmpty || value.trim().length > 0
return (
<BottomDrawer visible={visible} onClose={onCancel}>
<BottomDrawer visible={visible} onClose={onCancel} onAfterClose={onAfterClose}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
{message ? <Text style={styles.message}>{message}</Text> : null}
@@ -1,5 +1,10 @@
import { emitChange, syncTaskCheckboxesDisabled } from './editor-content'
import { restoreSelectionOrEnd, wrapSelection } from './editor-selection'
import {
rememberSelection,
restoreRememberedSelection,
restoreSelectionOrEnd,
wrapSelection
} from './editor-selection'
import { editorElement } from './editor-surface'
import { isSafeUrl } from './markdown-escaping'
import type { MobileRichMarkdownCommand } from '../mobile-rich-markdown-editor-contract'
@@ -43,6 +48,10 @@ function acceptsCommands(scope: RichMarkdownEditorScope, generation: number): bo
*
* The generation is read before the wait rather than passed in, which is the same instant:
* nothing between `runCommand`'s own read and this one yields.
*
* The caret is saved before the wait and put back after it, because the dialog is what takes it:
* the page's modal focuses its own field, and `execCommand` on a document that does not hold the
* selection inserts nothing.
*/
async function insertUrl(
scope: RichMarkdownEditorScope,
@@ -50,8 +59,10 @@ async function insertUrl(
command: 'createLink' | 'insertImage'
) {
const generation = scope.documentGeneration
rememberSelection(scope)
const url = await scope.promptForUrl(kind)
if (url && isSafeUrl(url) && acceptsCommands(scope, generation)) {
restoreRememberedSelection(scope)
exec(scope, command, url)
}
}
@@ -16,7 +16,9 @@ import type { RichMarkdownEditorDocument } from './document-host-seams'
*/
const started: RichMarkdownEditorDocument[] = []
function runtime(options: { caret?: 'paragraph-3' | null } = {}) {
function runtime(
options: { caret?: 'paragraph-3' | null; promptForUrl?: () => Promise<string | null> } = {}
) {
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
const editor = document.getElementById('editor')!
editor.innerHTML =
@@ -47,7 +49,8 @@ function runtime(options: { caret?: 'paragraph-3' | null } = {}) {
const document_ = createRichMarkdownEditorDocument({
postToHost: () => {},
keyboardInsetSource: () => null
keyboardInsetSource: () => null,
...(options.promptForUrl ? { promptForUrl: options.promptForUrl } : {})
})
started.push(document_)
@@ -145,3 +148,53 @@ describe('the editor document caret, across a keyboard dismissal', () => {
expect(editor.selectedContainer()).toBe('editor-end')
})
})
describe('the editor document caret, across the hosts URL dialog', () => {
/** A host that answers the way a modal does: it takes the focus, then it answers. */
const modalThatTakesTheFocus = () => {
const field = document.createElement('input')
document.body.appendChild(field)
field.focus()
window.getSelection()?.removeAllRanges()
return Promise.resolve('https://example.com/a')
}
it('puts the caret back where the dialog found it, so the command has one to act on', async () => {
// The page's modal focuses its own field, and `execCommand` on a document that does not hold
// the selection inserts nothing: measured in both engines, Link and Image did nothing at all.
const editor = runtime({ caret: 'paragraph-3', promptForUrl: modalThatTakesTheFocus })
await editor.handle.runCommand('link')
expect(editor.focused()).toBe(true)
expect(editor.selectedContainer()).toBe('paragraph-3')
})
it('falls back to the end when the dialog outlived the content it was opened over', async () => {
// The host can replace the content while its dialog is open, which detaches the nodes the
// remembered caret was in.
let replaceContent = () => {}
const editor = runtime({
caret: 'paragraph-3',
promptForUrl: () => {
replaceContent()
return modalThatTakesTheFocus()
}
})
replaceContent = editor.detachContent
await editor.handle.runCommand('link')
expect(editor.selectedContainer()).toBe('editor-end')
})
it('runs no command when the dialog is cancelled, and leaves the caret alone', async () => {
const editor = runtime({
caret: 'paragraph-3',
promptForUrl: () => {
window.getSelection()?.removeAllRanges()
return Promise.resolve(null)
}
})
await editor.handle.runCommand('link')
// Nothing restored, because nothing is going to run: the restore is the command's, not the
// dialog's.
expect(editor.selectedContainer()).toBe(null)
})
})
@@ -75,12 +75,36 @@ export function restoreSelectionOrEnd(scope: RichMarkdownEditorScope) {
if (selection.rangeCount > 0) {
return
}
collapseToEnd(scope)
}
/** The caret at the end of the document, which is where a command with nothing to act on goes. */
function collapseToEnd(scope: RichMarkdownEditorScope) {
const range = scope.getDocument().createRange()
range.selectNodeContents(editorElement(scope))
range.collapse(false)
applySelectionRange(scope, range)
}
/**
* Puts the caret back where the host's dialog found it.
*
* A command that has to ask for a URL gives the caret up while it waits: the page's modal takes
* focus into its own field, and `execCommand` on a document that does not hold the selection
* inserts nothing at all — measured in both engines, with Link and Image doing nothing on a page
* whose modal had just answered. Unconditional, unlike `restoreSelectionOrEnd`, because the wait
* itself is the blur and there is nothing for a flag to tell it.
*/
export function restoreRememberedSelection(scope: RichMarkdownEditorScope) {
focusEditor(scope)
const saved = scope.savedSelectionRange
if (saved && editorElement(scope).contains(saved.commonAncestorContainer)) {
applySelectionRange(scope, saved)
return
}
collapseToEnd(scope)
}
/**
* Wraps the selection in one element, for the formats `execCommand` has no verb for.
*