From d9954000b308ec86a310927ed50c561fa17561b2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:16:23 -0400 Subject: [PATCH] refactor(mobile): the rich editor's document becomes scope-threaded modules and a bundled factory (OTA phase C, C7.10 C1) (#21969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(mobile): split the rich editor document's stylesheet and markup apart The body constant carried the tail of a `:root` block, every CSS rule and the editable surface's markup in one string, which only the HTML builder could splice. A page mounting the document needs the stylesheet and the markup separately, so they become a function over the theme and a constant. Byte-for-byte inert: `mobile-rich-markdown-editor-document.test.ts`'s digest of the shipped document is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the keyboard-inset normaliser its own module It is the host's half of the inset, read by the controller, and it sat in the module holding the document's in-page script. The script is about to become ordinary TypeScript under `rich-markdown/`, where a native-side normaliser does not belong. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the rich editor's document becomes scope-threaded modules and a factory The editor's ~600-line program lived in seven string constants a concatenator glued into one `' + +/** 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 `