diff --git a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs index 45bce378d43..c64aecce9ba 100644 --- a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs +++ b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs @@ -50,8 +50,10 @@ import { * the first time, when main had drifted the haptics module after recording its own number, and a * sum would have read 4283 and been wrong about a module neither side of that merge touched. * - * Both sides read with `mobileWebAppRouteClosure(SESSION_ROUTE)` and the four postinstall - * generators run first, the before side in a scratch worktree detached at the same sha, and the + * Both sides read with `mobileWebAppRouteClosure(SESSION_ROUTE)` and every postinstall generator + * `mobile/package.json` names run first — five at this reading, since C7.10 C1 added the rich + * Markdown editor's document, and the list is read there rather than counted from here because it + * grows. The before side is a scratch worktree detached at the same sha, and the * three modules above read out of the after side's list by name rather than inferred from the * total. Measured rather than taken from main's pin because the pin covers only the module count, * so the local count beside it would otherwise be a number nobody had read. diff --git a/mobile/.gitignore b/mobile/.gitignore index 474807b74b5..85bf6e054a2 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -2,6 +2,7 @@ node_modules/ src/terminal/terminal-webview-engine.generated.ts src/terminal/terminal-webview-engine-css.generated.ts src/terminal/terminal-webview-document-script.generated.ts +src/components/rich-markdown-editor-document-script.generated.ts src/components/pr-sidebar/mermaid-webview-engine.generated.ts src/components/pr-sidebar/mermaid-page-engine.generated.ts .expo/ diff --git a/mobile/package.json b/mobile/package.json index f1359d96a7b..5c2ac13abb9 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -7,7 +7,7 @@ "start": "node scripts/start-expo.mjs", "android": "expo run:android", "ios": "expo run:ios", - "postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs && node scripts/build-mermaid-page-engine.mjs && node scripts/build-terminal-document-script.mjs", + "postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs && node scripts/build-mermaid-page-engine.mjs && node scripts/build-terminal-document-script.mjs && node scripts/build-rich-markdown-editor-script.mjs", "test": "vitest run", "typecheck": "tsc --noEmit", "typecheck:tests": "tsc --noEmit -p tsconfig.test.json", diff --git a/mobile/scripts/build-rich-markdown-editor-script.mjs b/mobile/scripts/build-rich-markdown-editor-script.mjs new file mode 100644 index 00000000000..c173f96f791 --- /dev/null +++ b/mobile/scripts/build-rich-markdown-editor-script.mjs @@ -0,0 +1,95 @@ +import { writeFile } from 'node:fs/promises' +import path from 'node:path' +import esbuild from 'esbuild' + +/** + * The in-WebView rich Markdown editor document, bundled from its modules. + * + * The document is a string the native WebView loads inside its HTML, so it cannot be an ES module + * there — and it is ordinary TypeScript everywhere else, which a page imports directly. So this is + * one esbuild bundle of the entry that calls `createRichMarkdownEditorDocument()` with no host, + * written out as a string, exactly as the terminal's document beside it is. + * + * `iife`, so the bundle's module scope is its own and nothing it declares reaches the page it is + * pasted into. Not minified: the document is read in the WebView's own console, and it is a few + * tens of kilobytes rather than an engine. + */ +const mobileRoot = path.join(import.meta.dirname, '..') + +/** The oldest WebView Orca supports, matching the terminal document's own floor (#7030). */ +const TARGET = 'chrome74' + +const ENTRY = path.join( + mobileRoot, + 'src', + 'components', + 'rich-markdown', + 'native-document-entry.ts' +) + +export const RICH_MARKDOWN_EDITOR_SCRIPT_PATH = path.join( + mobileRoot, + 'src', + 'components', + 'rich-markdown-editor-document-script.generated.ts' +) + +const GENERATED_HEADER = + `// Generated by scripts/build-rich-markdown-editor-script.mjs. Do not edit.\n` + + `// The source is mobile/src/components/rich-markdown/, bundled from native-document-entry.ts.\n` + + `// Target: ${TARGET}. Regenerate via pnpm postinstall.` + +/** + * One options object, so a census of what the bundle contains measures the bundle that ships. + * + * `absWorkingDir` is load-bearing: esbuild writes each module's path into the bundle as a comment, + * relative to the working directory, so without it the artifact's bytes depend on where the + * generator was run from — three cwds gave three digests, and from outside the repo the comments + * carry an absolute path with the builder's home directory in it. + */ +export function richMarkdownEditorBuildOptions(extra = {}) { + return { + absWorkingDir: mobileRoot, + entryPoints: [ENTRY], + bundle: true, + format: 'iife', + minify: false, + platform: 'browser', + target: TARGET, + legalComments: 'none', + write: false, + logLevel: 'silent', + ...extra + } +} + +/** + * One bundle, and everything a reader asks about it. + * + * The text and the module list come from the same build because they are two readings of one + * thing: a census that built its own would answer about a bundle nobody ships. + */ +export async function richMarkdownEditorBundle() { + const result = await esbuild.build(richMarkdownEditorBuildOptions({ metafile: true })) + const [output] = result.outputFiles + if (!output) { + throw new Error('[build-rich-markdown-editor-script] esbuild emitted no document bundle') + } + return { script: output.text.trimEnd(), inputs: Object.keys(result.metafile.inputs) } +} + +export async function buildRichMarkdownEditorScript() { + return (await richMarkdownEditorBundle()).script +} + +async function main() { + const script = await buildRichMarkdownEditorScript() + await writeFile( + RICH_MARKDOWN_EDITOR_SCRIPT_PATH, + `${GENERATED_HEADER}\nexport const RICH_MARKDOWN_EDITOR_DOCUMENT_SCRIPT = ${JSON.stringify(script)}\n` + ) +} + +if (process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]))) { + await main() +} diff --git a/mobile/scripts/build-terminal-document-script.mjs b/mobile/scripts/build-terminal-document-script.mjs index 0fa4e1a5569..67b3b77ac49 100644 --- a/mobile/scripts/build-terminal-document-script.mjs +++ b/mobile/scripts/build-terminal-document-script.mjs @@ -38,9 +38,15 @@ const GENERATED_HEADER = * * `minify: false` is load-bearing beyond readability: the engine-error overlay reports the line and * column `window.onerror` hands it, and a minified document makes both useless. + * + * So is `absWorkingDir`: esbuild writes each module's path into the bundle as a comment, relative + * to the working directory, so without it the artifact's bytes depend on where the generator was + * run from — and from outside the repo the comments carry an absolute path with the builder's home + * directory in it. */ export function terminalDocumentBuildOptions(extra = {}) { return { + absWorkingDir: mobileRoot, entryPoints: [ENTRY], bundle: true, format: 'iife', diff --git a/mobile/src/components/mobile-rich-markdown-editor-document-body.ts b/mobile/src/components/mobile-rich-markdown-editor-document-body.ts deleted file mode 100644 index a381b7d38e4..00000000000 --- a/mobile/src/components/mobile-rich-markdown-editor-document-body.ts +++ /dev/null @@ -1,186 +0,0 @@ -// Head of the editor document through the editable surface; the script follows it. -export const MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_BODY = [ - ';', - ' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;', - ' --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;', - ' }', - ' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }', - ' html, body {', - ' width: 100%;', - ' min-height: 100%;', - ' margin: 0;', - ' background: var(--editor-surface);', - ' color: var(--foreground);', - ' font-family: var(--font-sans);', - ' overscroll-behavior: contain;', - ' }', - ' body { overflow: auto; }', - ' #editor {', - ' min-height: 100vh;', - ' padding: 18px 16px 112px;', - ' outline: none;', - ' font-size: 14px;', - ' line-height: 1.7;', - ' word-wrap: break-word;', - ' overflow-wrap: anywhere;', - ' caret-color: var(--foreground);', - ' }', - ' #editor[contenteditable="false"] {', - ' opacity: 0.78;', - ' }', - ' #editor:empty::before,', - ' #editor p.is-empty:first-child::before {', - ' content: attr(data-placeholder);', - ' color: var(--muted-foreground);', - ' pointer-events: none;', - ' }', - ' #editor > :first-child { margin-top: 0; }', - ' h1, h2, h3, h4, h5, h6 {', - ' margin: 1.5em 0 0.5em;', - ' font-weight: 600;', - ' line-height: 1.3;', - ' letter-spacing: 0;', - ' }', - ' h1 { font-size: 1.85em; font-weight: 700; }', - ' h2 { font-size: 1.4em; }', - ' h3 { font-size: 1.15em; }', - ' p, ul, ol, blockquote { margin: 0.75em 0; }', - ' ul, ol { padding-left: 1.5em; }', - ' ul { list-style: disc; }', - ' ol { list-style: decimal; }', - ' li { margin: 0.15em 0; }', - ' li > p { margin: 0; }', - ' ul[data-type="taskList"] {', - ' padding-left: 0;', - ' list-style: none;', - ' }', - ' ul[data-type="taskList"] > li {', - ' display: flex;', - ' align-items: flex-start;', - ' gap: 6px;', - ' }', - ' ul[data-type="taskList"] > li > label {', - ' flex-shrink: 0;', - ' display: flex;', - ' align-items: center;', - ' height: 1.55em;', - ' user-select: none;', - ' }', - ' ul[data-type="taskList"] input[type="checkbox"] {', - ' appearance: none;', - ' width: 16px;', - ' height: 16px;', - ' margin: 0;', - ' border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);', - ' border-radius: 4px;', - ' background: transparent;', - ' position: relative;', - ' }', - ' ul[data-type="taskList"] input[type="checkbox"]:checked {', - ' background: var(--primary);', - ' border-color: var(--primary);', - ' }', - ' ul[data-type="taskList"] input[type="checkbox"]:checked::after {', - ' content: "";', - ' position: absolute;', - ' left: 4px;', - ' top: 1px;', - ' width: 5px;', - ' height: 9px;', - ' border: solid var(--primary-foreground);', - ' border-width: 0 2px 2px 0;', - ' transform: rotate(45deg);', - ' }', - ' ul[data-type="taskList"] input[type="checkbox"]:disabled {', - ' opacity: 0.65;', - ' }', - ' ul[data-type="taskList"] > li > div {', - ' flex: 1;', - ' min-width: 0;', - ' }', - ' ul[data-type="taskList"] > li[data-checked="true"] > div {', - ' text-decoration: line-through;', - ' color: var(--muted-foreground);', - ' }', - ' blockquote {', - ' padding: 0.5em 1em;', - ' border-left: 3px solid var(--border);', - ' border-radius: 0 6px 6px 0;', - ' color: var(--muted-foreground);', - ' background: color-mix(in srgb, var(--foreground) 2%, transparent);', - ' }', - ' table {', - ' width: 100%;', - ' margin: 1em 0;', - ' border-collapse: collapse;', - ' font-size: 0.95em;', - ' }', - ' th, td {', - ' padding: 8px 14px;', - ' border: 1px solid var(--border);', - ' text-align: left;', - ' vertical-align: top;', - ' }', - ' th {', - ' font-weight: 600;', - ' background: color-mix(in srgb, var(--foreground) 4%, transparent);', - ' }', - ' tr:nth-child(odd) td {', - ' background: color-mix(in srgb, var(--foreground) 1.5%, transparent);', - ' }', - ' code {', - ' padding: 0.2em 0.4em;', - ' border-radius: 5px;', - ' background: color-mix(in srgb, var(--foreground) 8%, transparent);', - ' font-size: 0.88em;', - ' font-family: var(--font-mono);', - ' }', - ' pre {', - ' margin: 0.75em 0;', - ' padding: 14px 18px;', - ' border-radius: 8px;', - ' border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);', - ' overflow-x: auto;', - ' line-height: 1.55;', - ' background: color-mix(in srgb, var(--foreground) 6%, transparent);', - ' font-family: var(--font-mono);', - ' white-space: pre-wrap;', - ' }', - ' pre::before {', - ' content: attr(data-language);', - ' display: block;', - ' min-height: 13px;', - ' margin-bottom: 4px;', - ' color: var(--muted-foreground);', - ' font-size: 11px;', - ' text-transform: uppercase;', - ' }', - ' pre code {', - ' padding: 0;', - ' border-radius: 0;', - ' background: transparent;', - ' font-size: 0.92em;', - ' white-space: pre-wrap;', - ' }', - ' hr {', - ' margin: 1.5em 0;', - ' border: none;', - ' border-top: 1px solid var(--border);', - ' }', - ' a {', - ' color: var(--accent-link);', - ' text-decoration: underline;', - ' text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);', - ' text-underline-offset: 2px;', - ' }', - ' img {', - ' display: block;', - ' max-width: 100%;', - ' margin: 0.75em 0;', - ' border-radius: 8px;', - ' }', - ' ', - '', - '', - '
' -].join('\n') diff --git a/mobile/src/components/mobile-rich-markdown-editor-document-suffix.ts b/mobile/src/components/mobile-rich-markdown-editor-document-suffix.ts deleted file mode 100644 index fb9bd80b3c6..00000000000 --- a/mobile/src/components/mobile-rich-markdown-editor-document-suffix.ts +++ /dev/null @@ -1,90 +0,0 @@ -export const MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS = [ - '', - ' function runCommand(command) {', - " if (!editable || editor.getAttribute('contenteditable') !== 'true') return;", - ' restoreSelectionOrEnd();', - " if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');", - " else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');", - " else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');", - " else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');", - " else if (command === 'bold') document.execCommand('bold');", - " else if (command === 'italic') document.execCommand('italic');", - " else if (command === 'strike') document.execCommand('strikeThrough');", - " else if (command === 'bulletList') document.execCommand('insertUnorderedList');", - " else if (command === 'orderedList') document.execCommand('insertOrderedList');", - " else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');", - " else if (command === 'inlineCode') wrapSelection('code');", - " else if (command === 'codeBlock') document.execCommand('insertHTML', false, '
code


');", - ' else if (command === \'taskList\') document.execCommand(\'insertHTML\', false, \'\');', - " else if (command === 'link') {", - " var href = window.prompt('Link URL');", - " if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);", - " } else if (command === 'image') {", - " var src = window.prompt('Image URL');", - " if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);", - ' }', - ' syncTaskCheckboxesDisabled();', - ' emitChange();', - ' }', - '', - " editor.addEventListener('input', function () {", - ' selectionDroppedOnBlur = false;', - ' if (editable) emitChange();', - ' });', - " editor.addEventListener('change', function (event) {", - ' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');', - ' if (input) {', - ' if (!editable) {', - ' event.preventDefault();', - ' return;', - ' }', - " var li = input.closest('li');", - " if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');", - ' }', - ' if (editable) emitChange();', - ' });', - " editor.addEventListener('click', function (event) {", - " var link = event.target && event.target.closest && event.target.closest('a[href]');", - ' if (link) {', - ' event.preventDefault();', - " post({ type: 'openLink', url: link.getAttribute('href') || '' });", - ' return;', - ' }', - ' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');', - ' if (!input) {', - ' if (!editable) return;', - ' // Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.', - ' var uneditable = event.target && event.target.closest && event.target.closest(\'[contenteditable="false"]\');', - ' if (uneditable && uneditable !== editor) return;', - ' selectionDroppedOnBlur = false;', - ' if (document.activeElement === editor) return;', - ' // Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.', - ' var caret = caretRangeAtPoint(event.clientX, event.clientY);', - ' focusEditor();', - ' if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);', - ' return;', - ' }', - ' if (!editable) {', - ' event.preventDefault();', - ' return;', - ' }', - " var li = input.closest('li');", - " if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');", - ' emitChange();', - ' });', - " editor.addEventListener('keydown', function (event) {", - " if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {", - ' event.preventDefault();', - " runCommand('bold');", - ' }', - ' });', - '', - ' window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };', - '' -].join('\n') - -export const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_END = [ - '', - " post({ type: 'ready' });", - ' })();' -].join('\n') diff --git a/mobile/src/components/mobile-rich-markdown-editor-document.test.ts b/mobile/src/components/mobile-rich-markdown-editor-document.test.ts index f45d45139c6..97000bfce29 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-document.test.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-document.test.ts @@ -1,19 +1,57 @@ import { createHash } from 'node:crypto' import { describe, expect, it } from 'vitest' import { buildMobileRichMarkdownEditorHtml } from './mobile-rich-markdown-editor-html' +import { RICH_MARKDOWN_EDITOR_DOCUMENT_SCRIPT } from './rich-markdown-editor-document-script.generated' -// Digest of main's document at e80fae0c4d, captured before the body/script split. Splitting the -// constants must not move a single byte of what the WebView loads. A hash rather than a -// checked-in HTML file, because the formatter would rewrite the file and defeat the check. -const PRE_SPLIT_DOCUMENT_SHA256 = '1ef29c8802170800011e8accf1966bc542cdd7dd5c9600bacb6e0860f77b6df8' -const PRE_SPLIT_DOCUMENT_BYTES = 29852 +/** + * Everything of the WebView's page that is not the document itself, pinned byte for byte. + * + * The whole-document digest this file used to carry cannot survive C7.10 C1 and does not need to: + * the script is now an esbuild bundle of `src/components/rich-markdown/` rather than seven string + * constants a concatenator glued together, so its bytes are the bundler's and the proof that it is + * the same *document* is `rich-markdown/native-document-bundle.test.ts`, which runs it. + * + * What did not move is the page around it — the head, the stylesheet, the markup — and that is + * still a byte fact worth holding, because a stray character in the CSS is invisible to every + * behavioural test there is. + * + * Two different digests, so which is which: + * + * - The one this file used to assert was of main's *whole document*, script included: + * `1ef29c8802170800011e8accf1966bc542cdd7dd5c9600bacb6e0860f77b6df8`, 29,852 bytes. It is gone, + * and nothing below reproduces it. + * - `DOCUMENT_SHELL_SHA256` below is of the *page around the script*, the document with its + * `' + +/** The document with its script region emptied, which is what the digest above is of. */ +function documentShell(html: string): string { + const open = html.indexOf(SCRIPT_OPEN) + SCRIPT_OPEN.length + const close = html.indexOf(SCRIPT_CLOSE, open) + expect(open).toBeGreaterThan(SCRIPT_OPEN.length - 1) + expect(close).toBeGreaterThan(open) + return html.slice(0, open) + html.slice(close) +} describe('mobile rich markdown editor document', () => { - it('reproduces the pre-split document byte for byte', () => { - const document = buildMobileRichMarkdownEditorHtml() - expect(Buffer.byteLength(document, 'utf8')).toBe(PRE_SPLIT_DOCUMENT_BYTES) - expect(createHash('sha256').update(document, 'utf8').digest('hex')).toBe( - PRE_SPLIT_DOCUMENT_SHA256 - ) + it('reproduces the page around the document byte for byte', () => { + const shell = documentShell(buildMobileRichMarkdownEditorHtml()) + expect(Buffer.byteLength(shell, 'utf8')).toBe(DOCUMENT_SHELL_BYTES) + expect(createHash('sha256').update(shell, 'utf8').digest('hex')).toBe(DOCUMENT_SHELL_SHA256) + }) + + it('carries the bundled document, whole, as its only script', () => { + const html = buildMobileRichMarkdownEditorHtml() + expect(html).toContain(`${SCRIPT_OPEN}${RICH_MARKDOWN_EDITOR_DOCUMENT_SCRIPT}${SCRIPT_CLOSE}`) + // One script, so the digest above is over the whole of what is not the document. + expect(html.split(' ` diff --git a/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.ts b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.ts deleted file mode 100644 index 4182d133f00..00000000000 --- a/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.ts +++ /dev/null @@ -1,28 +0,0 @@ -// In-page script that reports the height covered by the on-screen keyboard. -// Native Keyboard events are unreliable while focus lives in the editor -// WebView, so measure the covered region directly from visualViewport and let -// RN lift its native Save/Discard bar above it. -export function normalizeMobileRichMarkdownKeyboardInset(value: number): number | null { - if (!Number.isFinite(value)) { - return null - } - return Math.max(0, Math.round(value)) -} - -export const MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT = ` - var lastInset = -1; - function reportKeyboardInset() { - var viewport = window.visualViewport; - var bottom = viewport - ? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop) - : 0; - var rounded = Math.round(bottom); - if (rounded === lastInset) return; - lastInset = rounded; - post({ type: 'keyboardInset', bottom: rounded }); - } - if (window.visualViewport) { - window.visualViewport.addEventListener('resize', reportKeyboardInset); - window.visualViewport.addEventListener('scroll', reportKeyboardInset); - reportKeyboardInset(); - }` diff --git a/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.test.ts b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset.test.ts similarity index 93% rename from mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.test.ts rename to mobile/src/components/mobile-rich-markdown-editor-keyboard-inset.test.ts index c29b6ab5ba9..a07a3de1906 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.test.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset-script' +import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset' describe('normalizeMobileRichMarkdownKeyboardInset', () => { it('rounds finite inset measurements for native layout', () => { diff --git a/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset.ts b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset.ts new file mode 100644 index 00000000000..4b65a08f05b --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset.ts @@ -0,0 +1,14 @@ +/** + * The inset the document reports, as the host reads it. + * + * Native `Keyboard` events under-report the area covered while focus lives inside the editor's + * WebView, so the document measures the covered region itself and posts it. This is the host's + * half: a measurement that is not a finite number is no measurement, and the caller keeps the + * inset it had rather than lifting its bar by `NaN`. + */ +export function normalizeMobileRichMarkdownKeyboardInset(value: number): number | null { + if (!Number.isFinite(value)) { + return null + } + return Math.max(0, Math.round(value)) +} diff --git a/mobile/src/components/mobile-rich-markdown-editor-script-primary.ts b/mobile/src/components/mobile-rich-markdown-editor-script-primary.ts deleted file mode 100644 index 1f0e5aa7b3b..00000000000 --- a/mobile/src/components/mobile-rich-markdown-editor-script-primary.ts +++ /dev/null @@ -1,95 +0,0 @@ -export const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_PRIMARY = [ - ' (function () {', - " var editor = document.getElementById('editor');", - " var lastMarkdown = '';", - ' var inputTimer = null;', - ' var documentGeneration = 0;', - ' var editable = true;', - ' var suppressInput = false;', - '', - ' function post(message) {', - ' window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));', - ' }', - '', - ' function decodeMarkdownEntities(value) {', - ' return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {', - ' var lower = String(entity).toLowerCase();', - " if (lower === 'amp') return '&';", - " if (lower === 'lt') return '<';", - " if (lower === 'gt') return '>';", - " if (lower === 'quot') return '\"';", - " if (lower === 'apos') return \"'\";", - " if (lower.indexOf('#x') === 0) {", - ' var hex = Number.parseInt(lower.slice(2), 16);', - ' return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;', - ' }', - " if (lower.indexOf('#') === 0) {", - ' var code = Number.parseInt(lower.slice(1), 10);', - ' return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;', - ' }', - ' return match;', - ' });', - ' }', - '', - ' function escapeHtml(value) {', - ' return decodeMarkdownEntities(value).replace(/[&<>"\']/g, function (char) {', - " return ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[char];", - ' });', - ' }', - '', - ' function escapeAttr(value) {', - " return escapeHtml(value).replace(/\\n/g, ' ');", - ' }', - '', - ' function isSafeUrl(value) {', - " var trimmed = String(value || '').trim();", - ' return !/^javascript:/i.test(trimmed);', - ' }', - '', - ' function splitTableRow(line) {', - " return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {", - ' return cell.trim();', - ' });', - ' }', - '', - ' function isTableSeparator(line) {', - ' var cells = splitTableRow(line);', - ' return cells.length > 0 && cells.every(function (cell) {', - ' return /^:?-{3,}:?$/.test(cell);', - ' });', - ' }', - '', - ' function renderInline(text) {', - " var output = '';", - ' var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|`[^`]+`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;', - ' var lastIndex = 0;', - ' var match;', - ' while ((match = pattern.exec(text))) {', - ' output += escapeHtml(text.slice(lastIndex, match.index));', - ' var token = match[0];', - ' var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);', - ' var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);', - ' if (image && isSafeUrl(image[2])) {', - " output += '\"'';", - ' } else if (link && isSafeUrl(link[2])) {', - " output += '' + renderInline(link[1]) + '';", - ' } else if (/^https?:\\/\\//i.test(token)) {', - " output += '' + escapeHtml(token) + '';", - " } else if (token.indexOf('`') === 0) {", - " output += '' + escapeHtml(token.slice(1, -1)) + '';", - " } else if (token.indexOf('~~') === 0) {", - " output += '' + renderInline(token.slice(2, -2)) + '';", - " } else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {", - " output += '' + renderInline(token.slice(2, -2)) + '';", - ' } else {', - " output += '' + renderInline(token.slice(1, -1)) + '';", - ' }', - ' lastIndex = pattern.lastIndex;', - ' }', - ' output += escapeHtml(text.slice(lastIndex));', - ' return output;', - ' }', - '', - ' function isBlockStart(line) {', - ' return /^(```|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);' -].join('\n') diff --git a/mobile/src/components/mobile-rich-markdown-editor-script-secondary.ts b/mobile/src/components/mobile-rich-markdown-editor-script-secondary.ts deleted file mode 100644 index 9578bdc49ea..00000000000 --- a/mobile/src/components/mobile-rich-markdown-editor-script-secondary.ts +++ /dev/null @@ -1,278 +0,0 @@ -export const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_SECONDARY = [ - ' }', - '', - ' function indentationWidth(value) {', - " return String(value || '').replace(/\\t/g, ' ').length;", - ' }', - '', - ' function parseListLine(line) {', - " var match = String(line || '').match(/^(\\s*)((?:[-*+])|(?:\\d+[.)]))\\s+(.+)$/);", - ' if (!match) return null;', - " var rawText = match[3] || '';", - ' var task = rawText.match(/^\\[([ xX])\\]\\s+(.+)$/);', - ' return {', - " indent: indentationWidth(match[1] || ''),", - " ordered: /^\\d/.test(match[2] || ''),", - " orderedNumber: /^\\d/.test(match[2] || '') ? Number.parseInt(match[2], 10) : null,", - " task: task ? task[1].toLowerCase() === 'x' : null,", - ' text: task ? task[2] : rawText,', - ' children: []', - ' };', - ' }', - '', - ' function listKind(item) {', - " if (item.task !== null) return 'task';", - " return item.ordered ? 'ol' : 'ul';", - ' }', - '', - ' function parseListTree(lines, startIndex) {', - ' var root = { indent: -1, children: [] };', - ' var stack = [root];', - ' var index = startIndex;', - ' while (index < lines.length) {', - " var item = parseListLine(lines[index] || '');", - ' if (!item) break;', - ' while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {', - ' stack.pop();', - ' }', - ' stack[stack.length - 1].children.push(item);', - ' stack.push(item);', - ' index += 1;', - ' }', - ' return { items: root.children, nextIndex: index };', - ' }', - '', - ' function renderListItems(items) {', - ' var html = [];', - ' var index = 0;', - ' while (index < items.length) {', - ' var kind = listKind(items[index]);', - ' var group = [];', - ' while (index < items.length && listKind(items[index]) === kind) {', - ' group.push(items[index]);', - ' index += 1;', - ' }', - " var tag = kind === 'ol' ? 'ol' : 'ul';", - " var attrs = kind === 'task' ? ' data-type=\"taskList\"' : kind === 'ol' && group[0].orderedNumber !== null ? ' start=\"' + group[0].orderedNumber + '\"' : '';", - " html.push('<' + tag + attrs + '>' + group.map(function (item) {", - " var children = item.children.length ? renderListItems(item.children) : '';", - " if (kind === 'task') {", - ' var checked = item.task === true;', - " return '
  • ' + renderInline(item.text) + '

    ' + children + '
  • ';", - ' }', - " var orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ' value=\"' + item.orderedNumber + '\" data-list-number=\"' + item.orderedNumber + '\"' : '';", - " return '

    ' + renderInline(item.text) + '

    ' + children + '';", - " }).join('') + '');", - ' }', - " return html.join('');", - ' }', - '', - ' function markdownToHtml(markdown) {', - " var lines = String(markdown || '').replace(/\\r\\n?/g, '\\n').split('\\n');", - ' var html = [];', - ' var index = 0;', - ' while (index < lines.length) {', - " var line = lines[index] || '';", - ' if (!line.trim()) {', - ' index += 1;', - ' continue;', - ' }', - ' var fence = line.match(/^\\```([^\\s`]*)\\s*$/);', - ' if (fence) {', - ' index += 1;', - ' var code = [];', - " while (index < lines.length && !/^\\```\\s*$/.test(lines[index] || '')) {", - " code.push(lines[index] || '');", - ' index += 1;', - ' }', - ' if (index < lines.length) index += 1;', - " html.push('
    ' + escapeHtml(code.join('\\n')) + '
    ');", - ' continue;', - ' }', - ' if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {', - " html.push('
    ');", - ' index += 1;', - ' continue;', - ' }', - " if (line.indexOf('|') >= 0 && index + 1 < lines.length && isTableSeparator(lines[index + 1] || '')) {", - ' var headers = splitTableRow(line);', - ' index += 2;', - ' var rows = [];', - " while (index < lines.length && (lines[index] || '').indexOf('|') >= 0 && (lines[index] || '').trim()) {", - " rows.push(splitTableRow(lines[index] || ''));", - ' index += 1;', - ' }', - " html.push('' + headers.map(function (cell) { return ''; }).join('') + '' + rows.map(function (row) {", - " return '' + headers.map(function (_, cellIndex) { return ''; }).join('') + '';", - " }).join('') + '
    ' + renderInline(cell) + '
    ' + renderInline(row[cellIndex] || '') + '
    ');", - ' continue;', - ' }', - ' var heading = line.match(/^(#{1,6})\\s+(.+)$/);', - ' if (heading) {', - " html.push('' + renderInline(heading[2].trim()) + '');", - ' index += 1;', - ' continue;', - ' }', - ' if (/^>\\s?/.test(line)) {', - ' var quote = [];', - " while (index < lines.length && /^>\\s?/.test(lines[index] || '')) {", - " quote.push((lines[index] || '').replace(/^>\\s?/, ''));", - ' index += 1;', - ' }', - " html.push('

    ' + renderInline(quote.join('\\n').trim()).replace(/\\n/g, '
    ') + '

    ');", - ' continue;', - ' }', - ' if (/^\\s*(?:[-*+]|\\d+[.)])\\s+/.test(line)) {', - ' var list = parseListTree(lines, index);', - ' html.push(renderListItems(list.items));', - ' index = list.nextIndex;', - ' continue;', - ' }', - ' var paragraph = [];', - " while (index < lines.length && (lines[index] || '').trim() && !isBlockStart(lines[index] || '') && !(index + 1 < lines.length && (lines[index] || '').indexOf('|') >= 0 && isTableSeparator(lines[index + 1] || ''))) {", - " paragraph.push(lines[index] || '');", - ' index += 1;', - ' }', - " html.push('

    ' + renderInline(paragraph.join('\\n')).replace(/\\n/g, '
    ') + '

    ');", - ' }', - " return html.join('\\n') || '


    ';", - ' }', - '', - ' function textContent(node) {', - " return (node.textContent || '').replace(/\\u00a0/g, ' ');", - ' }', - '', - ' function inlineMarkdown(node) {', - " if (!node) return '';", - ' if (node.nodeType === Node.TEXT_NODE) return textContent(node);', - " if (node.nodeType !== Node.ELEMENT_NODE) return '';", - ' var el = node;', - ' var tag = el.tagName.toLowerCase();', - " if (tag === 'br') return '\\n';", - " if (tag === 'strong' || tag === 'b') return '**' + inlineChildren(el) + '**';", - " if (tag === 'em' || tag === 'i') return '*' + inlineChildren(el) + '*';", - " if (tag === 's' || tag === 'del' || tag === 'strike') return '~~' + inlineChildren(el) + '~~';", - " if (tag === 'code' && el.parentElement && el.parentElement.tagName.toLowerCase() !== 'pre') return '`' + textContent(el) + '`';", - " if (tag === 'a') return '[' + inlineChildren(el) + '](' + (el.getAttribute('href') || '') + ')';", - " if (tag === 'img') return '![' + (el.getAttribute('alt') || '') + '](' + (el.getAttribute('src') || '') + ')';", - " if (tag === 'label') return '';", - ' return inlineChildren(el);', - ' }', - '', - ' function inlineChildren(el) {', - " return Array.prototype.map.call(el.childNodes, inlineMarkdown).join('');", - ' }', - '', - ' function listItemText(li) {', - ' var clone = li.cloneNode(true);', - " Array.prototype.forEach.call(clone.querySelectorAll('label'), function (label) { label.remove(); });", - " Array.prototype.forEach.call(clone.querySelectorAll('ul, ol'), function (list) { list.remove(); });", - ' return inlineChildren(clone).trim();', - ' }', - '', - ' function directNestedLists(li) {', - " return Array.prototype.filter.call(li.querySelectorAll('ul, ol'), function (list) {", - " return list.closest('li') === li;", - ' });', - ' }', - '', - ' function listMarkdown(el, depth) {', - ' var tag = el.tagName.toLowerCase();', - " var isTask = el.getAttribute('data-type') === 'taskList';", - " var orderedStart = tag === 'ol' ? Number.parseInt(el.getAttribute('start') || '1', 10) : 1;", - ' if (!Number.isFinite(orderedStart)) orderedStart = 1;', - " var indent = ' '.repeat(depth);", - ' return Array.prototype.map.call(el.children, function (li, index) {', - " if (li.tagName.toLowerCase() !== 'li') return '';", - ' var marker;', - ' if (isTask) {', - ' var input = li.querySelector(\'input[type="checkbox"]\');', - " marker = '- [' + (input && input.checked ? 'x' : ' ') + '] ';", - ' } else {', - ' var listNumber =', - ' li.getAttribute &&', - " (li.getAttribute('data-list-number') || li.getAttribute('value'));", - " marker = tag === 'ol' ? String(listNumber || orderedStart + index) + '. ' : '- ';", - ' }', - ' var line = indent + marker + listItemText(li);', - ' var nested = directNestedLists(li).map(function (list) {', - ' return listMarkdown(list, depth + 1);', - " }).filter(Boolean).join('\\n');", - " return nested ? line + '\\n' + nested : line;", - " }).filter(Boolean).join('\\n');", - ' }', - '', - ' function blockMarkdown(node) {', - ' if (node.nodeType === Node.TEXT_NODE) return textContent(node).trim();', - " if (node.nodeType !== Node.ELEMENT_NODE) return '';", - ' var el = node;', - ' var tag = el.tagName.toLowerCase();', - " if (tag.match(/^h[1-6]$/)) return '#'.repeat(Number(tag.slice(1))) + ' ' + inlineChildren(el).trim();", - " if (tag === 'p' || tag === 'div') return inlineChildren(el).trim();", - " if (tag === 'blockquote') {", - " return inlineChildren(el).trim().split('\\n').map(function (line) { return '> ' + line; }).join('\\n');", - ' }', - " if (tag === 'pre') {", - " var lang = el.getAttribute('data-language') || '';", - " var code = textContent(el.querySelector('code') || el).replace(/\\n+$/g, '');", - " return '```' + lang + '\\n' + code + '\\n```';", - ' }', - " if (tag === 'ul' || tag === 'ol') {", - ' return listMarkdown(el, 0);', - ' }', - " if (tag === 'table') {", - " var rows = Array.prototype.slice.call(el.querySelectorAll('tr'));", - " if (rows.length === 0) return '';", - ' var cellsFor = function (row) {', - ' return Array.prototype.map.call(row.children, function (cell) { return inlineChildren(cell).trim(); });', - ' };', - ' var headers = cellsFor(rows[0]);', - ' var bodyRows = rows.slice(1).map(cellsFor);', - " return '| ' + headers.join(' | ') + ' |\\n| ' + headers.map(function () { return '---'; }).join(' | ') + ' |' + (bodyRows.length ? '\\n' + bodyRows.map(function (row) { return '| ' + row.join(' | ') + ' |'; }).join('\\n') : '');", - ' }', - " if (tag === 'hr') return '---';", - " if (tag === 'img') return inlineMarkdown(el);", - ' return inlineChildren(el).trim();', - ' }', - '', - ' function currentMarkdown() {', - ' return Array.prototype.map.call(editor.childNodes, blockMarkdown).filter(function (block) {', - ' return block.trim().length > 0;', - " }).join('\\n\\n').trimEnd();", - ' }', - '', - ' function syncTaskCheckboxesDisabled() {', - ' Array.prototype.forEach.call(editor.querySelectorAll(\'input[type="checkbox"]\'), function (input) {', - ' input.disabled = !editable;', - ' });', - ' }', - '', - ' function emitChange() {', - ' if (suppressInput || !editable) return;', - ' window.clearTimeout(inputTimer);', - ' var pendingGeneration = documentGeneration;', - ' lastMarkdown = currentMarkdown();', - " post({ type: 'change', markdown: lastMarkdown, generation: pendingGeneration });", - ' }', - '', - ' function setMarkdown(markdown, generation) {', - ' window.clearTimeout(inputTimer);', - ' documentGeneration = Number(generation) || 0;', - ' suppressInput = true;', - " // Why: replacing innerHTML detaches the remembered caret's nodes.", - ' savedSelectionRange = null;', - ' selectionDroppedOnBlur = false;', - " lastMarkdown = String(markdown || '');", - ' editor.innerHTML = markdownToHtml(lastMarkdown);', - ' syncTaskCheckboxesDisabled();', - ' suppressInput = false;', - ' }', - '', - ' function setEditable(nextEditable) {', - ' editable = Boolean(nextEditable);', - " editor.setAttribute('contenteditable', editable ? 'true' : 'false');", - ' syncTaskCheckboxesDisabled();', - ' }', - '', - '' -].join('\n') diff --git a/mobile/src/components/mobile-rich-markdown-editor-script.ts b/mobile/src/components/mobile-rich-markdown-editor-script.ts deleted file mode 100644 index b8644705463..00000000000 --- a/mobile/src/components/mobile-rich-markdown-editor-script.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { - MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS, - MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_END -} from './mobile-rich-markdown-editor-document-suffix' -import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_PRIMARY } from './mobile-rich-markdown-editor-script-primary' -import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_SECONDARY } from './mobile-rich-markdown-editor-script-secondary' -import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script' -import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script' -import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script' - -/** The editor's whole program, independent of how a host delivers it to a WebView. */ -export const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT = `${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_PRIMARY} -${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_SECONDARY}${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT} -${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS}${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_END}` diff --git a/mobile/src/components/mobile-rich-markdown-keyboard-dismiss-script.ts b/mobile/src/components/mobile-rich-markdown-keyboard-dismiss-script.ts deleted file mode 100644 index a4520ce4261..00000000000 --- a/mobile/src/components/mobile-rich-markdown-keyboard-dismiss-script.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Composed into the editor document after MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT, whose -// rememberSelection/selectionDroppedOnBlur this depends on. -export const MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT = ` - function dismissKeyboard() { - // Why: WebKit discards the DOM selection on blur, so capture the caret before it goes. - rememberSelection(); - selectionDroppedOnBlur = true; - if (document.activeElement && document.activeElement.blur) { - document.activeElement.blur(); - } - editor.blur(); - } -` diff --git a/mobile/src/components/mobile-rich-markdown-selection-script.ts b/mobile/src/components/mobile-rich-markdown-selection-script.ts deleted file mode 100644 index c649bd09ed8..00000000000 --- a/mobile/src/components/mobile-rich-markdown-selection-script.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Caret and selection management for the WebView editor. Split out so the editor -// document script stays inside its line budget. -export const MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT = ` - var savedSelectionRange = null; - var selectionDroppedOnBlur = false; - - function focusEditor() { - editor.focus(); - } - - function rememberSelection() { - var selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return; - var range = selection.getRangeAt(0); - if (editor.contains(range.commonAncestorContainer)) savedSelectionRange = range.cloneRange(); - } - - function applySelectionRange(range) { - var selection = window.getSelection(); - if (!selection) return; - selection.removeAllRanges(); - selection.addRange(range); - savedSelectionRange = range.cloneRange(); - } - - function caretRangeAtPoint(x, y) { - if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y); - if (!document.caretPositionFromPoint) return null; - var position = document.caretPositionFromPoint(x, y); - if (!position) return null; - var range = document.createRange(); - range.setStart(position.offsetNode, position.offset); - range.collapse(true); - return range; - } - - function restoreSelectionOrEnd() { - focusEditor(); - var selection = window.getSelection(); - if (!selection) return; - // Why: the blur dropped the live selection, so commands would otherwise insert at the document end. - if (selectionDroppedOnBlur && savedSelectionRange && editor.contains(savedSelectionRange.commonAncestorContainer)) { - selectionDroppedOnBlur = false; - applySelectionRange(savedSelectionRange); - return; - } - if (selection.rangeCount > 0) return; - var range = document.createRange(); - range.selectNodeContents(editor); - range.collapse(false); - applySelectionRange(range); - } - - function wrapSelection(tagName) { - restoreSelectionOrEnd(); - var selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return; - var range = selection.getRangeAt(0); - if (range.collapsed) return; - var wrapper = document.createElement(tagName); - try { - range.surroundContents(wrapper); - } catch (_error) { - wrapper.appendChild(range.extractContents()); - range.insertNode(wrapper); - } - selection.removeAllRanges(); - selection.selectAllChildren(wrapper); - emitChange(); - } -` diff --git a/mobile/src/components/rich-markdown/create-rich-markdown-editor-document.ts b/mobile/src/components/rich-markdown/create-rich-markdown-editor-document.ts new file mode 100644 index 00000000000..3e2ad4ad0a3 --- /dev/null +++ b/mobile/src/components/rich-markdown/create-rich-markdown-editor-document.ts @@ -0,0 +1,82 @@ +import { createRichMarkdownEditorScope } from './document-scope' +import { currentMarkdown, setEditable, setMarkdown, stopEditorContent } from './editor-content' +import { startEditorListeners, stopEditorListeners } from './editor-listeners' +import { startEditorSurface } from './editor-surface' +import { startHostBridge } from './host-bridge' +import { dismissKeyboard } from './keyboard-dismiss' +import { runCommand } from './editor-commands' +import { startKeyboardInset, stopKeyboardInset } from './keyboard-inset' +import type { RichMarkdownEditorScope } from './document-scope' +import type { RichMarkdownEditorDocument, RichMarkdownEditorHost } from './document-host-seams' + +/** + * One rich Markdown editor document, started. + * + * The program both hosts run: the WebView loads it as a bundled script that calls this once with + * no host, and the page imports it and calls it per mount with its own hooks. A call owns + * everything it touches — the scope below is local to it — so two editors on one page are two + * editors, and a listener left over from a mount that has gone reads the scope it closed over + * rather than the live one. + * + * The sequence is here rather than derived from a list, because it *is* the document's shape: the + * surface is read, the listeners that need it are installed, the keyboard measurement starts, and + * only then is the host told the document is ready. + */ +export function createRichMarkdownEditorDocument( + host: RichMarkdownEditorHost = {} +): RichMarkdownEditorDocument { + const scope = createRichMarkdownEditorScope(host) + startRichMarkdownEditorDocument(scope) + return { + send: { + setMarkdown: (markdown, generation) => { + setMarkdown(scope, markdown, generation) + }, + setEditable: (editable) => { + setEditable(scope, editable) + }, + runCommand: (command) => runCommand(scope, command), + currentMarkdown: () => currentMarkdown(scope), + dismissKeyboard: () => { + dismissKeyboard(scope) + } + }, + stop: () => { + stopRichMarkdownEditorDocument(scope) + } + } +} + +/** + * Every module's start, in the order the document runs them. + * + * A start that throws has left the ones before it holding the surface's listeners or the + * viewport's, and there is no handle for anyone to stop with, so the undo runs here. Every stop is + * a no-op against a start that never ran, which is what makes the whole sequence the right undo + * for a partial one. + * + * Exported because a test that drives one module still needs the surface and the listeners the + * others put in place, and the order is not a thing to write twice. + */ +export function startRichMarkdownEditorDocument(scope: RichMarkdownEditorScope) { + try { + startEditorSurface(scope) + startEditorListeners(scope) + startKeyboardInset(scope) + startHostBridge(scope) + } catch (error) { + stopRichMarkdownEditorDocument(scope) + throw error + } +} + +/** + * The undo, in reverse, so nothing is torn down under something still using it, with the pending + * timer taken back last — after the listeners that could have scheduled another one are gone. + */ +export function stopRichMarkdownEditorDocument(scope: RichMarkdownEditorScope) { + scope.stopped = true + stopKeyboardInset(scope) + stopEditorListeners(scope) + stopEditorContent(scope) +} diff --git a/mobile/src/components/rich-markdown/document-host-seams.ts b/mobile/src/components/rich-markdown/document-host-seams.ts new file mode 100644 index 00000000000..1d61ddc972e --- /dev/null +++ b/mobile/src/components/rich-markdown/document-host-seams.ts @@ -0,0 +1,146 @@ +import type { + MobileRichMarkdownCommand, + MobileRichMarkdownEditorMessage +} from '../mobile-rich-markdown-editor-contract' + +/** + * The six seams between the editor document and whatever is hosting it, as the document's own + * defaults. + * + * Inside the WebView the host is React Native and every seam is the window read the hand-written + * script already did; on the page the host is the component that mounted these modules, where + * `window.ReactNativeWebView` is the *shell's* bridge and `window.prompt` is a dialog the shell's + * WebView never shows. Each function below is that window read or write, kept at call time rather + * than captured when the scope is built, and the scope carries it as a field the page assigns over. + */ + +/** Which URL a command is asking the user for; the default turns it into the prompt's own text. */ +export type RichMarkdownUrlPromptKind = 'link' | 'image' + +/** + * Where the covered height comes from, and what says it may have changed. + * + * Null when the host has no such measurement: inside the WebView that is a runtime without + * `visualViewport`, and on the page it is every host, because the screen measures its own keyboard + * and a second report would lift its bar twice. + */ +export type RichMarkdownKeyboardInsetReader = { + /** The height the keyboard covers right now, in CSS pixels. */ + measure: () => number + /** Calls back when the covered height may have moved, handing back its removal. */ + observe: (onChange: () => void) => () => void +} + +/** The five things a host can ask a running document to do. */ +export type RichMarkdownEditorApi = { + setMarkdown: (markdown: string, generation: number) => void + setEditable: (editable: boolean) => void + runCommand: (command: MobileRichMarkdownCommand) => Promise + currentMarkdown: () => string + dismissKeyboard: () => void +} + +/** + * A running document: what a host sends into one, and how it takes it down. + * + * `send` is the object the WebView reaches through its injected global and the page holds + * directly. `stop` runs every module's stop; the page's dispose calls it, and the WebView never + * does, because there the document outlives nothing. + */ +export type RichMarkdownEditorDocument = { + send: RichMarkdownEditorApi + stop: () => void +} + +export type RichMarkdownEditorHostSeams = { + /** `host-bridge`: where a message for the host goes. */ + postToHost: (message: MobileRichMarkdownEditorMessage) => void + /** `editor-commands`: the URL the Link and Image commands insert, or null when cancelled. */ + promptForUrl: (kind: RichMarkdownUrlPromptKind) => Promise + /** `keyboard-inset`: the covered height and its changes, or null when the host has none. */ + keyboardInsetSource: () => RichMarkdownKeyboardInsetReader | null + /** `editor-content`: cancels the pending input timer. */ + clearTimer: (handle: number | null) => void + /** `editor-selection`: the live selection this document's caret lives in. */ + getSelection: () => Selection | null + /** `editor-commands`, `editor-selection`: the document ranges, elements and `execCommand` come from. */ + getDocument: () => Document +} + +/** + * What a host may hand the document instead of a window read. + * + * Every seam has a default, so a host names only the ones it owns differently: inside the WebView + * that is none of them. Absent and present-but-undefined mean the same thing, which is why the + * scope's spread filters rather than trusting key order. + */ +export type RichMarkdownEditorHost = Partial + +declare global { + interface Window { + ReactNativeWebView?: { postMessage: (message: string) => void } + /** The native host's handle on the document, installed by the bundle's entry. */ + __orcaRichMarkdown?: RichMarkdownEditorApi + } +} + +export function postToReactNativeWebView(message: MobileRichMarkdownEditorMessage) { + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify(message)) + } +} + +/** The labels the WebView's dialog carried, which is the whole of what the prompt kind means there. */ +const URL_PROMPT_LABELS: Record = { + link: 'Link URL', + image: 'Image URL' +} + +/** + * The WebView's own dialog, as a promise because a host that answers with a modal cannot answer + * synchronously. + * + * Measured to return null in both shells — neither implements the delegate the dialog needs — so + * the page passes its own and this default is what the native document keeps until it does. + */ +export function promptWindowForUrl(kind: RichMarkdownUrlPromptKind) { + return Promise.resolve(window.prompt(URL_PROMPT_LABELS[kind])) +} + +/** The WebView's own measurement: what `visualViewport` says the keyboard covers. */ +export function windowVisualViewportInset(): RichMarkdownKeyboardInsetReader | null { + const viewport = window.visualViewport + if (!viewport) { + return null + } + return { + measure: () => Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop), + observe: (onChange) => { + viewport.addEventListener('resize', onChange) + viewport.addEventListener('scroll', onChange) + return () => { + viewport.removeEventListener('resize', onChange) + viewport.removeEventListener('scroll', onChange) + } + } + } +} + +export function clearWindowTimer(handle: number | null) { + window.clearTimeout(handle ?? undefined) +} + +export function windowSelection() { + return window.getSelection() +} + +/** + * The page the document's elements and ranges are in. + * + * A function rather than a field, so the read happens where the other five do. The native document + * *is* its page; a page mounting these modules hands back the same global object, and the host + * element it planted the markup in is the only thing that differs. + */ +export function windowDocument() { + return document +} diff --git a/mobile/src/components/rich-markdown/document-lifecycle.test.ts b/mobile/src/components/rich-markdown/document-lifecycle.test.ts new file mode 100644 index 00000000000..78388bb737f --- /dev/null +++ b/mobile/src/components/rich-markdown/document-lifecycle.test.ts @@ -0,0 +1,233 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createRichMarkdownEditorDocument, + startRichMarkdownEditorDocument, + stopRichMarkdownEditorDocument +} from './create-rich-markdown-editor-document' +import { createRichMarkdownEditorScope } from './document-scope' +import { emitChange, setMarkdown } from './editor-content' +import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup' +import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract' +import type { RichMarkdownEditorDocument } from './document-host-seams' + +/** + * What a `stop` owes, and what a second call gets. + * + * The WebView never stops its document — there the page is the document's whole life — so every + * case here is about the host that does: a page mounts the editor, unmounts it and mounts it + * again, and the same modules answer. A listener or an observer the first mount left behind would + * make the second one report twice and hold the markup the first one read (rulings 20, 21). + */ +const started: RichMarkdownEditorDocument[] = [] + +function plantMarkup() { + document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP + return document.getElementById('editor')! +} + +function viewportDouble() { + const observers: (() => void)[] = [] + return { + observers, + source: () => ({ + measure: () => 120, + observe: (onChange: () => void) => { + observers.push(onChange) + return () => { + observers.splice(observers.indexOf(onChange), 1) + } + } + }) + } +} + +function mount(posted: MobileRichMarkdownEditorMessage[], keyboard = viewportDouble()) { + const document_ = createRichMarkdownEditorDocument({ + postToHost: (message) => posted.push(message), + keyboardInsetSource: keyboard.source + }) + started.push(document_) + return { handle: document_.send, stop: () => document_.stop(), keyboard } +} + +afterEach(() => { + while (started.length > 0) { + started.pop()!.stop() + } + document.body.innerHTML = '' +}) + +describe('an editor document that is stopped', () => { + it('takes its own listeners off the surface', () => { + const posted: MobileRichMarkdownEditorMessage[] = [] + const editor = plantMarkup() + const mounted = mount(posted) + mounted.handle.setMarkdown('body', 1) + posted.length = 0 + + mounted.stop() + editor.dispatchEvent(new Event('input')) + editor.dispatchEvent(new Event('change')) + editor.dispatchEvent(new MouseEvent('click', { bubbles: true })) + editor.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true })) + expect(posted).toEqual([]) + }) + + it('stops observing the viewport, so no inset of its own reaches the next mount', () => { + const posted: MobileRichMarkdownEditorMessage[] = [] + plantMarkup() + const mounted = mount(posted) + expect(mounted.keyboard.observers).toHaveLength(1) + mounted.stop() + expect(mounted.keyboard.observers).toHaveLength(0) + }) + + it('leaves a second mount a document of its own, not the first one continued', () => { + const first: MobileRichMarkdownEditorMessage[] = [] + plantMarkup() + const one = mount(first) + one.handle.setMarkdown('first content', 9) + one.stop() + + const second: MobileRichMarkdownEditorMessage[] = [] + const editor = plantMarkup() + const two = mount(second) + expect(second).toEqual([{ type: 'keyboardInset', bottom: 120 }, { type: 'ready' }]) + second.length = 0 + // Its own generation and its own surface: the first mount's 9 is not carried over, and the + // element it read is the one planted for this mount. + two.handle.setMarkdown('second content', 1) + editor.dispatchEvent(new Event('input')) + expect(second).toEqual([{ type: 'change', markdown: 'second content', generation: 1 }]) + expect(first).not.toContainEqual( + expect.objectContaining({ type: 'change', markdown: 'second content' }) + ) + }) + + it('is two editors when two are mounted, each reading its own scope', () => { + // Not two on one page — the ids collide there, which is the page component's problem — but two + // documents over the same markup, which is what says the state is per call rather than shared. + const first: MobileRichMarkdownEditorMessage[] = [] + const second: MobileRichMarkdownEditorMessage[] = [] + const editor = plantMarkup() + const one = mount(first) + const two = mount(second) + first.length = 0 + second.length = 0 + one.handle.setMarkdown('shared markup', 3) + two.handle.setEditable(false) + editor.dispatchEvent(new Event('input')) + // The second document is read-only and says nothing; the first still reports its own + // generation, which it would not if `editable` lived in a module. + expect(second).toEqual([]) + expect(first).toEqual([{ type: 'change', markdown: 'shared markup', generation: 3 }]) + }) + + it('cancels a change still waiting on a timer, which no listener removal can reach', () => { + // A listener comes off with the element it was on; a scheduled callback holds the scope and + // would fire into a document the host has already unmounted. Nothing schedules the handle + // today, so the pending change is planted here — the seam and the field exist for the day + // something does, and the cancel has to already be in `stop` when it arrives. + const posted: MobileRichMarkdownEditorMessage[] = [] + plantMarkup() + vi.useFakeTimers() + try { + const scope = createRichMarkdownEditorScope({ + postToHost: (message) => posted.push(message), + keyboardInsetSource: () => null + }) + startRichMarkdownEditorDocument(scope) + setMarkdown(scope, 'body', 2) + posted.length = 0 + + // The control: while the document is running, the pending change is posted. + scope.inputTimer = window.setTimeout(() => emitChange(scope), 0) + vi.runAllTimers() + expect(posted).toEqual([{ type: 'change', markdown: 'body', generation: 2 }]) + + posted.length = 0 + scope.inputTimer = window.setTimeout(() => emitChange(scope), 0) + stopRichMarkdownEditorDocument(scope) + expect(scope.inputTimer).toBe(null) + vi.runAllTimers() + expect(posted).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it('drops a command whose dialog answered after the host moved on', async () => { + // `promptForUrl` is a modal on the page, so it is a task boundary: between the toolbar press + // and the URL coming back, the host can replace the content, make the editor read-only or + // unmount it. Inside the WebView it is `window.prompt`, which answers within a microtask — + // which is why nothing here can happen on native, and everything here can happen on the page. + const commands: string[] = [] + const answer: ((url: string) => void)[] = [] + plantMarkup() + Object.defineProperty(document, 'execCommand', { + value: (command: string) => { + commands.push(command) + return true + }, + configurable: true + }) + try { + const posted: MobileRichMarkdownEditorMessage[] = [] + const document_ = createRichMarkdownEditorDocument({ + postToHost: (message) => posted.push(message), + keyboardInsetSource: () => null, + promptForUrl: () => new Promise((resolve) => answer.push(resolve)) + }) + started.push(document_) + document_.send.setMarkdown('before', 1) + + // The control: nothing moved, so the answer is applied and the change is reported. + const applied = document_.send.runCommand('link') + answer.pop()!('https://example.com/a') + await applied + expect(commands).toEqual(['createLink']) + expect(posted.at(-1)).toEqual({ type: 'change', markdown: 'before', generation: 1 }) + + // Replaced content: the answer belongs to markdown nobody is looking at any more. + posted.length = 0 + const stale = document_.send.runCommand('link') + document_.send.setMarkdown('after', 2) + answer.pop()!('https://example.com/b') + await stale + expect(commands).toEqual(['createLink']) + expect(posted).toEqual([]) + + // Read-only, and then stopped: neither takes the command either. + const whileReadOnly = document_.send.runCommand('image') + document_.send.setEditable(false) + answer.pop()!('https://example.com/c') + await whileReadOnly + document_.send.setEditable(true) + const whileStopped = document_.send.runCommand('image') + document_.stop() + answer.pop()!('https://example.com/d') + await whileStopped + expect(commands).toEqual(['createLink']) + } finally { + Reflect.deleteProperty(document, 'execCommand') + } + }) + + it('unwinds a start that throws rather than leaving the listeners it already installed', () => { + const posted: MobileRichMarkdownEditorMessage[] = [] + const editor = plantMarkup() + expect(() => + createRichMarkdownEditorDocument({ + postToHost: (message) => posted.push(message), + keyboardInsetSource: () => { + throw new Error('no viewport') + } + }) + ).toThrow('no viewport') + // Nothing reported itself ready, and the surface carries no listener of the failed start. + expect(posted).toEqual([]) + editor.dispatchEvent(new Event('input')) + expect(posted).toEqual([]) + }) +}) diff --git a/mobile/src/components/rich-markdown/document-markup.ts b/mobile/src/components/rich-markdown/document-markup.ts new file mode 100644 index 00000000000..b25e0af2e0a --- /dev/null +++ b/mobile/src/components/rich-markdown/document-markup.ts @@ -0,0 +1,12 @@ +/** + * The one element the document's modules reach for: the editable surface. + * + * It is here rather than in the HTML builder because both hosts plant it — the native document + * carries it in its ``, and a page mounting these modules puts the same markup in its host + * element — and a document whose markup differed between the two would be two documents. + */ +export const RICH_MARKDOWN_EDITOR_MARKUP = + '
    ' + +/** The id the markup gives the editable surface, read once when the document starts. */ +export const RICH_MARKDOWN_EDITOR_ELEMENT_ID = 'editor' diff --git a/mobile/src/components/rich-markdown/document-scope.ts b/mobile/src/components/rich-markdown/document-scope.ts new file mode 100644 index 00000000000..e8865098216 --- /dev/null +++ b/mobile/src/components/rich-markdown/document-scope.ts @@ -0,0 +1,100 @@ +import { + clearWindowTimer, + postToReactNativeWebView, + promptWindowForUrl, + windowDocument, + windowSelection, + windowVisualViewportInset, + type RichMarkdownEditorHost, + type RichMarkdownEditorHostSeams +} from './document-host-seams' +export type { + RichMarkdownEditorApi, + RichMarkdownEditorDocument, + RichMarkdownEditorHost, + RichMarkdownEditorHostSeams +} from './document-host-seams' + +/** + * The state one editor document shares across its modules. + * + * Every mutable binding the hand-written script declared is here, because a module's own `let` + * would be shared by every document on the page: the second mount would inherit the first's + * generation, its remembered caret and its last reported inset (ruling 21). One object per call, + * built by the factory, so two editors on one page are two editors. + */ +export type RichMarkdownEditorState = { + /** The editable surface, read once when the document starts. */ + editor: HTMLElement | null + /** `editor-content`: the markdown the document last rendered or serialized. */ + lastMarkdown: string + /** + * `editor-content`: the pending input timer, cleared before every content replacement. + * + * Nothing schedules it today — the change message is posted straight from the input listener — + * and it is kept because the clear is what a debounce would need and costs nothing without one. + */ + inputTimer: number | null + /** `editor-content`: the host's generation, echoed back on every change so it can drop stale ones. */ + documentGeneration: number + /** `editor-content`: whether the surface accepts edits. */ + editable: boolean + /** `editor-content`: set while the document rewrites itself, so its own input is not a change. */ + suppressInput: boolean + /** `editor-selection`: the caret captured before a blur could drop it. */ + savedSelectionRange: Range | null + /** `editor-selection`: whether the last blur was the document's own keyboard dismissal. */ + selectionDroppedOnBlur: boolean + /** `keyboard-inset`: the last covered height posted, to suppress repeats. */ + lastInset: number + /** `editor-listeners`: takes the four surface listeners off again, or null before them. */ + removeEditorListeners: (() => void) | null + /** `keyboard-inset`: takes the viewport's two listeners off again, or null before them. */ + removeKeyboardInset: (() => void) | null + /** `create-rich-markdown-editor-document`: whether the host has taken this document down. */ + stopped: boolean +} + +/** The document's whole scope: its state, and the seams to whatever is hosting it. */ +export type RichMarkdownEditorScope = RichMarkdownEditorState & RichMarkdownEditorHostSeams + +/** The initial values, which are the ones the script's own declarations carried. */ +function createRichMarkdownEditorState(): RichMarkdownEditorState { + return { + editor: null, + lastMarkdown: '', + inputTimer: null, + documentGeneration: 0, + editable: true, + suppressInput: false, + savedSelectionRange: null, + selectionDroppedOnBlur: false, + lastInset: -1, + removeEditorListeners: null, + removeKeyboardInset: null, + stopped: false + } +} + +/** The seams' defaults: the window reads and writes the script already did. */ +function createRichMarkdownEditorHostSeams(): RichMarkdownEditorHostSeams { + return { + postToHost: postToReactNativeWebView, + promptForUrl: promptWindowForUrl, + keyboardInsetSource: windowVisualViewportInset, + clearTimer: clearWindowTimer, + getSelection: windowSelection, + getDocument: windowDocument + } +} + +export function createRichMarkdownEditorScope( + host: RichMarkdownEditorHost = {} +): RichMarkdownEditorScope { + const named = Object.fromEntries(Object.entries(host).filter(([, hook]) => hook !== undefined)) + return { + ...createRichMarkdownEditorState(), + ...createRichMarkdownEditorHostSeams(), + ...named + } +} diff --git a/mobile/src/components/rich-markdown/document-style.ts b/mobile/src/components/rich-markdown/document-style.ts new file mode 100644 index 00000000000..5a4d116ad5d --- /dev/null +++ b/mobile/src/components/rich-markdown/document-style.ts @@ -0,0 +1,200 @@ +import { colors } from '../../theme/mobile-theme' + +/** + * The editor document's stylesheet: the theme variables and every rule that reads them. + * + * A function rather than a constant because the variables are the app's own theme values, read + * when the document is built. The native host wraps it in the document's `