mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
feat(mobile): the rich Markdown editor mounts on the page (OTA phase C, C7.10 C2) (#22099)
* feat(mobile): the editor document reads its surface from its host's root The markup gives the editable surface an id, and inside the WebView that is unambiguous because the document is the page. On the page it is not: a stack transition keeps the outgoing session screen mounted while the incoming one starts, so two hosts carry `#editor` at once and a page-wide `getElementById` hands both documents the first one. The seventh seam is the root, exactly as it is the terminal's ninth (ruling 24): the WebView names none of them and gets the whole page, the page names the element its mount planted the markup in. Red first, `vitest run src/components/rich-markdown/document-host-root.test.ts` against the page-wide read: 4 failed, 1 passed — content written into the second host landed in the first, both documents serialized the first surface, an edit in the second reported through the first document's host, and stopping the first took the listeners off the surface the second was still using. The one that passed is the control: a document with no root still reads the whole page, which is what the WebView gets. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): hold the editor's document rules under its host element The editor's sheet says `:root`, `*`, `html` and `body` because inside the WebView it owns the page. Appended to the head of a React Native Web application all four restyle every screen the shell can show, so the page mount may inject only what it owns — ruling 19's rule for `window.onerror`, applied to CSS. The terminal's half of the scoper drops those rules and repaints through a seam, because the colour `html, body` was setting belongs to the application. The editor has no such seam and needs none: its host element *is* that editor's page, so `scopeDocumentStyleToHost` moves the document's own rules onto the host — the variables every other rule reads, the surface colour, the font, the box model — and everything else hangs under it. A selector that merely starts at the document (`body p`) throws rather than being rewritten into something it did not say. Red first, `vitest run src/components/rich-markdown/page-stylesheet.test.ts`: 6 failed, 0 passed, all on the absent export. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): the fifteen toolbar commands are one row both surfaces render The row of controls is not the WebView's: a press becomes an injected `runCommand` there and a call on the page, and neither difference belongs in the toolbar. Extracted so the page's editor does not declare fifteen rows of its own that would drift from the phone's. `MobileRichMarkdownToolbar.test.tsx` adds the fence a second copy would have needed: the row names every command in the contract, exactly once. Verified red by dropping `codeBlock` from the row — "names every command in the contract, once" failed on the 14-member list before the case went back. The native component's own test and the web fallbacks file stay green unchanged, which is what says the extraction moved nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the toolbar test inside the tests-typecheck ratchet `check-tests-typecheck-ratchet.mjs` reported the new file as newly failing `tsc -p tsconfig.test.json`: the `ScrollView` mock's spread did not match any `createElement` overload, and comparing a node's `ElementType` against the string `'Pressable'` is a no-overlap comparison. Host strings for the mock and `String(node.type)` for the read, rather than a cast. Ratchet back to OK at 800 files. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the rich Markdown editor mounts on the page `react-native-webview` renders nothing in a browser, so C7.6 gave the page a plain Markdown field and recorded the toolbar and the rendered view as a degradation. Ruling 26 makes that debt rather than done: the page mounts the document itself. `rich-markdown-web-document-mount.ts` is the editor's half of what `terminal-web-document-mount.ts` does for the terminal — the sheet held under the host's class, the markup planted in the host, one factory call, and a dispose that gives the host back. `MobileRichMarkdownEditor.web.tsx` is the component over it, with the same fifteen-command toolbar and the same controller the phone uses, so `MarkdownReader` cannot tell which sibling it has. Three seams are the page's rather than the window's. Messages reach `handleMessage` directly and never `window.ReactNativeWebView`, which on the page is the shell's bridge. The URL for Link and Image comes from `TextInputModal`: `window.prompt` was measured to return null in both shells, so those two commands silently did nothing. And no inset source is supplied, so `onKeyboardInsetChange` is never called — the screen's `keyboard-occlusion.web.ts` measures the same viewport with the same formula, and a report here would lift its bar twice. Red first, two runs. `rich-markdown-web-document-mount.test.ts`: 9 failed on the absent module, and its listener case is the one that holds ruling 21 — a second mount reports its own edits and the first mount's detached surface reports nothing, with an event dispatched on it to say so. The four new cases in `mobile-webview-editor-web-fallbacks.test.tsx`, run against the plain field still in the tree: 4 failed, 6 passed — no toolbar, no URL modal, and the `TextInput` the page is meant to have lost. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): put the editor's surface on the 16 px floor, and grow a census that can see it `#editor` computed to 14 px, measured in both engines. iOS zooms the page on focus of any editable under 16 px and does not zoom back, and `keyboard-occlusion.web.ts` answers 0 for the rest of the session at a scale other than 1 — the exact failure the floor exists for, on the page's only full-screen writing surface. The size now comes from the text-input seam, which is also where the two hosts part: the phone keeps the app's body size because a WebView has no page to zoom, the page gets the raise, and one binding moves both if the floor ever does. The `TextInput` census could not have caught it. `modulesDeclaringTextInput` matches JSX tags and `style` props, and this is a `contenteditable` in a markup string sized by a rule in a stylesheet. `mobile-web-app-editable-host-font-size.mjs` starts from the markup instead: it finds every editable host a closure declares, follows its id to the rule beside it, and reads the size the same way — a literal at or above the floor, or the seam's own export imported from the seam's module. An editable with no id, or one no sibling sheet styles, is reported unresolved rather than passed. Red first. The rule's own file reported `src/components/rich-markdown/document-style.ts:36` as the offender before the fix (4 failed, 3 passed on the first run, the other three being the brace scanner and the line-start anchor the fixtures found). The closure case in `mobile-web-app-session-terminal-closure.test.mjs` now names the editor as the one editable in the session route's closure and its offender list is empty: 1 passed, 4 skipped under `-t editables`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the caret survives the host's URL dialog, so Link and Image insert Measured in the render check, on both engines: the Link and Image commands opened the modal, took the URL, and inserted nothing. The dialog is what takes the caret — the modal focuses its own field — and `execCommand` on a document that does not hold the selection does nothing at all. So the page had swapped one silent failure for another: `window.prompt` returning null on the phone, and a command with no selection on the page. Two halves. The document remembers its caret before it waits and puts it back after (`restoreRememberedSelection`, unconditional where `restoreSelectionOrEnd` needs a flag, because the wait itself is the blur); if the host replaced the content while the dialog was open, the remembered range is gone from the document and the caret goes to the end instead. And the component answers the promise from the drawer's `onAfterClose` rather than from the submit, because WebKit would not take the focus back while the field still held it — with the answer released on submit, chromium inserted and WebKit did not. `TextInputModal` forwards `onAfterClose` for that, which is the one thing it did not already pass through to `BottomDrawer`. Red first, `editor-selection.test.ts` against the previous `editor-commands.ts`: 2 failed, 7 passed — the caret was left in the dialog's field, and a replaced document did not fall back to the end. The render check's Link/Image case went from failing on both engines to inserting on both, with the inserted image's `naturalWidth` above zero under the shipped policy. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the page's rich Markdown editor, in both engines under the shipped header `config/scripts/mobile-web-app-rich-markdown-render.test.mjs`: the real component, mounted by the real React, driven through its toolbar in chromium and webkit under the policy read out of the shell's own Kotlin source. Sixteen cases, eight per engine. What it measures rather than asserts: all fifteen commands change the document, each with the precondition that what it produces was not there first; the surface computes to the 16 px floor and the document's own `--editor-surface` variable is set on the host and nowhere on the root element; `ready` and `change` cross the seam while `window.ReactNativeWebView` — defined by the rig so its absence is a reading — is never touched; Link and Image are answered by the modal, and the inserted image paints with a non-zero `naturalWidth`; one change per checkbox tap and one per inline code; a link tap reaches the host instead of navigating; a remount leaves the listener snapshot and the scheduler exactly where one whole cycle left them (rulings 20 and 21); and two editors on one page hold their own content and report their own edits. Four harness facts the first runs found, each now in a comment: the entry needs four of `MOBILE_WEB_APP_SHIMS` (`isFabric` threw `global is not defined` and every case failed at `data-ready`); `.web.jsx` in `resolveExtensions` or `react-native-svg` resolves its Fabric components; a `SafeAreaProvider`, which the route's navigator supplies and a bare mount does not; and the document's markup, not its text, as the oracle for a content reset — `### body text here` and `body text here` read the same, so a text wait passed on the document it was replacing. One finding, reported not fixed: WebKit's `insertUnorderedList` nests the `<ul>` inside the `<p>` it was given and the serializer walks back out with the same text, so a bullet list does not survive a round trip there. The phone's WebView is the same engine, so this is not something the page introduces; the case names the command's own element and the reset is numbered per command to work around it. Run 5 of 5: 16 passed, 0 failed, 0 errors, exit 0, 6.58s. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the session route's closure for the editor on the page Both sides measured with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base `9267423f22`, all five postinstall generators run first, the before side a scratch worktree detached at that sha: modules 4333 -> 4360 (+27) local modules 991 -> 1018 (+27) All 27 are local and none is vendored, which is the point: the editor is the app's own code, not a library. The document's 24 modules under `src/components/rich-markdown/` were reachable from nothing on the page while it rendered a plain field, and the other three are the mount, the shared toolbar, and the controller with its keyboard-inset module. Nothing leaves, because the web sibling replaces its own native file and that file was never in this closure. Named by diffing the two `local` lists, not inferred from the total. `document-style-scoping.ts` is on both sides: the terminal's mount already brings it, so the editor's second export costs no module. The generation, measured the same way on both sides: 8,028,418 -> 8,056,166 bytes (+27,748) across 109 assets against the 9 MiB ceiling, 85.1% -> 85.4%. The script count does not move (67 against the 76 the chunk fence allows for 15 routes) and neither does the entry's static closure (1,612,052 bytes against 3 MiB) — this is code the route already reached for, not a new chunk boundary. The grant census needs nothing: `openExternalLink` is the editor's only seam with a grant behind it, and the session route already declares `externalLink` for six other openers. `mobile-web-app-page-grant-call-sites.test.mjs` passes unchanged. Closure, webview-consumer and grant censuses together: 28 passed, 0 failed, exit 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop an oxlint directive the changed-code gate reads as unused `check-changed-code-quality.mjs` failed with one finding: the mount effect's `react-hooks/exhaustive-deps` disable reports no problem under that config, so the directive itself is the finding. The reason it carried is worth keeping and now reads as a plain comment — the effect mounts once, with `promptForUrl` taken from the closure, because re-running it would throw away a live document and the caret in it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(config): the editable-host census counts every editable tag, not every id CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:50`, and right on the code: the pattern started from `id="…"`, so it matched only hosts that carry one. The no-id guard fired for a file with *no* named host at all, which means a file holding a named host beside an anonymous one reported the named one as clean and said nothing about the other. An editable is its tag; the id is read out of the tag afterwards. Also `:145`, also right: the sibling search was `startsWith(directory + '/')`, which reaches the subtree, and the walk stops at the first file whose sheet opens the host's selector. The closure's order is the bundler's rather than alphabetical, so a sheet one directory down could answer for the sibling the host actually gets. Now the immediate directory only. Red first, both cases in the census's own file. The mixed fixture reported one host where two were planted (1 failed, 7 passed); the nested fixture, with the nested sheet first in the closure and a compliant 18 px rule in it, hid a 14 px sibling and reported no offender (1 failed, 8 passed). 9 passed after. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(config): an editable with no declared size is unresolved, not a pass CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:110`, and right for the CSS case: `readFontSize` answered `onSeam: true` for a rule that declares no `font-size`, so the offender check accepted the host without being able to say what size it gets. The inherited value comes from a rule this walk does not read — the host element's own, or the page's root — and it can be 14 px. So "no declaration" becomes "cannot say" and lands in `unresolvedEditableHostStyles`, which the session closure census holds at empty. Not an offender: an offender is a size this walk read and found under the floor. The `TextInput` half of the seam still lets an absent `fontSize` through as inheritance. That is main's policy and it is about a prop rather than a cascade, so it is not touched here; the divergence is stated in the reader's own comment. Red first: the inheritance fixture reported no unresolved host where the size is unknowable (1 failed, 8 passed), 9 passed after. The real tree is unaffected — the editor declares its size on the seam — and the closure census still reads an empty unresolved list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(config): the editable-host census reads the font-size the cascade uses CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:119`, and right: the walk read the first `font-size` in a rule, and CSS takes the last of equal importance. `font-size: 16px; font-size: 14px;` was therefore reported compliant for a surface the browser renders at 14 px. `!important` outranks every declaration that is not, whatever the order. The flag is also stripped from the value, which the finding did not name but the fixture caught: without that, a compliant size carrying `!important` was reported as an offender, because it matched neither the literal nor the substitution shape. The declarations are split on the separator rather than matched with a value pattern. A pattern excluding `}` cut `${TEXT_INPUT_FONT_SIZE}px` at the brace of its own interpolation and reported the real editor as an offender — caught on the first run of the fix, and the reason the split is the shape here. Red first: 2 failed, 9 passed — the repeated-declaration fixture reported no offender, and the important-declaration pair reported the wrong one of the two. 11 passed after. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): the render check reads the floor from the seam instead of retyping it pullfrog, `mobile-web-app-rich-markdown-render.test.mjs:37`, and right: the comment said the floor was read from the seam and the constant was the literal `16`, which is the shape the seam exists to prevent. It now comes from `textInputFontSizeFloor(mobileDir)`, the same reader the closure census uses, which throws rather than defaulting when the seam is gone. The assertion becomes "at or above the floor" rather than equal to it. The seam is `Math.max(bodySize, floor)`, so a theme raising the body size past the floor raises what the page computes; equality against the floor would have been the same stale literal one module further away. Two controls, both run. Raising `TEXT_INPUT_FONT_SIZE_FLOOR` to 18 in the seam keeps the case green on both engines, because the stylesheet reads the same module and the page computed 18 — the two moving together is the point. Replacing the stylesheet's `${TEXT_INPUT_FONT_SIZE}px` with a literal `14px` reds it on both engines, `expected 14 to be greater than or equal to 16`, which is what says the assertion carries weight. Both files were restored. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the editable-host census reads every rule that sizes the host (round 3) `ruleFor` returned the first exact `#id` rule and the walk stopped there, so a later exact rule of equal specificity, or a higher-specificity subject rule that still targets the host, could lower the rendered size unseen. Every exact rule in the sheet is now collected in source order and read as one cascade, and any other rule whose subject compound targets the host and declares `font-size` makes the host unresolved rather than compliant. No specificity arithmetic, and the sibling walk is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* The 16 px floor for an editable the page styles with CSS rather than with a `TextInput` prop.
|
||||
*
|
||||
* `mobile-web-app-text-input-font-size-seam.mjs` reads the floor and holds every `TextInput` in a
|
||||
* route's closure to it. It cannot see the rich Markdown editor: that surface is a
|
||||
* `contenteditable` element in a string of markup, sized by a rule in a stylesheet the same module
|
||||
* emits, and the walk there matches JSX `TextInput` tags and `style` props. So the editor shipped
|
||||
* at 14 px and was measured at 14 px in both engines — the exact condition the floor exists for,
|
||||
* because iOS zooms the page on focus of any editable under 16 px, never zooms back, and
|
||||
* `keyboard-occlusion.web.ts` then answers 0 for the rest of the session at a scale other than 1.
|
||||
*
|
||||
* The rule is over the closure rather than over a list of known editors, for the same reason the
|
||||
* `TextInput` one is: the next editable host is the one nobody remembers to add.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { textInputFontSizeFloor } from './mobile-web-app-text-input-font-size-seam.mjs'
|
||||
|
||||
/** The seam's export, which is how a size states the floor rather than restating the number. */
|
||||
const SEAM_EXPORT = 'TEXT_INPUT_FONT_SIZE'
|
||||
|
||||
/**
|
||||
* Each editable tag in a markup string, whole.
|
||||
*
|
||||
* The tag is what an editable *is*; its id is optional and is read out of the tag afterwards. A
|
||||
* pattern that started from the id matched only the hosts that have one, so a file holding a named
|
||||
* host and an anonymous one reported the named host and said nothing about the other.
|
||||
*/
|
||||
const EDITABLE_TAG = /<[A-Za-z][^>]*\bcontenteditable="true"[^>]*>/g
|
||||
|
||||
/** The id a matched tag carries, or null for one that carries none. */
|
||||
const TAG_ID = /\bid="([A-Za-z][\w-]*)"/
|
||||
|
||||
function readOrNull(path) {
|
||||
try {
|
||||
return readFileSync(path, 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every editable host a closure declares, as `{ file, id }`, one entry per tag.
|
||||
*
|
||||
* The completeness half of the verdict below: an empty offender list is only evidence when the
|
||||
* walk found the editables it is judging. A host with no id lands here with `id: null` and is
|
||||
* reported as unresolved rather than passing, and it does so whether or not a named host sits
|
||||
* beside it in the same file.
|
||||
*/
|
||||
export function editableHostsIn(mobileDir, closure) {
|
||||
const found = []
|
||||
for (const file of closure.local) {
|
||||
const source = readOrNull(join(mobileDir, file))
|
||||
if (source === null || !source.includes('contenteditable="true"')) {
|
||||
continue
|
||||
}
|
||||
for (const [tag] of source.matchAll(EDITABLE_TAG)) {
|
||||
found.push({ file, id: TAG_ID.exec(tag)?.[1] ?? null })
|
||||
}
|
||||
}
|
||||
return found.sort((left, right) =>
|
||||
left.file === right.file
|
||||
? String(left.id).localeCompare(String(right.id))
|
||||
: left.file < right.file
|
||||
? -1
|
||||
: 1
|
||||
)
|
||||
}
|
||||
|
||||
/** The selector as a pattern, with the boundary that keeps `#editor` off `#editor-notes`. */
|
||||
function selectorPattern(selector) {
|
||||
return `${selector.replace(/[$()*+.?[\\\]^{|}]/g, '\\$&')}(?![\\w-])`
|
||||
}
|
||||
|
||||
/** The declarations of the rule whose block opens at `open`, or null for one that never closes. */
|
||||
function blockFrom(source, open) {
|
||||
let depth = 1
|
||||
for (let at = open; at < source.length; at += 1) {
|
||||
if (source[at] === '{') {
|
||||
depth += 1
|
||||
continue
|
||||
}
|
||||
if (source[at] === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
return source.slice(open, at)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every rule in a stylesheet string whose selector list mentions the selector, in source order.
|
||||
*
|
||||
* The list runs from a line start, the end of the rule before it, a comma, or the backtick the
|
||||
* template literal opens with, up to the `{`; requiring the selector somewhere inside it is what
|
||||
* keeps the surrounding TypeScript's own braces out of the walk. Kept as a list rather than
|
||||
* collapsed to one selector because a comma binds every selector in it to the same declarations,
|
||||
* so the host can be hiding in any of them.
|
||||
*
|
||||
* Textual, and flat: the sheets this reads have no at-rules and no nesting, which is the same
|
||||
* assumption `document-style-scoping.ts` makes and refuses to exceed.
|
||||
*/
|
||||
function rulesMentioning(source, selector) {
|
||||
const pattern = new RegExp(
|
||||
`(?:^|[},\`])([^{};\`]*${selectorPattern(selector)}[^{};\`]*)\\{`,
|
||||
'dgm'
|
||||
)
|
||||
const rules = []
|
||||
for (let match = pattern.exec(source); match !== null; match = pattern.exec(source)) {
|
||||
// Counted rather than matched to the first `}`: a declaration reading the seam is written
|
||||
// `${TEXT_INPUT_FONT_SIZE}px`, whose own closing brace would have ended the block one
|
||||
// declaration early and left the size looking absent.
|
||||
const declarations = blockFrom(source, match.index + match[0].length)
|
||||
if (declarations === null) {
|
||||
continue
|
||||
}
|
||||
const list = match[1]
|
||||
rules.push({
|
||||
selectors: list
|
||||
.split(',')
|
||||
.map((one) => one.trim())
|
||||
.filter((one) => one !== ''),
|
||||
declarations,
|
||||
// The selector's own start, not the anchor's: the anchor is the previous rule's `}`, a line up.
|
||||
index: match.indices[1][0] + (list.length - list.trimStart().length)
|
||||
})
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
/** The last compound of a selector — the element the rule is about, not one of its ancestors. */
|
||||
function subjectCompound(selector) {
|
||||
return selector.split(/[\s>+~]+/).at(-1) ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* The `font-size` the cascade actually uses out of one rule.
|
||||
*
|
||||
* A rule may declare the property more than once, and CSS takes the last of equal importance, with
|
||||
* `!important` outranking every declaration that is not. Reading the first one reported
|
||||
* `font-size: 16px; font-size: 14px;` as compliant for a surface the browser renders at 14 px.
|
||||
*
|
||||
* The flag is stripped from the value it returns, so a compliant size that carries it is read as
|
||||
* the size it sets rather than as a shape this walk does not model.
|
||||
*/
|
||||
function winningFontSize(declarations) {
|
||||
const found = []
|
||||
// Split on the separator rather than matching a value pattern: a size read from the seam is
|
||||
// written `${TEXT_INPUT_FONT_SIZE}px`, whose own closing brace ends any value pattern that
|
||||
// excludes one, and the last declaration in a rule need not carry a trailing semicolon.
|
||||
for (const piece of declarations.split(';')) {
|
||||
const match = /(?:^|[^\w-])font-size:\s*([\s\S]*)$/.exec(piece)
|
||||
if (match === null) {
|
||||
continue
|
||||
}
|
||||
const raw = match[1].trim()
|
||||
found.push({
|
||||
text: raw.replace(/\s*!\s*important$/i, '').trim(),
|
||||
important: /!\s*important$/i.test(raw)
|
||||
})
|
||||
}
|
||||
if (found.length === 0) {
|
||||
return null
|
||||
}
|
||||
return found.findLast((one) => one.important) ?? found.at(-1)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the winning `font-size` is worth: a literal, a seam substitution, or something else.
|
||||
*
|
||||
* Null for a rule that declares no size at all, which is unresolved rather than a pass: the value
|
||||
* an editable then takes comes from a rule this walk does not read — the host element's own, or the
|
||||
* page's root — so it can be 14 px and the census cannot prove otherwise. Where the `TextInput`
|
||||
* half treats an absent prop as inheritance and lets it through, that policy is main's and about a
|
||||
* prop; this is CSS, and the inherited value is genuinely out of view.
|
||||
*/
|
||||
function readFontSize(mobileDir, source, declarations) {
|
||||
const winning = winningFontSize(declarations)
|
||||
if (winning === null) {
|
||||
return null
|
||||
}
|
||||
const text = winning.text
|
||||
const literal = /^(\d+(?:\.\d+)?)px$/.exec(text)
|
||||
if (literal !== null) {
|
||||
return { text, onSeam: Number(literal[1]) >= textInputFontSizeFloor(mobileDir) }
|
||||
}
|
||||
// A substitution, which is only the seam when this module imported the seam's export: the same
|
||||
// name declared locally, or imported from somewhere else, is exactly the regression the seam
|
||||
// exists to stop wearing its name.
|
||||
const substituted = /^\$\{([A-Za-z_$][\w$]*)\}px$/.exec(text)
|
||||
if (substituted === null) {
|
||||
return { text, onSeam: false }
|
||||
}
|
||||
const imported = new RegExp(
|
||||
`import\\s*\\{[^}]*\\b${SEAM_EXPORT}\\b[^}]*\\}\\s*from\\s*'[^']*text-input-font-size'`
|
||||
)
|
||||
return { text, onSeam: substituted[1] === SEAM_EXPORT && imported.test(source) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every rule in a sheet that applies exactly this selector, in source order.
|
||||
*
|
||||
* All of them rather than the first: rules of equal specificity are ranked by source order, so a
|
||||
* sheet that declares 16 px and then 14 px renders at 14 px, and reading only the first one called
|
||||
* that surface compliant.
|
||||
*/
|
||||
function exactRules(source, selector) {
|
||||
return rulesMentioning(source, selector).filter((rule) => rule.selectors.includes(selector))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every rule in a sheet that sizes the host through a selector this walk cannot rank against the
|
||||
* exact one.
|
||||
*
|
||||
* A subject of higher specificity that still targets the host (`main#editor`, `#editor.x`,
|
||||
* `div > #editor`, `#editor:empty`) beats the exact rule, and this census does no specificity
|
||||
* arithmetic: such a rule declaring `font-size` makes the host unresolved rather than compliant. A
|
||||
* descendant (`#editor p`) is about another element and a pseudo-element (`#editor:empty::before`)
|
||||
* is a box the host generates, so neither one is in the way.
|
||||
*/
|
||||
function unrankableHostRules(source, selector) {
|
||||
return rulesMentioning(source, selector).filter(
|
||||
(rule) =>
|
||||
winningFontSize(rule.declarations) !== null &&
|
||||
rule.selectors.some(
|
||||
(one) =>
|
||||
one !== selector &&
|
||||
!one.includes('::') &&
|
||||
new RegExp(selectorPattern(selector)).test(subjectCompound(one))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each editable host's size is declared, as `{ at, size }`.
|
||||
*
|
||||
* The size is looked for in the same module the markup came from and in the modules directly beside
|
||||
* it: a document's markup and its stylesheet are two exports of one program, so the rule is stated
|
||||
* over that program's own directory rather than over the whole closure or over its subtree.
|
||||
*/
|
||||
function editableHostSizes(mobileDir, closure) {
|
||||
const resolutions = []
|
||||
for (const host of editableHostsIn(mobileDir, closure)) {
|
||||
if (host.id === null) {
|
||||
resolutions.push({ at: host.file, size: null })
|
||||
continue
|
||||
}
|
||||
const directory = host.file.slice(0, host.file.lastIndexOf('/'))
|
||||
// The immediate directory, not the subtree: the walk stops at the first file whose sheet opens
|
||||
// the host's selector, and the closure's order is the bundler's rather than alphabetical, so a
|
||||
// sheet one directory down could answer for the sibling the host actually gets.
|
||||
const siblings = closure.local.filter(
|
||||
(file) => file.slice(0, file.lastIndexOf('/')) === directory
|
||||
)
|
||||
const selector = `#${host.id}`
|
||||
let resolved = null
|
||||
for (const file of siblings) {
|
||||
const source = readOrNull(join(mobileDir, file))
|
||||
if (source === null) {
|
||||
continue
|
||||
}
|
||||
const exact = exactRules(source, selector)
|
||||
if (exact.length === 0) {
|
||||
continue
|
||||
}
|
||||
const unrankable = unrankableHostRules(source, selector)
|
||||
const named = unrankable[0] ?? exact[0]
|
||||
resolved = {
|
||||
at: `${file}:${source.slice(0, named.index).split('\n').length}`,
|
||||
// Joined in source order because that is the cascade among rules of equal specificity, and
|
||||
// `winningFontSize` already reads the last of equal importance out of a declaration string.
|
||||
size:
|
||||
unrankable.length > 0
|
||||
? null
|
||||
: readFontSize(mobileDir, source, exact.map((one) => one.declarations).join(';'))
|
||||
}
|
||||
break
|
||||
}
|
||||
resolutions.push(resolved ?? { at: `${host.file} (#${host.id})`, size: null })
|
||||
}
|
||||
return resolutions
|
||||
}
|
||||
|
||||
/**
|
||||
* Every editable host whose size this walk could not follow to a rule, as it names it.
|
||||
*
|
||||
* A hole rather than a pass: an editable planted with no id, one whose selector no stylesheet
|
||||
* beside it opens, or one a higher-specificity rule sizes out of this walk's reach, is a surface
|
||||
* the rule cannot judge and has to say so.
|
||||
*/
|
||||
export function unresolvedEditableHostStyles(mobileDir, closure) {
|
||||
return editableHostSizes(mobileDir, closure)
|
||||
.filter((entry) => entry.size === null)
|
||||
.map((entry) => entry.at)
|
||||
.sort()
|
||||
}
|
||||
|
||||
/** Every editable host in a closure sized below the floor and off the seam, as `path:line`. */
|
||||
export function editableHostFontSizeOffenders(mobileDir, closure) {
|
||||
return [
|
||||
...new Set(
|
||||
editableHostSizes(mobileDir, closure)
|
||||
.filter((entry) => entry.size !== null && !entry.size.onSeam)
|
||||
.map((entry) => entry.at)
|
||||
)
|
||||
].sort()
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* The editable-host rule, over the tree it ships against and over fixtures of its own.
|
||||
*
|
||||
* Two halves, because an offender list is only evidence when the walk read something. The first
|
||||
* runs the rule over the rich Markdown editor's real modules and says which line carries the size;
|
||||
* the second drives the readings the real tree does not have — a size below the floor, a name that
|
||||
* merely spells the seam's, an editable with no id — against a fixture tree whose only reason to
|
||||
* exist is that those readings have to be observable somewhere.
|
||||
*/
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
editableHostFontSizeOffenders,
|
||||
editableHostsIn,
|
||||
unresolvedEditableHostStyles
|
||||
} from './mobile-web-app-editable-host-font-size.mjs'
|
||||
|
||||
const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url))
|
||||
|
||||
/** The editor's own two modules, as a closure naming nothing else. */
|
||||
const EDITOR_CLOSURE = {
|
||||
local: [
|
||||
'src/components/rich-markdown/document-markup.ts',
|
||||
'src/components/rich-markdown/document-style.ts'
|
||||
]
|
||||
}
|
||||
|
||||
/** The seam's web half, copied into a fixture tree so the floor is read rather than restated. */
|
||||
const SEAM_SOURCE = `export const TEXT_INPUT_FONT_SIZE_FLOOR = 16\n`
|
||||
|
||||
let fixtureDir = null
|
||||
|
||||
/** A fixture tree with the seam in it, plus whatever markup and stylesheet a case needs. */
|
||||
async function fixture(name, markup, style) {
|
||||
const root = join(fixtureDir, name)
|
||||
await mkdir(join(root, 'src/platform'), { recursive: true })
|
||||
await mkdir(join(root, 'src/doc'), { recursive: true })
|
||||
await writeFile(join(root, 'src/platform/text-input-font-size.web.ts'), SEAM_SOURCE, 'utf8')
|
||||
await writeFile(join(root, 'src/doc/markup.ts'), markup, 'utf8')
|
||||
await writeFile(join(root, 'src/doc/style.ts'), style, 'utf8')
|
||||
return { root, closure: { local: ['src/doc/markup.ts', 'src/doc/style.ts'] } }
|
||||
}
|
||||
|
||||
/** The same tree with a second stylesheet one directory down, and a closure that reads it first. */
|
||||
async function fixtureWithNested(name, markup, style, nestedStyle) {
|
||||
const { root } = await fixture(name, markup, style)
|
||||
await mkdir(join(root, 'src/doc/nested'), { recursive: true })
|
||||
await writeFile(join(root, 'src/doc/nested/style.ts'), nestedStyle, 'utf8')
|
||||
return {
|
||||
root,
|
||||
// Nested first, which is what makes this a measurement: the closure's order is the bundler's,
|
||||
// so a walk that accepted any file under the directory would stop here.
|
||||
closure: { local: ['src/doc/nested/style.ts', 'src/doc/markup.ts', 'src/doc/style.ts'] }
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureDir = await mkdtemp(join(tmpdir(), 'orca-editable-host-'))
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (fixtureDir) {
|
||||
await rm(fixtureDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('the editable-host font-size rule', () => {
|
||||
it('finds the editor the TextInput census cannot see', () => {
|
||||
// The precondition every verdict below needs: this walk reads the editor's real markup and
|
||||
// names the surface the page mounts.
|
||||
expect(editableHostsIn(mobileDir, EDITOR_CLOSURE)).toEqual([
|
||||
{ file: 'src/components/rich-markdown/document-markup.ts', id: 'editor' }
|
||||
])
|
||||
})
|
||||
|
||||
it('follows the surface to the rule in the stylesheet beside it', () => {
|
||||
expect(unresolvedEditableHostStyles(mobileDir, EDITOR_CLOSURE)).toEqual([])
|
||||
expect(editableHostFontSizeOffenders(mobileDir, EDITOR_CLOSURE)).toEqual([])
|
||||
})
|
||||
|
||||
it('reds on an editable under the floor', async () => {
|
||||
const { root, closure } = await fixture(
|
||||
'under',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
'export function style() {\n return ` #editor {\n font-size: 14px;\n }`\n}\n'
|
||||
)
|
||||
expect(editableHostFontSizeOffenders(root, closure)).toEqual(['src/doc/style.ts:2'])
|
||||
})
|
||||
|
||||
it('accepts a literal that already clears the floor, and a size read from the seam', async () => {
|
||||
const literal = await fixture(
|
||||
'literal',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
'export function style() {\n return ` #editor {\n font-size: 18px;\n }`\n}\n'
|
||||
)
|
||||
expect(editableHostFontSizeOffenders(literal.root, literal.closure)).toEqual([])
|
||||
|
||||
const bound = await fixture(
|
||||
'bound',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
"import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'\n" +
|
||||
'export function style() {\n return ` #editor {\n font-size: ${TEXT_INPUT_FONT_SIZE}px;\n }`\n}\n'
|
||||
)
|
||||
expect(editableHostFontSizeOffenders(bound.root, bound.closure)).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses a name that only spells the seam’s', async () => {
|
||||
// A local `const TEXT_INPUT_FONT_SIZE = 14` two lines up is exactly the regression the seam
|
||||
// exists to stop, wearing its name.
|
||||
const { root, closure } = await fixture(
|
||||
'local',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
'const TEXT_INPUT_FONT_SIZE = 14\n' +
|
||||
'export function style() {\n return ` #editor {\n font-size: ${TEXT_INPUT_FONT_SIZE}px;\n }`\n}\n'
|
||||
)
|
||||
expect(editableHostFontSizeOffenders(root, closure)).toEqual(['src/doc/style.ts:3'])
|
||||
})
|
||||
|
||||
it('counts a no-id editable beside a named one, rather than only the named one', async () => {
|
||||
// The tag is what an editable is, and its id is optional: a walk that started from the id
|
||||
// matched the named host and never saw the one beside it, so a file holding both reported the
|
||||
// named one as clean and said nothing at all about the other.
|
||||
const { root, closure } = await fixture(
|
||||
'mixed',
|
||||
'export const NAMED = \'<main id="editor" contenteditable="true"></main>\'\n' +
|
||||
'export const ANONYMOUS = \'<section contenteditable="true"></section>\'\n',
|
||||
'export function style() {\n return ` #editor {\n font-size: 18px;\n }`\n}\n'
|
||||
)
|
||||
expect(editableHostsIn(root, closure)).toEqual([
|
||||
{ file: 'src/doc/markup.ts', id: 'editor' },
|
||||
{ file: 'src/doc/markup.ts', id: null }
|
||||
])
|
||||
expect(unresolvedEditableHostStyles(root, closure)).toEqual(['src/doc/markup.ts'])
|
||||
})
|
||||
|
||||
it('reads the sheet beside the markup, not one a directory down', async () => {
|
||||
// The walk stops at the first file whose sheet opens `#editor`, and the closure's order is the
|
||||
// bundler's rather than alphabetical, so a nested sheet could answer for a sibling that is the
|
||||
// one the host actually gets.
|
||||
const { root, closure } = await fixtureWithNested(
|
||||
'nested',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
'export function style() {\n return ` #editor {\n font-size: 14px;\n }`\n}\n',
|
||||
'export function nested() {\n return ` #editor {\n font-size: 18px;\n }`\n}\n'
|
||||
)
|
||||
expect(editableHostFontSizeOffenders(root, closure)).toEqual(['src/doc/style.ts:2'])
|
||||
})
|
||||
|
||||
it('reports an editable it cannot judge rather than passing it', async () => {
|
||||
const noId = await fixture(
|
||||
'no-id',
|
||||
'export const MARKUP = \'<main contenteditable="true"></main>\'\n',
|
||||
'export function style() {\n return ` main { font-size: 18px; }`\n}\n'
|
||||
)
|
||||
expect(unresolvedEditableHostStyles(noId.root, noId.closure)).toEqual(['src/doc/markup.ts'])
|
||||
expect(editableHostFontSizeOffenders(noId.root, noId.closure)).toEqual([])
|
||||
|
||||
const noRule = await fixture(
|
||||
'no-rule',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
'export function style() {\n return ` main { font-size: 18px; }`\n}\n'
|
||||
)
|
||||
expect(unresolvedEditableHostStyles(noRule.root, noRule.closure)).toEqual([
|
||||
'src/doc/markup.ts (#editor)'
|
||||
])
|
||||
})
|
||||
|
||||
it('reads the declaration CSS uses, not the first one in the rule', async () => {
|
||||
// Equal importance, so the last one wins. A walk that stopped at the first read 16 px and
|
||||
// called a 14 px surface compliant.
|
||||
const { root, closure } = await fixture(
|
||||
'repeated',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
'export function style() {\n return ` #editor {\n font-size: 16px;\n' +
|
||||
' font-size: 14px;\n }`\n}\n'
|
||||
)
|
||||
expect(editableHostFontSizeOffenders(root, closure)).toEqual(['src/doc/style.ts:2'])
|
||||
})
|
||||
|
||||
it('lets an important declaration outrank a later one, as the cascade does', async () => {
|
||||
const important = await fixture(
|
||||
'important-wins',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
'export function style() {\n return ` #editor {\n font-size: 18px !important;\n' +
|
||||
' font-size: 14px;\n }`\n}\n'
|
||||
)
|
||||
expect(editableHostFontSizeOffenders(important.root, important.closure)).toEqual([])
|
||||
|
||||
// And an important declaration is still read as the size it sets, rather than as a shape the
|
||||
// walk does not model: without stripping the flag, a compliant `!important` size on the seam
|
||||
// would have been reported as an offender.
|
||||
const offending = await fixture(
|
||||
'important-offends',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
'export function style() {\n return ` #editor {\n font-size: 14px !important;\n }`\n}\n'
|
||||
)
|
||||
expect(editableHostFontSizeOffenders(offending.root, offending.closure)).toEqual([
|
||||
'src/doc/style.ts:2'
|
||||
])
|
||||
})
|
||||
|
||||
it('cannot judge an editable that declares no size, and says so', async () => {
|
||||
// Inheritance is not a pass here. The value would come from a rule in a file this walk does not
|
||||
// read — the host element's own, or the page's root — so "no declaration" is "cannot say" and
|
||||
// belongs in the unresolved list, which the closure census holds at empty.
|
||||
const { root, closure } = await fixture(
|
||||
'inherits',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
'export function style() {\n return ` #editor {\n padding: 8px;\n }`\n}\n'
|
||||
)
|
||||
expect(unresolvedEditableHostStyles(root, closure)).toEqual(['src/doc/style.ts:2'])
|
||||
// Not an offender either: an offender is a size this walk read and found under the floor.
|
||||
expect(editableHostFontSizeOffenders(root, closure)).toEqual([])
|
||||
})
|
||||
|
||||
it('reads every exact rule in the sheet, in source order, as the cascade does', async () => {
|
||||
// Equal specificity, so the last rule wins. Reading only the first called a 14 px surface
|
||||
// compliant because a compliant rule happened to sit above it.
|
||||
const later = await fixture(
|
||||
'later-exact',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
'export function style() {\n return ` #editor {\n font-size: 18px;\n }\n' +
|
||||
' #editor {\n font-size: 14px;\n }`\n}\n'
|
||||
)
|
||||
expect(editableHostFontSizeOffenders(later.root, later.closure)).toEqual(['src/doc/style.ts:2'])
|
||||
})
|
||||
|
||||
it('takes source order rather than the lowest exact rule in the sheet', async () => {
|
||||
// The control the reading above needs: the same two rules the other way round are compliant,
|
||||
// so the verdict is the cascade rather than "any rule under the floor anywhere in the sheet".
|
||||
const earlier = await fixture(
|
||||
'earlier-exact',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
'export function style() {\n return ` #editor {\n font-size: 14px;\n }\n' +
|
||||
' #editor {\n font-size: 18px;\n }`\n}\n'
|
||||
)
|
||||
expect(editableHostFontSizeOffenders(earlier.root, earlier.closure)).toEqual([])
|
||||
})
|
||||
|
||||
it('cannot rank a higher-specificity subject rule, and says so rather than passing', async () => {
|
||||
// `main#editor` outranks `#editor` and this census does no specificity arithmetic, so a rule
|
||||
// like it declaring a size is a hole, named at its own line.
|
||||
const { root, closure } = await fixture(
|
||||
'subject-specificity',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
'export function style() {\n return ` #editor {\n font-size: 18px;\n }\n' +
|
||||
' main#editor {\n font-size: 14px;\n }`\n}\n'
|
||||
)
|
||||
expect(unresolvedEditableHostStyles(root, closure)).toEqual(['src/doc/style.ts:5'])
|
||||
// Not an offender either: an offender is a size this walk read and could rank.
|
||||
expect(editableHostFontSizeOffenders(root, closure)).toEqual([])
|
||||
})
|
||||
|
||||
it('splits a selector list, so a host riding in one still reaches the verdict', async () => {
|
||||
const { root, closure } = await fixture(
|
||||
'subject-in-list',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
'export function style() {\n return ` #editor {\n font-size: 18px;\n }\n' +
|
||||
' h1, main#editor {\n font-size: 14px;\n }`\n}\n'
|
||||
)
|
||||
expect(unresolvedEditableHostStyles(root, closure)).toEqual(['src/doc/style.ts:5'])
|
||||
})
|
||||
|
||||
it('reads the host’s own id, not a longer one that starts with it', async () => {
|
||||
// The selector list is now read whole, so `#editor` has to stop at an id boundary: without one,
|
||||
// a rule for the element beside the host would have made the host unresolved.
|
||||
const { root, closure } = await fixture(
|
||||
'neighbour-id',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
'export function style() {\n return ` #editor {\n font-size: 18px;\n }\n' +
|
||||
' #editor-notes {\n font-size: 14px;\n }`\n}\n'
|
||||
)
|
||||
expect(unresolvedEditableHostStyles(root, closure)).toEqual([])
|
||||
expect(editableHostFontSizeOffenders(root, closure)).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a descendant rule and a pseudo-element rule out of the way', async () => {
|
||||
// Neither one is the host: `#editor p` is about another element, and `::before` is a box the
|
||||
// host generates. Counting either as a hole would report the shipped sheet unresolved.
|
||||
const { root, closure } = await fixture(
|
||||
'not-the-host',
|
||||
'export const MARKUP = \'<main id="editor" contenteditable="true"></main>\'\n',
|
||||
'export function style() {\n return ` #editor {\n font-size: 18px;\n }\n' +
|
||||
' #editor::before {\n font-size: 12px;\n }\n' +
|
||||
' #editor p {\n font-size: 0.9em;\n }`\n}\n'
|
||||
)
|
||||
expect(unresolvedEditableHostStyles(root, closure)).toEqual([])
|
||||
expect(editableHostFontSizeOffenders(root, closure)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,674 @@
|
||||
/**
|
||||
* The rich Markdown editor in the page, in a real browser, under the policy the shell ships.
|
||||
*
|
||||
* The native component puts a hand-written document inside a `WebView` and talks to it over
|
||||
* `postMessage` and `injectJavaScript`. The page has no WebView, so it mounts the same modules and
|
||||
* calls them. That makes four claims this file measures rather than asserts: that all fifteen
|
||||
* toolbar commands change the document under the shipped header with no violation; that `ready`
|
||||
* and `change` reach the component through its own seam and never through the shell's bridge
|
||||
* object; that a remount leaves nothing of the first mount behind (rulings 20 and 21); and that the
|
||||
* surface is on the 16 px floor and the two URL commands are answered by a modal rather than by the
|
||||
* `null` both shells return from `window.prompt`.
|
||||
*
|
||||
* Both engines, because the shell is WKWebView on one platform and a Chromium WebView on the other,
|
||||
* and `document.execCommand` — which the whole program is built on — is the engine's.
|
||||
*/
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import * as esbuild from 'esbuild'
|
||||
import { chromium, webkit } from 'playwright-core'
|
||||
import { MOBILE_WEB_APP_ROOT_RESET, lucideBarrelPlugin } from './build-mobile-web-app-bundle.mjs'
|
||||
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
||||
import { textInputFontSizeFloor } from './mobile-web-app-text-input-font-size-seam.mjs'
|
||||
import {
|
||||
createBundleServer,
|
||||
installCspViolationRecorder,
|
||||
installListenerRecorder,
|
||||
installSchedulerRecorder,
|
||||
readShellCsp
|
||||
} from './mobile-web-app-render-harness.mjs'
|
||||
|
||||
const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url))
|
||||
const componentDir = join(mobileDir, 'src/components')
|
||||
|
||||
/**
|
||||
* The surface's floor, read out of the seam's own module rather than retyped.
|
||||
*
|
||||
* The number was a literal `16` under a comment claiming it was read, which is the shape the seam
|
||||
* exists to prevent: a theme that raised the body size past the floor would move what the page
|
||||
* computes and leave this asserting the old number. `textInputFontSizeFloor` is the same reader the
|
||||
* closure census uses, and it throws rather than defaulting when the seam is gone.
|
||||
*/
|
||||
const FLOOR = textInputFontSizeFloor(mobileDir)
|
||||
|
||||
/** Every command, with how to set the document up for it and what it must produce. */
|
||||
const COMMANDS = [
|
||||
{ label: 'H1', command: 'heading1', select: 'all', expect: 'h1' },
|
||||
{ label: 'H2', command: 'heading2', select: 'all', expect: 'h2' },
|
||||
{ label: 'H3', command: 'heading3', select: 'all', expect: 'h3' },
|
||||
{ label: 'Bold', command: 'bold', select: 'word', expect: 'b,strong' },
|
||||
{ label: 'Italic', command: 'italic', select: 'word', expect: 'i,em' },
|
||||
{ label: 'Strike', command: 'strike', select: 'word', expect: 'strike,s,del' },
|
||||
{ label: 'Bullet list', command: 'bulletList', select: 'all', expect: 'ul' },
|
||||
{ label: 'Numbered list', command: 'orderedList', select: 'all', expect: 'ol' },
|
||||
{ label: 'Checklist', command: 'taskList', select: 'all', expect: 'ul[data-type="taskList"]' },
|
||||
{ label: 'Quote', command: 'quote', select: 'all', expect: 'blockquote' },
|
||||
{ label: 'Inline code', command: 'inlineCode', select: 'word', expect: 'code' },
|
||||
{ label: 'Code block', command: 'codeBlock', select: 'all', expect: 'pre' }
|
||||
]
|
||||
|
||||
/** Paragraph is the fifteenth, and it is the only one whose proof is a document it undoes. */
|
||||
const PARAGRAPH = { label: 'Body', command: 'paragraph' }
|
||||
|
||||
/**
|
||||
* The two that need a URL, and the element each inserts.
|
||||
*
|
||||
* The image's URL is this server's own, because an inserted `<img>` is fetched: a name that does
|
||||
* not resolve put a load failure in the console, and WebKit reports it where chromium does not.
|
||||
* Serving it is also the stronger reading — the element the command inserted actually painted
|
||||
* under the shipped policy rather than merely appearing in the markup.
|
||||
*/
|
||||
const IMAGE_PATH = '/inserted.png'
|
||||
const URL_COMMANDS = [
|
||||
{ label: 'Link', title: 'Link URL', path: '/linked', expect: 'a[href]' },
|
||||
{ label: 'Image', title: 'Image URL', path: IMAGE_PATH, expect: 'img' }
|
||||
]
|
||||
|
||||
/** One transparent pixel, served for the image the Image command inserts. */
|
||||
const PIXEL = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
)
|
||||
|
||||
const ENGINES = [
|
||||
{
|
||||
name: 'chromium',
|
||||
// CI runs this against the runner's Google Chrome rather than paying for a download, the same
|
||||
// override shape as every other render check here.
|
||||
launch: () => {
|
||||
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
|
||||
return chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
|
||||
}
|
||||
},
|
||||
{ name: 'webkit', launch: () => webkit.launch({ headless: true }) }
|
||||
]
|
||||
|
||||
/**
|
||||
* The page under test: the real component, mounted by the real React, with a handle on its props.
|
||||
*
|
||||
* Not a re-implementation. The controller, the mount, the document's own modules and the toolbar
|
||||
* are all the behaviour under test, and a probe that called `runCommand` itself would prove nothing
|
||||
* about any of them.
|
||||
*
|
||||
* `content` is fed back from `onChange`, which is what `MarkdownReader` does: a harness that held
|
||||
* the prop still would have the controller replacing the document under every edit.
|
||||
*/
|
||||
const PAGE_ENTRY = `
|
||||
import { createElement, useEffect, useRef, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||
import { MobileRichMarkdownEditor } from './MobileRichMarkdownEditor'
|
||||
|
||||
/**
|
||||
* What the route's own navigator supplies and a bare mount does not: react-navigation wraps every
|
||||
* screen in a safe-area provider, and the URL modal's drawer reads the insets from it. Without one
|
||||
* the modal throws where the page has no problem at all.
|
||||
*/
|
||||
const METRICS = {
|
||||
frame: { x: 0, y: 0, width: 390, height: 844 },
|
||||
insets: { top: 0, left: 0, right: 0, bottom: 0 }
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
const [state, setState] = useState({
|
||||
mounted: true,
|
||||
generation: 0,
|
||||
content: '',
|
||||
secondContent: '',
|
||||
editable: true,
|
||||
both: false
|
||||
})
|
||||
const handle = useRef(null)
|
||||
useEffect(() => {
|
||||
globalThis.__orcaEditor = {
|
||||
set: (next) => setState((previous) => ({ ...previous, ...next })),
|
||||
changes: [],
|
||||
secondChanges: [],
|
||||
links: [],
|
||||
insets: [],
|
||||
dismiss: () => handle.current?.dismissKeyboard()
|
||||
}
|
||||
document.body.setAttribute('data-ready', 'yes')
|
||||
}, [])
|
||||
// Each editor holds its own content, which is what makes the two-surface case a measurement:
|
||||
// sharing one prop would show the second's edit on the first whatever the document did.
|
||||
const editor = (key, withHandle, content, onChanged) =>
|
||||
createElement(MobileRichMarkdownEditor, {
|
||||
key,
|
||||
ref: withHandle ? handle : undefined,
|
||||
content,
|
||||
editable: state.editable,
|
||||
onChange: onChanged,
|
||||
onOpenLink: (url) => globalThis.__orcaEditor.links.push(url),
|
||||
onKeyboardInsetChange: (bottom) => globalThis.__orcaEditor.insets.push(bottom)
|
||||
})
|
||||
return createElement(
|
||||
SafeAreaProvider,
|
||||
{ initialMetrics: METRICS },
|
||||
createElement(
|
||||
'div',
|
||||
{ style: { display: 'flex', flexDirection: 'row', height: '100vh' } },
|
||||
state.mounted
|
||||
? createElement(
|
||||
'div',
|
||||
{ id: 'first-surface', style: { flex: 1, display: 'flex', minHeight: 0 } },
|
||||
editor('first-' + state.generation, true, state.content, (next) => {
|
||||
globalThis.__orcaEditor.changes.push(next)
|
||||
setState((previous) => ({ ...previous, content: next }))
|
||||
})
|
||||
)
|
||||
: null,
|
||||
state.both
|
||||
? createElement(
|
||||
'div',
|
||||
{ id: 'second-surface', style: { flex: 1, display: 'flex', minHeight: 0 } },
|
||||
editor('second', false, state.secondContent, (next) => {
|
||||
globalThis.__orcaEditor.secondChanges.push(next)
|
||||
setState((previous) => ({ ...previous, secondContent: next }))
|
||||
})
|
||||
)
|
||||
: null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(createElement(Harness))
|
||||
`
|
||||
|
||||
/**
|
||||
* A recorder over `window.ReactNativeWebView`, installed before the bundle runs.
|
||||
*
|
||||
* Ruling 19's claim on the page is an absence, and an absence needs an instrument: on the shell
|
||||
* that object is the bridge's, so an editor message posted through it would put editor JSON into
|
||||
* the bridge's own channel. Defined rather than left undefined, so "the page never reaches for it"
|
||||
* is measured against something that would have answered.
|
||||
*/
|
||||
function installBridgeObjectRecorder() {
|
||||
globalThis.__orcaBridgeReads = []
|
||||
const bridge = {
|
||||
postMessage: (message) => globalThis.__orcaBridgeReads.push(`post ${String(message)}`)
|
||||
}
|
||||
Object.defineProperty(globalThis, 'ReactNativeWebView', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
globalThis.__orcaBridgeReads.push('read')
|
||||
return bridge
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const bundles = mobileWebAppDependenciesPresent()
|
||||
const describeEditor = bundles ? describe : describe.skip
|
||||
|
||||
let scratch = null
|
||||
let server = null
|
||||
let origin = null
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!bundles) {
|
||||
return
|
||||
}
|
||||
// Inside mobile/ rather than the system temp dir: the entry resolves the component beside it,
|
||||
// and esbuild resolves a bare specifier from the importer upward.
|
||||
await mkdir(join(mobileDir, '.tmp'), { recursive: true })
|
||||
scratch = await mkdtemp(join(mobileDir, '.tmp', 'rich-markdown-render-'))
|
||||
const outDir = join(scratch, 'bundle')
|
||||
await mkdir(outDir, { recursive: true })
|
||||
await esbuild.build({
|
||||
absWorkingDir: mobileDir,
|
||||
stdin: {
|
||||
contents: PAGE_ENTRY,
|
||||
resolveDir: componentDir,
|
||||
loader: 'ts',
|
||||
sourcefile: 'rich-markdown-check.ts'
|
||||
},
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
outdir: outDir,
|
||||
entryNames: 'rich-markdown-check',
|
||||
target: ['es2022'],
|
||||
jsx: 'automatic',
|
||||
logLevel: 'silent',
|
||||
nodePaths: [join(mobileDir, 'node_modules')],
|
||||
alias: { 'react-native': 'react-native-web' },
|
||||
// The barrel re-exports a `LucideProvider` its own context module does not export, which is the
|
||||
// same shape the app bundle carries this plugin for.
|
||||
plugins: [lucideBarrelPlugin],
|
||||
// `.web.jsx` and `.web.js` are here for the reason the app bundle has them: without them
|
||||
// `react-native-svg`, which the toolbar's icons pull in, resolves its Fabric components and
|
||||
// fails on `codegenNativeComponent`.
|
||||
resolveExtensions: ['.web.tsx', '.web.ts', '.web.jsx', '.web.js', '.tsx', '.ts', '.jsx', '.js'],
|
||||
// Four of `MOBILE_WEB_APP_SHIMS`, because this entry reaches the same React Native modules the
|
||||
// app bundle does: RN ships untranspiled JSX in `.js`, reads `process.env` at module scope, and
|
||||
// assumes a Metro `global` — measured, `isFabric` threw `global is not defined` before the page
|
||||
// mounted at all, and every case in this file failed at `data-ready`.
|
||||
loader: { '.js': 'jsx' },
|
||||
banner: {
|
||||
js: "globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };"
|
||||
},
|
||||
define: {
|
||||
global: 'globalThis',
|
||||
__DEV__: 'false',
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
'process.env.EXPO_OS': '"web"'
|
||||
}
|
||||
})
|
||||
await writeFile(
|
||||
join(outDir, 'index.html'),
|
||||
// The root reset the shipped document carries: every box below the mount is `flex: 1`, so
|
||||
// without a definite height on all three the editor measures 0 and paints nothing.
|
||||
`<!doctype html><html><head><meta charset="utf-8">${MOBILE_WEB_APP_ROOT_RESET}</head>` +
|
||||
'<body><div id="root"></div>' +
|
||||
'<script type="module" src="/rich-markdown-check.js"></script></body></html>'
|
||||
)
|
||||
const served = await createBundleServer({
|
||||
outDir,
|
||||
cspHeader: await readShellCsp(),
|
||||
handleRequest: (_request, response, path) => {
|
||||
if (path !== IMAGE_PATH) {
|
||||
return false
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'image/png' })
|
||||
response.end(PIXEL)
|
||||
return true
|
||||
}
|
||||
})
|
||||
server = served.server
|
||||
origin = served.origin
|
||||
}, 600_000)
|
||||
|
||||
afterAll(async () => {
|
||||
server?.close()
|
||||
if (scratch) {
|
||||
// This run's directory only: `mobile/.tmp` is a shared ignored root and another suite may hold
|
||||
// one of its own.
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function openPage(browser) {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
|
||||
const consoleErrors = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text())
|
||||
}
|
||||
})
|
||||
page.on('pageerror', (error) => consoleErrors.push(`pageerror: ${error.message}`))
|
||||
await page.addInitScript(installBridgeObjectRecorder)
|
||||
await page.addInitScript(installCspViolationRecorder)
|
||||
await page.addInitScript(installListenerRecorder)
|
||||
await page.addInitScript(installSchedulerRecorder)
|
||||
await page.goto(`${origin}/`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForFunction(() => document.body.dataset.ready === 'yes')
|
||||
await page.waitForSelector('#first-surface #editor')
|
||||
return { page, consoleErrors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the content through the component's prop and waits for the document to hold that markup.
|
||||
*
|
||||
* The oracle is the surface's whole `innerHTML`, not its text. Text cannot tell one block type from
|
||||
* another — `### body text here` and `body text here` read the same — so a run that waited on text
|
||||
* passed while the document was still the one the command before it left, and the next case selected
|
||||
* a range inside an element that was not there any more. Measured: `setEnd` threw
|
||||
* `IndexSizeError` in chromium and the fifteen-command loop timed out in webkit.
|
||||
*/
|
||||
async function setContent(page, markdown, html) {
|
||||
await page.evaluate((next) => globalThis.__orcaEditor.set({ content: next }), markdown)
|
||||
await page.waitForFunction(
|
||||
(expected) => document.querySelector('#first-surface #editor')?.innerHTML === expected,
|
||||
html,
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same, for the two documents whose markup this file does not write down.
|
||||
*
|
||||
* A checklist and a link render nested markup whose exact serialization is the engine's, so the
|
||||
* wait names the element the case is about to act on instead.
|
||||
*/
|
||||
async function setContentWithin(page, markdown, selector) {
|
||||
await page.evaluate((next) => globalThis.__orcaEditor.set({ content: next }), markdown)
|
||||
await page.waitForFunction(
|
||||
(expected) =>
|
||||
document.querySelector('#first-surface #editor')?.querySelector(expected) !== null,
|
||||
selector,
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The plain paragraph a command case starts from, numbered so no two are the same.
|
||||
*
|
||||
* The content prop is what resets the document, and the controller only pushes when it differs
|
||||
* from what the editor last reported. One command breaks that: WebKit's `insertUnorderedList`
|
||||
* nests the `<ul>` inside the `<p>` it was given, and the serializer walks back out with the same
|
||||
* text — so re-setting the same string after it is a no-op, and the next command ran against the
|
||||
* list rather than against a paragraph.
|
||||
*/
|
||||
const bodyFor = (index) => `body text ${String(index)}`
|
||||
|
||||
/** The surface's markup, which is what every command is read off. */
|
||||
function readSurface(page, surface = '#first-surface') {
|
||||
return page.evaluate((id) => document.querySelector(`${id} #editor`)?.innerHTML ?? null, surface)
|
||||
}
|
||||
|
||||
/** Selects the whole surface, or its first word, through the browser's own selection. */
|
||||
async function select(page, how) {
|
||||
await page.evaluate((mode) => {
|
||||
const editor = document.querySelector('#first-surface #editor')
|
||||
editor.focus()
|
||||
const selection = window.getSelection()
|
||||
selection.removeAllRanges()
|
||||
const range = document.createRange()
|
||||
if (mode === 'all') {
|
||||
range.selectNodeContents(editor)
|
||||
} else {
|
||||
const text = editor.querySelector('p')?.firstChild ?? editor.firstChild
|
||||
range.setStart(text, 0)
|
||||
range.setEnd(text, 4)
|
||||
}
|
||||
selection.addRange(range)
|
||||
}, how)
|
||||
}
|
||||
|
||||
/** One toolbar press, as a user makes it. */
|
||||
async function press(page, label) {
|
||||
await page.locator(`[aria-label="${label}"]`).click()
|
||||
}
|
||||
|
||||
describeEditor(
|
||||
'the rich Markdown editor on the page',
|
||||
() => {
|
||||
for (const engine of ENGINES) {
|
||||
describe(engine.name, () => {
|
||||
let browser = null
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await engine.launch()
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
})
|
||||
|
||||
it('mounts the document, reports ready through the seam and never touches the bridge', async () => {
|
||||
const { page, consoleErrors } = await openPage(browser)
|
||||
try {
|
||||
await setContent(page, '# Title', '<h1>Title</h1>')
|
||||
expect(await readSurface(page)).toBe('<h1>Title</h1>')
|
||||
// The document's `ready` is what made the controller push that content, so the markup
|
||||
// above is the seam working end to end.
|
||||
expect(await page.evaluate(() => globalThis.__orcaBridgeReads)).toEqual([])
|
||||
expect(await page.evaluate(() => globalThis.__orcaCspViolations)).toEqual([])
|
||||
expect(consoleErrors).toEqual([])
|
||||
// Never: the screen's own `keyboard-occlusion.web.ts` measures the same viewport with
|
||||
// the same formula, and a report here would lift its bar twice.
|
||||
expect(await page.evaluate(() => globalThis.__orcaEditor.insets)).toEqual([])
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 600_000)
|
||||
|
||||
it('sits on the 16 px floor, which is what stops iOS zooming and never zooming back', async () => {
|
||||
const { page } = await openPage(browser)
|
||||
try {
|
||||
const size = await page.evaluate(
|
||||
() => getComputedStyle(document.querySelector('#first-surface #editor')).fontSize
|
||||
)
|
||||
// At or above, not equal to the floor: the seam is `Math.max(bodySize, floor)`, so a
|
||||
// theme whose body size passes the floor raises what the page computes and still keeps
|
||||
// the rule. Equality against the floor would be the stale literal again, one module
|
||||
// further away.
|
||||
expect(Number.parseFloat(size)).toBeGreaterThanOrEqual(FLOOR)
|
||||
// And the sheet reaches only the editor. The oracle is one of the document's own
|
||||
// variables, which its `:root` rule declares and everything under it reads: set on the
|
||||
// host, and nowhere else. A sheet appended unscoped would have it on the root element,
|
||||
// where it would recolour every screen the shell can show.
|
||||
const variable = await page.evaluate(() => ({
|
||||
onRoot: getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--editor-surface')
|
||||
.trim(),
|
||||
onHost: getComputedStyle(document.querySelector('.orca-rich-markdown-document-host'))
|
||||
.getPropertyValue('--editor-surface')
|
||||
.trim()
|
||||
}))
|
||||
expect(variable.onHost).not.toBe('')
|
||||
expect(variable.onRoot).toBe('')
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 600_000)
|
||||
|
||||
it('runs every one of the fifteen commands against the document', async () => {
|
||||
const { page, consoleErrors } = await openPage(browser)
|
||||
try {
|
||||
for (const [index, entry] of COMMANDS.entries()) {
|
||||
const body = bodyFor(index)
|
||||
await setContent(page, body, `<p>${body}</p>`)
|
||||
// The precondition each command needs: what it is about to produce is not there yet.
|
||||
// Without it a command the controller never delivered would pass on the document the
|
||||
// one before it left.
|
||||
expect(
|
||||
await page.evaluate(
|
||||
(selector) =>
|
||||
document.querySelector('#first-surface #editor')?.querySelector(selector),
|
||||
entry.expect
|
||||
),
|
||||
`${entry.label} was already applied before it ran`
|
||||
).toBeNull()
|
||||
await select(page, entry.select)
|
||||
await press(page, entry.label)
|
||||
await page.waitForFunction(
|
||||
(selector) =>
|
||||
document.querySelector('#first-surface #editor')?.querySelector(selector) !==
|
||||
null,
|
||||
entry.expect,
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
// Paragraph is the fifteenth and the only one whose effect is to undo another's: it is
|
||||
// a true no-op on a `<p>`, which is why a run that starts from one measures nothing.
|
||||
await setContent(page, '# Title', '<h1>Title</h1>')
|
||||
await select(page, 'all')
|
||||
await press(page, PARAGRAPH.label)
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector('#first-surface #editor')?.querySelector('h1') === null &&
|
||||
document.querySelector('#first-surface #editor')?.querySelector('p') !== null,
|
||||
null,
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
|
||||
expect(await page.evaluate(() => globalThis.__orcaCspViolations)).toEqual([])
|
||||
expect(consoleErrors).toEqual([])
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 600_000)
|
||||
|
||||
it('answers Link and Image from a modal rather than from a prompt that returns null', async () => {
|
||||
const { page, consoleErrors } = await openPage(browser)
|
||||
try {
|
||||
for (const [index, entry] of URL_COMMANDS.entries()) {
|
||||
const body = bodyFor(index)
|
||||
await setContent(page, body, `<p>${body}</p>`)
|
||||
await select(page, 'word')
|
||||
await press(page, entry.label)
|
||||
// The seam both shells could not answer: neither implements the delegate
|
||||
// `window.prompt` needs, so on the phone these two commands silently do nothing.
|
||||
await expect
|
||||
.poll(() => page.locator(`text=${entry.title}`).count(), { timeout: 15_000 })
|
||||
.toBeGreaterThan(0)
|
||||
const field = page.locator('input[placeholder="https://"]')
|
||||
await field.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await field.fill(`${origin}${entry.path}`)
|
||||
await page.locator('text=Insert').first().click()
|
||||
await page.waitForFunction(
|
||||
(selector) =>
|
||||
document.querySelector('#first-surface #editor')?.querySelector(selector) !==
|
||||
null,
|
||||
entry.expect,
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
}
|
||||
// The inserted image painted: `naturalWidth` is 0 for an element the browser refused
|
||||
// or never fetched, which is what a policy that did not admit it would leave.
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.querySelector('#first-surface #editor img')?.naturalWidth ?? 0
|
||||
)
|
||||
).toBeGreaterThan(0)
|
||||
expect(await page.evaluate(() => globalThis.__orcaCspViolations)).toEqual([])
|
||||
expect(consoleErrors).toEqual([])
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 600_000)
|
||||
|
||||
it('reports one change per checkbox tap and one per inline code', async () => {
|
||||
const { page } = await openPage(browser)
|
||||
try {
|
||||
await setContentWithin(page, '- [ ] one', 'input[type="checkbox"]')
|
||||
await page.evaluate(() => {
|
||||
globalThis.__orcaEditor.changes.length = 0
|
||||
})
|
||||
await page.locator('#first-surface #editor input[type="checkbox"]').first().click()
|
||||
await page.waitForFunction(() => globalThis.__orcaEditor.changes.length > 0)
|
||||
// One tap raises click, input and change, and each of the three used to report.
|
||||
expect(await page.evaluate(() => globalThis.__orcaEditor.changes)).toHaveLength(1)
|
||||
expect(await page.evaluate(() => globalThis.__orcaEditor.changes[0])).toContain('[x]')
|
||||
|
||||
await setContent(page, bodyFor(0), `<p>${bodyFor(0)}</p>`)
|
||||
await select(page, 'word')
|
||||
await page.evaluate(() => {
|
||||
globalThis.__orcaEditor.changes.length = 0
|
||||
})
|
||||
await press(page, 'Inline code')
|
||||
await page.waitForFunction(() => globalThis.__orcaEditor.changes.length > 0)
|
||||
expect(await page.evaluate(() => globalThis.__orcaEditor.changes)).toHaveLength(1)
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 600_000)
|
||||
|
||||
it('opens a link through the host rather than navigating the page', async () => {
|
||||
const { page } = await openPage(browser)
|
||||
try {
|
||||
await setContentWithin(page, '[a](https://example.com/a)', 'a[href]')
|
||||
await page.locator('#first-surface #editor a').first().click()
|
||||
await page.waitForFunction(() => globalThis.__orcaEditor.links.length > 0)
|
||||
expect(await page.evaluate(() => globalThis.__orcaEditor.links)).toEqual([
|
||||
'https://example.com/a'
|
||||
])
|
||||
expect(page.url()).toBe(`${origin}/`)
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 600_000)
|
||||
|
||||
it('leaves no listener, timer or frame of the first mount in the second', async () => {
|
||||
const { page, consoleErrors } = await openPage(browser)
|
||||
const listeners = () => page.evaluate(() => globalThis.__orcaListeners.snapshot())
|
||||
try {
|
||||
await setContent(page, 'first document', '<p>first document</p>')
|
||||
|
||||
// A first mount installs listeners no dispose can take off — react-native-web's
|
||||
// responder system arms itself on the first `View` the page renders — so the baseline a
|
||||
// per-mount leak would move is the snapshot after one whole cycle, not before it.
|
||||
await page.evaluate(() => globalThis.__orcaEditor.set({ mounted: false }))
|
||||
await page.waitForFunction(() => document.querySelector('#first-surface') === null)
|
||||
const afterOneCycle = await listeners()
|
||||
// The precondition that makes the comparison below non-vacuous: the recorder is reading
|
||||
// real listeners, so a snapshot of nothing cannot pass as a snapshot of no leak.
|
||||
expect(Object.keys(afterOneCycle).length).toBeGreaterThan(0)
|
||||
|
||||
await page.evaluate(() => {
|
||||
globalThis.__orcaScheduler.watching = true
|
||||
globalThis.__orcaEditor.set({ mounted: true, generation: 1 })
|
||||
})
|
||||
await page.waitForSelector('#first-surface #editor')
|
||||
await setContent(page, 'second document', '<p>second document</p>')
|
||||
await select(page, 'all')
|
||||
await press(page, 'Bold')
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector('#first-surface #editor')?.querySelector('b,strong') !== null
|
||||
)
|
||||
|
||||
// The second mount is live, and the page is listening to exactly what it was after the
|
||||
// first cycle. A mount that registered anything of its own would show up here.
|
||||
expect(await listeners()).toEqual(afterOneCycle)
|
||||
expect(await page.evaluate(() => globalThis.__orcaScheduler.leaked)).toEqual([])
|
||||
expect(await page.evaluate(() => globalThis.__orcaCspViolations)).toEqual([])
|
||||
expect(consoleErrors).toEqual([])
|
||||
// The sheet stays in the head across mounts, and there is one of it.
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.querySelectorAll('#orca-rich-markdown-document-style').length
|
||||
)
|
||||
).toBe(1)
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 600_000)
|
||||
|
||||
it('gives two editors on one page their own surfaces', async () => {
|
||||
const { page, consoleErrors } = await openPage(browser)
|
||||
try {
|
||||
await setContent(page, 'first only', '<p>first only</p>')
|
||||
await page.evaluate(() =>
|
||||
globalThis.__orcaEditor.set({ both: true, secondContent: 'second only' })
|
||||
)
|
||||
await page.waitForSelector('#second-surface #editor')
|
||||
// The markup's id is the same in both hosts, so a page-wide read would have handed the
|
||||
// second document the first one's surface: one would be empty and the other would hold
|
||||
// both documents' content and both documents' listeners.
|
||||
await page.waitForFunction(() =>
|
||||
(document.querySelector('#second-surface #editor')?.textContent ?? '').includes(
|
||||
'second only'
|
||||
)
|
||||
)
|
||||
expect(await readSurface(page, '#first-surface')).toContain('first only')
|
||||
expect(await readSurface(page, '#second-surface')).not.toContain('first only')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const editor = document.querySelector('#second-surface #editor')
|
||||
editor.innerHTML = '<p>typed in the second</p>'
|
||||
editor.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
await page.waitForFunction(() =>
|
||||
globalThis.__orcaEditor.secondChanges.includes('typed in the second')
|
||||
)
|
||||
// The edit reached the second editor's own host, and the first document neither
|
||||
// reported it nor lost its content.
|
||||
expect(await page.evaluate(() => globalThis.__orcaEditor.changes)).not.toContain(
|
||||
'typed in the second'
|
||||
)
|
||||
expect(await readSurface(page, '#first-surface')).toContain('first only')
|
||||
expect(consoleErrors).toEqual([])
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 600_000)
|
||||
})
|
||||
}
|
||||
},
|
||||
1_800_000
|
||||
)
|
||||
@@ -4,6 +4,11 @@ import { describe, expect, it } from 'vitest'
|
||||
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
|
||||
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
||||
import { mobileWebAppRouteChunkClosure } from './mobile-web-app-route-chunk-closure.mjs'
|
||||
import {
|
||||
editableHostFontSizeOffenders,
|
||||
editableHostsIn,
|
||||
unresolvedEditableHostStyles
|
||||
} from './mobile-web-app-editable-host-font-size.mjs'
|
||||
import {
|
||||
textInputFontSizeOffenders,
|
||||
unresolvedTextInputStyles
|
||||
@@ -216,7 +221,33 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/'
|
||||
* entered the page bundle between C7.7's measurement on `f07bf8544c` and its merge. Named by
|
||||
* diffing the closure at `f07bf8544c` against `2739246058`; nothing on the C7.7 side moved.
|
||||
*
|
||||
* Then ruling 36 gave the screen to the microphone and two local modules left:
|
||||
* Then C7.10 item C put the rich Markdown editor on the page, and the list moved again. Its own
|
||||
* reading, both sides measured with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base `9267423f22`
|
||||
* with all five postinstall generators run first:
|
||||
*
|
||||
* modules 4333 -> 4360 (+27)
|
||||
* local modules 991 -> 1018 (+27)
|
||||
*
|
||||
* Every one of the 27 is local and none is vendored, because the editor is the app's own code
|
||||
* rather than a library: the document's 24 modules under `src/components/rich-markdown/` — which
|
||||
* C7.6's plain field did not reach at all — plus the page's mount, the toolbar both siblings render,
|
||||
* and the controller and keyboard-inset module the native component already had. Nothing leaves:
|
||||
* the web sibling replaces its own native file, which was never in this closure. Named by diffing
|
||||
* the two `local` lists rather than inferred from the total.
|
||||
*
|
||||
* `terminal-webview-html/document-style-scoping.ts` is in the reading on both sides and costs
|
||||
* nothing: the terminal's own mount already brings it, and the editor's mount imports the second
|
||||
* export it grew rather than a module of its own.
|
||||
*
|
||||
* What the generation weighs, measured the same way on both sides: 8,028,418 -> 8,056,166 bytes
|
||||
* (+27,748) across 109 assets, against the 9 MiB ceiling in `verify-mobile-web-app-bundle.mjs` —
|
||||
* 85.1% of it before and 85.4% after. The script count does not move at all (67, against the
|
||||
* 76 the chunk fence allows for 15 routes) and neither does the entry's static closure
|
||||
* (1,612,052 bytes against a 3 MiB bound): the editor is code the session route already reached
|
||||
* for, not a new chunk boundary.
|
||||
*
|
||||
* Main's own paragraph for the same pin, kept because the two provenances are independent: ruling
|
||||
* 36 gave the screen to the microphone and two local modules left,
|
||||
* `src/hooks/mobile-dictation-keep-awake.ts` and
|
||||
* `src/hooks/mobile-dictation-foreground-keep-awake.ts`, the page's wake-tag owner and its Android
|
||||
* foreground re-acquire. Both are deleted rather than moved — the device module that opens the
|
||||
@@ -225,11 +256,6 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/'
|
||||
* modules 4333 -> 4331 (-2)
|
||||
* local modules 991 -> 989 (-2)
|
||||
*
|
||||
* Measured on this merge rather than subtracted from the line above, and the two local lists
|
||||
* diffed to name the difference: those two leave and nothing joins. The same measurement, taken
|
||||
* before #22067 landed, is how this branch read main's pin of 4,330 as three modules stale — the
|
||||
* three the paragraph above names.
|
||||
*
|
||||
* Then the two other table parsers gave up their own row splitters and read the editor's
|
||||
* `src/components/rich-markdown/markdown-table-rows.ts` instead, which the session page reaches
|
||||
* through the PR comment renderer. It is the one module that joins, and the only one it can be: it
|
||||
@@ -246,8 +272,28 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/'
|
||||
*
|
||||
* modules 4332 -> 4333 (+1)
|
||||
* local modules 990 -> 991 (+1)
|
||||
*
|
||||
* C7.10 item C then put the whole of that directory on the page, and the merge of the two is
|
||||
* measured rather than summed — which is what this reading keeps having to do. The arithmetic of
|
||||
* 4,358 on this branch and 4,332 on main double-counts `markdown-table-rows.ts`: main reached it
|
||||
* first through the comment renderer, and it is also one of the 27 the editor brings. Measured on
|
||||
* the merged head with every generator run first:
|
||||
*
|
||||
* modules 4333 -> 4359 (+26)
|
||||
* local modules 991 -> 1017 (+26)
|
||||
*
|
||||
* and the two local lists diffed against main's, which names the difference module by module: the
|
||||
* editor's own 26, with `markdown-table-rows.ts` already on both sides and the dictation pair
|
||||
* already gone from both. 26 rather than 27 for exactly that reason — main reached the row splitter
|
||||
* first, so it is not this branch's to add twice.
|
||||
*
|
||||
* The 4,333 the merge is measured against is main's corrected reading, not the 4,332 it pinned at
|
||||
* `059ee59a48`: that census failed, `expected [ …(4333) ] to have a length of 4332`, because the
|
||||
* icon above joined beside the row splitter and was not counted. #22119 (`197550c952`) repinned
|
||||
* main to 4,333 with the paragraph above, and folding it here moves nothing — the icon was already
|
||||
* on both sides of the +26, so the pin below is this merge's own measurement, unchanged.
|
||||
*/
|
||||
const SESSION_ROUTE_MODULES = 4333
|
||||
const SESSION_ROUTE_MODULES = 4359
|
||||
|
||||
/** What the page enters this route through once the route is a switch with a `.web.tsx` sibling. */
|
||||
const ROUTE_ENTRY = [
|
||||
@@ -335,6 +381,19 @@ describeClosure(
|
||||
expect(unresolvedTextInputStyles(mobileDir, closure)).toEqual([])
|
||||
expect(textInputFontSizeOffenders(mobileDir, closure)).toHaveLength(EXPECTED_OFFENDERS)
|
||||
}, 300_000)
|
||||
|
||||
it('holds the editables the TextInput census cannot see to the same floor', async () => {
|
||||
const closure = await mobileWebAppRouteClosure(SESSION_ROUTE)
|
||||
// The rich Markdown editor's surface is a `contenteditable` in a markup string, sized by a
|
||||
// rule in a stylesheet: `modulesDeclaringTextInput` matches JSX tags and never sees it, so it
|
||||
// shipped at 14 px and was measured at 14 px in both engines. The same floor, read by a rule
|
||||
// that starts from the markup instead of from a prop.
|
||||
expect(editableHostsIn(mobileDir, closure)).toEqual([
|
||||
{ file: 'src/components/rich-markdown/document-markup.ts', id: 'editor' }
|
||||
])
|
||||
expect(unresolvedEditableHostStyles(mobileDir, closure)).toEqual([])
|
||||
expect(editableHostFontSizeOffenders(mobileDir, closure)).toEqual([])
|
||||
}, 300_000)
|
||||
},
|
||||
900_000
|
||||
)
|
||||
|
||||
@@ -5,30 +5,13 @@ import {
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ComponentType,
|
||||
type ForwardedRef
|
||||
} from 'react'
|
||||
import { Keyboard, Pressable, ScrollView, StyleSheet, View } from 'react-native'
|
||||
import { Keyboard, StyleSheet, View } from 'react-native'
|
||||
import { openExternalLink } from '../platform/external-link'
|
||||
import {
|
||||
Bold,
|
||||
Code2,
|
||||
FileCode2,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
ImageIcon,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListTodo,
|
||||
Pilcrow,
|
||||
Quote,
|
||||
Strikethrough
|
||||
} from 'lucide-react-native'
|
||||
import WebView, { type WebViewMessageEvent } from 'react-native-webview'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { MobileRichMarkdownToolbar } from './MobileRichMarkdownToolbar'
|
||||
import type {
|
||||
MobileRichMarkdownCommand,
|
||||
MobileRichMarkdownEditorMessage,
|
||||
@@ -55,30 +38,6 @@ export type MobileRichMarkdownEditorHandle = {
|
||||
dismissKeyboard: () => void
|
||||
}
|
||||
|
||||
type ToolbarItem = {
|
||||
command: MobileRichMarkdownCommand
|
||||
label: string
|
||||
icon: ComponentType<{ size?: number; color?: string }>
|
||||
}
|
||||
|
||||
const TOOLBAR_ITEMS: ToolbarItem[] = [
|
||||
{ command: 'paragraph', label: 'Body', icon: Pilcrow },
|
||||
{ command: 'heading1', label: 'H1', icon: Heading1 },
|
||||
{ command: 'heading2', label: 'H2', icon: Heading2 },
|
||||
{ command: 'heading3', label: 'H3', icon: Heading3 },
|
||||
{ command: 'bold', label: 'Bold', icon: Bold },
|
||||
{ command: 'italic', label: 'Italic', icon: Italic },
|
||||
{ command: 'strike', label: 'Strike', icon: Strikethrough },
|
||||
{ command: 'bulletList', label: 'Bullet list', icon: List },
|
||||
{ command: 'orderedList', label: 'Numbered list', icon: ListOrdered },
|
||||
{ command: 'taskList', label: 'Checklist', icon: ListTodo },
|
||||
{ command: 'quote', label: 'Quote', icon: Quote },
|
||||
{ command: 'link', label: 'Link', icon: Link },
|
||||
{ command: 'image', label: 'Image', icon: ImageIcon },
|
||||
{ command: 'inlineCode', label: 'Inline code', icon: Code2 },
|
||||
{ command: 'codeBlock', label: 'Code block', icon: FileCode2 }
|
||||
]
|
||||
|
||||
function MobileRichMarkdownEditorInner(
|
||||
{
|
||||
content,
|
||||
@@ -171,34 +130,7 @@ function MobileRichMarkdownEditorInner(
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.toolbar}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.toolbarContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{TOOLBAR_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Pressable
|
||||
key={item.command}
|
||||
disabled={!editable}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={item.label}
|
||||
onPress={() => runCommand(item.command)}
|
||||
style={({ pressed }) => [
|
||||
styles.toolbarButton,
|
||||
pressed && editable ? styles.toolbarButtonPressed : null,
|
||||
!editable ? styles.toolbarButtonDisabled : null
|
||||
]}
|
||||
>
|
||||
<Icon size={15} color={editable ? colors.textPrimary : colors.textMuted} />
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
<MobileRichMarkdownToolbar editable={editable} onCommand={runCommand} />
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ html, baseUrl: EDITOR_DOCUMENT_URL }}
|
||||
@@ -228,32 +160,6 @@ const styles = StyleSheet.create({
|
||||
minHeight: 0,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
toolbar: {
|
||||
minHeight: 42,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
toolbarContent: {
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 6
|
||||
},
|
||||
toolbarButton: {
|
||||
minWidth: 30,
|
||||
height: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
toolbarButtonPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
toolbarButtonDisabled: {
|
||||
opacity: 0.55
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
import { forwardRef, memo, useImperativeHandle, useRef, type ForwardedRef } from 'react'
|
||||
import { StyleSheet, Text, TextInput, View } from 'react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ForwardedRef
|
||||
} from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { openExternalLink } from '../platform/external-link'
|
||||
import { MobileRichMarkdownToolbar } from './MobileRichMarkdownToolbar'
|
||||
import { TextInputModal } from './TextInputModal'
|
||||
import {
|
||||
mountRichMarkdownWebDocument,
|
||||
type RichMarkdownWebDocument
|
||||
} from './rich-markdown/rich-markdown-web-document-mount'
|
||||
import { RICH_MARKDOWN_URL_PROMPT_LABELS } from './rich-markdown/document-host-seams'
|
||||
import { useMobileRichMarkdownEditorController } from './use-mobile-rich-markdown-editor-controller'
|
||||
import type {
|
||||
RichMarkdownEditorApi,
|
||||
RichMarkdownUrlPromptKind
|
||||
} from './rich-markdown/document-host-seams'
|
||||
import type { MobileRichMarkdownEditorMessage } from './mobile-rich-markdown-editor-contract'
|
||||
// The native component's own props and handle, so a change to either fails here rather than
|
||||
// drifting.
|
||||
import type {
|
||||
@@ -10,51 +33,181 @@ import type {
|
||||
} from './MobileRichMarkdownEditor'
|
||||
|
||||
/**
|
||||
* Web sibling: the Markdown source in a plain field, which is the state the rich editor degrades
|
||||
* to when its document is unreachable.
|
||||
* Web sibling: the same editor, with the WebView taken out.
|
||||
*
|
||||
* The native one is a ProseMirror document inside a `WebView`, and `react-native-webview` is a
|
||||
* native component with no browser counterpart — importing it runs a codegen lookup that throws,
|
||||
* and the route manifest imports every route, so one such import takes the whole page down rather
|
||||
* than one editor.
|
||||
* `react-native-webview` has no web build that renders anything, so what the page does instead is
|
||||
* mount the document itself — the modules under `rich-markdown/` are the program the WebView's
|
||||
* script is bundled from, called here with the page's own hooks. The toolbar above it is the same
|
||||
* row of fifteen the phone renders, and the controller between the two is the same controller, so
|
||||
* `MarkdownReader` and the screen around it cannot tell which of the two they have.
|
||||
*
|
||||
* A DOM editor is reachable and is deliberately not here (ruling 8): the toolbar's fifteen
|
||||
* commands are the rich document's, and reimplementing them against `contenteditable` is a
|
||||
* different surface with its own escaping and its own proof, not a smaller version of this one.
|
||||
* What the page keeps is the whole of what the screen around it needs — the text, every edit
|
||||
* reported through `onChange`, and Save, Discard, Copy and Refresh unchanged. What it loses is
|
||||
* the formatting toolbar and the rendered view, recorded as a degradation.
|
||||
* Two seams are the page's rather than the window's. Messages go to `handleMessage` directly and
|
||||
* never through `window.ReactNativeWebView`, which on the page is the *shell's* bridge. The URL the
|
||||
* Link and Image commands need comes from `TextInputModal`: `window.prompt` was measured to return
|
||||
* null in both shells — neither implements the delegate the dialog needs — so on the phone those
|
||||
* two commands silently do nothing, and here they ask.
|
||||
*
|
||||
* `onKeyboardInsetChange` is never called, which is correct rather than missing: it exists because
|
||||
* native `Keyboard` events under-report a WebView's covered area, and here the screen's own
|
||||
* `keyboard-occlusion.web.ts` measurement is the only one there is.
|
||||
* `onKeyboardInsetChange` is accepted and never called, which is correct rather than missing. It
|
||||
* exists because native `Keyboard` events under-report a WebView's covered area; on the page the
|
||||
* document's `visualViewport` reads and the screen's `keyboard-occlusion.web.ts` are the same
|
||||
* measurement of the same viewport with the same formula, so driving the prop would lift the
|
||||
* screen's own bar twice. The mount supplies no inset source, so there is nothing to report.
|
||||
*/
|
||||
function MobileRichMarkdownEditorWebInner(
|
||||
{ content, editable, onChange }: MobileRichMarkdownEditorComponentProps,
|
||||
{ content, editable, onChange, onOpenLink }: MobileRichMarkdownEditorComponentProps,
|
||||
ref: ForwardedRef<MobileRichMarkdownEditorHandle>
|
||||
) {
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const hostRef = useRef<View>(null)
|
||||
const documentRef = useRef<RichMarkdownWebDocument | null>(null)
|
||||
// The document reports itself ready from inside the mount call, so the controller answers it —
|
||||
// setting the content and the editable flag — while the effect below is still on the line that
|
||||
// built the document and `documentRef` is null. Those are the calls this holds, replayed the
|
||||
// moment there is a handle. Dropping them would leave the editor empty for good.
|
||||
const beforeMountRef = useRef<((send: RichMarkdownEditorApi) => void)[]>([])
|
||||
const receiveRef = useRef<((message: MobileRichMarkdownEditorMessage) => void) | null>(null)
|
||||
const [urlPromptKind, setUrlPromptKind] = useState<RichMarkdownUrlPromptKind | null>(null)
|
||||
// The modal's half of `promptForUrl`: the command is waiting on this, and it is answered once,
|
||||
// by a submit, a cancel, or the unmount below.
|
||||
const pendingUrlRef = useRef<((url: string | null) => void) | null>(null)
|
||||
// What the user typed, held until the drawer has gone. Measured in WebKit: answering while the
|
||||
// field still had the focus left `execCommand` acting on a document that did not hold the
|
||||
// selection, and Link and Image inserted nothing at all.
|
||||
const answeredUrlRef = useRef<string | null>(null)
|
||||
|
||||
// Blur rather than `Keyboard.dismiss`: react-native-web's `Keyboard` is a stub, and the caret is
|
||||
// in this field rather than in a document that has to be told to give it up.
|
||||
useImperativeHandle(ref, () => ({ dismissKeyboard: () => inputRef.current?.blur() }), [])
|
||||
const send = useCallback((call: (api: RichMarkdownEditorApi) => void) => {
|
||||
const mounted = documentRef.current
|
||||
if (mounted) {
|
||||
call(mounted.send)
|
||||
return
|
||||
}
|
||||
beforeMountRef.current.push(call)
|
||||
}, [])
|
||||
|
||||
const transport = useMemo(
|
||||
() => ({
|
||||
setMarkdown: (markdown: string, generation: number) =>
|
||||
send((api) => {
|
||||
api.setMarkdown(markdown, generation)
|
||||
}),
|
||||
setEditable: (nextEditable: boolean) =>
|
||||
send((api) => {
|
||||
api.setEditable(nextEditable)
|
||||
}),
|
||||
runCommand: (command: Parameters<RichMarkdownEditorApi['runCommand']>[0]) =>
|
||||
send((api) => {
|
||||
void api.runCommand(command)
|
||||
})
|
||||
}),
|
||||
[send]
|
||||
)
|
||||
|
||||
const openLink = useCallback(
|
||||
(url: string) => {
|
||||
if (onOpenLink) {
|
||||
onOpenLink(url)
|
||||
return
|
||||
}
|
||||
openExternalLink(url)
|
||||
},
|
||||
[onOpenLink]
|
||||
)
|
||||
|
||||
const { handleMessage, runCommand } = useMobileRichMarkdownEditorController({
|
||||
content,
|
||||
editable,
|
||||
onChange,
|
||||
onOpenLink: openLink,
|
||||
transport
|
||||
})
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
dismissKeyboard: () => {
|
||||
send((api) => {
|
||||
api.dismissKeyboard()
|
||||
})
|
||||
}
|
||||
}),
|
||||
[send]
|
||||
)
|
||||
|
||||
// In an effect, not during render: React may replay or discard render work, and the document
|
||||
// reads this ref from a callback that outlives the render that mounted it. The mount effect
|
||||
// below is declared after this one, so the first read already sees a sink.
|
||||
useEffect(() => {
|
||||
receiveRef.current = handleMessage
|
||||
}, [handleMessage])
|
||||
|
||||
/** Closes the modal, keeping the answer for the moment the field no longer has the focus. */
|
||||
const answerUrlPrompt = useCallback((url: string | null) => {
|
||||
answeredUrlRef.current = url
|
||||
setUrlPromptKind(null)
|
||||
}, [])
|
||||
|
||||
/** The drawer has gone: the document may have its caret back, and its command may run. */
|
||||
const releaseUrlPrompt = useCallback(() => {
|
||||
const pending = pendingUrlRef.current
|
||||
const url = answeredUrlRef.current
|
||||
pendingUrlRef.current = null
|
||||
answeredUrlRef.current = null
|
||||
pending?.(url)
|
||||
}, [])
|
||||
|
||||
const promptForUrl = useCallback(
|
||||
(kind: RichMarkdownUrlPromptKind) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
// A second ask while one is open cancels the first, so no command is left awaiting a modal
|
||||
// that has been replaced.
|
||||
pendingUrlRef.current?.(null)
|
||||
answeredUrlRef.current = null
|
||||
pendingUrlRef.current = resolve
|
||||
setUrlPromptKind(kind)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: react-native-web renders View as a div and forwards the ref to it; this module only ever runs in that build.
|
||||
const host = hostRef.current as unknown as HTMLElement | null
|
||||
if (!host) {
|
||||
return
|
||||
}
|
||||
const live = mountRichMarkdownWebDocument(host, {
|
||||
postToHost: (message) => receiveRef.current?.(message),
|
||||
promptForUrl
|
||||
})
|
||||
documentRef.current = live
|
||||
for (const call of beforeMountRef.current) {
|
||||
call(live.send)
|
||||
}
|
||||
beforeMountRef.current = []
|
||||
return () => {
|
||||
documentRef.current = null
|
||||
// Answered before the document goes, so a command awaiting the modal resumes into a stopped
|
||||
// document — which refuses it — rather than holding this mount's scope for good.
|
||||
pendingUrlRef.current?.(null)
|
||||
pendingUrlRef.current = null
|
||||
live.dispose()
|
||||
}
|
||||
// Mounted once, with `promptForUrl` read from the closure rather than named as a dependency:
|
||||
// re-running this would throw away a live document and the caret in it, and every callback
|
||||
// prop above changes identity on each render.
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.notice}>
|
||||
<Text style={styles.noticeText}>markdown source</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={styles.input}
|
||||
value={content}
|
||||
editable={editable}
|
||||
onChangeText={onChange}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
accessibilityLabel="Markdown source"
|
||||
<MobileRichMarkdownToolbar editable={editable} onCommand={runCommand} />
|
||||
<View ref={hostRef} style={styles.host} />
|
||||
<TextInputModal
|
||||
visible={urlPromptKind !== null}
|
||||
title={urlPromptKind === null ? '' : RICH_MARKDOWN_URL_PROMPT_LABELS[urlPromptKind]}
|
||||
placeholder="https://"
|
||||
submitLabel="Insert"
|
||||
keyboardType="url"
|
||||
onSubmit={answerUrlPrompt}
|
||||
onCancel={() => answerUrlPrompt(null)}
|
||||
onAfterClose={releaseUrlPrompt}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
@@ -62,31 +215,8 @@ function MobileRichMarkdownEditorWebInner(
|
||||
|
||||
export const MobileRichMarkdownEditor = memo(forwardRef(MobileRichMarkdownEditorWebInner))
|
||||
|
||||
// The native component's own frame, so the degradation sits where the editor sat rather than
|
||||
// looking like a second design.
|
||||
// The native component's own frame, so the editor sits where the editor sat.
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, minHeight: 0, backgroundColor: colors.bgBase },
|
||||
notice: {
|
||||
minHeight: 42,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
noticeText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
padding: spacing.md,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
// The 16px seam: an input under it makes iOS zoom the page on focus and never zoom back, and
|
||||
// the keyboard seam reads that scale as "no keyboard" for the rest of the session.
|
||||
fontSize: TEXT_INPUT_FONT_SIZE
|
||||
}
|
||||
host: { flex: 1, minHeight: 0, backgroundColor: colors.bgBase }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Bold: 'Bold',
|
||||
Code2: 'Code2',
|
||||
FileCode2: 'FileCode2',
|
||||
Heading1: 'Heading1',
|
||||
Heading2: 'Heading2',
|
||||
Heading3: 'Heading3',
|
||||
ImageIcon: 'ImageIcon',
|
||||
Italic: 'Italic',
|
||||
Link: 'Link',
|
||||
List: 'List',
|
||||
ListOrdered: 'ListOrdered',
|
||||
ListTodo: 'ListTodo',
|
||||
Pilcrow: 'Pilcrow',
|
||||
Quote: 'Quote',
|
||||
Strikethrough: 'Strikethrough'
|
||||
}))
|
||||
|
||||
import {
|
||||
MOBILE_RICH_MARKDOWN_TOOLBAR_COMMANDS,
|
||||
MobileRichMarkdownToolbar
|
||||
} from './MobileRichMarkdownToolbar'
|
||||
import type { MobileRichMarkdownCommand } from './mobile-rich-markdown-editor-contract'
|
||||
|
||||
/**
|
||||
* The one row of controls both surfaces render.
|
||||
*
|
||||
* The WebView turns a press into an injected `runCommand` and the page turns it into a call, and
|
||||
* neither difference belongs in the row. What is pinned here is the thing a second copy would have
|
||||
* drifted on: that the row names every command the contract has, exactly once, so an editor whose
|
||||
* document answers a command the toolbar cannot reach is a compile error rather than a control
|
||||
* nobody has.
|
||||
*/
|
||||
const CONTRACT_COMMANDS: MobileRichMarkdownCommand[] = [
|
||||
'paragraph',
|
||||
'heading1',
|
||||
'heading2',
|
||||
'heading3',
|
||||
'bold',
|
||||
'italic',
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'taskList',
|
||||
'quote',
|
||||
'inlineCode',
|
||||
'codeBlock',
|
||||
'link',
|
||||
'image'
|
||||
]
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function render(editable: boolean, onCommand: (command: MobileRichMarkdownCommand) => void) {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileRichMarkdownToolbar, { editable, onCommand }))
|
||||
})
|
||||
return renderer!.root.findAll((node) => String(node.type) === 'Pressable')
|
||||
}
|
||||
|
||||
describe('the rich Markdown toolbar', () => {
|
||||
it('names every command in the contract, once', () => {
|
||||
expect([...MOBILE_RICH_MARKDOWN_TOOLBAR_COMMANDS].sort()).toEqual([...CONTRACT_COMMANDS].sort())
|
||||
expect(new Set(MOBILE_RICH_MARKDOWN_TOOLBAR_COMMANDS).size).toBe(15)
|
||||
})
|
||||
|
||||
it('renders one labelled button per command and reports the press', () => {
|
||||
const onCommand = vi.fn()
|
||||
const buttons = render(true, onCommand)
|
||||
expect(buttons).toHaveLength(15)
|
||||
expect(buttons.map((button) => button.props.accessibilityLabel)).toEqual([
|
||||
'Body',
|
||||
'H1',
|
||||
'H2',
|
||||
'H3',
|
||||
'Bold',
|
||||
'Italic',
|
||||
'Strike',
|
||||
'Bullet list',
|
||||
'Numbered list',
|
||||
'Checklist',
|
||||
'Quote',
|
||||
'Link',
|
||||
'Image',
|
||||
'Inline code',
|
||||
'Code block'
|
||||
])
|
||||
act(() => buttons[4]?.props.onPress())
|
||||
expect(onCommand.mock.calls).toEqual([['bold']])
|
||||
})
|
||||
|
||||
it('disables every button against a document that cannot be edited', () => {
|
||||
const buttons = render(false, vi.fn())
|
||||
expect(buttons.filter((button) => button.props.disabled !== true)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { memo, type ComponentType } from 'react'
|
||||
import { Pressable, ScrollView, StyleSheet, View } from 'react-native'
|
||||
import {
|
||||
Bold,
|
||||
Code2,
|
||||
FileCode2,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
ImageIcon,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListTodo,
|
||||
Pilcrow,
|
||||
Quote,
|
||||
Strikethrough
|
||||
} from 'lucide-react-native'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
import type { MobileRichMarkdownCommand } from './mobile-rich-markdown-editor-contract'
|
||||
|
||||
type ToolbarItem = {
|
||||
command: MobileRichMarkdownCommand
|
||||
label: string
|
||||
icon: ComponentType<{ size?: number; color?: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* The fifteen commands, in the order they are pressed in.
|
||||
*
|
||||
* Shared rather than declared twice because both surfaces drive the same document: inside the
|
||||
* WebView the press becomes an injected `runCommand` and on the page it is a call, but the row of
|
||||
* controls is the same row and a command added to the contract has to appear on both.
|
||||
*/
|
||||
const TOOLBAR_ITEMS: ToolbarItem[] = [
|
||||
{ command: 'paragraph', label: 'Body', icon: Pilcrow },
|
||||
{ command: 'heading1', label: 'H1', icon: Heading1 },
|
||||
{ command: 'heading2', label: 'H2', icon: Heading2 },
|
||||
{ command: 'heading3', label: 'H3', icon: Heading3 },
|
||||
{ command: 'bold', label: 'Bold', icon: Bold },
|
||||
{ command: 'italic', label: 'Italic', icon: Italic },
|
||||
{ command: 'strike', label: 'Strike', icon: Strikethrough },
|
||||
{ command: 'bulletList', label: 'Bullet list', icon: List },
|
||||
{ command: 'orderedList', label: 'Numbered list', icon: ListOrdered },
|
||||
{ command: 'taskList', label: 'Checklist', icon: ListTodo },
|
||||
{ command: 'quote', label: 'Quote', icon: Quote },
|
||||
{ command: 'link', label: 'Link', icon: Link },
|
||||
{ command: 'image', label: 'Image', icon: ImageIcon },
|
||||
{ command: 'inlineCode', label: 'Inline code', icon: Code2 },
|
||||
{ command: 'codeBlock', label: 'Code block', icon: FileCode2 }
|
||||
]
|
||||
|
||||
/** The commands alone, for a caller that drives the row rather than renders it. */
|
||||
export const MOBILE_RICH_MARKDOWN_TOOLBAR_COMMANDS = TOOLBAR_ITEMS.map((item) => item.command)
|
||||
|
||||
export const MobileRichMarkdownToolbar = memo(function MobileRichMarkdownToolbar({
|
||||
editable,
|
||||
onCommand
|
||||
}: {
|
||||
editable: boolean
|
||||
onCommand: (command: MobileRichMarkdownCommand) => void
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.toolbar}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.toolbarContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{TOOLBAR_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Pressable
|
||||
key={item.command}
|
||||
disabled={!editable}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={item.label}
|
||||
onPress={() => onCommand(item.command)}
|
||||
style={({ pressed }) => [
|
||||
styles.toolbarButton,
|
||||
pressed && editable ? styles.toolbarButtonPressed : null,
|
||||
!editable ? styles.toolbarButtonDisabled : null
|
||||
]}
|
||||
>
|
||||
<Icon size={15} color={editable ? colors.textPrimary : colors.textMuted} />
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
toolbar: {
|
||||
minHeight: 42,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
toolbarContent: {
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 6
|
||||
},
|
||||
toolbarButton: {
|
||||
minWidth: 30,
|
||||
height: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
toolbarButtonPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
toolbarButtonDisabled: {
|
||||
opacity: 0.55
|
||||
}
|
||||
})
|
||||
@@ -24,6 +24,8 @@ type Props = {
|
||||
keyboardType?: KeyboardTypeOptions
|
||||
onSubmit: (value: string) => void
|
||||
onCancel: () => void
|
||||
/** Called once the drawer has gone, which is when the field stops holding the focus. */
|
||||
onAfterClose?: () => void
|
||||
}
|
||||
|
||||
export function TextInputModal({
|
||||
@@ -37,7 +39,8 @@ export function TextInputModal({
|
||||
allowEmpty = false,
|
||||
keyboardType,
|
||||
onSubmit,
|
||||
onCancel
|
||||
onCancel,
|
||||
onAfterClose
|
||||
}: Props) {
|
||||
const [value, setValue] = useState(defaultValue)
|
||||
const [previousVisible, setPreviousVisible] = useState(visible)
|
||||
@@ -64,7 +67,7 @@ export function TextInputModal({
|
||||
const canSubmit = allowEmpty || value.trim().length > 0
|
||||
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onCancel}>
|
||||
<BottomDrawer visible={visible} onClose={onCancel} onAfterClose={onAfterClose}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
|
||||
@@ -5,20 +5,19 @@
|
||||
* importing it runs a codegen lookup that throws, and the route manifest imports every route, so
|
||||
* one such import takes the whole bundle down rather than one editor.
|
||||
*
|
||||
* The two are no longer in the same state. Ruling 26 makes C7.6's fallbacks debt rather than done,
|
||||
* and C7.10's PR A has already paid it for the HTML preview: it renders the artifact in a sealed
|
||||
* `srcdoc` frame with the toggle intact, so what is pinned for it here is the frame's shape and the
|
||||
* toggle's two positions. What a browser does with that frame is not a question this renderer can
|
||||
* answer and is measured in `mobile-web-app-html-preview-render.test.mjs` instead. The rich Markdown
|
||||
* editor is still the plain field, and its degradation is still what is pinned below.
|
||||
* Ruling 26 makes C7.6's fallbacks debt rather than done, and both are paid now. The preview
|
||||
* renders the artifact in a sealed `srcdoc` frame with the toggle intact; the rich Markdown editor
|
||||
* mounts the same document program the WebView runs, under the same fifteen-command toolbar. What a
|
||||
* browser does with either — the frame's sandbox, the editor's commands, its listeners across a
|
||||
* remount — is not a question this renderer can answer, and is measured in
|
||||
* `mobile-web-app-html-preview-render.test.mjs` and
|
||||
* `mobile-web-app-rich-markdown-render.test.mjs` instead. What is pinned here is the shape above
|
||||
* the document: that the plain field is gone, that the toolbar is the one both siblings render, and
|
||||
* that the two props the page answers differently are answered differently.
|
||||
*/
|
||||
import { createElement, createRef } from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
// The unsuffixed path, which is what the component imports: under vitest that is the native
|
||||
// module, and the page's `.web.ts` is what the C7.2 closure census judges against the 16px floor.
|
||||
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
const host =
|
||||
@@ -46,10 +45,34 @@ vi.mock('react-native', async () => {
|
||||
// The preview's toggle carries two icons, and `lucide-react-native` imports a `LucideProvider` its
|
||||
// own context module does not export, so the real barrel does not load under vitest at all.
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Bold: () => null,
|
||||
Code: () => null,
|
||||
Eye: () => null
|
||||
Code2: () => null,
|
||||
Eye: () => null,
|
||||
FileCode2: () => null,
|
||||
Heading1: () => null,
|
||||
Heading2: () => null,
|
||||
Heading3: () => null,
|
||||
ImageIcon: () => null,
|
||||
Italic: () => null,
|
||||
Link: () => null,
|
||||
List: () => null,
|
||||
ListOrdered: () => null,
|
||||
ListTodo: () => null,
|
||||
Pilcrow: () => null,
|
||||
Quote: () => null,
|
||||
Strikethrough: () => null
|
||||
}))
|
||||
|
||||
// The modal is a bottom drawer with an animation and a portal, none of which this file is about:
|
||||
// what it pins is that the editor carries one and that it is shut.
|
||||
vi.mock('./TextInputModal', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
TextInputModal: (props: Record<string, unknown>) => React.createElement('TextInputModal', props)
|
||||
}
|
||||
})
|
||||
|
||||
// Mocked so the native sibling can be rendered beside the web one for the toggle case below: the real
|
||||
// import is the codegen lookup this whole file exists because of.
|
||||
vi.mock('react-native-webview', async () => {
|
||||
@@ -63,7 +86,6 @@ vi.mock('react-native-webview', async () => {
|
||||
import { MobileHtmlPreview, MOBILE_HTML_PREVIEW_SANDBOX } from './MobileHtmlPreview.web'
|
||||
import { MobileHtmlPreview as PhoneHtmlPreview } from './MobileHtmlPreview'
|
||||
import { MobileRichMarkdownEditor } from './MobileRichMarkdownEditor.web'
|
||||
import type { MobileRichMarkdownEditorHandle } from './MobileRichMarkdownEditor'
|
||||
|
||||
const renderers: ReactTestRenderer[] = []
|
||||
|
||||
@@ -94,27 +116,20 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('the rich markdown editor on the page', () => {
|
||||
it('renders the source in one editable field and reports every edit', () => {
|
||||
const onChange = vi.fn()
|
||||
it('renders no plain field, because the document it degraded to is gone', () => {
|
||||
const renderer = render(
|
||||
createElement(MobileRichMarkdownEditor, {
|
||||
content: '# Title\n\nbody',
|
||||
editable: true,
|
||||
onChange
|
||||
onChange: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
const inputs = findHosts(renderer, 'TextInput')
|
||||
expect(inputs).toHaveLength(1)
|
||||
expect(inputs[0]?.props.value).toBe('# Title\n\nbody')
|
||||
expect(inputs[0]?.props.editable).toBe(true)
|
||||
act(() => inputs[0]?.props.onChangeText('# Title\n\nedited'))
|
||||
expect(onChange.mock.calls).toEqual([['# Title\n\nedited']])
|
||||
// The whole of C7.6's sibling: one `TextInput` holding the Markdown source, and a notice bar
|
||||
// over it saying so. Both go, because the page runs the real document now.
|
||||
expect(findHosts(renderer, 'TextInput')).toEqual([])
|
||||
})
|
||||
|
||||
it('renders no toolbar, which is the degradation rather than an omission', () => {
|
||||
// Fifteen commands drive a rich document this page does not have; a toolbar that could not
|
||||
// run them would be fifteen controls that do nothing.
|
||||
it('renders the fifteen-command toolbar the phone renders', () => {
|
||||
const renderer = render(
|
||||
createElement(MobileRichMarkdownEditor, {
|
||||
content: 'body',
|
||||
@@ -122,11 +137,13 @@ describe('the rich markdown editor on the page', () => {
|
||||
onChange: vi.fn()
|
||||
})
|
||||
)
|
||||
expect(findHosts(renderer, 'Pressable')).toEqual([])
|
||||
expect(findHosts(renderer, 'ScrollView')).toEqual([])
|
||||
const buttons = findHosts(renderer, 'Pressable')
|
||||
expect(buttons).toHaveLength(15)
|
||||
expect(buttons.map((node) => node.props.accessibilityLabel)).toContain('Image')
|
||||
expect(buttons.filter((node) => node.props.disabled === true)).toEqual([])
|
||||
})
|
||||
|
||||
it('locks the field when the document is not editable', () => {
|
||||
it('disables every command against a document that cannot be edited', () => {
|
||||
const renderer = render(
|
||||
createElement(MobileRichMarkdownEditor, {
|
||||
content: 'body',
|
||||
@@ -134,12 +151,15 @@ describe('the rich markdown editor on the page', () => {
|
||||
onChange: vi.fn()
|
||||
})
|
||||
)
|
||||
expect(findHosts(renderer, 'TextInput')[0]?.props.editable).toBe(false)
|
||||
const buttons = findHosts(renderer, 'Pressable')
|
||||
expect(buttons).toHaveLength(15)
|
||||
expect(buttons.filter((node) => node.props.disabled !== true)).toEqual([])
|
||||
})
|
||||
|
||||
it('sits on the text-input seam rather than on a size of its own', () => {
|
||||
// The floor itself is the `.web.ts` sibling's and is judged by the closure census; what is
|
||||
// pinned here is that this field is bound to the seam at all, which is what makes it move.
|
||||
it('keeps the URL modal mounted and closed until a command asks for one', () => {
|
||||
// The seam `window.prompt` could not be: both shells cancel the dialog without showing it, so
|
||||
// Link and Image did nothing at all on the phone. Whether the modal opens on a command is the
|
||||
// render check's to measure; that the page carries one, shut, is this file's.
|
||||
const renderer = render(
|
||||
createElement(MobileRichMarkdownEditor, {
|
||||
content: 'body',
|
||||
@@ -147,28 +167,12 @@ describe('the rich markdown editor on the page', () => {
|
||||
onChange: vi.fn()
|
||||
})
|
||||
)
|
||||
expect(findHosts(renderer, 'TextInput')[0]?.props.style.fontSize).toBe(TEXT_INPUT_FONT_SIZE)
|
||||
const modals = findHosts(renderer, 'TextInputModal')
|
||||
expect(modals).toHaveLength(1)
|
||||
expect(modals[0]?.props.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('dismisses the keyboard by blurring the field the caret is actually in', () => {
|
||||
// The native handle calls into the WebView's document; here the caret is in this field, and
|
||||
// react-native-web's `Keyboard.dismiss` is a stub that would have done nothing.
|
||||
const ref = createRef<MobileRichMarkdownEditorHandle>()
|
||||
const blur = vi.fn()
|
||||
render(
|
||||
createElement(MobileRichMarkdownEditor, {
|
||||
ref,
|
||||
content: 'body',
|
||||
editable: true,
|
||||
onChange: vi.fn()
|
||||
}),
|
||||
{ blur }
|
||||
)
|
||||
act(() => ref.current?.dismissKeyboard())
|
||||
expect(blur).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never calls onKeyboardInsetChange, because there is no WebView to measure', () => {
|
||||
it('never calls onKeyboardInsetChange, because the screen measures the same viewport', () => {
|
||||
const onKeyboardInsetChange = vi.fn()
|
||||
render(
|
||||
createElement(MobileRichMarkdownEditor, {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
|
||||
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
|
||||
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
|
||||
import type { RichMarkdownEditorDocument } from './document-host-seams'
|
||||
|
||||
/**
|
||||
* Two editor documents on one page, each reading the surface its own host carries.
|
||||
*
|
||||
* The markup gives the surface an id, and inside the WebView that is unambiguous because the
|
||||
* document *is* the page. On the page it is not: a stack transition keeps the outgoing session
|
||||
* screen mounted while the incoming one starts, so two hosts carry `#editor` at once and a
|
||||
* page-wide read hands both documents whichever came first in the tree. The second editor then
|
||||
* types into the first, and stopping either takes the listeners off the same element.
|
||||
*
|
||||
* So the root is the seventh seam, exactly as it is the terminal's ninth: the WebView names none
|
||||
* of them and gets the whole page, and the page names the element the mount planted the markup in.
|
||||
*/
|
||||
const startedDocuments: RichMarkdownEditorDocument[] = []
|
||||
|
||||
/** One host element with the document's markup in it, and a document started against it. */
|
||||
function mountedIn(id: string) {
|
||||
const host = document.createElement('div')
|
||||
host.id = id
|
||||
host.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
document.body.appendChild(host)
|
||||
const posts: MobileRichMarkdownEditorMessage[] = []
|
||||
const started = createRichMarkdownEditorDocument({
|
||||
root: host,
|
||||
postToHost: (message) => posts.push(message),
|
||||
keyboardInsetSource: () => null,
|
||||
promptForUrl: () => Promise.resolve(null)
|
||||
})
|
||||
startedDocuments.push(started)
|
||||
return { host, posts, started }
|
||||
}
|
||||
|
||||
/** The editable surface inside one host, by the id the markup gives it. */
|
||||
function surfaceOf(host: HTMLElement) {
|
||||
return host.querySelector<HTMLElement>('#editor')!
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (startedDocuments.length > 0) {
|
||||
startedDocuments.pop()!.stop()
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('two editor documents on one page', () => {
|
||||
it('writes each host content into that host, and never into the other', () => {
|
||||
const first = mountedIn('first')
|
||||
const second = mountedIn('second')
|
||||
|
||||
first.started.send.setMarkdown('# first', 1)
|
||||
second.started.send.setMarkdown('# second', 1)
|
||||
|
||||
expect(surfaceOf(first.host).textContent).toBe('first')
|
||||
expect(surfaceOf(second.host).textContent).toBe('second')
|
||||
})
|
||||
|
||||
it('serializes the surface it was started against rather than the page’s first one', () => {
|
||||
const first = mountedIn('first')
|
||||
const second = mountedIn('second')
|
||||
surfaceOf(first.host).innerHTML = '<p>from the first</p>'
|
||||
surfaceOf(second.host).innerHTML = '<p>from the second</p>'
|
||||
|
||||
expect(first.started.send.currentMarkdown()).toBe('from the first')
|
||||
expect(second.started.send.currentMarkdown()).toBe('from the second')
|
||||
})
|
||||
|
||||
it('reports an edit in the second host to the second host’s own host', () => {
|
||||
const first = mountedIn('first')
|
||||
const second = mountedIn('second')
|
||||
first.posts.length = 0
|
||||
second.posts.length = 0
|
||||
|
||||
surfaceOf(second.host).innerHTML = '<p>typed</p>'
|
||||
surfaceOf(second.host).dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(second.posts).toEqual([{ type: 'change', markdown: 'typed', generation: 0 }])
|
||||
expect(first.posts).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves the other document live when one of the two is stopped', () => {
|
||||
const first = mountedIn('first')
|
||||
const second = mountedIn('second')
|
||||
first.started.stop()
|
||||
second.posts.length = 0
|
||||
|
||||
surfaceOf(second.host).innerHTML = '<p>still here</p>'
|
||||
surfaceOf(second.host).dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(second.posts).toEqual([{ type: 'change', markdown: 'still here', generation: 0 }])
|
||||
})
|
||||
|
||||
it('reads the whole page when no root is named, which is what the WebView gets', () => {
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
const posts: MobileRichMarkdownEditorMessage[] = []
|
||||
const started = createRichMarkdownEditorDocument({
|
||||
postToHost: (message) => posts.push(message),
|
||||
keyboardInsetSource: () => null
|
||||
})
|
||||
startedDocuments.push(started)
|
||||
|
||||
started.send.setMarkdown('# native', 1)
|
||||
|
||||
expect(document.querySelector('#editor')!.textContent).toBe('native')
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
} from '../mobile-rich-markdown-editor-contract'
|
||||
|
||||
/**
|
||||
* The six seams between the editor document and whatever is hosting it, as the document's own
|
||||
* The seven 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
|
||||
@@ -65,6 +65,20 @@ export type RichMarkdownEditorHostSeams = {
|
||||
getSelection: () => Selection | null
|
||||
/** `editor-commands`, `editor-selection`: the document ranges, elements and `execCommand` come from. */
|
||||
getDocument: () => Document
|
||||
/**
|
||||
* `editor-surface`: where this document's own markup is, which is the last thing two of them
|
||||
* shared.
|
||||
*
|
||||
* The surface's id is in the markup every host plants, so a page-wide read hands both documents
|
||||
* whichever host came first in the tree — and two at once is not a corner on the page, because a
|
||||
* stack transition keeps the outgoing screen mounted while the incoming one starts. Inside the
|
||||
* WebView the document *is* the page, so it names nothing and gets the whole of it.
|
||||
*
|
||||
* Null rather than `document` as the default, because this is the one seam whose value is data: a
|
||||
* default of `document` would be read when the scope is built rather than when the surface is,
|
||||
* and the rule for every seam above it is that the window read happens at the call.
|
||||
*/
|
||||
root: ParentNode | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,8 +104,13 @@ export function postToReactNativeWebView(message: MobileRichMarkdownEditorMessag
|
||||
}
|
||||
}
|
||||
|
||||
/** The labels the WebView's dialog carried, which is the whole of what the prompt kind means there. */
|
||||
const URL_PROMPT_LABELS: Record<RichMarkdownUrlPromptKind, string> = {
|
||||
/**
|
||||
* What each command asks for, which is the whole of what the prompt kind means.
|
||||
*
|
||||
* Exported because the page asks the same question through a modal, and an editor that said
|
||||
* "Link URL" on the phone and something else on the page would be two editors.
|
||||
*/
|
||||
export const RICH_MARKDOWN_URL_PROMPT_LABELS: Record<RichMarkdownUrlPromptKind, string> = {
|
||||
link: 'Link URL',
|
||||
image: 'Image URL'
|
||||
}
|
||||
@@ -104,7 +123,7 @@ const URL_PROMPT_LABELS: Record<RichMarkdownUrlPromptKind, string> = {
|
||||
* 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]))
|
||||
return Promise.resolve(window.prompt(RICH_MARKDOWN_URL_PROMPT_LABELS[kind]))
|
||||
}
|
||||
|
||||
/** The WebView's own measurement: what `visualViewport` says the keyboard covers. */
|
||||
|
||||
@@ -84,7 +84,8 @@ function createRichMarkdownEditorHostSeams(): RichMarkdownEditorHostSeams {
|
||||
keyboardInsetSource: windowVisualViewportInset,
|
||||
clearTimer: clearWindowTimer,
|
||||
getSelection: windowSelection,
|
||||
getDocument: windowDocument
|
||||
getDocument: windowDocument,
|
||||
root: null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import { TEXT_INPUT_FONT_SIZE } from '../../platform/text-input-font-size'
|
||||
|
||||
/**
|
||||
* The editor document's stylesheet: the theme variables and every rule that reads them.
|
||||
@@ -6,6 +7,12 @@ import { colors } from '../../theme/mobile-theme'
|
||||
* 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 `<style>`; a page mounting
|
||||
* these modules scopes it to the host element it planted the markup in.
|
||||
*
|
||||
* The surface's size comes from the text-input seam rather than from a number here, and that is
|
||||
* where the two hosts differ: the phone keeps the app's body size because a WebView has no page to
|
||||
* zoom, and the page gets the seam's raise because iOS zooms on focus of any editable under 16 px,
|
||||
* never zooms back, and `keyboard-occlusion.web.ts` reads that scale as "no keyboard" for the rest
|
||||
* of the session. One binding, so a floor that moved would move both halves together.
|
||||
*/
|
||||
export function richMarkdownEditorStyle(): string {
|
||||
return ` :root {
|
||||
@@ -37,7 +44,7 @@ export function richMarkdownEditorStyle(): string {
|
||||
min-height: 100vh;
|
||||
padding: 18px 16px 112px;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
font-size: ${TEXT_INPUT_FONT_SIZE}px;
|
||||
line-height: 1.7;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { emitChange, syncTaskCheckboxesDisabled } from './editor-content'
|
||||
import { restoreSelectionOrEnd, wrapSelection } from './editor-selection'
|
||||
import {
|
||||
rememberSelection,
|
||||
restoreRememberedSelection,
|
||||
restoreSelectionOrEnd,
|
||||
wrapSelection
|
||||
} from './editor-selection'
|
||||
import { editorElement } from './editor-surface'
|
||||
import { isSafeUrl } from './markdown-escaping'
|
||||
import type { MobileRichMarkdownCommand } from '../mobile-rich-markdown-editor-contract'
|
||||
@@ -43,6 +48,10 @@ function acceptsCommands(scope: RichMarkdownEditorScope, generation: number): bo
|
||||
*
|
||||
* The generation is read before the wait rather than passed in, which is the same instant:
|
||||
* nothing between `runCommand`'s own read and this one yields.
|
||||
*
|
||||
* The caret is saved before the wait and put back after it, because the dialog is what takes it:
|
||||
* the page's modal focuses its own field, and `execCommand` on a document that does not hold the
|
||||
* selection inserts nothing.
|
||||
*/
|
||||
async function insertUrl(
|
||||
scope: RichMarkdownEditorScope,
|
||||
@@ -50,8 +59,10 @@ async function insertUrl(
|
||||
command: 'createLink' | 'insertImage'
|
||||
) {
|
||||
const generation = scope.documentGeneration
|
||||
rememberSelection(scope)
|
||||
const url = await scope.promptForUrl(kind)
|
||||
if (url && isSafeUrl(url) && acceptsCommands(scope, generation)) {
|
||||
restoreRememberedSelection(scope)
|
||||
exec(scope, command, url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ import type { RichMarkdownEditorDocument } from './document-host-seams'
|
||||
*/
|
||||
const started: RichMarkdownEditorDocument[] = []
|
||||
|
||||
function runtime(options: { caret?: 'paragraph-3' | null } = {}) {
|
||||
function runtime(
|
||||
options: { caret?: 'paragraph-3' | null; promptForUrl?: () => Promise<string | null> } = {}
|
||||
) {
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
const editor = document.getElementById('editor')!
|
||||
editor.innerHTML =
|
||||
@@ -47,7 +49,8 @@ function runtime(options: { caret?: 'paragraph-3' | null } = {}) {
|
||||
|
||||
const document_ = createRichMarkdownEditorDocument({
|
||||
postToHost: () => {},
|
||||
keyboardInsetSource: () => null
|
||||
keyboardInsetSource: () => null,
|
||||
...(options.promptForUrl ? { promptForUrl: options.promptForUrl } : {})
|
||||
})
|
||||
started.push(document_)
|
||||
|
||||
@@ -145,3 +148,53 @@ describe('the editor document caret, across a keyboard dismissal', () => {
|
||||
expect(editor.selectedContainer()).toBe('editor-end')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the editor document caret, across the host’s URL dialog', () => {
|
||||
/** A host that answers the way a modal does: it takes the focus, then it answers. */
|
||||
const modalThatTakesTheFocus = () => {
|
||||
const field = document.createElement('input')
|
||||
document.body.appendChild(field)
|
||||
field.focus()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
return Promise.resolve('https://example.com/a')
|
||||
}
|
||||
|
||||
it('puts the caret back where the dialog found it, so the command has one to act on', async () => {
|
||||
// The page's modal focuses its own field, and `execCommand` on a document that does not hold
|
||||
// the selection inserts nothing: measured in both engines, Link and Image did nothing at all.
|
||||
const editor = runtime({ caret: 'paragraph-3', promptForUrl: modalThatTakesTheFocus })
|
||||
await editor.handle.runCommand('link')
|
||||
expect(editor.focused()).toBe(true)
|
||||
expect(editor.selectedContainer()).toBe('paragraph-3')
|
||||
})
|
||||
|
||||
it('falls back to the end when the dialog outlived the content it was opened over', async () => {
|
||||
// The host can replace the content while its dialog is open, which detaches the nodes the
|
||||
// remembered caret was in.
|
||||
let replaceContent = () => {}
|
||||
const editor = runtime({
|
||||
caret: 'paragraph-3',
|
||||
promptForUrl: () => {
|
||||
replaceContent()
|
||||
return modalThatTakesTheFocus()
|
||||
}
|
||||
})
|
||||
replaceContent = editor.detachContent
|
||||
await editor.handle.runCommand('link')
|
||||
expect(editor.selectedContainer()).toBe('editor-end')
|
||||
})
|
||||
|
||||
it('runs no command when the dialog is cancelled, and leaves the caret alone', async () => {
|
||||
const editor = runtime({
|
||||
caret: 'paragraph-3',
|
||||
promptForUrl: () => {
|
||||
window.getSelection()?.removeAllRanges()
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
})
|
||||
await editor.handle.runCommand('link')
|
||||
// Nothing restored, because nothing is going to run: the restore is the command's, not the
|
||||
// dialog's.
|
||||
expect(editor.selectedContainer()).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,12 +75,36 @@ export function restoreSelectionOrEnd(scope: RichMarkdownEditorScope) {
|
||||
if (selection.rangeCount > 0) {
|
||||
return
|
||||
}
|
||||
collapseToEnd(scope)
|
||||
}
|
||||
|
||||
/** The caret at the end of the document, which is where a command with nothing to act on goes. */
|
||||
function collapseToEnd(scope: RichMarkdownEditorScope) {
|
||||
const range = scope.getDocument().createRange()
|
||||
range.selectNodeContents(editorElement(scope))
|
||||
range.collapse(false)
|
||||
applySelectionRange(scope, range)
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the caret back where the host's dialog found it.
|
||||
*
|
||||
* A command that has to ask for a URL gives the caret up while it waits: the page's modal takes
|
||||
* focus into its own field, and `execCommand` on a document that does not hold the selection
|
||||
* inserts nothing at all — measured in both engines, with Link and Image doing nothing on a page
|
||||
* whose modal had just answered. Unconditional, unlike `restoreSelectionOrEnd`, because the wait
|
||||
* itself is the blur and there is nothing for a flag to tell it.
|
||||
*/
|
||||
export function restoreRememberedSelection(scope: RichMarkdownEditorScope) {
|
||||
focusEditor(scope)
|
||||
const saved = scope.savedSelectionRange
|
||||
if (saved && editorElement(scope).contains(saved.commonAncestorContainer)) {
|
||||
applySelectionRange(scope, saved)
|
||||
return
|
||||
}
|
||||
collapseToEnd(scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the selection in one element, for the formats `execCommand` has no verb for.
|
||||
*
|
||||
|
||||
@@ -14,11 +14,15 @@ export function editorElement(scope: RichMarkdownEditorScope): HTMLElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the surface out of the host's page, once per document.
|
||||
* Reads the surface out of the host's own root, once per document.
|
||||
*
|
||||
* At start rather than where the modules are parsed (ruling 20): an ES module body runs once per
|
||||
* page, so a read there would hand every later mount the first one's element.
|
||||
*
|
||||
* `querySelector` under the root rather than `getElementById`, because a root may be an element:
|
||||
* the page's host carries the markup, and only the WebView's document is a whole document.
|
||||
*/
|
||||
export function startEditorSurface(scope: RichMarkdownEditorScope) {
|
||||
scope.editor = scope.getDocument().getElementById(RICH_MARKDOWN_EDITOR_ELEMENT_ID)
|
||||
const root = scope.root ?? scope.getDocument()
|
||||
scope.editor = root.querySelector<HTMLElement>(`#${RICH_MARKDOWN_EDITOR_ELEMENT_ID}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { scopeDocumentStyleToHost } from '../../terminal/terminal-webview-html/document-style-scoping'
|
||||
import { richMarkdownEditorStyle } from './document-style'
|
||||
|
||||
/**
|
||||
* The editor's sheet, held under the element the page mounts it in.
|
||||
*
|
||||
* Inside the WebView the document owns its page, so the sheet says `:root`, `*`, `html` and `body`
|
||||
* and means all four. Appended to the head of a React Native Web application those four restyle
|
||||
* every screen the shell can show, so ruling 19's rule for `window.onerror` applies to CSS: the
|
||||
* page mount may style only what it owns.
|
||||
*
|
||||
* Where this differs from the terminal's half is what happens to those four. The terminal drops
|
||||
* them and repaints through a seam, because the colour it was setting belongs to the application.
|
||||
* The editor has no such seam and needs none: the host element *is* the editor's page, so the
|
||||
* document's own rules move onto it — the variables the whole sheet reads, the surface colour, the
|
||||
* font, the box model — and a bare `p` or `code` still reaches only what is inside it.
|
||||
*/
|
||||
const PREFIX = '.orca-rich-markdown-document-host'
|
||||
|
||||
/** Every selector in a sheet, one per entry, as the rewrite leaves them. */
|
||||
function selectorsOf(css: string): string[] {
|
||||
return [...css.matchAll(/(?:^|\})\s*([^{}]+)\{/g)].flatMap((match) =>
|
||||
match[1]!.split(',').map((one) => one.trim())
|
||||
)
|
||||
}
|
||||
|
||||
describe('the editor stylesheet on the page', () => {
|
||||
it('reaches nothing outside the host', () => {
|
||||
const selectors = selectorsOf(scopeDocumentStyleToHost(richMarkdownEditorStyle(), PREFIX))
|
||||
expect(selectors.length).toBeGreaterThan(40)
|
||||
expect(selectors.filter((one) => one !== PREFIX && !one.startsWith(`${PREFIX} `))).toEqual([])
|
||||
})
|
||||
|
||||
it('moves the document’s own rules onto the host rather than dropping them', () => {
|
||||
const scoped = scopeDocumentStyleToHost(richMarkdownEditorStyle(), PREFIX)
|
||||
// The variables every other rule reads. Dropped, the sheet would render unstyled while every
|
||||
// selector in it still looked correctly scoped.
|
||||
expect(scoped).toContain('--foreground:')
|
||||
const rootRules = scoped
|
||||
.split('}')
|
||||
.filter((rule) => rule.includes('--foreground:') || rule.includes('overscroll-behavior:'))
|
||||
expect(rootRules.length).toBeGreaterThan(0)
|
||||
for (const rule of rootRules) {
|
||||
expect(rule.trimStart().startsWith(PREFIX)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the universal rule as the host and everything in it', () => {
|
||||
const scoped = scopeDocumentStyleToHost('* { box-sizing: border-box; }', PREFIX)
|
||||
expect(selectorsOf(scoped)).toEqual([PREFIX, `${PREFIX} *`])
|
||||
})
|
||||
|
||||
it('collapses the document’s three names to one host selector', () => {
|
||||
expect(
|
||||
selectorsOf(scopeDocumentStyleToHost(':root, html, body { color: red; }', PREFIX))
|
||||
).toEqual([PREFIX])
|
||||
})
|
||||
|
||||
it('refuses a document-level selector it cannot move, rather than scoping it wrongly', () => {
|
||||
// `.host body p` matches nothing and `.host p` is not what `body p` said. Either reading is a
|
||||
// silent change to the sheet, so the shape is refused instead.
|
||||
expect(() => scopeDocumentStyleToHost('body p { color: red; }', PREFIX)).toThrow(
|
||||
'cannot be moved onto a host'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a descendant of the document root, which reads as no leading element at all', () => {
|
||||
// `:root` starts with a colon, so the leading-element read answers the empty string and the
|
||||
// selector fell through to `.host :root .foo` — a rule that matches nothing, silently.
|
||||
for (const selector of [':root .foo', ':root > .foo', ':root.theme']) {
|
||||
expect(() => scopeDocumentStyleToHost(`${selector} { color: red; }`, PREFIX)).toThrow(
|
||||
'cannot be moved onto a host'
|
||||
)
|
||||
}
|
||||
// The bare name is still the host itself, which is the case the sheet actually has.
|
||||
expect(scopeDocumentStyleToHost(':root { color: red; }', PREFIX)).toContain(`${PREFIX} {`)
|
||||
})
|
||||
|
||||
it('refuses a sheet whose shape it cannot rewrite, as the other half does', () => {
|
||||
expect(() =>
|
||||
scopeDocumentStyleToHost('@media (min-width: 1px) { .a { color: red; } }', PREFIX)
|
||||
).toThrow('at-rules cannot be scoped')
|
||||
expect(() => scopeDocumentStyleToHost('.a { color: red;', PREFIX)).toThrow('never closes')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
RICH_MARKDOWN_HOST_CLASS,
|
||||
mountRichMarkdownWebDocument,
|
||||
type RichMarkdownWebDocument
|
||||
} from './rich-markdown-web-document-mount'
|
||||
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
|
||||
|
||||
/**
|
||||
* The page's mount of the editor document, as a unit.
|
||||
*
|
||||
* What it owns is everything between the component and the factory: the sheet planted once per
|
||||
* page and reaching only inside the host, the markup the document reads its surface out of, and
|
||||
* the dispose that has to leave the page as it found it. Rulings 20 and 21 are the point of the
|
||||
* last one — a second mount must not inherit the first's listeners, and the first's must not go on
|
||||
* reporting into a host that has gone.
|
||||
*
|
||||
* What a browser makes of the sheet is not a question this environment can answer and is measured
|
||||
* in `config/scripts/mobile-web-app-rich-markdown-render.test.mjs` instead.
|
||||
*/
|
||||
const mounted: RichMarkdownWebDocument[] = []
|
||||
|
||||
function mount(posts: MobileRichMarkdownEditorMessage[] = [], url: string | null = null) {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const live = mountRichMarkdownWebDocument(host, {
|
||||
postToHost: (message) => posts.push(message),
|
||||
promptForUrl: () => Promise.resolve(url)
|
||||
})
|
||||
mounted.push(live)
|
||||
return { host, live, posts }
|
||||
}
|
||||
|
||||
const surfaceIn = (host: HTMLElement) => host.querySelector<HTMLElement>('#editor')
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
document.head.innerHTML = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) {
|
||||
mounted.pop()!.dispose()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('the editor document mounted in the page', () => {
|
||||
it('plants the markup in the host and reports itself ready through the seam', () => {
|
||||
const { host, posts } = mount()
|
||||
expect(surfaceIn(host)?.getAttribute('contenteditable')).toBe('true')
|
||||
expect(posts).toEqual([{ type: 'ready' }])
|
||||
// Never the shell's bridge: on the page that object is the host's own channel.
|
||||
expect(window.ReactNativeWebView).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never reports a keyboard inset, because the screen measures the same viewport', () => {
|
||||
// The seam supplies no source, so the document has nothing to observe and nothing to post. A
|
||||
// report here would lift the screen's own bar a second time.
|
||||
const { posts } = mount()
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
expect(posts.filter((message) => message.type === 'keyboardInset')).toEqual([])
|
||||
})
|
||||
|
||||
it('injects one sheet for the page, held entirely under the host class', () => {
|
||||
mount()
|
||||
mount()
|
||||
const sheets = document.querySelectorAll('#orca-rich-markdown-document-style')
|
||||
expect(sheets).toHaveLength(1)
|
||||
const text = sheets[0]!.textContent ?? ''
|
||||
expect(text.length).toBeGreaterThan(1000)
|
||||
const selectors = [...text.matchAll(/(?:^|\})\s*([^{}]+)\{/g)].flatMap((match) =>
|
||||
match[1]!.split(',').map((one) => one.trim())
|
||||
)
|
||||
expect(selectors.length).toBeGreaterThan(40)
|
||||
expect(selectors.filter((one) => !one.startsWith(`.${RICH_MARKDOWN_HOST_CLASS}`))).toEqual([])
|
||||
})
|
||||
|
||||
it('gives the host back on dispose, so every rule of the sheet matches nothing', () => {
|
||||
const { host, live } = mount()
|
||||
expect(host.classList.contains(RICH_MARKDOWN_HOST_CLASS)).toBe(true)
|
||||
live.dispose()
|
||||
mounted.pop()
|
||||
expect(host.innerHTML).toBe('')
|
||||
expect(host.classList.contains(RICH_MARKDOWN_HOST_CLASS)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves no listener of the first mount reporting into the second', () => {
|
||||
const first = mount()
|
||||
const firstSurface = surfaceIn(first.host)!
|
||||
first.live.dispose()
|
||||
mounted.pop()
|
||||
first.posts.length = 0
|
||||
|
||||
const second = mount()
|
||||
second.posts.length = 0
|
||||
surfaceIn(second.host)!.innerHTML = '<p>second</p>'
|
||||
surfaceIn(second.host)!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(second.posts).toEqual([{ type: 'change', markdown: 'second', generation: 0 }])
|
||||
// The precondition the empty list needs: the detached surface is still an element events can
|
||||
// be dispatched on, so nothing reporting from it is the listeners being gone rather than the
|
||||
// event never happening.
|
||||
firstSurface.innerHTML = '<p>first</p>'
|
||||
firstSurface.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
expect(first.posts).toEqual([])
|
||||
})
|
||||
|
||||
it('gives the host back when the document fails to start, rather than leaving a dead one', () => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
// The one failure the start sequence has: a surface it cannot read. Faked at the element read
|
||||
// rather than at the factory, so the unwind under test is the real one.
|
||||
vi.spyOn(host, 'querySelector').mockReturnValue(null)
|
||||
|
||||
expect(() =>
|
||||
mountRichMarkdownWebDocument(host, {
|
||||
postToHost: () => {},
|
||||
promptForUrl: () => Promise.resolve(null)
|
||||
})
|
||||
).toThrow()
|
||||
expect(host.innerHTML).toBe('')
|
||||
expect(host.classList.contains(RICH_MARKDOWN_HOST_CLASS)).toBe(false)
|
||||
})
|
||||
|
||||
it('answers the URL commands from the host rather than from a dialog the shell never shows', async () => {
|
||||
// `execCommand` is the browser's and happy-dom has none, so what the command *does* is the
|
||||
// render check's to measure; what this can say is that the URL comes from the host's seam and
|
||||
// reaches the command with it.
|
||||
const executed: [string, string | undefined][] = []
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: (command: string, _ui: boolean, value?: string) => {
|
||||
executed.push([command, value])
|
||||
return true
|
||||
}
|
||||
})
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const promptForUrl = vi.fn(() => Promise.resolve('https://example.com/a'))
|
||||
const live = mountRichMarkdownWebDocument(host, { postToHost: () => {}, promptForUrl })
|
||||
mounted.push(live)
|
||||
|
||||
await live.send.runCommand('image')
|
||||
|
||||
expect(promptForUrl.mock.calls).toEqual([['image']])
|
||||
expect(executed).toContainEqual(['insertImage', 'https://example.com/a'])
|
||||
})
|
||||
|
||||
it('runs nothing when the host cancels, which is what the shell used to do for every URL', async () => {
|
||||
const executed: string[] = []
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: (command: string) => {
|
||||
executed.push(command)
|
||||
return true
|
||||
}
|
||||
})
|
||||
const { live } = mount([], null)
|
||||
await live.send.runCommand('link')
|
||||
expect(executed).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
|
||||
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
|
||||
import { richMarkdownEditorStyle } from './document-style'
|
||||
import { scopeDocumentStyleToHost } from '../../terminal/terminal-webview-html/document-style-scoping'
|
||||
import type { RichMarkdownEditorApi, RichMarkdownUrlPromptKind } from './document-host-seams'
|
||||
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
|
||||
|
||||
/**
|
||||
* The rich Markdown editor document, mounted in the page instead of in a WebView.
|
||||
*
|
||||
* Same program: the factory the WebView's script is bundled from, called here with the page's own
|
||||
* hooks instead of the WebView's window. What the WebView's HTML gave the document — a stylesheet,
|
||||
* the markup it reads its surface out of, a `postMessage` back to React Native and a `prompt` for
|
||||
* the two commands that need a URL — this supplies instead, through the seams and the host element.
|
||||
*
|
||||
* A call is a document. Nothing here is shared between two of them and nothing is reset: each call
|
||||
* builds its own scope and reads its own surface, so a second editor on the page cannot reach the
|
||||
* first one's state and a stale callback from a mount that has gone reads the scope it closed over.
|
||||
*/
|
||||
|
||||
export type RichMarkdownWebDocument = {
|
||||
/** What the WebView reaches through its injected global, held directly. */
|
||||
send: RichMarkdownEditorApi
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
/** What the page has to answer that the window cannot. */
|
||||
export type RichMarkdownWebDocumentHooks = {
|
||||
postToHost: (message: MobileRichMarkdownEditorMessage) => void
|
||||
promptForUrl: (kind: RichMarkdownUrlPromptKind) => Promise<string | null>
|
||||
}
|
||||
|
||||
const STYLE_ELEMENT_ID = 'orca-rich-markdown-document-style'
|
||||
|
||||
/** The class the host carries, and the prefix every injected rule is held under. */
|
||||
export const RICH_MARKDOWN_HOST_CLASS = 'orca-rich-markdown-document-host'
|
||||
|
||||
/**
|
||||
* The stylesheet, planted in the head once per page and reaching only inside the host.
|
||||
*
|
||||
* The document's rules are written against element names — `h1`, `p`, `code`, `pre`, `a` — so the
|
||||
* sheet the WebView's `<head>` carries would restyle every screen the shell can show and keep
|
||||
* doing it after the editor is gone. `scopeDocumentStyleToHost` holds all of them under the host's
|
||||
* class, the document's own four included: the host element *is* this editor's page, so the
|
||||
* variables the sheet reads, its surface colour and its font belong on it.
|
||||
*
|
||||
* Left in the head after unmount, like the terminal's: it matches nothing once the host has
|
||||
* dropped the class, and the next mount wants it back.
|
||||
*/
|
||||
function ensureDocumentStyle() {
|
||||
if (document.getElementById(STYLE_ELEMENT_ID)) {
|
||||
return
|
||||
}
|
||||
const style = document.createElement('style')
|
||||
style.id = STYLE_ELEMENT_ID
|
||||
style.textContent = scopeDocumentStyleToHost(
|
||||
richMarkdownEditorStyle(),
|
||||
`.${RICH_MARKDOWN_HOST_CLASS}`
|
||||
)
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
/**
|
||||
* The document, mounted: style, markup, one call, and the handle that stops it.
|
||||
*
|
||||
* Synchronous, because the modules are a static import and building a document is a function call.
|
||||
* A caller's cleanup can therefore never arrive before there is something to clean up.
|
||||
*/
|
||||
export function mountRichMarkdownWebDocument(
|
||||
host: HTMLElement,
|
||||
hooks: RichMarkdownWebDocumentHooks
|
||||
): RichMarkdownWebDocument {
|
||||
ensureDocumentStyle()
|
||||
host.classList.add(RICH_MARKDOWN_HOST_CLASS)
|
||||
host.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
const started = startDocumentOrGiveTheHostBack(host, hooks)
|
||||
return {
|
||||
send: started.send,
|
||||
dispose: () => {
|
||||
started.stop()
|
||||
host.innerHTML = ''
|
||||
// The sheet stays in the head; the class does not, so every rule in it matches nothing again
|
||||
// the moment the editor is gone.
|
||||
host.classList.remove(RICH_MARKDOWN_HOST_CLASS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The call, and the host given back if it throws.
|
||||
*
|
||||
* A start that throws is unwound inside the factory, which leaves the document stopped and the
|
||||
* page holding this function's own two edits: the markup and the class. Neither has an owner once
|
||||
* there is no handle.
|
||||
*/
|
||||
function startDocumentOrGiveTheHostBack(host: HTMLElement, hooks: RichMarkdownWebDocumentHooks) {
|
||||
try {
|
||||
return createRichMarkdownEditorDocument({
|
||||
// The mount's own element, so two editors on one page read their own surfaces: the markup's
|
||||
// id is the same in both hosts, and a stack transition keeps the outgoing screen mounted
|
||||
// while the incoming one starts.
|
||||
root: host,
|
||||
|
||||
// Ruling 19: on the page `window.ReactNativeWebView` is the *shell's* bridge, so an editor
|
||||
// message posted through it would put editor JSON into the bridge's own channel.
|
||||
postToHost: hooks.postToHost,
|
||||
|
||||
// Measured to return null in both shells, neither of which implements the delegate the
|
||||
// dialog needs, so Link and Image did nothing at all. The page answers with a modal.
|
||||
promptForUrl: hooks.promptForUrl,
|
||||
|
||||
// None, deliberately. The document's `visualViewport` reads and the screen's own
|
||||
// `keyboard-occlusion.web.ts` are the same measurement of the same viewport with the same
|
||||
// formula, so reporting an inset here would lift the screen's bar twice.
|
||||
keyboardInsetSource: () => null
|
||||
})
|
||||
} catch (error) {
|
||||
host.innerHTML = ''
|
||||
host.classList.remove(RICH_MARKDOWN_HOST_CLASS)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The terminal's own rules, rewritten to reach only what the host element contains.
|
||||
* A document's own rules, rewritten to reach only what the host element contains.
|
||||
*
|
||||
* Inside the WebView the document owns its page, so its stylesheet says `*`, `html` and `body`
|
||||
* and means it. On the page the document is a guest: the same sheet, appended to the head of a
|
||||
@@ -12,9 +12,15 @@
|
||||
* `document.getElementById`, which does not cross a shadow boundary, and xterm's own sheet is
|
||||
* written against `.xterm` in the same document. Both would need a different program.
|
||||
*
|
||||
* The rewrite is textual because the input is: two flat stylesheets this repository writes or
|
||||
* The rewrite is textual because the input is: flat stylesheets this repository writes or
|
||||
* generates, with no at-rules and no nesting. Anything else throws rather than passing a rule
|
||||
* through unscoped, and `document-style-scoping.test.ts` holds that.
|
||||
*
|
||||
* Two page mounts use it and they want opposite things from the document's own rules, which is why
|
||||
* there are two exports rather than a flag. The terminal drops them, because the colour `html, body`
|
||||
* was setting belongs to the application and a seam repaints the host instead. The rich Markdown
|
||||
* editor moves them onto the host, because the host element *is* that editor's page: its variables,
|
||||
* its surface colour and its font are what every other rule in the sheet reads.
|
||||
*/
|
||||
|
||||
/** A rule's selector list and its declaration block, as the source text writes them. */
|
||||
@@ -90,3 +96,62 @@ export function scopeStyleToHost(css: string, prefix: string): string {
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/** The three names a document uses for itself, all of which the host element answers to. */
|
||||
const DOCUMENT_ROOT_SELECTORS = ['html', 'body', ':root']
|
||||
|
||||
/**
|
||||
* Whether a selector starts at the document rather than being one of its names.
|
||||
*
|
||||
* `leadingElement` splits on `:`, so it answers the empty string for anything beginning `:root` and
|
||||
* the check below would have let `:root .foo` through to `${prefix} :root .foo` — a rule that
|
||||
* matches nothing, silently. `html` and `body` are read the way they always were.
|
||||
*/
|
||||
function startsAtDocumentRoot(selector: string): boolean {
|
||||
return (
|
||||
DOCUMENT_ROOT_SELECTORS.includes(leadingElement(selector)) || /^:root(?![\w-])/.test(selector)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One selector as the host's subtree spells it.
|
||||
*
|
||||
* `*` becomes the host and everything in it, because that is what the document meant by it — the
|
||||
* box model it sets has to reach the element the padding is on. A name for the document itself
|
||||
* becomes the host. Anything else hangs under the host.
|
||||
*
|
||||
* A selector that only *starts* at the document (`body p`) throws: `${prefix} body p` matches
|
||||
* nothing and `${prefix} p` is not what it said, so either rewrite would change the sheet in
|
||||
* silence.
|
||||
*/
|
||||
function hostSelectors(selector: string, prefix: string): string[] {
|
||||
const one = selector.trim()
|
||||
if (one === '*') {
|
||||
return [prefix, `${prefix} *`]
|
||||
}
|
||||
if (DOCUMENT_ROOT_SELECTORS.includes(one)) {
|
||||
return [prefix]
|
||||
}
|
||||
if (startsAtDocumentRoot(one)) {
|
||||
throw new Error(`a selector under the document cannot be moved onto a host: ${one}`)
|
||||
}
|
||||
return [`${prefix} ${one}`]
|
||||
}
|
||||
|
||||
/**
|
||||
* The same stylesheet with the whole document held under `prefix`, its own rules included.
|
||||
*
|
||||
* The counterpart of `scopeStyleToHost` for a document whose host element is the page it thought
|
||||
* it had. Duplicates are collapsed, so `html, body { … }` is one rule on one selector rather than
|
||||
* the same selector written twice.
|
||||
*/
|
||||
export function scopeDocumentStyleToHost(css: string, prefix: string): string {
|
||||
return parseStyleRules(css)
|
||||
.map((rule) => {
|
||||
const scoped = [
|
||||
...new Set(rule.selectors.split(',').flatMap((one) => hostSelectors(one, prefix)))
|
||||
].join(',\n')
|
||||
return `${scoped} ${rule.declarations}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user