diff --git a/mobile/src/components/MobileRichMarkdownEditor.web.tsx b/mobile/src/components/MobileRichMarkdownEditor.web.tsx index 0a3be9255bf..b5c93009548 100644 --- a/mobile/src/components/MobileRichMarkdownEditor.web.tsx +++ b/mobile/src/components/MobileRichMarkdownEditor.web.tsx @@ -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(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} /> ) diff --git a/mobile/src/components/TextInputModal.tsx b/mobile/src/components/TextInputModal.tsx index 3fd007d6a5c..709d1ecd26e 100644 --- a/mobile/src/components/TextInputModal.tsx +++ b/mobile/src/components/TextInputModal.tsx @@ -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 ( - + {title} {message ? {message} : null} diff --git a/mobile/src/components/rich-markdown/editor-commands.ts b/mobile/src/components/rich-markdown/editor-commands.ts index 76edab42c6f..64a499c48aa 100644 --- a/mobile/src/components/rich-markdown/editor-commands.ts +++ b/mobile/src/components/rich-markdown/editor-commands.ts @@ -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) } } diff --git a/mobile/src/components/rich-markdown/editor-selection.test.ts b/mobile/src/components/rich-markdown/editor-selection.test.ts index c4293daf0ce..fafb3690682 100644 --- a/mobile/src/components/rich-markdown/editor-selection.test.ts +++ b/mobile/src/components/rich-markdown/editor-selection.test.ts @@ -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 } = {} +) { 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 host’s 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) + }) +}) diff --git a/mobile/src/components/rich-markdown/editor-selection.ts b/mobile/src/components/rich-markdown/editor-selection.ts index f5af6e93ce0..9cba8a256cc 100644 --- a/mobile/src/components/rich-markdown/editor-selection.ts +++ b/mobile/src/components/rich-markdown/editor-selection.ts @@ -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. *