mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
refactor(mobile): the rich editor's document becomes scope-threaded modules and a bundled factory (OTA phase C, C7.10 C1) (#21969)
* refactor(mobile): split the rich editor document's stylesheet and markup apart The body constant carried the tail of a `:root` block, every CSS rule and the editable surface's markup in one string, which only the HTML builder could splice. A page mounting the document needs the stylesheet and the markup separately, so they become a function over the theme and a constant. Byte-for-byte inert: `mobile-rich-markdown-editor-document.test.ts`'s digest of the shipped document is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the keyboard-inset normaliser its own module It is the host's half of the inset, read by the controller, and it sat in the module holding the document's in-page script. The script is about to become ordinary TypeScript under `rich-markdown/`, where a native-side normaliser does not belong. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the rich editor's document becomes scope-threaded modules and a factory The editor's ~600-line program lived in seven string constants a concatenator glued into one `<script>`: unreadable, untypeable, and unreachable from a page, which is where the OTA shell has to run it (ruling 26). It is now ordinary TypeScript under `src/components/rich-markdown/`. Every function that touches editor state takes `scope: RichMarkdownEditorScope` first, `createRichMarkdownEditorDocument(host)` builds the scope, runs the start sequence and returns `{ send, stop }`, and the six window reads the script did are host seams with those reads as their defaults: `postToHost`, `promptForUrl`, `keyboardInsetSource`, `clearTimer`, `getSelection`, `getDocument`. `runCommand` is async because a host that answers the URL prompt with a modal cannot answer synchronously; the thirteen commands that never wait stay one synchronous act. No module holds a `let` and none does work at parse time (rulings 20, 21), so a second mount starts from its own state and `stop` takes back both the surface's four listeners and the viewport's two. The native document is an esbuild IIFE bundle of `native-document-entry.ts`, written beside the terminal document's artifact by a fifth postinstall generator. Nothing ships it yet: the HTML builder still splices the old strings, which the next commit changes. Red-first: `rich-markdown-document-parse-time.test.ts` and `rich-markdown-host-seams.test.ts`. Their readers are the terminal census's, extracted to `src/test-support/webview-document-census.ts` and pointed at both documents rather than copied. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): ship the bundled document and retire the editor's script strings `buildMobileRichMarkdownEditorHtml` splices the esbuild bundle of `src/components/rich-markdown/`, and the seven string constants and their concatenator go. `escapeInjectedJavaScriptString` stays: it is the escape for `injectJavaScript`, which is still how the native host reaches the document. Equivalence, since a byte golden over the script cannot survive a bundler: - `rich-markdown/native-document-bundle.test.ts` evaluates the shipped artifact exactly as the WebView does — its markup, its bridge, its `execCommand`, its `prompt`, its `visualViewport` — and drives it through the injected handle: `keyboardInset` then `ready`, all five members, a markdown round trip through the real escape, an edit under the host's generation, every toolbar command's engine verb, the `javascript:` refusal, a tapped link, and the module list. - `mobile-rich-markdown-editor-document.test.ts` keeps a byte pin, now over the page around the document. Measured on main's own document with its script region removed and on this one: 5,621 bytes, both `5054e1d5c87e4ce1805d4856ddc8bf36804e697675e6013d84da453d3e81af25`. The whole-document digest it replaces was `1ef29c88…`, 29,852 bytes. Every assertion `mobile-rich-markdown-editor-html.test.ts` made by extracting functions out of the emitted text is kept, aimed at the modules: - nested/ordered/task list rendering and serialization, entities, explicit numbering, the parent-start fallback, read-only checkboxes → `markdown-round-trip.test.ts`, over real elements rather than shaped objects. - the emitChange/setEditable guards and the generation carried through a replacement → `editor-content.test.ts`, behaviourally. - dismissKeyboard, the tapped caret, the label tap, the restored caret, the end-of-document fallback, the detached caret → `editor-selection.test.ts`, with a blur that drops the ranges the way WebKit does. - parseable script and the injection escape stay in the HTML test. New with the factory: `document-lifecycle.test.ts` — stop takes the four surface listeners and the viewport observer off, a second mount is its own document, two documents do not share `editable`, and a start that throws unwinds. `use-mobile-rich-markdown-editor-controller`, `MobileRichMarkdownEditor` and the web fallback tests are untouched and green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the document's mutable bindings from the tree, not the line start The census matched `/^(let|var) /gm`, so `export let`, a declaration indented inside a top-level block and a `for (let …)` head were all invisible — three shapes of the one binding two documents would share — and its single precondition proved only the shape it could already see. `moduleLevelMutableBindings` walks the program instead and stops at every function body, because a binding one call owns is not module state. Its preconditions are one per shape, with the kind each reports, and a negative case over a `const` and a function-local `let`/`var` so the empty list is a measurement rather than a reader that refuses everything. Red-first: `export let pendingReport = 0` planted in `keyboard-inset.ts` reds it with `keyboard-inset: let pendingReport`, which the old matcher passed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pin both WebView document bundles to the mobile root esbuild writes each module's path into a bundle as a comment relative to the working directory, and neither generator set `absWorkingDir`. So the artifact's bytes followed the cwd of whatever postinstall run wrote it: measured from the repo root, `mobile/`, and `mobile/src`, three digests — and from outside the repo the comments carried `/Users/<name>/…`, a machine path in the one file every bundle test compares against a build it makes itself. Both generators now pin the mobile root, so the four cwds measured agree, and both bundle tests carry the pin: a digest built in a child process from the OS temp directory equals the committed artifact's, and no comment in either artifact is an absolute path or climbs out with `../`. `build-terminal-document-script.mjs` had the defect verbatim on main; C1 copied its shape, so both are fixed here rather than leaving the original to be found again. Neither artifact's bytes move: both were generated from `mobile/`, which is what `absWorkingDir` now names. Red-first: deleting the `absWorkingDir` line from either generator reds that generator's case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cover the getSelection seam's override, not just its default Five of the six seams had both halves and this one had only its window default, which is the half that cannot fail on the page: there the caret has to come from the object the host hands over, because a document mounted inside a screen shares `window` with every other field on it. The case gives the document a selection of its own, blurs the surface the way WebKit does — dropping the ranges, which is the whole reason a caret is saved — and reads the restored caret back out of the host's object. The window's own selection stays empty throughout, which is what says the default was never consulted. Red-first: `rememberSelection` reading `window.getSelection()` instead of the field reds it; every other case in the file stays green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the editor document's stop cancel its pending timer `stop` took the surface's four listeners and the viewport's observer off and left the input timer, while the scope kept the handle and the `clearTimer` seam kept the means to cancel it. A listener comes off with the element it was on; a scheduled callback holds the scope and fires into a document the host has already unmounted, posting a change under the generation of content it has replaced. `stopEditorContent` cancels it through the seam and clears the field, and the sequence runs it last — after the listeners that could have scheduled another one are gone. Nothing schedules the handle today. The cancel is here because the seam and the field exist for the day something does, and that is not the moment to discover `stop` never reached it. The case plants the pending change rather than waiting for a debounce, and carries its own control: the same timer posts while the document is running, and posts nothing once it is stopped. Red-first: dropping `stopEditorContent` from the sequence reds both that case and the parse-time census's start/stop set comparison. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct the postinstall generator count in both censuses Two comments said four generators and six generated files. There are five generators writing six files, and the six are not the six either comment described: `census-source-files.ts` still named the page's copy of the terminal document, which ruling 25 retired and #21962 stopped ignoring, while C7.10 C1 added the rich Markdown editor's. Both now name the lists of record — `mobile/package.json`'s postinstall for the generators, `mobile/.gitignore` for the files — and say the count is a reading that grows rather than a fence, which is what made the old numbers wrong twice over. Verified against both lists: 5 and 6. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say which digest is the document and which is the page around it The docstring put main's whole-document digest and byte count in the sentence introducing the shell pin, so it read as if `1ef29c88…` and 29,852 bytes were what the constant below asserts. They are not: that digest is of main's whole document, script included, and nothing in the file reproduces it. The constant is of the document with its `<script>` region emptied, taken on main's document and on this one. Both are now named and separated, with what each covers and why the shell one was read twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name the parse-time fixture by its role Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop an editor command whose dialog answered after the host moved on C7.10 C1 made `runCommand` async so a host can answer the URL prompt with a modal. Inside the WebView that changes nothing — `window.prompt` resolves within a microtask, and the host reaches the document through `injectJavaScript`, which is a later task — but on the page the modal is a real task boundary, and while it is open the host can replace the content, make the editor read-only or unmount it entirely. The continuation ran anyway: `createLink` against markdown nobody chose, and a change posted under the new generation carrying an edit made against the old one. `acceptsCommands` is the question both halves ask: not stopped, still editable, still the same generation, still contenteditable. `insertUrl` asks it before `execCommand` and `runCommand` asks it again before emitting, each against the generation read before its own wait. The scope gains `stopped`, which `stopRichMarkdownEditorDocument` sets. Inert on native, where no state can change across a microtask, so the answer to both questions is the one the old code assumed. Red-first: with either check removed, the new case reports `[ 'createLink', 'createLink' ]` against `[ 'createLink' ]`. The case carries its own control — an answer that arrives while nothing has moved is still applied and still reported. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the editor's block reader always consume a line `markdownToHtml` looped forever on `# `, `- ` and `1. `. `isBlockStart` admits a marker followed by a space, and the list test admits the same, but the heading reader requires text after the hashes and `parseListLine` requires text after the marker — so on those lines the list branch consumed nothing and returned the index it was given, and the paragraph loop gathered nothing and pushed an empty paragraph without advancing. A one-line file the host handed to `setMarkdown` froze the WebView. Two guards, both by the same rule: a branch may only commit if it moved the index. The list branch falls through when its run is empty, and the paragraph falls back to the line itself when it gathered none. Present on main verbatim, so this is inherited rather than introduced — but the fix is observationally inert, because the only inputs it changes are the ones that previously never returned. Every input that produced output produces the same output. Evidence, from a probe that bounds the loop from the inside rather than waiting on it: before, `# ` and `- ` both UNBOUNDED; after, twenty marker and fence shapes all return. The pinned cases carry their own control, `# ok` and `- ok`, so the fallback is not swallowing the readers it falls back from. A red-first case is not possible here: without the fix the case does not fail, it hangs the worker. The probe above is the measurement. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): see every declaration that runs as a document module is evaluated The parse-time reader inspected only variable declarations, while `DECLARATION_KINDS` admits classes and default exports. So `class A { static value = install() }`, a static block, and `export default install()` all passed a census whose whole job is to refuse exactly that — and a static field reading `document` passed too, which is the remount defect the rule exists for, wearing a different shape. Three shapes now, each reported by what it does rather than what it looks like: a variable initialiser, a class's static members, and a default export that is an expression. `DECLARES_WITHOUT_RUNNING` keeps the last one from walking into the body of `export default function () {}`, whose calls run when something calls it. The preconditions are one per shape, with a negative case beside them: an instance field runs per `new` and nothing in a document is ever constructed, and a default-exported function declares a body rather than running one. Inherited from the terminal's census, which had the same reader; both use this one, and both are green. Red-first: removing the class branch reds the new precondition case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read lifecycle exports from the tree, not from one exact spelling The reader was a regular expression needing `export function`, one line, the scope parameter and no return type. `export async function startX(`, a return type, or a parameter list the formatter wrapped made a real lifecycle export vanish — and the comparison it feeds is a set against the names the sequence calls, so a function missing from *both* lists makes them agree. A start nobody runs would have read as a start nobody needs. It now qualifies a function by what it is: exported, named for its lifecycle, and taking the document's scope as its only parameter. That last clause is ruling 20's own wording — a start takes nothing the scope does not already carry — and the regex was enforcing it by accident, through the single parameter its pattern happened to allow. Surfaced by the change: the terminal's `startEdgeScroll(scope, dir)`, which the regex never matched and the sequence never calls. It takes a direction, so it is the overlay's act for a drag rather than a module's lifecycle, and the one- parameter rule refuses it for the stated reason instead of by accident. Both censuses are green. Red-first: restoring the regex reds the new precondition case, which covers `async`, a return type and wrapped parameters, with refusals beside them for a two-parameter start, another document's scope type, and an unexported function. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -50,8 +50,10 @@ import {
|
||||
* the first time, when main had drifted the haptics module after recording its own number, and a
|
||||
* sum would have read 4283 and been wrong about a module neither side of that merge touched.
|
||||
*
|
||||
* Both sides read with `mobileWebAppRouteClosure(SESSION_ROUTE)` and the four postinstall
|
||||
* generators run first, the before side in a scratch worktree detached at the same sha, and the
|
||||
* Both sides read with `mobileWebAppRouteClosure(SESSION_ROUTE)` and every postinstall generator
|
||||
* `mobile/package.json` names run first — five at this reading, since C7.10 C1 added the rich
|
||||
* Markdown editor's document, and the list is read there rather than counted from here because it
|
||||
* grows. The before side is a scratch worktree detached at the same sha, and the
|
||||
* three modules above read out of the after side's list by name rather than inferred from the
|
||||
* total. Measured rather than taken from main's pin because the pin covers only the module count,
|
||||
* so the local count beside it would otherwise be a number nobody had read.
|
||||
|
||||
@@ -2,6 +2,7 @@ node_modules/
|
||||
src/terminal/terminal-webview-engine.generated.ts
|
||||
src/terminal/terminal-webview-engine-css.generated.ts
|
||||
src/terminal/terminal-webview-document-script.generated.ts
|
||||
src/components/rich-markdown-editor-document-script.generated.ts
|
||||
src/components/pr-sidebar/mermaid-webview-engine.generated.ts
|
||||
src/components/pr-sidebar/mermaid-page-engine.generated.ts
|
||||
.expo/
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"start": "node scripts/start-expo.mjs",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs && node scripts/build-mermaid-page-engine.mjs && node scripts/build-terminal-document-script.mjs",
|
||||
"postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs && node scripts/build-mermaid-page-engine.mjs && node scripts/build-terminal-document-script.mjs && node scripts/build-rich-markdown-editor-script.mjs",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:tests": "tsc --noEmit -p tsconfig.test.json",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import esbuild from 'esbuild'
|
||||
|
||||
/**
|
||||
* The in-WebView rich Markdown editor document, bundled from its modules.
|
||||
*
|
||||
* The document is a string the native WebView loads inside its HTML, so it cannot be an ES module
|
||||
* there — and it is ordinary TypeScript everywhere else, which a page imports directly. So this is
|
||||
* one esbuild bundle of the entry that calls `createRichMarkdownEditorDocument()` with no host,
|
||||
* written out as a string, exactly as the terminal's document beside it is.
|
||||
*
|
||||
* `iife`, so the bundle's module scope is its own and nothing it declares reaches the page it is
|
||||
* pasted into. Not minified: the document is read in the WebView's own console, and it is a few
|
||||
* tens of kilobytes rather than an engine.
|
||||
*/
|
||||
const mobileRoot = path.join(import.meta.dirname, '..')
|
||||
|
||||
/** The oldest WebView Orca supports, matching the terminal document's own floor (#7030). */
|
||||
const TARGET = 'chrome74'
|
||||
|
||||
const ENTRY = path.join(
|
||||
mobileRoot,
|
||||
'src',
|
||||
'components',
|
||||
'rich-markdown',
|
||||
'native-document-entry.ts'
|
||||
)
|
||||
|
||||
export const RICH_MARKDOWN_EDITOR_SCRIPT_PATH = path.join(
|
||||
mobileRoot,
|
||||
'src',
|
||||
'components',
|
||||
'rich-markdown-editor-document-script.generated.ts'
|
||||
)
|
||||
|
||||
const GENERATED_HEADER =
|
||||
`// Generated by scripts/build-rich-markdown-editor-script.mjs. Do not edit.\n` +
|
||||
`// The source is mobile/src/components/rich-markdown/, bundled from native-document-entry.ts.\n` +
|
||||
`// Target: ${TARGET}. Regenerate via pnpm postinstall.`
|
||||
|
||||
/**
|
||||
* One options object, so a census of what the bundle contains measures the bundle that ships.
|
||||
*
|
||||
* `absWorkingDir` is load-bearing: esbuild writes each module's path into the bundle as a comment,
|
||||
* relative to the working directory, so without it the artifact's bytes depend on where the
|
||||
* generator was run from — three cwds gave three digests, and from outside the repo the comments
|
||||
* carry an absolute path with the builder's home directory in it.
|
||||
*/
|
||||
export function richMarkdownEditorBuildOptions(extra = {}) {
|
||||
return {
|
||||
absWorkingDir: mobileRoot,
|
||||
entryPoints: [ENTRY],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
minify: false,
|
||||
platform: 'browser',
|
||||
target: TARGET,
|
||||
legalComments: 'none',
|
||||
write: false,
|
||||
logLevel: 'silent',
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One bundle, and everything a reader asks about it.
|
||||
*
|
||||
* The text and the module list come from the same build because they are two readings of one
|
||||
* thing: a census that built its own would answer about a bundle nobody ships.
|
||||
*/
|
||||
export async function richMarkdownEditorBundle() {
|
||||
const result = await esbuild.build(richMarkdownEditorBuildOptions({ metafile: true }))
|
||||
const [output] = result.outputFiles
|
||||
if (!output) {
|
||||
throw new Error('[build-rich-markdown-editor-script] esbuild emitted no document bundle')
|
||||
}
|
||||
return { script: output.text.trimEnd(), inputs: Object.keys(result.metafile.inputs) }
|
||||
}
|
||||
|
||||
export async function buildRichMarkdownEditorScript() {
|
||||
return (await richMarkdownEditorBundle()).script
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const script = await buildRichMarkdownEditorScript()
|
||||
await writeFile(
|
||||
RICH_MARKDOWN_EDITOR_SCRIPT_PATH,
|
||||
`${GENERATED_HEADER}\nexport const RICH_MARKDOWN_EDITOR_DOCUMENT_SCRIPT = ${JSON.stringify(script)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]))) {
|
||||
await main()
|
||||
}
|
||||
@@ -38,9 +38,15 @@ const GENERATED_HEADER =
|
||||
*
|
||||
* `minify: false` is load-bearing beyond readability: the engine-error overlay reports the line and
|
||||
* column `window.onerror` hands it, and a minified document makes both useless.
|
||||
*
|
||||
* So is `absWorkingDir`: esbuild writes each module's path into the bundle as a comment, relative
|
||||
* to the working directory, so without it the artifact's bytes depend on where the generator was
|
||||
* run from — and from outside the repo the comments carry an absolute path with the builder's home
|
||||
* directory in it.
|
||||
*/
|
||||
export function terminalDocumentBuildOptions(extra = {}) {
|
||||
return {
|
||||
absWorkingDir: mobileRoot,
|
||||
entryPoints: [ENTRY],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
// Head of the editor document through the editable surface; the script follows it.
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_BODY = [
|
||||
';',
|
||||
' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;',
|
||||
' --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;',
|
||||
' }',
|
||||
' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }',
|
||||
' html, body {',
|
||||
' width: 100%;',
|
||||
' min-height: 100%;',
|
||||
' margin: 0;',
|
||||
' background: var(--editor-surface);',
|
||||
' color: var(--foreground);',
|
||||
' font-family: var(--font-sans);',
|
||||
' overscroll-behavior: contain;',
|
||||
' }',
|
||||
' body { overflow: auto; }',
|
||||
' #editor {',
|
||||
' min-height: 100vh;',
|
||||
' padding: 18px 16px 112px;',
|
||||
' outline: none;',
|
||||
' font-size: 14px;',
|
||||
' line-height: 1.7;',
|
||||
' word-wrap: break-word;',
|
||||
' overflow-wrap: anywhere;',
|
||||
' caret-color: var(--foreground);',
|
||||
' }',
|
||||
' #editor[contenteditable="false"] {',
|
||||
' opacity: 0.78;',
|
||||
' }',
|
||||
' #editor:empty::before,',
|
||||
' #editor p.is-empty:first-child::before {',
|
||||
' content: attr(data-placeholder);',
|
||||
' color: var(--muted-foreground);',
|
||||
' pointer-events: none;',
|
||||
' }',
|
||||
' #editor > :first-child { margin-top: 0; }',
|
||||
' h1, h2, h3, h4, h5, h6 {',
|
||||
' margin: 1.5em 0 0.5em;',
|
||||
' font-weight: 600;',
|
||||
' line-height: 1.3;',
|
||||
' letter-spacing: 0;',
|
||||
' }',
|
||||
' h1 { font-size: 1.85em; font-weight: 700; }',
|
||||
' h2 { font-size: 1.4em; }',
|
||||
' h3 { font-size: 1.15em; }',
|
||||
' p, ul, ol, blockquote { margin: 0.75em 0; }',
|
||||
' ul, ol { padding-left: 1.5em; }',
|
||||
' ul { list-style: disc; }',
|
||||
' ol { list-style: decimal; }',
|
||||
' li { margin: 0.15em 0; }',
|
||||
' li > p { margin: 0; }',
|
||||
' ul[data-type="taskList"] {',
|
||||
' padding-left: 0;',
|
||||
' list-style: none;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li {',
|
||||
' display: flex;',
|
||||
' align-items: flex-start;',
|
||||
' gap: 6px;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li > label {',
|
||||
' flex-shrink: 0;',
|
||||
' display: flex;',
|
||||
' align-items: center;',
|
||||
' height: 1.55em;',
|
||||
' user-select: none;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"] {',
|
||||
' appearance: none;',
|
||||
' width: 16px;',
|
||||
' height: 16px;',
|
||||
' margin: 0;',
|
||||
' border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);',
|
||||
' border-radius: 4px;',
|
||||
' background: transparent;',
|
||||
' position: relative;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"]:checked {',
|
||||
' background: var(--primary);',
|
||||
' border-color: var(--primary);',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"]:checked::after {',
|
||||
' content: "";',
|
||||
' position: absolute;',
|
||||
' left: 4px;',
|
||||
' top: 1px;',
|
||||
' width: 5px;',
|
||||
' height: 9px;',
|
||||
' border: solid var(--primary-foreground);',
|
||||
' border-width: 0 2px 2px 0;',
|
||||
' transform: rotate(45deg);',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"]:disabled {',
|
||||
' opacity: 0.65;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li > div {',
|
||||
' flex: 1;',
|
||||
' min-width: 0;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li[data-checked="true"] > div {',
|
||||
' text-decoration: line-through;',
|
||||
' color: var(--muted-foreground);',
|
||||
' }',
|
||||
' blockquote {',
|
||||
' padding: 0.5em 1em;',
|
||||
' border-left: 3px solid var(--border);',
|
||||
' border-radius: 0 6px 6px 0;',
|
||||
' color: var(--muted-foreground);',
|
||||
' background: color-mix(in srgb, var(--foreground) 2%, transparent);',
|
||||
' }',
|
||||
' table {',
|
||||
' width: 100%;',
|
||||
' margin: 1em 0;',
|
||||
' border-collapse: collapse;',
|
||||
' font-size: 0.95em;',
|
||||
' }',
|
||||
' th, td {',
|
||||
' padding: 8px 14px;',
|
||||
' border: 1px solid var(--border);',
|
||||
' text-align: left;',
|
||||
' vertical-align: top;',
|
||||
' }',
|
||||
' th {',
|
||||
' font-weight: 600;',
|
||||
' background: color-mix(in srgb, var(--foreground) 4%, transparent);',
|
||||
' }',
|
||||
' tr:nth-child(odd) td {',
|
||||
' background: color-mix(in srgb, var(--foreground) 1.5%, transparent);',
|
||||
' }',
|
||||
' code {',
|
||||
' padding: 0.2em 0.4em;',
|
||||
' border-radius: 5px;',
|
||||
' background: color-mix(in srgb, var(--foreground) 8%, transparent);',
|
||||
' font-size: 0.88em;',
|
||||
' font-family: var(--font-mono);',
|
||||
' }',
|
||||
' pre {',
|
||||
' margin: 0.75em 0;',
|
||||
' padding: 14px 18px;',
|
||||
' border-radius: 8px;',
|
||||
' border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);',
|
||||
' overflow-x: auto;',
|
||||
' line-height: 1.55;',
|
||||
' background: color-mix(in srgb, var(--foreground) 6%, transparent);',
|
||||
' font-family: var(--font-mono);',
|
||||
' white-space: pre-wrap;',
|
||||
' }',
|
||||
' pre::before {',
|
||||
' content: attr(data-language);',
|
||||
' display: block;',
|
||||
' min-height: 13px;',
|
||||
' margin-bottom: 4px;',
|
||||
' color: var(--muted-foreground);',
|
||||
' font-size: 11px;',
|
||||
' text-transform: uppercase;',
|
||||
' }',
|
||||
' pre code {',
|
||||
' padding: 0;',
|
||||
' border-radius: 0;',
|
||||
' background: transparent;',
|
||||
' font-size: 0.92em;',
|
||||
' white-space: pre-wrap;',
|
||||
' }',
|
||||
' hr {',
|
||||
' margin: 1.5em 0;',
|
||||
' border: none;',
|
||||
' border-top: 1px solid var(--border);',
|
||||
' }',
|
||||
' a {',
|
||||
' color: var(--accent-link);',
|
||||
' text-decoration: underline;',
|
||||
' text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);',
|
||||
' text-underline-offset: 2px;',
|
||||
' }',
|
||||
' img {',
|
||||
' display: block;',
|
||||
' max-width: 100%;',
|
||||
' margin: 0.75em 0;',
|
||||
' border-radius: 8px;',
|
||||
' }',
|
||||
' </style>',
|
||||
'</head>',
|
||||
'<body>',
|
||||
' <main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>'
|
||||
].join('\n')
|
||||
@@ -1,90 +0,0 @@
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS = [
|
||||
'',
|
||||
' function runCommand(command) {',
|
||||
" if (!editable || editor.getAttribute('contenteditable') !== 'true') return;",
|
||||
' restoreSelectionOrEnd();',
|
||||
" if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');",
|
||||
" else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');",
|
||||
" else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');",
|
||||
" else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');",
|
||||
" else if (command === 'bold') document.execCommand('bold');",
|
||||
" else if (command === 'italic') document.execCommand('italic');",
|
||||
" else if (command === 'strike') document.execCommand('strikeThrough');",
|
||||
" else if (command === 'bulletList') document.execCommand('insertUnorderedList');",
|
||||
" else if (command === 'orderedList') document.execCommand('insertOrderedList');",
|
||||
" else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');",
|
||||
" else if (command === 'inlineCode') wrapSelection('code');",
|
||||
" else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=\"\"><code>code</code></pre><p><br></p>');",
|
||||
' else if (command === \'taskList\') document.execCommand(\'insertHTML\', false, \'<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>Task</p></div></li></ul>\');',
|
||||
" else if (command === 'link') {",
|
||||
" var href = window.prompt('Link URL');",
|
||||
" if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);",
|
||||
" } else if (command === 'image') {",
|
||||
" var src = window.prompt('Image URL');",
|
||||
" if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);",
|
||||
' }',
|
||||
' syncTaskCheckboxesDisabled();',
|
||||
' emitChange();',
|
||||
' }',
|
||||
'',
|
||||
" editor.addEventListener('input', function () {",
|
||||
' selectionDroppedOnBlur = false;',
|
||||
' if (editable) emitChange();',
|
||||
' });',
|
||||
" editor.addEventListener('change', function (event) {",
|
||||
' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');',
|
||||
' if (input) {',
|
||||
' if (!editable) {',
|
||||
' event.preventDefault();',
|
||||
' return;',
|
||||
' }',
|
||||
" var li = input.closest('li');",
|
||||
" if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');",
|
||||
' }',
|
||||
' if (editable) emitChange();',
|
||||
' });',
|
||||
" editor.addEventListener('click', function (event) {",
|
||||
" var link = event.target && event.target.closest && event.target.closest('a[href]');",
|
||||
' if (link) {',
|
||||
' event.preventDefault();',
|
||||
" post({ type: 'openLink', url: link.getAttribute('href') || '' });",
|
||||
' return;',
|
||||
' }',
|
||||
' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');',
|
||||
' if (!input) {',
|
||||
' if (!editable) return;',
|
||||
' // Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.',
|
||||
' var uneditable = event.target && event.target.closest && event.target.closest(\'[contenteditable="false"]\');',
|
||||
' if (uneditable && uneditable !== editor) return;',
|
||||
' selectionDroppedOnBlur = false;',
|
||||
' if (document.activeElement === editor) return;',
|
||||
' // Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.',
|
||||
' var caret = caretRangeAtPoint(event.clientX, event.clientY);',
|
||||
' focusEditor();',
|
||||
' if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);',
|
||||
' return;',
|
||||
' }',
|
||||
' if (!editable) {',
|
||||
' event.preventDefault();',
|
||||
' return;',
|
||||
' }',
|
||||
" var li = input.closest('li');",
|
||||
" if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');",
|
||||
' emitChange();',
|
||||
' });',
|
||||
" editor.addEventListener('keydown', function (event) {",
|
||||
" if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {",
|
||||
' event.preventDefault();',
|
||||
" runCommand('bold');",
|
||||
' }',
|
||||
' });',
|
||||
'',
|
||||
' window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_END = [
|
||||
'',
|
||||
" post({ type: 'ready' });",
|
||||
' })();'
|
||||
].join('\n')
|
||||
@@ -1,19 +1,57 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildMobileRichMarkdownEditorHtml } from './mobile-rich-markdown-editor-html'
|
||||
import { RICH_MARKDOWN_EDITOR_DOCUMENT_SCRIPT } from './rich-markdown-editor-document-script.generated'
|
||||
|
||||
// Digest of main's document at e80fae0c4d, captured before the body/script split. Splitting the
|
||||
// constants must not move a single byte of what the WebView loads. A hash rather than a
|
||||
// checked-in HTML file, because the formatter would rewrite the file and defeat the check.
|
||||
const PRE_SPLIT_DOCUMENT_SHA256 = '1ef29c8802170800011e8accf1966bc542cdd7dd5c9600bacb6e0860f77b6df8'
|
||||
const PRE_SPLIT_DOCUMENT_BYTES = 29852
|
||||
/**
|
||||
* Everything of the WebView's page that is not the document itself, pinned byte for byte.
|
||||
*
|
||||
* The whole-document digest this file used to carry cannot survive C7.10 C1 and does not need to:
|
||||
* the script is now an esbuild bundle of `src/components/rich-markdown/` rather than seven string
|
||||
* constants a concatenator glued together, so its bytes are the bundler's and the proof that it is
|
||||
* the same *document* is `rich-markdown/native-document-bundle.test.ts`, which runs it.
|
||||
*
|
||||
* What did not move is the page around it — the head, the stylesheet, the markup — and that is
|
||||
* still a byte fact worth holding, because a stray character in the CSS is invisible to every
|
||||
* behavioural test there is.
|
||||
*
|
||||
* Two different digests, so which is which:
|
||||
*
|
||||
* - The one this file used to assert was of main's *whole document*, script included:
|
||||
* `1ef29c8802170800011e8accf1966bc542cdd7dd5c9600bacb6e0860f77b6df8`, 29,852 bytes. It is gone,
|
||||
* and nothing below reproduces it.
|
||||
* - `DOCUMENT_SHELL_SHA256` below is of the *page around the script*, the document with its
|
||||
* `<script>` region emptied. It was taken twice — on main's document and on this one — and the
|
||||
* two readings agreed, which is what says the head, the stylesheet and the markup did not move.
|
||||
*/
|
||||
const DOCUMENT_SHELL_SHA256 = '5054e1d5c87e4ce1805d4856ddc8bf36804e697675e6013d84da453d3e81af25'
|
||||
const DOCUMENT_SHELL_BYTES = 5621
|
||||
|
||||
const SCRIPT_OPEN = ' <script>\n'
|
||||
const SCRIPT_CLOSE = '\n </script>'
|
||||
|
||||
/** The document with its script region emptied, which is what the digest above is of. */
|
||||
function documentShell(html: string): string {
|
||||
const open = html.indexOf(SCRIPT_OPEN) + SCRIPT_OPEN.length
|
||||
const close = html.indexOf(SCRIPT_CLOSE, open)
|
||||
expect(open).toBeGreaterThan(SCRIPT_OPEN.length - 1)
|
||||
expect(close).toBeGreaterThan(open)
|
||||
return html.slice(0, open) + html.slice(close)
|
||||
}
|
||||
|
||||
describe('mobile rich markdown editor document', () => {
|
||||
it('reproduces the pre-split document byte for byte', () => {
|
||||
const document = buildMobileRichMarkdownEditorHtml()
|
||||
expect(Buffer.byteLength(document, 'utf8')).toBe(PRE_SPLIT_DOCUMENT_BYTES)
|
||||
expect(createHash('sha256').update(document, 'utf8').digest('hex')).toBe(
|
||||
PRE_SPLIT_DOCUMENT_SHA256
|
||||
)
|
||||
it('reproduces the page around the document byte for byte', () => {
|
||||
const shell = documentShell(buildMobileRichMarkdownEditorHtml())
|
||||
expect(Buffer.byteLength(shell, 'utf8')).toBe(DOCUMENT_SHELL_BYTES)
|
||||
expect(createHash('sha256').update(shell, 'utf8').digest('hex')).toBe(DOCUMENT_SHELL_SHA256)
|
||||
})
|
||||
|
||||
it('carries the bundled document, whole, as its only script', () => {
|
||||
const html = buildMobileRichMarkdownEditorHtml()
|
||||
expect(html).toContain(`${SCRIPT_OPEN}${RICH_MARKDOWN_EDITOR_DOCUMENT_SCRIPT}${SCRIPT_CLOSE}`)
|
||||
// One script, so the digest above is over the whole of what is not the document.
|
||||
expect(html.split('<script>')).toHaveLength(2)
|
||||
// The precondition for the shell digest: emptying the region actually removes the document.
|
||||
expect(documentShell(html)).not.toContain('createRichMarkdownEditorDocument')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,210 +4,41 @@ import {
|
||||
escapeInjectedJavaScriptString
|
||||
} from './mobile-rich-markdown-editor-html'
|
||||
|
||||
/**
|
||||
* The page the WebView loads, as a page.
|
||||
*
|
||||
* What the document *does* is asserted where it lives, against the modules under `rich-markdown/`
|
||||
* and against the bundle those modules build; this file is about the HTML around it. It used to
|
||||
* carry both, by extracting named functions out of the emitted script with `new Function` — a
|
||||
* harness the bundle ends, because esbuild renames what collides and the functions now take the
|
||||
* scope as their first argument. Every one of those assertions moved to a module test beside the
|
||||
* code it is about, which is where the readers of this file would look for them anyway.
|
||||
*/
|
||||
function editorScript(): string {
|
||||
const html = buildMobileRichMarkdownEditorHtml()
|
||||
const script = html.match(/<script>([\s\S]*)<\/script>/)?.[1]
|
||||
const script = buildMobileRichMarkdownEditorHtml().match(/<script>([\s\S]*)<\/script>/)?.[1]
|
||||
expect(script).toBeTruthy()
|
||||
return script ?? ''
|
||||
}
|
||||
|
||||
function extractBracedSource(script: string, start: number, label: string): string {
|
||||
const bodyStart = script.indexOf('{', start)
|
||||
let depth = 0
|
||||
for (let index = bodyStart; index < script.length; index += 1) {
|
||||
const char = script[index]
|
||||
if (char === '{') {
|
||||
depth += 1
|
||||
}
|
||||
if (char === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
return script.slice(start, index + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not extract ${label}`)
|
||||
}
|
||||
|
||||
function extractFunctionSource(script: string, name: string): string {
|
||||
const start = script.indexOf(`function ${name}`)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
return extractBracedSource(script, start, name)
|
||||
}
|
||||
|
||||
function extractEditorListenerSource(script: string, type: string): string {
|
||||
const start = script.indexOf(`editor.addEventListener('${type}'`)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
return `${extractBracedSource(script, start, `${type} listener`)});`
|
||||
}
|
||||
|
||||
function runtimeMarkdownToHtml(markdown: string, editable: boolean): string {
|
||||
const script = editorScript()
|
||||
const sources = [
|
||||
'var editable = arguments[1];',
|
||||
extractFunctionSource(script, 'decodeMarkdownEntities'),
|
||||
extractFunctionSource(script, 'escapeHtml'),
|
||||
extractFunctionSource(script, 'escapeAttr'),
|
||||
extractFunctionSource(script, 'isSafeUrl'),
|
||||
extractFunctionSource(script, 'splitTableRow'),
|
||||
extractFunctionSource(script, 'isTableSeparator'),
|
||||
extractFunctionSource(script, 'renderInline'),
|
||||
extractFunctionSource(script, 'isBlockStart'),
|
||||
extractFunctionSource(script, 'indentationWidth'),
|
||||
extractFunctionSource(script, 'parseListLine'),
|
||||
extractFunctionSource(script, 'listKind'),
|
||||
extractFunctionSource(script, 'parseListTree'),
|
||||
extractFunctionSource(script, 'renderListItems'),
|
||||
extractFunctionSource(script, 'markdownToHtml'),
|
||||
'return markdownToHtml(arguments[0]);'
|
||||
].join('\n')
|
||||
return new Function(sources)(markdown, editable) as string
|
||||
}
|
||||
|
||||
function runtimeListMarkdown(): (list: unknown) => string {
|
||||
const script = editorScript()
|
||||
const sources = [
|
||||
'function listItemText(li) { return li.text; }',
|
||||
'function directNestedLists(li) { return li.nestedLists || []; }',
|
||||
extractFunctionSource(script, 'listMarkdown'),
|
||||
'return function (list) { return listMarkdown(list, 0); };'
|
||||
].join('\n')
|
||||
return new Function(sources)() as (list: unknown) => string
|
||||
}
|
||||
|
||||
type FakeRange = {
|
||||
commonAncestorContainer: unknown
|
||||
cloneRange: () => FakeRange
|
||||
selectNodeContents: (node: unknown) => void
|
||||
collapse: (toStart: boolean) => void
|
||||
}
|
||||
|
||||
function createFakeRange(container: unknown): FakeRange {
|
||||
const range: FakeRange = {
|
||||
commonAncestorContainer: container,
|
||||
cloneRange: () => createFakeRange(range.commonAncestorContainer),
|
||||
selectNodeContents: (node) => {
|
||||
range.commonAncestorContainer = node
|
||||
},
|
||||
collapse: () => {}
|
||||
}
|
||||
return range
|
||||
}
|
||||
|
||||
type FakeClickEvent = {
|
||||
clientX: number
|
||||
clientY: number
|
||||
target: { closest: (selector: string) => unknown }
|
||||
preventDefault: () => void
|
||||
}
|
||||
|
||||
// Drives the editor's real selection and click handling against a stub DOM whose blur
|
||||
// drops the selection, the way WebKit does.
|
||||
function createSelectionRuntime(caretContainer: string | null) {
|
||||
const liveNodes = new Set(caretContainer ? [caretContainer] : [])
|
||||
const listeners = new Map<string, (event: FakeClickEvent) => void>()
|
||||
let focused = caretContainer != null
|
||||
let ranges: FakeRange[] = caretContainer ? [createFakeRange(caretContainer)] : []
|
||||
let caretAtPoint: string | null = null
|
||||
|
||||
const editor = {
|
||||
contains: (node: unknown) => typeof node === 'string' && liveNodes.has(node),
|
||||
focus: () => {
|
||||
focused = true
|
||||
fakeDocument.activeElement = editor
|
||||
},
|
||||
blur: () => {
|
||||
focused = false
|
||||
fakeDocument.activeElement = null
|
||||
ranges = []
|
||||
},
|
||||
addEventListener: (type: string, handler: (event: FakeClickEvent) => void) => {
|
||||
listeners.set(type, handler)
|
||||
}
|
||||
}
|
||||
const fakeDocument: {
|
||||
activeElement: unknown
|
||||
createRange: () => FakeRange
|
||||
caretRangeFromPoint: () => FakeRange | null
|
||||
} = {
|
||||
activeElement: focused ? editor : null,
|
||||
createRange: () => createFakeRange('detached'),
|
||||
caretRangeFromPoint: () => (caretAtPoint ? createFakeRange(caretAtPoint) : null)
|
||||
}
|
||||
const fakeWindow = {
|
||||
getSelection: () => ({
|
||||
get rangeCount() {
|
||||
return ranges.length
|
||||
},
|
||||
getRangeAt: (index: number) => ranges[index],
|
||||
removeAllRanges: () => {
|
||||
ranges = []
|
||||
},
|
||||
addRange: (range: FakeRange) => {
|
||||
ranges = [range]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const script = editorScript()
|
||||
const sources = [
|
||||
'var editor = arguments[0];',
|
||||
'var document = arguments[1];',
|
||||
'var window = arguments[2];',
|
||||
'var editable = true;',
|
||||
'var savedSelectionRange = null;',
|
||||
'var selectionDroppedOnBlur = false;',
|
||||
'function post() {}',
|
||||
'function emitChange() {}',
|
||||
extractFunctionSource(script, 'focusEditor'),
|
||||
extractFunctionSource(script, 'rememberSelection'),
|
||||
extractFunctionSource(script, 'applySelectionRange'),
|
||||
extractFunctionSource(script, 'caretRangeAtPoint'),
|
||||
extractFunctionSource(script, 'dismissKeyboard'),
|
||||
extractFunctionSource(script, 'restoreSelectionOrEnd'),
|
||||
extractEditorListenerSource(script, 'click'),
|
||||
'return { dismissKeyboard: dismissKeyboard, restoreSelectionOrEnd: restoreSelectionOrEnd };'
|
||||
].join('\n')
|
||||
const api = new Function(sources)(editor, fakeDocument, fakeWindow) as {
|
||||
dismissKeyboard: () => void
|
||||
restoreSelectionOrEnd: () => void
|
||||
}
|
||||
|
||||
return {
|
||||
dismissKeyboard: api.dismissKeyboard,
|
||||
restoreSelectionOrEnd: api.restoreSelectionOrEnd,
|
||||
tapAt: (container: string, options?: { uneditableAncestor?: string }) => {
|
||||
liveNodes.add(container)
|
||||
caretAtPoint = container
|
||||
listeners.get('click')?.({
|
||||
clientX: 12,
|
||||
clientY: 34,
|
||||
target: {
|
||||
closest: (selector: string) =>
|
||||
selector === '[contenteditable="false"]' ? (options?.uneditableAncestor ?? null) : null
|
||||
},
|
||||
preventDefault: () => {}
|
||||
})
|
||||
},
|
||||
detachEditorContent: () => liveNodes.clear(),
|
||||
selectedContainer: () => {
|
||||
if (ranges.length === 0) {
|
||||
return null
|
||||
}
|
||||
const container = ranges[0].commonAncestorContainer
|
||||
return container === editor ? 'editor-end' : (container as string)
|
||||
},
|
||||
get focused() {
|
||||
return focused
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile rich markdown editor HTML', () => {
|
||||
it('builds parseable WebView JavaScript', () => {
|
||||
const script = editorScript()
|
||||
expect(() => new Function(editorScript())).not.toThrow()
|
||||
})
|
||||
|
||||
expect(() => new Function(script)).not.toThrow()
|
||||
it('carries the editable surface the document reaches for, once', () => {
|
||||
const html = buildMobileRichMarkdownEditorHtml()
|
||||
expect(html).toContain('<main id="editor" contenteditable="true"')
|
||||
expect(html.split('id="editor"')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('declares the theme variables its stylesheet reads', () => {
|
||||
// The document's colours are the app's own, interpolated when the page is built; a variable
|
||||
// the stylesheet uses and the `:root` block never declares renders as nothing at all.
|
||||
const style = buildMobileRichMarkdownEditorHtml().match(/<style>([\s\S]*?)<\/style>/)?.[1] ?? ''
|
||||
const declared = new Set([...style.matchAll(/^\s*(--[a-z-]+):/gm)].map((match) => match[1]))
|
||||
const used = new Set([...style.matchAll(/var\((--[a-z-]+)\)/g)].map((match) => match[1]))
|
||||
expect([...used].filter((name) => !declared.has(name))).toEqual([])
|
||||
expect(declared.size).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('escapes injected markdown without reopening script tags', () => {
|
||||
@@ -219,216 +50,9 @@ describe('mobile rich markdown editor HTML', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('renders and serializes nested bullet, ordered, and task lists with indentation intact', () => {
|
||||
const markdown = [
|
||||
'- Parent',
|
||||
' 1. Ordered child',
|
||||
' - [x] Done task',
|
||||
' - [ ] Open task',
|
||||
'- Sibling'
|
||||
].join('\n')
|
||||
|
||||
const html = runtimeMarkdownToHtml(markdown, true)
|
||||
|
||||
expect(html).toContain(
|
||||
'<ul><li><p>Parent</p><ol start="1"><li value="1" data-list-number="1"><p>Ordered child</p>'
|
||||
)
|
||||
expect(html).toContain('<ul data-type="taskList">')
|
||||
expect(html).toContain('<li><p>Sibling</p></li></ul>')
|
||||
|
||||
const listMarkdown = runtimeListMarkdown()
|
||||
const fakeList = {
|
||||
tagName: 'UL',
|
||||
getAttribute: () => null,
|
||||
children: [
|
||||
{
|
||||
tagName: 'LI',
|
||||
text: 'Parent',
|
||||
querySelector: () => null,
|
||||
nestedLists: [
|
||||
{
|
||||
tagName: 'OL',
|
||||
getAttribute: () => null,
|
||||
children: [
|
||||
{
|
||||
tagName: 'LI',
|
||||
text: 'Ordered child',
|
||||
querySelector: () => null,
|
||||
nestedLists: [
|
||||
{
|
||||
tagName: 'UL',
|
||||
getAttribute: (name: string) => (name === 'data-type' ? 'taskList' : null),
|
||||
children: [
|
||||
{
|
||||
tagName: 'LI',
|
||||
text: 'Done task',
|
||||
querySelector: () => ({ checked: true }),
|
||||
nestedLists: []
|
||||
},
|
||||
{
|
||||
tagName: 'LI',
|
||||
text: 'Open task',
|
||||
querySelector: () => ({ checked: false }),
|
||||
nestedLists: []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ tagName: 'LI', text: 'Sibling', querySelector: () => null, nestedLists: [] }
|
||||
]
|
||||
}
|
||||
|
||||
expect(listMarkdown(fakeList)).toBe(markdown)
|
||||
})
|
||||
|
||||
it('renders markdown entities as characters without double-escaping them', () => {
|
||||
const html = runtimeMarkdownToHtml('R&D & Sales and <tag>', true)
|
||||
|
||||
expect(html).toContain('R&D & Sales and <tag>')
|
||||
expect(html).not.toContain('&amp;')
|
||||
})
|
||||
|
||||
it('preserves explicit ordered-list numbering during serialization', () => {
|
||||
const markdown = ['3. Third step', '4. Fourth step'].join('\n')
|
||||
const html = runtimeMarkdownToHtml(markdown, true)
|
||||
|
||||
expect(html).toContain('<ol start="3">')
|
||||
expect(html).toContain('data-list-number="3"')
|
||||
|
||||
const listMarkdown = runtimeListMarkdown()
|
||||
const fakeList = {
|
||||
tagName: 'OL',
|
||||
getAttribute: () => null,
|
||||
children: [
|
||||
{
|
||||
tagName: 'LI',
|
||||
text: 'Third step',
|
||||
getAttribute: (name: string) => (name === 'data-list-number' ? '3' : null),
|
||||
querySelector: () => null,
|
||||
nestedLists: []
|
||||
},
|
||||
{
|
||||
tagName: 'LI',
|
||||
text: 'Fourth step',
|
||||
getAttribute: (name: string) => (name === 'data-list-number' ? '4' : null),
|
||||
querySelector: () => null,
|
||||
nestedLists: []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
expect(listMarkdown(fakeList)).toBe(markdown)
|
||||
})
|
||||
|
||||
it('serializes ordered lists from parent start when item metadata is missing', () => {
|
||||
const listMarkdown = runtimeListMarkdown()
|
||||
const fakeList = {
|
||||
tagName: 'OL',
|
||||
getAttribute: (name: string) => (name === 'start' ? '8' : null),
|
||||
children: [
|
||||
{
|
||||
tagName: 'LI',
|
||||
text: 'Pasted step',
|
||||
getAttribute: () => null,
|
||||
querySelector: () => null,
|
||||
nestedLists: []
|
||||
},
|
||||
{
|
||||
tagName: 'LI',
|
||||
text: 'Inserted step',
|
||||
getAttribute: () => null,
|
||||
querySelector: () => null,
|
||||
nestedLists: []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
expect(listMarkdown(fakeList)).toBe(['8. Pasted step', '9. Inserted step'].join('\n'))
|
||||
})
|
||||
|
||||
it('renders task checkboxes as disabled while read-only and guards mutation emitters', () => {
|
||||
const html = runtimeMarkdownToHtml('- [ ] Read-only task', false)
|
||||
const script = editorScript()
|
||||
|
||||
expect(html).toContain('type="checkbox" disabled')
|
||||
expect(extractFunctionSource(script, 'emitChange')).toContain('suppressInput || !editable')
|
||||
expect(extractFunctionSource(script, 'setEditable')).toContain('syncTaskCheckboxesDisabled')
|
||||
})
|
||||
|
||||
it('carries document generations through content replacement and change messages', () => {
|
||||
const script = editorScript()
|
||||
const setMarkdown = extractFunctionSource(script, 'setMarkdown')
|
||||
const emitChange = extractFunctionSource(script, 'emitChange')
|
||||
|
||||
expect(setMarkdown).toContain('window.clearTimeout(inputTimer)')
|
||||
expect(setMarkdown).toContain('documentGeneration = Number(generation) || 0')
|
||||
expect(emitChange).toContain('var pendingGeneration = documentGeneration')
|
||||
expect(emitChange).not.toContain('window.setTimeout')
|
||||
expect(emitChange).toContain('generation: pendingGeneration')
|
||||
})
|
||||
|
||||
it('exposes a keyboard dismissal command that blurs WebView focus', () => {
|
||||
const script = editorScript()
|
||||
const dismissKeyboard = extractFunctionSource(script, 'dismissKeyboard')
|
||||
|
||||
expect(dismissKeyboard).toContain('rememberSelection()')
|
||||
expect(dismissKeyboard).toContain('document.activeElement.blur()')
|
||||
expect(dismissKeyboard).toContain('editor.blur()')
|
||||
expect(script).toContain('dismissKeyboard: dismissKeyboard')
|
||||
})
|
||||
|
||||
it('reclaims editor focus at the tapped caret after keyboard dismissal', () => {
|
||||
const runtime = createSelectionRuntime('paragraph-3')
|
||||
|
||||
runtime.dismissKeyboard()
|
||||
runtime.tapAt('paragraph-7')
|
||||
|
||||
expect(runtime.focused).toBe(true)
|
||||
expect(runtime.selectedContainer()).toBe('paragraph-7')
|
||||
})
|
||||
|
||||
it('leaves task-list label taps to the checkbox instead of refocusing the editor', () => {
|
||||
const runtime = createSelectionRuntime('paragraph-3')
|
||||
|
||||
runtime.dismissKeyboard()
|
||||
runtime.tapAt('paragraph-7', { uneditableAncestor: 'task-label' })
|
||||
|
||||
expect(runtime.focused).toBe(false)
|
||||
expect(runtime.selectedContainer()).toBe(null)
|
||||
})
|
||||
|
||||
it('restores the pre-dismissal caret so commands do not insert at the document end', () => {
|
||||
const runtime = createSelectionRuntime('paragraph-3')
|
||||
|
||||
runtime.dismissKeyboard()
|
||||
expect(runtime.selectedContainer()).toBe(null)
|
||||
|
||||
runtime.restoreSelectionOrEnd()
|
||||
|
||||
expect(runtime.selectedContainer()).toBe('paragraph-3')
|
||||
expect(runtime.focused).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the document end when no caret was ever placed', () => {
|
||||
const runtime = createSelectionRuntime(null)
|
||||
|
||||
runtime.restoreSelectionOrEnd()
|
||||
|
||||
expect(runtime.selectedContainer()).toBe('editor-end')
|
||||
})
|
||||
|
||||
it('drops a remembered caret whose nodes left the document', () => {
|
||||
const runtime = createSelectionRuntime('paragraph-3')
|
||||
|
||||
runtime.dismissKeyboard()
|
||||
runtime.detachEditorContent()
|
||||
runtime.restoreSelectionOrEnd()
|
||||
|
||||
expect(runtime.selectedContainer()).toBe('editor-end')
|
||||
it('reaches the document through the handle the escaping is for', () => {
|
||||
// The native transport is a script evaluated in this page, so the only untrusted text that
|
||||
// crosses into it is what goes through the escape above.
|
||||
expect(editorScript()).toContain('window.__orcaRichMarkdown =')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_BODY } from './mobile-rich-markdown-editor-document-body'
|
||||
import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT } from './mobile-rich-markdown-editor-script'
|
||||
import { RICH_MARKDOWN_EDITOR_DOCUMENT_SCRIPT } from './rich-markdown-editor-document-script.generated'
|
||||
import { RICH_MARKDOWN_EDITOR_MARKUP } from './rich-markdown/document-markup'
|
||||
import { richMarkdownEditorStyle } from './rich-markdown/document-style'
|
||||
|
||||
export { escapeInjectedJavaScriptString } from './mobile-rich-markdown-editor-script-string'
|
||||
export { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT } from './mobile-rich-markdown-editor-script'
|
||||
|
||||
/**
|
||||
* The page the WebView loads: the document's stylesheet, its markup, and the document itself.
|
||||
*
|
||||
* The script is the bundle `scripts/build-rich-markdown-editor-script.mjs` writes from
|
||||
* `src/components/rich-markdown/`, which is the same program a page mounts by importing those
|
||||
* modules. Nothing is escaped into it: it is emitted TypeScript rather than content, and the only
|
||||
* text that crosses into this document at runtime is the markdown the host injects, which
|
||||
* `escapeInjectedJavaScriptString` handles at the call.
|
||||
*/
|
||||
export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
@@ -12,19 +20,13 @@ export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--background: ${colors.bgBase};
|
||||
--editor-surface: ${colors.bgBase};
|
||||
--foreground: ${colors.textPrimary};
|
||||
--muted-foreground: ${colors.textSecondary};
|
||||
--muted: ${colors.bgRaised};
|
||||
--border: ${colors.borderSubtle};
|
||||
--primary: ${colors.textPrimary};
|
||||
--primary-foreground: ${colors.bgBase};
|
||||
--accent-link: ${colors.accentBlue}${MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_BODY}
|
||||
${richMarkdownEditorStyle()}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${RICH_MARKDOWN_EDITOR_MARKUP}
|
||||
<script>
|
||||
${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT}
|
||||
${RICH_MARKDOWN_EDITOR_DOCUMENT_SCRIPT}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// In-page script that reports the height covered by the on-screen keyboard.
|
||||
// Native Keyboard events are unreliable while focus lives in the editor
|
||||
// WebView, so measure the covered region directly from visualViewport and let
|
||||
// RN lift its native Save/Discard bar above it.
|
||||
export function normalizeMobileRichMarkdownKeyboardInset(value: number): number | null {
|
||||
if (!Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
return Math.max(0, Math.round(value))
|
||||
}
|
||||
|
||||
export const MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT = `
|
||||
var lastInset = -1;
|
||||
function reportKeyboardInset() {
|
||||
var viewport = window.visualViewport;
|
||||
var bottom = viewport
|
||||
? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)
|
||||
: 0;
|
||||
var rounded = Math.round(bottom);
|
||||
if (rounded === lastInset) return;
|
||||
lastInset = rounded;
|
||||
post({ type: 'keyboardInset', bottom: rounded });
|
||||
}
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', reportKeyboardInset);
|
||||
window.visualViewport.addEventListener('scroll', reportKeyboardInset);
|
||||
reportKeyboardInset();
|
||||
}`
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset-script'
|
||||
import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset'
|
||||
|
||||
describe('normalizeMobileRichMarkdownKeyboardInset', () => {
|
||||
it('rounds finite inset measurements for native layout', () => {
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* The inset the document reports, as the host reads it.
|
||||
*
|
||||
* Native `Keyboard` events under-report the area covered while focus lives inside the editor's
|
||||
* WebView, so the document measures the covered region itself and posts it. This is the host's
|
||||
* half: a measurement that is not a finite number is no measurement, and the caller keeps the
|
||||
* inset it had rather than lifting its bar by `NaN`.
|
||||
*/
|
||||
export function normalizeMobileRichMarkdownKeyboardInset(value: number): number | null {
|
||||
if (!Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
return Math.max(0, Math.round(value))
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_PRIMARY = [
|
||||
' (function () {',
|
||||
" var editor = document.getElementById('editor');",
|
||||
" var lastMarkdown = '';",
|
||||
' var inputTimer = null;',
|
||||
' var documentGeneration = 0;',
|
||||
' var editable = true;',
|
||||
' var suppressInput = false;',
|
||||
'',
|
||||
' function post(message) {',
|
||||
' window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));',
|
||||
' }',
|
||||
'',
|
||||
' function decodeMarkdownEntities(value) {',
|
||||
' return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {',
|
||||
' var lower = String(entity).toLowerCase();',
|
||||
" if (lower === 'amp') return '&';",
|
||||
" if (lower === 'lt') return '<';",
|
||||
" if (lower === 'gt') return '>';",
|
||||
" if (lower === 'quot') return '\"';",
|
||||
" if (lower === 'apos') return \"'\";",
|
||||
" if (lower.indexOf('#x') === 0) {",
|
||||
' var hex = Number.parseInt(lower.slice(2), 16);',
|
||||
' return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;',
|
||||
' }',
|
||||
" if (lower.indexOf('#') === 0) {",
|
||||
' var code = Number.parseInt(lower.slice(1), 10);',
|
||||
' return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;',
|
||||
' }',
|
||||
' return match;',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function escapeHtml(value) {',
|
||||
' return decodeMarkdownEntities(value).replace(/[&<>"\']/g, function (char) {',
|
||||
" return ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[char];",
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function escapeAttr(value) {',
|
||||
" return escapeHtml(value).replace(/\\n/g, ' ');",
|
||||
' }',
|
||||
'',
|
||||
' function isSafeUrl(value) {',
|
||||
" var trimmed = String(value || '').trim();",
|
||||
' return !/^javascript:/i.test(trimmed);',
|
||||
' }',
|
||||
'',
|
||||
' function splitTableRow(line) {',
|
||||
" return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {",
|
||||
' return cell.trim();',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function isTableSeparator(line) {',
|
||||
' var cells = splitTableRow(line);',
|
||||
' return cells.length > 0 && cells.every(function (cell) {',
|
||||
' return /^:?-{3,}:?$/.test(cell);',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function renderInline(text) {',
|
||||
" var output = '';",
|
||||
' var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|`[^`]+`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;',
|
||||
' var lastIndex = 0;',
|
||||
' var match;',
|
||||
' while ((match = pattern.exec(text))) {',
|
||||
' output += escapeHtml(text.slice(lastIndex, match.index));',
|
||||
' var token = match[0];',
|
||||
' var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);',
|
||||
' var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);',
|
||||
' if (image && isSafeUrl(image[2])) {',
|
||||
" output += '<img src=\"' + escapeAttr(image[2]) + '\" alt=\"' + escapeAttr(image[1] || '') + '\" />';",
|
||||
' } else if (link && isSafeUrl(link[2])) {',
|
||||
" output += '<a href=\"' + escapeAttr(link[2]) + '\">' + renderInline(link[1]) + '</a>';",
|
||||
' } else if (/^https?:\\/\\//i.test(token)) {',
|
||||
" output += '<a href=\"' + escapeAttr(token) + '\">' + escapeHtml(token) + '</a>';",
|
||||
" } else if (token.indexOf('`') === 0) {",
|
||||
" output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';",
|
||||
" } else if (token.indexOf('~~') === 0) {",
|
||||
" output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';",
|
||||
" } else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {",
|
||||
" output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';",
|
||||
' } else {',
|
||||
" output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';",
|
||||
' }',
|
||||
' lastIndex = pattern.lastIndex;',
|
||||
' }',
|
||||
' output += escapeHtml(text.slice(lastIndex));',
|
||||
' return output;',
|
||||
' }',
|
||||
'',
|
||||
' function isBlockStart(line) {',
|
||||
' return /^(```|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);'
|
||||
].join('\n')
|
||||
@@ -1,278 +0,0 @@
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_SECONDARY = [
|
||||
' }',
|
||||
'',
|
||||
' function indentationWidth(value) {',
|
||||
" return String(value || '').replace(/\\t/g, ' ').length;",
|
||||
' }',
|
||||
'',
|
||||
' function parseListLine(line) {',
|
||||
" var match = String(line || '').match(/^(\\s*)((?:[-*+])|(?:\\d+[.)]))\\s+(.+)$/);",
|
||||
' if (!match) return null;',
|
||||
" var rawText = match[3] || '';",
|
||||
' var task = rawText.match(/^\\[([ xX])\\]\\s+(.+)$/);',
|
||||
' return {',
|
||||
" indent: indentationWidth(match[1] || ''),",
|
||||
" ordered: /^\\d/.test(match[2] || ''),",
|
||||
" orderedNumber: /^\\d/.test(match[2] || '') ? Number.parseInt(match[2], 10) : null,",
|
||||
" task: task ? task[1].toLowerCase() === 'x' : null,",
|
||||
' text: task ? task[2] : rawText,',
|
||||
' children: []',
|
||||
' };',
|
||||
' }',
|
||||
'',
|
||||
' function listKind(item) {',
|
||||
" if (item.task !== null) return 'task';",
|
||||
" return item.ordered ? 'ol' : 'ul';",
|
||||
' }',
|
||||
'',
|
||||
' function parseListTree(lines, startIndex) {',
|
||||
' var root = { indent: -1, children: [] };',
|
||||
' var stack = [root];',
|
||||
' var index = startIndex;',
|
||||
' while (index < lines.length) {',
|
||||
" var item = parseListLine(lines[index] || '');",
|
||||
' if (!item) break;',
|
||||
' while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {',
|
||||
' stack.pop();',
|
||||
' }',
|
||||
' stack[stack.length - 1].children.push(item);',
|
||||
' stack.push(item);',
|
||||
' index += 1;',
|
||||
' }',
|
||||
' return { items: root.children, nextIndex: index };',
|
||||
' }',
|
||||
'',
|
||||
' function renderListItems(items) {',
|
||||
' var html = [];',
|
||||
' var index = 0;',
|
||||
' while (index < items.length) {',
|
||||
' var kind = listKind(items[index]);',
|
||||
' var group = [];',
|
||||
' while (index < items.length && listKind(items[index]) === kind) {',
|
||||
' group.push(items[index]);',
|
||||
' index += 1;',
|
||||
' }',
|
||||
" var tag = kind === 'ol' ? 'ol' : 'ul';",
|
||||
" var attrs = kind === 'task' ? ' data-type=\"taskList\"' : kind === 'ol' && group[0].orderedNumber !== null ? ' start=\"' + group[0].orderedNumber + '\"' : '';",
|
||||
" html.push('<' + tag + attrs + '>' + group.map(function (item) {",
|
||||
" var children = item.children.length ? renderListItems(item.children) : '';",
|
||||
" if (kind === 'task') {",
|
||||
' var checked = item.task === true;',
|
||||
" return '<li data-checked=\"' + String(checked) + '\"><label contenteditable=\"false\"><input type=\"checkbox\" ' + (checked ? 'checked ' : '') + (editable ? '' : 'disabled ') + '/></label><div><p>' + renderInline(item.text) + '</p>' + children + '</div></li>';",
|
||||
' }',
|
||||
" var orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ' value=\"' + item.orderedNumber + '\" data-list-number=\"' + item.orderedNumber + '\"' : '';",
|
||||
" return '<li' + orderedAttrs + '><p>' + renderInline(item.text) + '</p>' + children + '</li>';",
|
||||
" }).join('') + '</' + tag + '>');",
|
||||
' }',
|
||||
" return html.join('');",
|
||||
' }',
|
||||
'',
|
||||
' function markdownToHtml(markdown) {',
|
||||
" var lines = String(markdown || '').replace(/\\r\\n?/g, '\\n').split('\\n');",
|
||||
' var html = [];',
|
||||
' var index = 0;',
|
||||
' while (index < lines.length) {',
|
||||
" var line = lines[index] || '';",
|
||||
' if (!line.trim()) {',
|
||||
' index += 1;',
|
||||
' continue;',
|
||||
' }',
|
||||
' var fence = line.match(/^\\```([^\\s`]*)\\s*$/);',
|
||||
' if (fence) {',
|
||||
' index += 1;',
|
||||
' var code = [];',
|
||||
" while (index < lines.length && !/^\\```\\s*$/.test(lines[index] || '')) {",
|
||||
" code.push(lines[index] || '');",
|
||||
' index += 1;',
|
||||
' }',
|
||||
' if (index < lines.length) index += 1;',
|
||||
" html.push('<pre data-language=\"' + escapeAttr(fence[1] || '') + '\"><code>' + escapeHtml(code.join('\\n')) + '</code></pre>');",
|
||||
' continue;',
|
||||
' }',
|
||||
' if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {',
|
||||
" html.push('<hr />');",
|
||||
' index += 1;',
|
||||
' continue;',
|
||||
' }',
|
||||
" if (line.indexOf('|') >= 0 && index + 1 < lines.length && isTableSeparator(lines[index + 1] || '')) {",
|
||||
' var headers = splitTableRow(line);',
|
||||
' index += 2;',
|
||||
' var rows = [];',
|
||||
" while (index < lines.length && (lines[index] || '').indexOf('|') >= 0 && (lines[index] || '').trim()) {",
|
||||
" rows.push(splitTableRow(lines[index] || ''));",
|
||||
' index += 1;',
|
||||
' }',
|
||||
" html.push('<table><thead><tr>' + headers.map(function (cell) { return '<th>' + renderInline(cell) + '</th>'; }).join('') + '</tr></thead><tbody>' + rows.map(function (row) {",
|
||||
" return '<tr>' + headers.map(function (_, cellIndex) { return '<td>' + renderInline(row[cellIndex] || '') + '</td>'; }).join('') + '</tr>';",
|
||||
" }).join('') + '</tbody></table>');",
|
||||
' continue;',
|
||||
' }',
|
||||
' var heading = line.match(/^(#{1,6})\\s+(.+)$/);',
|
||||
' if (heading) {',
|
||||
" html.push('<h' + heading[1].length + '>' + renderInline(heading[2].trim()) + '</h' + heading[1].length + '>');",
|
||||
' index += 1;',
|
||||
' continue;',
|
||||
' }',
|
||||
' if (/^>\\s?/.test(line)) {',
|
||||
' var quote = [];',
|
||||
" while (index < lines.length && /^>\\s?/.test(lines[index] || '')) {",
|
||||
" quote.push((lines[index] || '').replace(/^>\\s?/, ''));",
|
||||
' index += 1;',
|
||||
' }',
|
||||
" html.push('<blockquote><p>' + renderInline(quote.join('\\n').trim()).replace(/\\n/g, '<br />') + '</p></blockquote>');",
|
||||
' continue;',
|
||||
' }',
|
||||
' if (/^\\s*(?:[-*+]|\\d+[.)])\\s+/.test(line)) {',
|
||||
' var list = parseListTree(lines, index);',
|
||||
' html.push(renderListItems(list.items));',
|
||||
' index = list.nextIndex;',
|
||||
' continue;',
|
||||
' }',
|
||||
' var paragraph = [];',
|
||||
" while (index < lines.length && (lines[index] || '').trim() && !isBlockStart(lines[index] || '') && !(index + 1 < lines.length && (lines[index] || '').indexOf('|') >= 0 && isTableSeparator(lines[index + 1] || ''))) {",
|
||||
" paragraph.push(lines[index] || '');",
|
||||
' index += 1;',
|
||||
' }',
|
||||
" html.push('<p>' + renderInline(paragraph.join('\\n')).replace(/\\n/g, '<br />') + '</p>');",
|
||||
' }',
|
||||
" return html.join('\\n') || '<p class=\"is-empty\"><br /></p>';",
|
||||
' }',
|
||||
'',
|
||||
' function textContent(node) {',
|
||||
" return (node.textContent || '').replace(/\\u00a0/g, ' ');",
|
||||
' }',
|
||||
'',
|
||||
' function inlineMarkdown(node) {',
|
||||
" if (!node) return '';",
|
||||
' if (node.nodeType === Node.TEXT_NODE) return textContent(node);',
|
||||
" if (node.nodeType !== Node.ELEMENT_NODE) return '';",
|
||||
' var el = node;',
|
||||
' var tag = el.tagName.toLowerCase();',
|
||||
" if (tag === 'br') return '\\n';",
|
||||
" if (tag === 'strong' || tag === 'b') return '**' + inlineChildren(el) + '**';",
|
||||
" if (tag === 'em' || tag === 'i') return '*' + inlineChildren(el) + '*';",
|
||||
" if (tag === 's' || tag === 'del' || tag === 'strike') return '~~' + inlineChildren(el) + '~~';",
|
||||
" if (tag === 'code' && el.parentElement && el.parentElement.tagName.toLowerCase() !== 'pre') return '`' + textContent(el) + '`';",
|
||||
" if (tag === 'a') return '[' + inlineChildren(el) + '](' + (el.getAttribute('href') || '') + ')';",
|
||||
" if (tag === 'img') return ' || '') + ')';",
|
||||
" if (tag === 'label') return '';",
|
||||
' return inlineChildren(el);',
|
||||
' }',
|
||||
'',
|
||||
' function inlineChildren(el) {',
|
||||
" return Array.prototype.map.call(el.childNodes, inlineMarkdown).join('');",
|
||||
' }',
|
||||
'',
|
||||
' function listItemText(li) {',
|
||||
' var clone = li.cloneNode(true);',
|
||||
" Array.prototype.forEach.call(clone.querySelectorAll('label'), function (label) { label.remove(); });",
|
||||
" Array.prototype.forEach.call(clone.querySelectorAll('ul, ol'), function (list) { list.remove(); });",
|
||||
' return inlineChildren(clone).trim();',
|
||||
' }',
|
||||
'',
|
||||
' function directNestedLists(li) {',
|
||||
" return Array.prototype.filter.call(li.querySelectorAll('ul, ol'), function (list) {",
|
||||
" return list.closest('li') === li;",
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function listMarkdown(el, depth) {',
|
||||
' var tag = el.tagName.toLowerCase();',
|
||||
" var isTask = el.getAttribute('data-type') === 'taskList';",
|
||||
" var orderedStart = tag === 'ol' ? Number.parseInt(el.getAttribute('start') || '1', 10) : 1;",
|
||||
' if (!Number.isFinite(orderedStart)) orderedStart = 1;',
|
||||
" var indent = ' '.repeat(depth);",
|
||||
' return Array.prototype.map.call(el.children, function (li, index) {',
|
||||
" if (li.tagName.toLowerCase() !== 'li') return '';",
|
||||
' var marker;',
|
||||
' if (isTask) {',
|
||||
' var input = li.querySelector(\'input[type="checkbox"]\');',
|
||||
" marker = '- [' + (input && input.checked ? 'x' : ' ') + '] ';",
|
||||
' } else {',
|
||||
' var listNumber =',
|
||||
' li.getAttribute &&',
|
||||
" (li.getAttribute('data-list-number') || li.getAttribute('value'));",
|
||||
" marker = tag === 'ol' ? String(listNumber || orderedStart + index) + '. ' : '- ';",
|
||||
' }',
|
||||
' var line = indent + marker + listItemText(li);',
|
||||
' var nested = directNestedLists(li).map(function (list) {',
|
||||
' return listMarkdown(list, depth + 1);',
|
||||
" }).filter(Boolean).join('\\n');",
|
||||
" return nested ? line + '\\n' + nested : line;",
|
||||
" }).filter(Boolean).join('\\n');",
|
||||
' }',
|
||||
'',
|
||||
' function blockMarkdown(node) {',
|
||||
' if (node.nodeType === Node.TEXT_NODE) return textContent(node).trim();',
|
||||
" if (node.nodeType !== Node.ELEMENT_NODE) return '';",
|
||||
' var el = node;',
|
||||
' var tag = el.tagName.toLowerCase();',
|
||||
" if (tag.match(/^h[1-6]$/)) return '#'.repeat(Number(tag.slice(1))) + ' ' + inlineChildren(el).trim();",
|
||||
" if (tag === 'p' || tag === 'div') return inlineChildren(el).trim();",
|
||||
" if (tag === 'blockquote') {",
|
||||
" return inlineChildren(el).trim().split('\\n').map(function (line) { return '> ' + line; }).join('\\n');",
|
||||
' }',
|
||||
" if (tag === 'pre') {",
|
||||
" var lang = el.getAttribute('data-language') || '';",
|
||||
" var code = textContent(el.querySelector('code') || el).replace(/\\n+$/g, '');",
|
||||
" return '```' + lang + '\\n' + code + '\\n```';",
|
||||
' }',
|
||||
" if (tag === 'ul' || tag === 'ol') {",
|
||||
' return listMarkdown(el, 0);',
|
||||
' }',
|
||||
" if (tag === 'table') {",
|
||||
" var rows = Array.prototype.slice.call(el.querySelectorAll('tr'));",
|
||||
" if (rows.length === 0) return '';",
|
||||
' var cellsFor = function (row) {',
|
||||
' return Array.prototype.map.call(row.children, function (cell) { return inlineChildren(cell).trim(); });',
|
||||
' };',
|
||||
' var headers = cellsFor(rows[0]);',
|
||||
' var bodyRows = rows.slice(1).map(cellsFor);',
|
||||
" return '| ' + headers.join(' | ') + ' |\\n| ' + headers.map(function () { return '---'; }).join(' | ') + ' |' + (bodyRows.length ? '\\n' + bodyRows.map(function (row) { return '| ' + row.join(' | ') + ' |'; }).join('\\n') : '');",
|
||||
' }',
|
||||
" if (tag === 'hr') return '---';",
|
||||
" if (tag === 'img') return inlineMarkdown(el);",
|
||||
' return inlineChildren(el).trim();',
|
||||
' }',
|
||||
'',
|
||||
' function currentMarkdown() {',
|
||||
' return Array.prototype.map.call(editor.childNodes, blockMarkdown).filter(function (block) {',
|
||||
' return block.trim().length > 0;',
|
||||
" }).join('\\n\\n').trimEnd();",
|
||||
' }',
|
||||
'',
|
||||
' function syncTaskCheckboxesDisabled() {',
|
||||
' Array.prototype.forEach.call(editor.querySelectorAll(\'input[type="checkbox"]\'), function (input) {',
|
||||
' input.disabled = !editable;',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function emitChange() {',
|
||||
' if (suppressInput || !editable) return;',
|
||||
' window.clearTimeout(inputTimer);',
|
||||
' var pendingGeneration = documentGeneration;',
|
||||
' lastMarkdown = currentMarkdown();',
|
||||
" post({ type: 'change', markdown: lastMarkdown, generation: pendingGeneration });",
|
||||
' }',
|
||||
'',
|
||||
' function setMarkdown(markdown, generation) {',
|
||||
' window.clearTimeout(inputTimer);',
|
||||
' documentGeneration = Number(generation) || 0;',
|
||||
' suppressInput = true;',
|
||||
" // Why: replacing innerHTML detaches the remembered caret's nodes.",
|
||||
' savedSelectionRange = null;',
|
||||
' selectionDroppedOnBlur = false;',
|
||||
" lastMarkdown = String(markdown || '');",
|
||||
' editor.innerHTML = markdownToHtml(lastMarkdown);',
|
||||
' syncTaskCheckboxesDisabled();',
|
||||
' suppressInput = false;',
|
||||
' }',
|
||||
'',
|
||||
' function setEditable(nextEditable) {',
|
||||
' editable = Boolean(nextEditable);',
|
||||
" editor.setAttribute('contenteditable', editable ? 'true' : 'false');",
|
||||
' syncTaskCheckboxesDisabled();',
|
||||
' }',
|
||||
'',
|
||||
''
|
||||
].join('\n')
|
||||
@@ -1,14 +0,0 @@
|
||||
import {
|
||||
MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS,
|
||||
MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_END
|
||||
} from './mobile-rich-markdown-editor-document-suffix'
|
||||
import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_PRIMARY } from './mobile-rich-markdown-editor-script-primary'
|
||||
import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_SECONDARY } from './mobile-rich-markdown-editor-script-secondary'
|
||||
import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
|
||||
import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script'
|
||||
import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script'
|
||||
|
||||
/** The editor's whole program, independent of how a host delivers it to a WebView. */
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT = `${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_PRIMARY}
|
||||
${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_SECONDARY}${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
|
||||
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS}${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_END}`
|
||||
@@ -1,13 +0,0 @@
|
||||
// Composed into the editor document after MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT, whose
|
||||
// rememberSelection/selectionDroppedOnBlur this depends on.
|
||||
export const MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT = `
|
||||
function dismissKeyboard() {
|
||||
// Why: WebKit discards the DOM selection on blur, so capture the caret before it goes.
|
||||
rememberSelection();
|
||||
selectionDroppedOnBlur = true;
|
||||
if (document.activeElement && document.activeElement.blur) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
editor.blur();
|
||||
}
|
||||
`
|
||||
@@ -1,71 +0,0 @@
|
||||
// Caret and selection management for the WebView editor. Split out so the editor
|
||||
// document script stays inside its line budget.
|
||||
export const MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT = `
|
||||
var savedSelectionRange = null;
|
||||
var selectionDroppedOnBlur = false;
|
||||
|
||||
function focusEditor() {
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
function rememberSelection() {
|
||||
var selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
var range = selection.getRangeAt(0);
|
||||
if (editor.contains(range.commonAncestorContainer)) savedSelectionRange = range.cloneRange();
|
||||
}
|
||||
|
||||
function applySelectionRange(range) {
|
||||
var selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
savedSelectionRange = range.cloneRange();
|
||||
}
|
||||
|
||||
function caretRangeAtPoint(x, y) {
|
||||
if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y);
|
||||
if (!document.caretPositionFromPoint) return null;
|
||||
var position = document.caretPositionFromPoint(x, y);
|
||||
if (!position) return null;
|
||||
var range = document.createRange();
|
||||
range.setStart(position.offsetNode, position.offset);
|
||||
range.collapse(true);
|
||||
return range;
|
||||
}
|
||||
|
||||
function restoreSelectionOrEnd() {
|
||||
focusEditor();
|
||||
var selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
// Why: the blur dropped the live selection, so commands would otherwise insert at the document end.
|
||||
if (selectionDroppedOnBlur && savedSelectionRange && editor.contains(savedSelectionRange.commonAncestorContainer)) {
|
||||
selectionDroppedOnBlur = false;
|
||||
applySelectionRange(savedSelectionRange);
|
||||
return;
|
||||
}
|
||||
if (selection.rangeCount > 0) return;
|
||||
var range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
range.collapse(false);
|
||||
applySelectionRange(range);
|
||||
}
|
||||
|
||||
function wrapSelection(tagName) {
|
||||
restoreSelectionOrEnd();
|
||||
var selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
var range = selection.getRangeAt(0);
|
||||
if (range.collapsed) return;
|
||||
var wrapper = document.createElement(tagName);
|
||||
try {
|
||||
range.surroundContents(wrapper);
|
||||
} catch (_error) {
|
||||
wrapper.appendChild(range.extractContents());
|
||||
range.insertNode(wrapper);
|
||||
}
|
||||
selection.removeAllRanges();
|
||||
selection.selectAllChildren(wrapper);
|
||||
emitChange();
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createRichMarkdownEditorScope } from './document-scope'
|
||||
import { currentMarkdown, setEditable, setMarkdown, stopEditorContent } from './editor-content'
|
||||
import { startEditorListeners, stopEditorListeners } from './editor-listeners'
|
||||
import { startEditorSurface } from './editor-surface'
|
||||
import { startHostBridge } from './host-bridge'
|
||||
import { dismissKeyboard } from './keyboard-dismiss'
|
||||
import { runCommand } from './editor-commands'
|
||||
import { startKeyboardInset, stopKeyboardInset } from './keyboard-inset'
|
||||
import type { RichMarkdownEditorScope } from './document-scope'
|
||||
import type { RichMarkdownEditorDocument, RichMarkdownEditorHost } from './document-host-seams'
|
||||
|
||||
/**
|
||||
* One rich Markdown editor document, started.
|
||||
*
|
||||
* The program both hosts run: the WebView loads it as a bundled script that calls this once with
|
||||
* no host, and the page imports it and calls it per mount with its own hooks. A call owns
|
||||
* everything it touches — the scope below is local to it — so two editors on one page are two
|
||||
* editors, and a listener left over from a mount that has gone reads the scope it closed over
|
||||
* rather than the live one.
|
||||
*
|
||||
* The sequence is here rather than derived from a list, because it *is* the document's shape: the
|
||||
* surface is read, the listeners that need it are installed, the keyboard measurement starts, and
|
||||
* only then is the host told the document is ready.
|
||||
*/
|
||||
export function createRichMarkdownEditorDocument(
|
||||
host: RichMarkdownEditorHost = {}
|
||||
): RichMarkdownEditorDocument {
|
||||
const scope = createRichMarkdownEditorScope(host)
|
||||
startRichMarkdownEditorDocument(scope)
|
||||
return {
|
||||
send: {
|
||||
setMarkdown: (markdown, generation) => {
|
||||
setMarkdown(scope, markdown, generation)
|
||||
},
|
||||
setEditable: (editable) => {
|
||||
setEditable(scope, editable)
|
||||
},
|
||||
runCommand: (command) => runCommand(scope, command),
|
||||
currentMarkdown: () => currentMarkdown(scope),
|
||||
dismissKeyboard: () => {
|
||||
dismissKeyboard(scope)
|
||||
}
|
||||
},
|
||||
stop: () => {
|
||||
stopRichMarkdownEditorDocument(scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every module's start, in the order the document runs them.
|
||||
*
|
||||
* A start that throws has left the ones before it holding the surface's listeners or the
|
||||
* viewport's, and there is no handle for anyone to stop with, so the undo runs here. Every stop is
|
||||
* a no-op against a start that never ran, which is what makes the whole sequence the right undo
|
||||
* for a partial one.
|
||||
*
|
||||
* Exported because a test that drives one module still needs the surface and the listeners the
|
||||
* others put in place, and the order is not a thing to write twice.
|
||||
*/
|
||||
export function startRichMarkdownEditorDocument(scope: RichMarkdownEditorScope) {
|
||||
try {
|
||||
startEditorSurface(scope)
|
||||
startEditorListeners(scope)
|
||||
startKeyboardInset(scope)
|
||||
startHostBridge(scope)
|
||||
} catch (error) {
|
||||
stopRichMarkdownEditorDocument(scope)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The undo, in reverse, so nothing is torn down under something still using it, with the pending
|
||||
* timer taken back last — after the listeners that could have scheduled another one are gone.
|
||||
*/
|
||||
export function stopRichMarkdownEditorDocument(scope: RichMarkdownEditorScope) {
|
||||
scope.stopped = true
|
||||
stopKeyboardInset(scope)
|
||||
stopEditorListeners(scope)
|
||||
stopEditorContent(scope)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type {
|
||||
MobileRichMarkdownCommand,
|
||||
MobileRichMarkdownEditorMessage
|
||||
} from '../mobile-rich-markdown-editor-contract'
|
||||
|
||||
/**
|
||||
* The six seams between the editor document and whatever is hosting it, as the document's own
|
||||
* defaults.
|
||||
*
|
||||
* Inside the WebView the host is React Native and every seam is the window read the hand-written
|
||||
* script already did; on the page the host is the component that mounted these modules, where
|
||||
* `window.ReactNativeWebView` is the *shell's* bridge and `window.prompt` is a dialog the shell's
|
||||
* WebView never shows. Each function below is that window read or write, kept at call time rather
|
||||
* than captured when the scope is built, and the scope carries it as a field the page assigns over.
|
||||
*/
|
||||
|
||||
/** Which URL a command is asking the user for; the default turns it into the prompt's own text. */
|
||||
export type RichMarkdownUrlPromptKind = 'link' | 'image'
|
||||
|
||||
/**
|
||||
* Where the covered height comes from, and what says it may have changed.
|
||||
*
|
||||
* Null when the host has no such measurement: inside the WebView that is a runtime without
|
||||
* `visualViewport`, and on the page it is every host, because the screen measures its own keyboard
|
||||
* and a second report would lift its bar twice.
|
||||
*/
|
||||
export type RichMarkdownKeyboardInsetReader = {
|
||||
/** The height the keyboard covers right now, in CSS pixels. */
|
||||
measure: () => number
|
||||
/** Calls back when the covered height may have moved, handing back its removal. */
|
||||
observe: (onChange: () => void) => () => void
|
||||
}
|
||||
|
||||
/** The five things a host can ask a running document to do. */
|
||||
export type RichMarkdownEditorApi = {
|
||||
setMarkdown: (markdown: string, generation: number) => void
|
||||
setEditable: (editable: boolean) => void
|
||||
runCommand: (command: MobileRichMarkdownCommand) => Promise<void>
|
||||
currentMarkdown: () => string
|
||||
dismissKeyboard: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A running document: what a host sends into one, and how it takes it down.
|
||||
*
|
||||
* `send` is the object the WebView reaches through its injected global and the page holds
|
||||
* directly. `stop` runs every module's stop; the page's dispose calls it, and the WebView never
|
||||
* does, because there the document outlives nothing.
|
||||
*/
|
||||
export type RichMarkdownEditorDocument = {
|
||||
send: RichMarkdownEditorApi
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export type RichMarkdownEditorHostSeams = {
|
||||
/** `host-bridge`: where a message for the host goes. */
|
||||
postToHost: (message: MobileRichMarkdownEditorMessage) => void
|
||||
/** `editor-commands`: the URL the Link and Image commands insert, or null when cancelled. */
|
||||
promptForUrl: (kind: RichMarkdownUrlPromptKind) => Promise<string | null>
|
||||
/** `keyboard-inset`: the covered height and its changes, or null when the host has none. */
|
||||
keyboardInsetSource: () => RichMarkdownKeyboardInsetReader | null
|
||||
/** `editor-content`: cancels the pending input timer. */
|
||||
clearTimer: (handle: number | null) => void
|
||||
/** `editor-selection`: the live selection this document's caret lives in. */
|
||||
getSelection: () => Selection | null
|
||||
/** `editor-commands`, `editor-selection`: the document ranges, elements and `execCommand` come from. */
|
||||
getDocument: () => Document
|
||||
}
|
||||
|
||||
/**
|
||||
* What a host may hand the document instead of a window read.
|
||||
*
|
||||
* Every seam has a default, so a host names only the ones it owns differently: inside the WebView
|
||||
* that is none of them. Absent and present-but-undefined mean the same thing, which is why the
|
||||
* scope's spread filters rather than trusting key order.
|
||||
*/
|
||||
export type RichMarkdownEditorHost = Partial<RichMarkdownEditorHostSeams>
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ReactNativeWebView?: { postMessage: (message: string) => void }
|
||||
/** The native host's handle on the document, installed by the bundle's entry. */
|
||||
__orcaRichMarkdown?: RichMarkdownEditorApi
|
||||
}
|
||||
}
|
||||
|
||||
export function postToReactNativeWebView(message: MobileRichMarkdownEditorMessage) {
|
||||
if (window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify(message))
|
||||
}
|
||||
}
|
||||
|
||||
/** The labels the WebView's dialog carried, which is the whole of what the prompt kind means there. */
|
||||
const URL_PROMPT_LABELS: Record<RichMarkdownUrlPromptKind, string> = {
|
||||
link: 'Link URL',
|
||||
image: 'Image URL'
|
||||
}
|
||||
|
||||
/**
|
||||
* The WebView's own dialog, as a promise because a host that answers with a modal cannot answer
|
||||
* synchronously.
|
||||
*
|
||||
* Measured to return null in both shells — neither implements the delegate the dialog needs — so
|
||||
* the page passes its own and this default is what the native document keeps until it does.
|
||||
*/
|
||||
export function promptWindowForUrl(kind: RichMarkdownUrlPromptKind) {
|
||||
return Promise.resolve(window.prompt(URL_PROMPT_LABELS[kind]))
|
||||
}
|
||||
|
||||
/** The WebView's own measurement: what `visualViewport` says the keyboard covers. */
|
||||
export function windowVisualViewportInset(): RichMarkdownKeyboardInsetReader | null {
|
||||
const viewport = window.visualViewport
|
||||
if (!viewport) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
measure: () => Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop),
|
||||
observe: (onChange) => {
|
||||
viewport.addEventListener('resize', onChange)
|
||||
viewport.addEventListener('scroll', onChange)
|
||||
return () => {
|
||||
viewport.removeEventListener('resize', onChange)
|
||||
viewport.removeEventListener('scroll', onChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWindowTimer(handle: number | null) {
|
||||
window.clearTimeout(handle ?? undefined)
|
||||
}
|
||||
|
||||
export function windowSelection() {
|
||||
return window.getSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* The page the document's elements and ranges are in.
|
||||
*
|
||||
* A function rather than a field, so the read happens where the other five do. The native document
|
||||
* *is* its page; a page mounting these modules hands back the same global object, and the host
|
||||
* element it planted the markup in is the only thing that differs.
|
||||
*/
|
||||
export function windowDocument() {
|
||||
return document
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createRichMarkdownEditorDocument,
|
||||
startRichMarkdownEditorDocument,
|
||||
stopRichMarkdownEditorDocument
|
||||
} from './create-rich-markdown-editor-document'
|
||||
import { createRichMarkdownEditorScope } from './document-scope'
|
||||
import { emitChange, setMarkdown } from './editor-content'
|
||||
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
|
||||
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
|
||||
import type { RichMarkdownEditorDocument } from './document-host-seams'
|
||||
|
||||
/**
|
||||
* What a `stop` owes, and what a second call gets.
|
||||
*
|
||||
* The WebView never stops its document — there the page is the document's whole life — so every
|
||||
* case here is about the host that does: a page mounts the editor, unmounts it and mounts it
|
||||
* again, and the same modules answer. A listener or an observer the first mount left behind would
|
||||
* make the second one report twice and hold the markup the first one read (rulings 20, 21).
|
||||
*/
|
||||
const started: RichMarkdownEditorDocument[] = []
|
||||
|
||||
function plantMarkup() {
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
return document.getElementById('editor')!
|
||||
}
|
||||
|
||||
function viewportDouble() {
|
||||
const observers: (() => void)[] = []
|
||||
return {
|
||||
observers,
|
||||
source: () => ({
|
||||
measure: () => 120,
|
||||
observe: (onChange: () => void) => {
|
||||
observers.push(onChange)
|
||||
return () => {
|
||||
observers.splice(observers.indexOf(onChange), 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function mount(posted: MobileRichMarkdownEditorMessage[], keyboard = viewportDouble()) {
|
||||
const document_ = createRichMarkdownEditorDocument({
|
||||
postToHost: (message) => posted.push(message),
|
||||
keyboardInsetSource: keyboard.source
|
||||
})
|
||||
started.push(document_)
|
||||
return { handle: document_.send, stop: () => document_.stop(), keyboard }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (started.length > 0) {
|
||||
started.pop()!.stop()
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('an editor document that is stopped', () => {
|
||||
it('takes its own listeners off the surface', () => {
|
||||
const posted: MobileRichMarkdownEditorMessage[] = []
|
||||
const editor = plantMarkup()
|
||||
const mounted = mount(posted)
|
||||
mounted.handle.setMarkdown('body', 1)
|
||||
posted.length = 0
|
||||
|
||||
mounted.stop()
|
||||
editor.dispatchEvent(new Event('input'))
|
||||
editor.dispatchEvent(new Event('change'))
|
||||
editor.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
editor.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true }))
|
||||
expect(posted).toEqual([])
|
||||
})
|
||||
|
||||
it('stops observing the viewport, so no inset of its own reaches the next mount', () => {
|
||||
const posted: MobileRichMarkdownEditorMessage[] = []
|
||||
plantMarkup()
|
||||
const mounted = mount(posted)
|
||||
expect(mounted.keyboard.observers).toHaveLength(1)
|
||||
mounted.stop()
|
||||
expect(mounted.keyboard.observers).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('leaves a second mount a document of its own, not the first one continued', () => {
|
||||
const first: MobileRichMarkdownEditorMessage[] = []
|
||||
plantMarkup()
|
||||
const one = mount(first)
|
||||
one.handle.setMarkdown('first content', 9)
|
||||
one.stop()
|
||||
|
||||
const second: MobileRichMarkdownEditorMessage[] = []
|
||||
const editor = plantMarkup()
|
||||
const two = mount(second)
|
||||
expect(second).toEqual([{ type: 'keyboardInset', bottom: 120 }, { type: 'ready' }])
|
||||
second.length = 0
|
||||
// Its own generation and its own surface: the first mount's 9 is not carried over, and the
|
||||
// element it read is the one planted for this mount.
|
||||
two.handle.setMarkdown('second content', 1)
|
||||
editor.dispatchEvent(new Event('input'))
|
||||
expect(second).toEqual([{ type: 'change', markdown: 'second content', generation: 1 }])
|
||||
expect(first).not.toContainEqual(
|
||||
expect.objectContaining({ type: 'change', markdown: 'second content' })
|
||||
)
|
||||
})
|
||||
|
||||
it('is two editors when two are mounted, each reading its own scope', () => {
|
||||
// Not two on one page — the ids collide there, which is the page component's problem — but two
|
||||
// documents over the same markup, which is what says the state is per call rather than shared.
|
||||
const first: MobileRichMarkdownEditorMessage[] = []
|
||||
const second: MobileRichMarkdownEditorMessage[] = []
|
||||
const editor = plantMarkup()
|
||||
const one = mount(first)
|
||||
const two = mount(second)
|
||||
first.length = 0
|
||||
second.length = 0
|
||||
one.handle.setMarkdown('shared markup', 3)
|
||||
two.handle.setEditable(false)
|
||||
editor.dispatchEvent(new Event('input'))
|
||||
// The second document is read-only and says nothing; the first still reports its own
|
||||
// generation, which it would not if `editable` lived in a module.
|
||||
expect(second).toEqual([])
|
||||
expect(first).toEqual([{ type: 'change', markdown: 'shared markup', generation: 3 }])
|
||||
})
|
||||
|
||||
it('cancels a change still waiting on a timer, which no listener removal can reach', () => {
|
||||
// A listener comes off with the element it was on; a scheduled callback holds the scope and
|
||||
// would fire into a document the host has already unmounted. Nothing schedules the handle
|
||||
// today, so the pending change is planted here — the seam and the field exist for the day
|
||||
// something does, and the cancel has to already be in `stop` when it arrives.
|
||||
const posted: MobileRichMarkdownEditorMessage[] = []
|
||||
plantMarkup()
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const scope = createRichMarkdownEditorScope({
|
||||
postToHost: (message) => posted.push(message),
|
||||
keyboardInsetSource: () => null
|
||||
})
|
||||
startRichMarkdownEditorDocument(scope)
|
||||
setMarkdown(scope, 'body', 2)
|
||||
posted.length = 0
|
||||
|
||||
// The control: while the document is running, the pending change is posted.
|
||||
scope.inputTimer = window.setTimeout(() => emitChange(scope), 0)
|
||||
vi.runAllTimers()
|
||||
expect(posted).toEqual([{ type: 'change', markdown: 'body', generation: 2 }])
|
||||
|
||||
posted.length = 0
|
||||
scope.inputTimer = window.setTimeout(() => emitChange(scope), 0)
|
||||
stopRichMarkdownEditorDocument(scope)
|
||||
expect(scope.inputTimer).toBe(null)
|
||||
vi.runAllTimers()
|
||||
expect(posted).toEqual([])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a command whose dialog answered after the host moved on', async () => {
|
||||
// `promptForUrl` is a modal on the page, so it is a task boundary: between the toolbar press
|
||||
// and the URL coming back, the host can replace the content, make the editor read-only or
|
||||
// unmount it. Inside the WebView it is `window.prompt`, which answers within a microtask —
|
||||
// which is why nothing here can happen on native, and everything here can happen on the page.
|
||||
const commands: string[] = []
|
||||
const answer: ((url: string) => void)[] = []
|
||||
plantMarkup()
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
value: (command: string) => {
|
||||
commands.push(command)
|
||||
return true
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
try {
|
||||
const posted: MobileRichMarkdownEditorMessage[] = []
|
||||
const document_ = createRichMarkdownEditorDocument({
|
||||
postToHost: (message) => posted.push(message),
|
||||
keyboardInsetSource: () => null,
|
||||
promptForUrl: () => new Promise((resolve) => answer.push(resolve))
|
||||
})
|
||||
started.push(document_)
|
||||
document_.send.setMarkdown('before', 1)
|
||||
|
||||
// The control: nothing moved, so the answer is applied and the change is reported.
|
||||
const applied = document_.send.runCommand('link')
|
||||
answer.pop()!('https://example.com/a')
|
||||
await applied
|
||||
expect(commands).toEqual(['createLink'])
|
||||
expect(posted.at(-1)).toEqual({ type: 'change', markdown: 'before', generation: 1 })
|
||||
|
||||
// Replaced content: the answer belongs to markdown nobody is looking at any more.
|
||||
posted.length = 0
|
||||
const stale = document_.send.runCommand('link')
|
||||
document_.send.setMarkdown('after', 2)
|
||||
answer.pop()!('https://example.com/b')
|
||||
await stale
|
||||
expect(commands).toEqual(['createLink'])
|
||||
expect(posted).toEqual([])
|
||||
|
||||
// Read-only, and then stopped: neither takes the command either.
|
||||
const whileReadOnly = document_.send.runCommand('image')
|
||||
document_.send.setEditable(false)
|
||||
answer.pop()!('https://example.com/c')
|
||||
await whileReadOnly
|
||||
document_.send.setEditable(true)
|
||||
const whileStopped = document_.send.runCommand('image')
|
||||
document_.stop()
|
||||
answer.pop()!('https://example.com/d')
|
||||
await whileStopped
|
||||
expect(commands).toEqual(['createLink'])
|
||||
} finally {
|
||||
Reflect.deleteProperty(document, 'execCommand')
|
||||
}
|
||||
})
|
||||
|
||||
it('unwinds a start that throws rather than leaving the listeners it already installed', () => {
|
||||
const posted: MobileRichMarkdownEditorMessage[] = []
|
||||
const editor = plantMarkup()
|
||||
expect(() =>
|
||||
createRichMarkdownEditorDocument({
|
||||
postToHost: (message) => posted.push(message),
|
||||
keyboardInsetSource: () => {
|
||||
throw new Error('no viewport')
|
||||
}
|
||||
})
|
||||
).toThrow('no viewport')
|
||||
// Nothing reported itself ready, and the surface carries no listener of the failed start.
|
||||
expect(posted).toEqual([])
|
||||
editor.dispatchEvent(new Event('input'))
|
||||
expect(posted).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The one element the document's modules reach for: the editable surface.
|
||||
*
|
||||
* It is here rather than in the HTML builder because both hosts plant it — the native document
|
||||
* carries it in its `<body>`, and a page mounting these modules puts the same markup in its host
|
||||
* element — and a document whose markup differed between the two would be two documents.
|
||||
*/
|
||||
export const RICH_MARKDOWN_EDITOR_MARKUP =
|
||||
'<main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>'
|
||||
|
||||
/** The id the markup gives the editable surface, read once when the document starts. */
|
||||
export const RICH_MARKDOWN_EDITOR_ELEMENT_ID = 'editor'
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
clearWindowTimer,
|
||||
postToReactNativeWebView,
|
||||
promptWindowForUrl,
|
||||
windowDocument,
|
||||
windowSelection,
|
||||
windowVisualViewportInset,
|
||||
type RichMarkdownEditorHost,
|
||||
type RichMarkdownEditorHostSeams
|
||||
} from './document-host-seams'
|
||||
export type {
|
||||
RichMarkdownEditorApi,
|
||||
RichMarkdownEditorDocument,
|
||||
RichMarkdownEditorHost,
|
||||
RichMarkdownEditorHostSeams
|
||||
} from './document-host-seams'
|
||||
|
||||
/**
|
||||
* The state one editor document shares across its modules.
|
||||
*
|
||||
* Every mutable binding the hand-written script declared is here, because a module's own `let`
|
||||
* would be shared by every document on the page: the second mount would inherit the first's
|
||||
* generation, its remembered caret and its last reported inset (ruling 21). One object per call,
|
||||
* built by the factory, so two editors on one page are two editors.
|
||||
*/
|
||||
export type RichMarkdownEditorState = {
|
||||
/** The editable surface, read once when the document starts. */
|
||||
editor: HTMLElement | null
|
||||
/** `editor-content`: the markdown the document last rendered or serialized. */
|
||||
lastMarkdown: string
|
||||
/**
|
||||
* `editor-content`: the pending input timer, cleared before every content replacement.
|
||||
*
|
||||
* Nothing schedules it today — the change message is posted straight from the input listener —
|
||||
* and it is kept because the clear is what a debounce would need and costs nothing without one.
|
||||
*/
|
||||
inputTimer: number | null
|
||||
/** `editor-content`: the host's generation, echoed back on every change so it can drop stale ones. */
|
||||
documentGeneration: number
|
||||
/** `editor-content`: whether the surface accepts edits. */
|
||||
editable: boolean
|
||||
/** `editor-content`: set while the document rewrites itself, so its own input is not a change. */
|
||||
suppressInput: boolean
|
||||
/** `editor-selection`: the caret captured before a blur could drop it. */
|
||||
savedSelectionRange: Range | null
|
||||
/** `editor-selection`: whether the last blur was the document's own keyboard dismissal. */
|
||||
selectionDroppedOnBlur: boolean
|
||||
/** `keyboard-inset`: the last covered height posted, to suppress repeats. */
|
||||
lastInset: number
|
||||
/** `editor-listeners`: takes the four surface listeners off again, or null before them. */
|
||||
removeEditorListeners: (() => void) | null
|
||||
/** `keyboard-inset`: takes the viewport's two listeners off again, or null before them. */
|
||||
removeKeyboardInset: (() => void) | null
|
||||
/** `create-rich-markdown-editor-document`: whether the host has taken this document down. */
|
||||
stopped: boolean
|
||||
}
|
||||
|
||||
/** The document's whole scope: its state, and the seams to whatever is hosting it. */
|
||||
export type RichMarkdownEditorScope = RichMarkdownEditorState & RichMarkdownEditorHostSeams
|
||||
|
||||
/** The initial values, which are the ones the script's own declarations carried. */
|
||||
function createRichMarkdownEditorState(): RichMarkdownEditorState {
|
||||
return {
|
||||
editor: null,
|
||||
lastMarkdown: '',
|
||||
inputTimer: null,
|
||||
documentGeneration: 0,
|
||||
editable: true,
|
||||
suppressInput: false,
|
||||
savedSelectionRange: null,
|
||||
selectionDroppedOnBlur: false,
|
||||
lastInset: -1,
|
||||
removeEditorListeners: null,
|
||||
removeKeyboardInset: null,
|
||||
stopped: false
|
||||
}
|
||||
}
|
||||
|
||||
/** The seams' defaults: the window reads and writes the script already did. */
|
||||
function createRichMarkdownEditorHostSeams(): RichMarkdownEditorHostSeams {
|
||||
return {
|
||||
postToHost: postToReactNativeWebView,
|
||||
promptForUrl: promptWindowForUrl,
|
||||
keyboardInsetSource: windowVisualViewportInset,
|
||||
clearTimer: clearWindowTimer,
|
||||
getSelection: windowSelection,
|
||||
getDocument: windowDocument
|
||||
}
|
||||
}
|
||||
|
||||
export function createRichMarkdownEditorScope(
|
||||
host: RichMarkdownEditorHost = {}
|
||||
): RichMarkdownEditorScope {
|
||||
const named = Object.fromEntries(Object.entries(host).filter(([, hook]) => hook !== undefined))
|
||||
return {
|
||||
...createRichMarkdownEditorState(),
|
||||
...createRichMarkdownEditorHostSeams(),
|
||||
...named
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
|
||||
/**
|
||||
* The editor document's stylesheet: the theme variables and every rule that reads them.
|
||||
*
|
||||
* A function rather than a constant because the variables are the app's own theme values, read
|
||||
* when the document is built. The native host wraps it in the document's `<style>`; a page mounting
|
||||
* these modules scopes it to the host element it planted the markup in.
|
||||
*/
|
||||
export function richMarkdownEditorStyle(): string {
|
||||
return ` :root {
|
||||
color-scheme: dark;
|
||||
--background: ${colors.bgBase};
|
||||
--editor-surface: ${colors.bgBase};
|
||||
--foreground: ${colors.textPrimary};
|
||||
--muted-foreground: ${colors.textSecondary};
|
||||
--muted: ${colors.bgRaised};
|
||||
--border: ${colors.borderSubtle};
|
||||
--primary: ${colors.textPrimary};
|
||||
--primary-foreground: ${colors.bgBase};
|
||||
--accent-link: ${colors.accentBlue};
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
--font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
background: var(--editor-surface);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
body { overflow: auto; }
|
||||
#editor {
|
||||
min-height: 100vh;
|
||||
padding: 18px 16px 112px;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
#editor[contenteditable="false"] {
|
||||
opacity: 0.78;
|
||||
}
|
||||
#editor:empty::before,
|
||||
#editor p.is-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
}
|
||||
#editor > :first-child { margin-top: 0; }
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 1.5em 0 0.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
h1 { font-size: 1.85em; font-weight: 700; }
|
||||
h2 { font-size: 1.4em; }
|
||||
h3 { font-size: 1.15em; }
|
||||
p, ul, ol, blockquote { margin: 0.75em 0; }
|
||||
ul, ol { padding-left: 1.5em; }
|
||||
ul { list-style: disc; }
|
||||
ol { list-style: decimal; }
|
||||
li { margin: 0.15em 0; }
|
||||
li > p { margin: 0; }
|
||||
ul[data-type="taskList"] {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
ul[data-type="taskList"] > li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
ul[data-type="taskList"] > li > label {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 1.55em;
|
||||
user-select: none;
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"]:checked {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"]:checked::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid var(--primary-foreground);
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"]:disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
ul[data-type="taskList"] > li > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
ul[data-type="taskList"] > li[data-checked="true"] > div {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
blockquote {
|
||||
padding: 0.5em 1em;
|
||||
border-left: 3px solid var(--border);
|
||||
border-radius: 0 6px 6px 0;
|
||||
color: var(--muted-foreground);
|
||||
background: color-mix(in srgb, var(--foreground) 2%, transparent);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
th, td {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
}
|
||||
tr:nth-child(odd) td {
|
||||
background: color-mix(in srgb, var(--foreground) 1.5%, transparent);
|
||||
}
|
||||
code {
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
font-size: 0.88em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
pre {
|
||||
margin: 0.75em 0;
|
||||
padding: 14px 18px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
overflow-x: auto;
|
||||
line-height: 1.55;
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
pre::before {
|
||||
content: attr(data-language);
|
||||
display: block;
|
||||
min-height: 13px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
pre code {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-size: 0.92em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
hr {
|
||||
margin: 1.5em 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-link);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0.75em 0;
|
||||
border-radius: 8px;
|
||||
}`
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { emitChange, syncTaskCheckboxesDisabled } from './editor-content'
|
||||
import { restoreSelectionOrEnd, wrapSelection } from './editor-selection'
|
||||
import { editorElement } from './editor-surface'
|
||||
import { isSafeUrl } from './markdown-escaping'
|
||||
import type { MobileRichMarkdownCommand } from '../mobile-rich-markdown-editor-contract'
|
||||
import type { RichMarkdownEditorScope } from './document-scope'
|
||||
import type { RichMarkdownUrlPromptKind } from './document-host-seams'
|
||||
|
||||
const TASK_LIST_HTML =
|
||||
'<ul data-type="taskList"><li data-checked="false"><label contenteditable="false">' +
|
||||
'<input type="checkbox" /></label><div><p>Task</p></div></li></ul>'
|
||||
|
||||
const CODE_BLOCK_HTML = '<pre data-language=""><code>code</code></pre><p><br></p>'
|
||||
|
||||
function exec(scope: RichMarkdownEditorScope, command: string, value?: string) {
|
||||
scope.getDocument().execCommand(command, false, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the document a command started against is still the one in front of the user.
|
||||
*
|
||||
* The generation is the host's own: it bumps it on every content replacement, so a command that
|
||||
* waited while the host replaced the markdown would otherwise act on text nobody chose, under a
|
||||
* caret that belongs to the replaced content.
|
||||
*/
|
||||
function acceptsCommands(scope: RichMarkdownEditorScope, generation: number): boolean {
|
||||
return (
|
||||
!scope.stopped &&
|
||||
scope.editable &&
|
||||
scope.documentGeneration === generation &&
|
||||
editorElement(scope).getAttribute('contenteditable') === 'true'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The two commands that need a URL the document does not have.
|
||||
*
|
||||
* A promise because the answer is a dialog: inside the WebView that is `window.prompt`, and on a
|
||||
* page it is a modal the host renders, which cannot answer before it has been shown. The
|
||||
* difference matters — a modal is a task boundary, so while it is open the host can stop the
|
||||
* document, make it read-only or replace its content, and the answer comes back to a document
|
||||
* that is no longer the one the user was pointing at.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
async function insertUrl(
|
||||
scope: RichMarkdownEditorScope,
|
||||
kind: RichMarkdownUrlPromptKind,
|
||||
command: 'createLink' | 'insertImage'
|
||||
) {
|
||||
const generation = scope.documentGeneration
|
||||
const url = await scope.promptForUrl(kind)
|
||||
if (url && isSafeUrl(url) && acceptsCommands(scope, generation)) {
|
||||
exec(scope, command, url)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every toolbar command, by name.
|
||||
*
|
||||
* A record over the contract's union rather than a chain of comparisons, so a command added to the
|
||||
* contract fails to compile until the document answers it.
|
||||
*/
|
||||
const COMMANDS: Record<
|
||||
MobileRichMarkdownCommand,
|
||||
(scope: RichMarkdownEditorScope) => void | Promise<void>
|
||||
> = {
|
||||
paragraph: (scope) => exec(scope, 'formatBlock', 'p'),
|
||||
heading1: (scope) => exec(scope, 'formatBlock', 'h1'),
|
||||
heading2: (scope) => exec(scope, 'formatBlock', 'h2'),
|
||||
heading3: (scope) => exec(scope, 'formatBlock', 'h3'),
|
||||
bold: (scope) => exec(scope, 'bold'),
|
||||
italic: (scope) => exec(scope, 'italic'),
|
||||
strike: (scope) => exec(scope, 'strikeThrough'),
|
||||
bulletList: (scope) => exec(scope, 'insertUnorderedList'),
|
||||
orderedList: (scope) => exec(scope, 'insertOrderedList'),
|
||||
taskList: (scope) => exec(scope, 'insertHTML', TASK_LIST_HTML),
|
||||
quote: (scope) => exec(scope, 'formatBlock', 'blockquote'),
|
||||
inlineCode: (scope) => wrapSelection(scope, 'code'),
|
||||
codeBlock: (scope) => exec(scope, 'insertHTML', CODE_BLOCK_HTML),
|
||||
link: (scope) => insertUrl(scope, 'link', 'createLink'),
|
||||
image: (scope) => insertUrl(scope, 'image', 'insertImage')
|
||||
}
|
||||
|
||||
/**
|
||||
* One toolbar command against the current selection.
|
||||
*
|
||||
* The caret is restored first because the toolbar is outside the document and pressing it took the
|
||||
* focus; the change is emitted afterwards because `execCommand` rewrites the markup without
|
||||
* raising an input event the listeners would see.
|
||||
*
|
||||
* Awaited only where a command actually waits, so the thirteen that do not stay one synchronous
|
||||
* act from the host's call to the change it produces.
|
||||
*/
|
||||
export async function runCommand(
|
||||
scope: RichMarkdownEditorScope,
|
||||
command: MobileRichMarkdownCommand
|
||||
) {
|
||||
const generation = scope.documentGeneration
|
||||
if (!acceptsCommands(scope, generation)) {
|
||||
return
|
||||
}
|
||||
restoreSelectionOrEnd(scope)
|
||||
const pending = COMMANDS[command]?.(scope)
|
||||
if (pending) {
|
||||
await pending
|
||||
// The same question again, because the wait was the host's chance to move: a change emitted
|
||||
// now would carry the new generation over an edit made against the old one.
|
||||
if (!acceptsCommands(scope, generation)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
syncTaskCheckboxesDisabled(scope)
|
||||
emitChange(scope)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// @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'
|
||||
|
||||
/**
|
||||
* What the host sets, what it gets back, and the generation that tells the two apart.
|
||||
*
|
||||
* The host replaces the content while the user is typing into it, so every change carries the
|
||||
* generation the content was set under and a reply from before a replacement is one the host can
|
||||
* drop. The document's own rewrites are not changes at all, which is what makes a replacement
|
||||
* silent rather than a change the host would apply back to itself.
|
||||
*/
|
||||
const started: RichMarkdownEditorDocument[] = []
|
||||
|
||||
function editorDocument() {
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
const posted: MobileRichMarkdownEditorMessage[] = []
|
||||
const cleared: (number | null)[] = []
|
||||
const document_ = createRichMarkdownEditorDocument({
|
||||
postToHost: (message) => posted.push(message),
|
||||
keyboardInsetSource: () => null,
|
||||
clearTimer: (handle) => cleared.push(handle)
|
||||
})
|
||||
started.push(document_)
|
||||
posted.length = 0
|
||||
return {
|
||||
handle: document_.send,
|
||||
posted,
|
||||
cleared,
|
||||
editor: document.getElementById('editor')!,
|
||||
type: () => document.getElementById('editor')!.dispatchEvent(new Event('input'))
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (started.length > 0) {
|
||||
started.pop()!.stop()
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('the editor document content', () => {
|
||||
it('carries the generation it was set under into every change', () => {
|
||||
const editor = editorDocument()
|
||||
editor.handle.setMarkdown('first', 4)
|
||||
editor.type()
|
||||
editor.handle.setMarkdown('second', 5)
|
||||
editor.type()
|
||||
expect(editor.posted).toEqual([
|
||||
{ type: 'change', markdown: 'first', generation: 4 },
|
||||
{ type: 'change', markdown: 'second', generation: 5 }
|
||||
])
|
||||
})
|
||||
|
||||
it('reads a generation that is not a number as zero', () => {
|
||||
const editor = editorDocument()
|
||||
editor.handle.setMarkdown('body', Number.NaN)
|
||||
editor.type()
|
||||
expect(editor.posted).toEqual([{ type: 'change', markdown: 'body', generation: 0 }])
|
||||
})
|
||||
|
||||
it('posts the change on the spot, with no timer between the edit and the host', () => {
|
||||
// There is no debounce, and the pending handle is cleared before every replacement so that
|
||||
// adding one later cannot leak a change from the content it has already replaced.
|
||||
const editor = editorDocument()
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
editor.handle.setMarkdown('body', 1)
|
||||
editor.type()
|
||||
expect(editor.posted).toHaveLength(1)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
expect(editor.cleared).toEqual([null, null])
|
||||
})
|
||||
|
||||
it('says nothing while it is rewriting itself', () => {
|
||||
// `setMarkdown` replaces the markup, which a live browser reports as input; a change posted
|
||||
// from it would send the host back the content the host just set.
|
||||
const editor = editorDocument()
|
||||
editor.handle.setMarkdown('# Title', 2)
|
||||
expect(editor.posted).toEqual([])
|
||||
expect(editor.editor.innerHTML).toBe('<h1>Title</h1>')
|
||||
})
|
||||
|
||||
it('stops reporting, and disables every checkbox, once it is read-only', () => {
|
||||
const editor = editorDocument()
|
||||
editor.handle.setMarkdown('- [ ] Open\n- [x] Done', 1)
|
||||
editor.handle.setEditable(false)
|
||||
expect(editor.editor.getAttribute('contenteditable')).toBe('false')
|
||||
expect(
|
||||
Array.from(editor.editor.querySelectorAll('input')).map((input) => input.disabled)
|
||||
).toEqual([true, true])
|
||||
editor.type()
|
||||
expect(editor.posted).toEqual([])
|
||||
|
||||
editor.handle.setEditable(true)
|
||||
expect(
|
||||
Array.from(editor.editor.querySelectorAll('input')).map((input) => input.disabled)
|
||||
).toEqual([false, false])
|
||||
editor.type()
|
||||
expect(editor.posted).toEqual([
|
||||
{ type: 'change', markdown: '- [ ] Open\n- [x] Done', generation: 1 }
|
||||
])
|
||||
})
|
||||
|
||||
it('writes a ticked checkbox back into the markup the next serialization reads', () => {
|
||||
const editor = editorDocument()
|
||||
editor.handle.setMarkdown('- [ ] Open', 1)
|
||||
const checkbox = editor.editor.querySelector('input')!
|
||||
checkbox.checked = true
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
expect(editor.editor.querySelector('li')!.getAttribute('data-checked')).toBe('true')
|
||||
expect(editor.posted).toEqual([{ type: 'change', markdown: '- [x] Open', generation: 1 }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { blockMarkdown } from './html-block-markdown'
|
||||
import { markdownToHtml } from './markdown-to-html'
|
||||
import { post } from './host-bridge'
|
||||
import { editorElement } from './editor-surface'
|
||||
import type { RichMarkdownEditorScope } from './document-scope'
|
||||
|
||||
/** What the surface holds right now, as markdown: every block, blank ones dropped. */
|
||||
export function currentMarkdown(scope: RichMarkdownEditorScope): string {
|
||||
return Array.from(editorElement(scope).childNodes)
|
||||
.map((node) => blockMarkdown(node))
|
||||
.filter((block) => block.trim().length > 0)
|
||||
.join('\n\n')
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
/** A checkbox the user could move under a read-only document would record nothing, so it cannot. */
|
||||
export function syncTaskCheckboxesDisabled(scope: RichMarkdownEditorScope) {
|
||||
editorElement(scope)
|
||||
.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
|
||||
.forEach((input) => {
|
||||
input.disabled = !scope.editable
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the host what the surface now holds, under the generation it was given.
|
||||
*
|
||||
* The generation goes back out untouched so the host can drop a change that crossed with content
|
||||
* it had already replaced. The document's own rewrites are not changes, which is what
|
||||
* `suppressInput` says.
|
||||
*/
|
||||
export function emitChange(scope: RichMarkdownEditorScope) {
|
||||
if (scope.suppressInput || !scope.editable) {
|
||||
return
|
||||
}
|
||||
scope.clearTimer(scope.inputTimer)
|
||||
const pendingGeneration = scope.documentGeneration
|
||||
scope.lastMarkdown = currentMarkdown(scope)
|
||||
post(scope, { type: 'change', markdown: scope.lastMarkdown, generation: pendingGeneration })
|
||||
}
|
||||
|
||||
export function setMarkdown(scope: RichMarkdownEditorScope, markdown: string, generation: number) {
|
||||
scope.clearTimer(scope.inputTimer)
|
||||
scope.documentGeneration = Number(generation) || 0
|
||||
scope.suppressInput = true
|
||||
// Why: replacing innerHTML detaches the remembered caret's nodes.
|
||||
scope.savedSelectionRange = null
|
||||
scope.selectionDroppedOnBlur = false
|
||||
scope.lastMarkdown = String(markdown || '')
|
||||
editorElement(scope).innerHTML = markdownToHtml(scope, scope.lastMarkdown)
|
||||
syncTaskCheckboxesDisabled(scope)
|
||||
scope.suppressInput = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes back the pending input timer, which is the one thing the document owns that outlives it.
|
||||
*
|
||||
* A listener comes off with the element it was on, but a scheduled callback holds the scope and
|
||||
* fires into a document nobody is looking at any more — posting a change to a host that has
|
||||
* unmounted the editor, under the generation of content it has replaced. Nothing schedules the
|
||||
* handle today; it is cancelled here because the day something does, this is where the cancel has
|
||||
* to already be.
|
||||
*/
|
||||
export function stopEditorContent(scope: RichMarkdownEditorScope) {
|
||||
scope.clearTimer(scope.inputTimer)
|
||||
scope.inputTimer = null
|
||||
}
|
||||
|
||||
export function setEditable(scope: RichMarkdownEditorScope, editable: boolean) {
|
||||
scope.editable = Boolean(editable)
|
||||
editorElement(scope).setAttribute('contenteditable', scope.editable ? 'true' : 'false')
|
||||
syncTaskCheckboxesDisabled(scope)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { emitChange } from './editor-content'
|
||||
import { applySelectionRange, caretRangeAtPoint, focusEditor } from './editor-selection'
|
||||
import { editorElement } from './editor-surface'
|
||||
import { post } from './host-bridge'
|
||||
import { runCommand } from './editor-commands'
|
||||
import type { RichMarkdownEditorScope } from './document-scope'
|
||||
|
||||
/** What the event landed on, for a target that may be a text node or nothing at all. */
|
||||
function closestFrom(target: EventTarget | null, selector: string): Element | null {
|
||||
return target instanceof Element ? target.closest(selector) : null
|
||||
}
|
||||
|
||||
function checkboxAt(target: EventTarget | null): HTMLInputElement | null {
|
||||
const input = closestFrom(target, 'input[type="checkbox"]')
|
||||
return input instanceof HTMLInputElement ? input : null
|
||||
}
|
||||
|
||||
/** The markup carries the tick, because it is what the next serialization reads. */
|
||||
function mirrorCheckedState(input: HTMLInputElement) {
|
||||
const item = input.closest('li')
|
||||
if (item) {
|
||||
item.setAttribute('data-checked', input.checked ? 'true' : 'false')
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(scope: RichMarkdownEditorScope) {
|
||||
scope.selectionDroppedOnBlur = false
|
||||
if (scope.editable) {
|
||||
emitChange(scope)
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(scope: RichMarkdownEditorScope, event: Event) {
|
||||
const input = checkboxAt(event.target)
|
||||
if (input) {
|
||||
if (!scope.editable) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
mirrorCheckedState(input)
|
||||
}
|
||||
if (scope.editable) {
|
||||
emitChange(scope)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(scope: RichMarkdownEditorScope, event: MouseEvent) {
|
||||
const link = closestFrom(event.target, 'a[href]')
|
||||
if (link) {
|
||||
event.preventDefault()
|
||||
post(scope, { type: 'openLink', url: link.getAttribute('href') ?? '' })
|
||||
return
|
||||
}
|
||||
const input = checkboxAt(event.target)
|
||||
if (!input) {
|
||||
if (!scope.editable) {
|
||||
return
|
||||
}
|
||||
// Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.
|
||||
const uneditable = closestFrom(event.target, '[contenteditable="false"]')
|
||||
if (uneditable && uneditable !== editorElement(scope)) {
|
||||
return
|
||||
}
|
||||
scope.selectionDroppedOnBlur = false
|
||||
if (scope.getDocument().activeElement === editorElement(scope)) {
|
||||
return
|
||||
}
|
||||
// Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.
|
||||
const caret = caretRangeAtPoint(scope, event.clientX, event.clientY)
|
||||
focusEditor(scope)
|
||||
if (caret && editorElement(scope).contains(caret.commonAncestorContainer)) {
|
||||
applySelectionRange(scope, caret)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!scope.editable) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
mirrorCheckedState(input)
|
||||
emitChange(scope)
|
||||
}
|
||||
|
||||
function handleKeydown(scope: RichMarkdownEditorScope, event: KeyboardEvent) {
|
||||
// Both modifiers, because the same document runs under a Mac keyboard and a Windows one.
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {
|
||||
event.preventDefault()
|
||||
void runCommand(scope, 'bold')
|
||||
}
|
||||
}
|
||||
|
||||
/** The four listeners the surface carries, installed per document and taken off by `stop`. */
|
||||
export function startEditorListeners(scope: RichMarkdownEditorScope) {
|
||||
const editor = editorElement(scope)
|
||||
const onInput = () => handleInput(scope)
|
||||
const onChange = (event: Event) => handleChange(scope, event)
|
||||
const onClick = (event: MouseEvent) => handleClick(scope, event)
|
||||
const onKeydown = (event: KeyboardEvent) => handleKeydown(scope, event)
|
||||
editor.addEventListener('input', onInput)
|
||||
editor.addEventListener('change', onChange)
|
||||
editor.addEventListener('click', onClick)
|
||||
editor.addEventListener('keydown', onKeydown)
|
||||
scope.removeEditorListeners = () => {
|
||||
editor.removeEventListener('input', onInput)
|
||||
editor.removeEventListener('change', onChange)
|
||||
editor.removeEventListener('click', onClick)
|
||||
editor.removeEventListener('keydown', onKeydown)
|
||||
}
|
||||
}
|
||||
|
||||
export function stopEditorListeners(scope: RichMarkdownEditorScope) {
|
||||
scope.removeEditorListeners?.()
|
||||
scope.removeEditorListeners = null
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
|
||||
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
|
||||
import type { RichMarkdownEditorDocument } from './document-host-seams'
|
||||
|
||||
/**
|
||||
* The caret across a keyboard dismissal, which is the document's hardest piece of state.
|
||||
*
|
||||
* WebKit discards the DOM selection on blur, so the document saves the caret before it blurs
|
||||
* itself and restores it the next time a command needs one. Without that, every toolbar press
|
||||
* after the keyboard closed would insert at the end of the document instead of where the user was.
|
||||
*
|
||||
* The blur below drops the ranges, which is the whole point: a case over a browser that keeps them
|
||||
* would pass whatever the document did.
|
||||
*/
|
||||
const started: RichMarkdownEditorDocument[] = []
|
||||
|
||||
function runtime(options: { caret?: 'paragraph-3' | null } = {}) {
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
const editor = document.getElementById('editor')!
|
||||
editor.innerHTML =
|
||||
'<p id="paragraph-3">three</p><p id="paragraph-7">seven</p>' +
|
||||
'<p><label id="task-label" contenteditable="false">label</label></p>'
|
||||
// WebKit's own behaviour, and the reason the saved range exists.
|
||||
editor.addEventListener('blur', () => window.getSelection()?.removeAllRanges())
|
||||
// The selection outlives the markup, so a case starts from none rather than from the last one's.
|
||||
window.getSelection()?.removeAllRanges()
|
||||
|
||||
let caretAt: string | null = null
|
||||
const caretRangeFromPoint = () => {
|
||||
const node = caretAt === null ? null : document.getElementById(caretAt)
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(node)
|
||||
range.collapse(true)
|
||||
return range
|
||||
}
|
||||
Object.defineProperty(document, 'caretRangeFromPoint', {
|
||||
value: caretRangeFromPoint,
|
||||
configurable: true
|
||||
})
|
||||
// happy-dom implements no `execCommand`; what these cases read is the caret it would act on.
|
||||
Object.defineProperty(document, 'execCommand', { value: () => true, configurable: true })
|
||||
|
||||
const document_ = createRichMarkdownEditorDocument({
|
||||
postToHost: () => {},
|
||||
keyboardInsetSource: () => null
|
||||
})
|
||||
started.push(document_)
|
||||
|
||||
if (options.caret) {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(document.getElementById(options.caret)!)
|
||||
range.collapse(true)
|
||||
const selection = window.getSelection()!
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
editor.focus()
|
||||
}
|
||||
|
||||
return {
|
||||
handle: document_.send,
|
||||
tapAt: (target: string) => {
|
||||
caretAt = target
|
||||
document.getElementById(target)!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
},
|
||||
detachContent: () => {
|
||||
editor.innerHTML = '<p>replaced</p>'
|
||||
},
|
||||
focused: () => document.activeElement === editor,
|
||||
selectedContainer: () => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return null
|
||||
}
|
||||
const container = selection.getRangeAt(0).commonAncestorContainer
|
||||
if (container === editor) {
|
||||
return 'editor-end'
|
||||
}
|
||||
const element = container instanceof Element ? container : container.parentElement
|
||||
return element?.id ?? null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (started.length > 0) {
|
||||
started.pop()!.stop()
|
||||
}
|
||||
Reflect.deleteProperty(document, 'caretRangeFromPoint')
|
||||
Reflect.deleteProperty(document, 'execCommand')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('the editor document caret, across a keyboard dismissal', () => {
|
||||
it('blurs the surface, which is what closes the keyboard over a document', () => {
|
||||
const editor = runtime({ caret: 'paragraph-3' })
|
||||
expect(editor.focused()).toBe(true)
|
||||
editor.handle.dismissKeyboard()
|
||||
expect(editor.focused()).toBe(false)
|
||||
expect(editor.selectedContainer()).toBe(null)
|
||||
})
|
||||
|
||||
it('reclaims focus at the tapped caret rather than at the stale one', () => {
|
||||
const editor = runtime({ caret: 'paragraph-3' })
|
||||
editor.handle.dismissKeyboard()
|
||||
editor.tapAt('paragraph-7')
|
||||
expect(editor.focused()).toBe(true)
|
||||
expect(editor.selectedContainer()).toBe('paragraph-7')
|
||||
})
|
||||
|
||||
it('leaves an uneditable label tap to the checkbox instead of refocusing', () => {
|
||||
// A task-list label forwards its click to the checkbox, so refocusing here would steal it and
|
||||
// re-open the keyboard the user just dismissed.
|
||||
const editor = runtime({ caret: 'paragraph-3' })
|
||||
editor.handle.dismissKeyboard()
|
||||
editor.tapAt('task-label')
|
||||
expect(editor.focused()).toBe(false)
|
||||
expect(editor.selectedContainer()).toBe(null)
|
||||
})
|
||||
|
||||
it('restores the pre-dismissal caret so a command does not insert at the end', async () => {
|
||||
const editor = runtime({ caret: 'paragraph-3' })
|
||||
editor.handle.dismissKeyboard()
|
||||
expect(editor.selectedContainer()).toBe(null)
|
||||
await editor.handle.runCommand('bold')
|
||||
expect(editor.selectedContainer()).toBe('paragraph-3')
|
||||
expect(editor.focused()).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the end of the document when no caret was ever placed', async () => {
|
||||
const editor = runtime({ caret: null })
|
||||
await editor.handle.runCommand('bold')
|
||||
expect(editor.selectedContainer()).toBe('editor-end')
|
||||
})
|
||||
|
||||
it('drops a remembered caret whose nodes left the document', async () => {
|
||||
const editor = runtime({ caret: 'paragraph-3' })
|
||||
editor.handle.dismissKeyboard()
|
||||
editor.detachContent()
|
||||
await editor.handle.runCommand('bold')
|
||||
expect(editor.selectedContainer()).toBe('editor-end')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { emitChange } from './editor-content'
|
||||
import { editorElement } from './editor-surface'
|
||||
import type { RichMarkdownEditorScope } from './document-scope'
|
||||
|
||||
export function focusEditor(scope: RichMarkdownEditorScope) {
|
||||
editorElement(scope).focus()
|
||||
}
|
||||
|
||||
/** Keeps a copy of the caret while it is still live, for a blur that is about to drop it. */
|
||||
export function rememberSelection(scope: RichMarkdownEditorScope) {
|
||||
const selection = scope.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return
|
||||
}
|
||||
const range = selection.getRangeAt(0)
|
||||
if (editorElement(scope).contains(range.commonAncestorContainer)) {
|
||||
scope.savedSelectionRange = range.cloneRange()
|
||||
}
|
||||
}
|
||||
|
||||
export function applySelectionRange(scope: RichMarkdownEditorScope, range: Range) {
|
||||
const selection = scope.getSelection()
|
||||
if (!selection) {
|
||||
return
|
||||
}
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
scope.savedSelectionRange = range.cloneRange()
|
||||
}
|
||||
|
||||
/** Where a tap landed, through whichever of the two APIs this engine carries. */
|
||||
export function caretRangeAtPoint(
|
||||
scope: RichMarkdownEditorScope,
|
||||
x: number,
|
||||
y: number
|
||||
): Range | null {
|
||||
const hostDocument = scope.getDocument()
|
||||
if (hostDocument.caretRangeFromPoint) {
|
||||
return hostDocument.caretRangeFromPoint(x, y)
|
||||
}
|
||||
if (!hostDocument.caretPositionFromPoint) {
|
||||
return null
|
||||
}
|
||||
const position = hostDocument.caretPositionFromPoint(x, y)
|
||||
if (!position) {
|
||||
return null
|
||||
}
|
||||
const range = hostDocument.createRange()
|
||||
range.setStart(position.offsetNode, position.offset)
|
||||
range.collapse(true)
|
||||
return range
|
||||
}
|
||||
|
||||
/**
|
||||
* The caret a command should act on: the live one, the one the dismissal saved, or the end.
|
||||
*
|
||||
* WebKit drops the DOM selection on blur, so without the saved range every command after a
|
||||
* keyboard dismissal would insert at the end of the document rather than where the user left off.
|
||||
*/
|
||||
export function restoreSelectionOrEnd(scope: RichMarkdownEditorScope) {
|
||||
focusEditor(scope)
|
||||
const selection = scope.getSelection()
|
||||
if (!selection) {
|
||||
return
|
||||
}
|
||||
const saved = scope.savedSelectionRange
|
||||
if (
|
||||
scope.selectionDroppedOnBlur &&
|
||||
saved &&
|
||||
editorElement(scope).contains(saved.commonAncestorContainer)
|
||||
) {
|
||||
scope.selectionDroppedOnBlur = false
|
||||
applySelectionRange(scope, saved)
|
||||
return
|
||||
}
|
||||
if (selection.rangeCount > 0) {
|
||||
return
|
||||
}
|
||||
const range = scope.getDocument().createRange()
|
||||
range.selectNodeContents(editorElement(scope))
|
||||
range.collapse(false)
|
||||
applySelectionRange(scope, range)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the selection in one element, for the formats `execCommand` has no verb for.
|
||||
*
|
||||
* `surroundContents` refuses a range that crosses an element boundary, and the fallback extracts
|
||||
* and reinserts instead, which is the same result for every selection the toolbar can produce.
|
||||
*/
|
||||
export function wrapSelection(scope: RichMarkdownEditorScope, tagName: string) {
|
||||
restoreSelectionOrEnd(scope)
|
||||
const selection = scope.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return
|
||||
}
|
||||
const range = selection.getRangeAt(0)
|
||||
if (range.collapsed) {
|
||||
return
|
||||
}
|
||||
const wrapper = scope.getDocument().createElement(tagName)
|
||||
try {
|
||||
range.surroundContents(wrapper)
|
||||
} catch {
|
||||
wrapper.appendChild(range.extractContents())
|
||||
range.insertNode(wrapper)
|
||||
}
|
||||
selection.removeAllRanges()
|
||||
selection.selectAllChildren(wrapper)
|
||||
emitChange(scope)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { RICH_MARKDOWN_EDITOR_ELEMENT_ID } from './document-markup'
|
||||
import type { RichMarkdownEditorScope } from './document-scope'
|
||||
|
||||
/**
|
||||
* The editable surface.
|
||||
*
|
||||
* Null only before the start sequence has read it, which nothing exported from these modules is
|
||||
* reachable from: the factory starts the document before it hands a host anything to call. A host
|
||||
* whose markup carries no surface therefore fails on the first property read, exactly as the
|
||||
* script's own unguarded `editor` did.
|
||||
*/
|
||||
export function editorElement(scope: RichMarkdownEditorScope): HTMLElement {
|
||||
return scope.editor!
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the surface out of the host's page, 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.
|
||||
*/
|
||||
export function startEditorSurface(scope: RichMarkdownEditorScope) {
|
||||
scope.editor = scope.getDocument().getElementById(RICH_MARKDOWN_EDITOR_ELEMENT_ID)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
|
||||
import type { RichMarkdownEditorScope } from './document-scope'
|
||||
|
||||
/** Every message the document sends its host goes through here, and through the seam below it. */
|
||||
export function post(scope: RichMarkdownEditorScope, message: MobileRichMarkdownEditorMessage) {
|
||||
scope.postToHost(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* The document's last act at start: it is built and listening, so the host may send content.
|
||||
*
|
||||
* Last of the sequence rather than first, because a host that answers `ready` by setting markdown
|
||||
* would otherwise reach a surface whose listeners are not installed yet — and because the inset
|
||||
* the host lifts its bar by is measured before the host is told there is anything to lift for.
|
||||
*/
|
||||
export function startHostBridge(scope: RichMarkdownEditorScope) {
|
||||
post(scope, { type: 'ready' })
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { inlineChildren, inlineMarkdown, textContent } from './html-inline-markdown'
|
||||
import { listMarkdown } from './html-list-markdown'
|
||||
|
||||
/**
|
||||
* One top-level node of the editable surface as a markdown block.
|
||||
*
|
||||
* Anything with no block of its own — a stray `div`, an element the browser inserted — serializes
|
||||
* as its inline content, so an edit never loses text to a tag this reader does not know.
|
||||
*/
|
||||
export function blockMarkdown(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return textContent(node).trim()
|
||||
}
|
||||
if (!(node instanceof Element)) {
|
||||
return ''
|
||||
}
|
||||
const tag = node.tagName.toLowerCase()
|
||||
if (/^h[1-6]$/.test(tag)) {
|
||||
return `${'#'.repeat(Number(tag.slice(1)))} ${inlineChildren(node).trim()}`
|
||||
}
|
||||
if (tag === 'p' || tag === 'div') {
|
||||
return inlineChildren(node).trim()
|
||||
}
|
||||
if (tag === 'blockquote') {
|
||||
return inlineChildren(node)
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n')
|
||||
}
|
||||
if (tag === 'pre') {
|
||||
const language = node.getAttribute('data-language') ?? ''
|
||||
const code = textContent(node.querySelector('code') ?? node).replace(/\n+$/g, '')
|
||||
return `\`\`\`${language}\n${code}\n\`\`\``
|
||||
}
|
||||
if (tag === 'ul' || tag === 'ol') {
|
||||
return listMarkdown(node, 0)
|
||||
}
|
||||
if (tag === 'table') {
|
||||
const rows = Array.from(node.querySelectorAll('tr'))
|
||||
if (rows.length === 0) {
|
||||
return ''
|
||||
}
|
||||
const cellsFor = (row: Element) =>
|
||||
Array.from(row.children).map((cell) => inlineChildren(cell).trim())
|
||||
const headers = cellsFor(rows[0]!)
|
||||
const bodyRows = rows.slice(1).map(cellsFor)
|
||||
const separator = headers.map(() => '---').join(' | ')
|
||||
const body = bodyRows.length
|
||||
? `\n${bodyRows.map((row) => `| ${row.join(' | ')} |`).join('\n')}`
|
||||
: ''
|
||||
return `| ${headers.join(' | ')} |\n| ${separator} |${body}`
|
||||
}
|
||||
if (tag === 'hr') {
|
||||
return '---'
|
||||
}
|
||||
if (tag === 'img') {
|
||||
return inlineMarkdown(node)
|
||||
}
|
||||
return inlineChildren(node).trim()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/** A node's text with non-breaking spaces turned back into the spaces the source wrote. */
|
||||
export function textContent(node: Node): string {
|
||||
return (node.textContent ?? '').replace(/ /g, ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* One node of the editable surface as inline markdown.
|
||||
*
|
||||
* A task item's `<label>` is the checkbox's chrome rather than content, so it serializes to
|
||||
* nothing and the list writer supplies the marker instead.
|
||||
*/
|
||||
export function inlineMarkdown(node: Node | null | undefined): string {
|
||||
if (!node) {
|
||||
return ''
|
||||
}
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return textContent(node)
|
||||
}
|
||||
if (!(node instanceof Element)) {
|
||||
return ''
|
||||
}
|
||||
const tag = node.tagName.toLowerCase()
|
||||
if (tag === 'br') {
|
||||
return '\n'
|
||||
}
|
||||
if (tag === 'strong' || tag === 'b') {
|
||||
return `**${inlineChildren(node)}**`
|
||||
}
|
||||
if (tag === 'em' || tag === 'i') {
|
||||
return `*${inlineChildren(node)}*`
|
||||
}
|
||||
if (tag === 's' || tag === 'del' || tag === 'strike') {
|
||||
return `~~${inlineChildren(node)}~~`
|
||||
}
|
||||
if (tag === 'code' && node.parentElement && node.parentElement.tagName.toLowerCase() !== 'pre') {
|
||||
return `\`${textContent(node)}\``
|
||||
}
|
||||
if (tag === 'a') {
|
||||
return `[${inlineChildren(node)}](${node.getAttribute('href') ?? ''})`
|
||||
}
|
||||
if (tag === 'img') {
|
||||
return ` ?? ''})`
|
||||
}
|
||||
if (tag === 'label') {
|
||||
return ''
|
||||
}
|
||||
return inlineChildren(node)
|
||||
}
|
||||
|
||||
export function inlineChildren(element: Element): string {
|
||||
return Array.from(element.childNodes)
|
||||
.map((child) => inlineMarkdown(child))
|
||||
.join('')
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { inlineChildren } from './html-inline-markdown'
|
||||
|
||||
/**
|
||||
* An item's own text: its label and any nested list taken off first.
|
||||
*
|
||||
* On a copy, because both belong to the live document — the label carries the checkbox the user
|
||||
* ticks, and the nested lists are serialized separately at their own indentation.
|
||||
*/
|
||||
export function listItemText(item: Element): string {
|
||||
const clone = item.cloneNode(true)
|
||||
// `cloneNode` is typed as returning a `Node`; an element's deep copy is an element.
|
||||
if (!(clone instanceof Element)) {
|
||||
return ''
|
||||
}
|
||||
clone.querySelectorAll('label').forEach((label) => label.remove())
|
||||
clone.querySelectorAll('ul, ol').forEach((list) => list.remove())
|
||||
return inlineChildren(clone).trim()
|
||||
}
|
||||
|
||||
/** The lists directly inside an item, rather than every list anywhere beneath it. */
|
||||
export function directNestedLists(item: Element): Element[] {
|
||||
return Array.from(item.querySelectorAll('ul, ol')).filter((list) => list.closest('li') === item)
|
||||
}
|
||||
|
||||
/**
|
||||
* A list element as markdown, two spaces deeper per level of nesting.
|
||||
*
|
||||
* An ordered item's own number is preferred over its position, because the browser renumbers a
|
||||
* pasted or split item in the markup, and the source has to say what the surface shows.
|
||||
*/
|
||||
export function listMarkdown(element: Element, depth: number): string {
|
||||
const tag = element.tagName.toLowerCase()
|
||||
const isTask = element.getAttribute('data-type') === 'taskList'
|
||||
const parsedStart = tag === 'ol' ? Number.parseInt(element.getAttribute('start') ?? '1', 10) : 1
|
||||
const orderedStart = Number.isFinite(parsedStart) ? parsedStart : 1
|
||||
const indent = ' '.repeat(depth)
|
||||
return Array.from(element.children)
|
||||
.map((item, index) => {
|
||||
if (item.tagName.toLowerCase() !== 'li') {
|
||||
return ''
|
||||
}
|
||||
let marker: string
|
||||
if (isTask) {
|
||||
const input = item.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
||||
marker = `- [${input && input.checked ? 'x' : ' '}] `
|
||||
} else {
|
||||
// `||` rather than `??`: an empty attribute is no number, and the next source answers.
|
||||
const listNumber = item.getAttribute('data-list-number') || item.getAttribute('value')
|
||||
marker = tag === 'ol' ? `${listNumber || orderedStart + index}. ` : '- '
|
||||
}
|
||||
const line = indent + marker + listItemText(item)
|
||||
const nested = directNestedLists(item)
|
||||
.map((list) => listMarkdown(list, depth + 1))
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
return nested ? `${line}\n${nested}` : line
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { rememberSelection } from './editor-selection'
|
||||
import { editorElement } from './editor-surface'
|
||||
import type { RichMarkdownEditorScope } from './document-scope'
|
||||
|
||||
/**
|
||||
* Gives up focus, which is the only thing that closes the keyboard over a document.
|
||||
*
|
||||
* The caret is captured first because WebKit discards the DOM selection on blur, and the flag is
|
||||
* what tells the next command that the selection it cannot see is the saved one.
|
||||
*/
|
||||
export function dismissKeyboard(scope: RichMarkdownEditorScope) {
|
||||
rememberSelection(scope)
|
||||
scope.selectionDroppedOnBlur = true
|
||||
const active = scope.getDocument().activeElement
|
||||
if (active instanceof HTMLElement) {
|
||||
active.blur()
|
||||
}
|
||||
editorElement(scope).blur()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { post } from './host-bridge'
|
||||
import type { RichMarkdownKeyboardInsetReader } from './document-host-seams'
|
||||
import type { RichMarkdownEditorScope } from './document-scope'
|
||||
|
||||
/** Posts the covered height, and only when it has actually moved. */
|
||||
function reportKeyboardInset(
|
||||
scope: RichMarkdownEditorScope,
|
||||
source: RichMarkdownKeyboardInsetReader
|
||||
) {
|
||||
const rounded = Math.round(source.measure())
|
||||
if (rounded === scope.lastInset) {
|
||||
return
|
||||
}
|
||||
scope.lastInset = rounded
|
||||
post(scope, { type: 'keyboardInset', bottom: rounded })
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports how much of the viewport the keyboard covers, while there is a source that knows.
|
||||
*
|
||||
* Inside the WebView that is `visualViewport`, because native `Keyboard` events under-report a
|
||||
* WebView's covered area and the host's bar has to clear it. A host with no source — a page, whose
|
||||
* screen measures the same viewport with the same formula — is told nothing, rather than lifting
|
||||
* its own layout twice.
|
||||
*/
|
||||
export function startKeyboardInset(scope: RichMarkdownEditorScope) {
|
||||
const source = scope.keyboardInsetSource()
|
||||
if (!source) {
|
||||
return
|
||||
}
|
||||
const report = () => reportKeyboardInset(scope, source)
|
||||
scope.removeKeyboardInset = source.observe(report)
|
||||
report()
|
||||
}
|
||||
|
||||
export function stopKeyboardInset(scope: RichMarkdownEditorScope) {
|
||||
scope.removeKeyboardInset?.()
|
||||
scope.removeKeyboardInset = null
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/** The five characters that cannot appear literally in the document's own markup. */
|
||||
const HTML_ESCAPES: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
|
||||
/**
|
||||
* The entities markdown may already carry, resolved before the text is escaped again.
|
||||
*
|
||||
* Without this a source that says `&` renders as `&amp;`: the escape below would take the
|
||||
* ampersand of the entity for a literal one. Numeric forms are range-checked because
|
||||
* `String.fromCodePoint` throws above the Unicode ceiling, and an unresolvable entity is left as
|
||||
* the text it was.
|
||||
*/
|
||||
export function decodeMarkdownEntities(value: string): string {
|
||||
return value.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (match, entity: string) => {
|
||||
const lower = entity.toLowerCase()
|
||||
if (lower === 'amp') {
|
||||
return '&'
|
||||
}
|
||||
if (lower === 'lt') {
|
||||
return '<'
|
||||
}
|
||||
if (lower === 'gt') {
|
||||
return '>'
|
||||
}
|
||||
if (lower === 'quot') {
|
||||
return '"'
|
||||
}
|
||||
if (lower === 'apos') {
|
||||
return "'"
|
||||
}
|
||||
if (lower.startsWith('#x')) {
|
||||
const hex = Number.parseInt(lower.slice(2), 16)
|
||||
return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match
|
||||
}
|
||||
if (lower.startsWith('#')) {
|
||||
const code = Number.parseInt(lower.slice(1), 10)
|
||||
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff
|
||||
? String.fromCodePoint(code)
|
||||
: match
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return decodeMarkdownEntities(value).replace(/[&<>"']/g, (char) => HTML_ESCAPES[char] ?? char)
|
||||
}
|
||||
|
||||
/** An attribute value cannot carry a newline, which would end it in the markup being built. */
|
||||
export function escapeAttr(value: string): string {
|
||||
return escapeHtml(value).replace(/\n/g, ' ')
|
||||
}
|
||||
|
||||
/** The one scheme a link or image in untrusted markdown may not carry. */
|
||||
export function isSafeUrl(value: string | null | undefined): boolean {
|
||||
return !/^javascript:/i.test(String(value ?? '').trim())
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { escapeAttr, escapeHtml, isSafeUrl } from './markdown-escaping'
|
||||
|
||||
/**
|
||||
* One line of markdown as inline markup: images, code, strikethrough, emphasis, links and bare
|
||||
* URLs, with everything between them escaped.
|
||||
*
|
||||
* The pattern is built per call rather than shared: it is a global regex read with `exec`, so a
|
||||
* module-level one would carry its `lastIndex` into the next call — and this function recurses
|
||||
* into its own matches, so the next call is usually itself.
|
||||
*/
|
||||
export function renderInline(text: string): string {
|
||||
const pattern =
|
||||
/(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g
|
||||
let output = ''
|
||||
let lastIndex = 0
|
||||
let match = pattern.exec(text)
|
||||
while (match !== null) {
|
||||
output += escapeHtml(text.slice(lastIndex, match.index))
|
||||
const token = match[0]
|
||||
const image = token.match(/^!\[([^\]]*)\]\(([^)]+)\)$/)
|
||||
const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
|
||||
if (image && isSafeUrl(image[2])) {
|
||||
output += `<img src="${escapeAttr(image[2]!)}" alt="${escapeAttr(image[1] ?? '')}" />`
|
||||
} else if (link && isSafeUrl(link[2])) {
|
||||
output += `<a href="${escapeAttr(link[2]!)}">${renderInline(link[1]!)}</a>`
|
||||
} else if (/^https?:\/\//i.test(token)) {
|
||||
output += `<a href="${escapeAttr(token)}">${escapeHtml(token)}</a>`
|
||||
} else if (token.startsWith('`')) {
|
||||
output += `<code>${escapeHtml(token.slice(1, -1))}</code>`
|
||||
} else if (token.startsWith('~~')) {
|
||||
output += `<s>${renderInline(token.slice(2, -2))}</s>`
|
||||
} else if (token.startsWith('**') || token.startsWith('__')) {
|
||||
output += `<strong>${renderInline(token.slice(2, -2))}</strong>`
|
||||
} else {
|
||||
output += `<em>${renderInline(token.slice(1, -1))}</em>`
|
||||
}
|
||||
lastIndex = pattern.lastIndex
|
||||
match = pattern.exec(text)
|
||||
}
|
||||
return output + escapeHtml(text.slice(lastIndex))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/** One list line, with its marker read and its nesting resolved against the lines around it. */
|
||||
export type ParsedListItem = {
|
||||
indent: number
|
||||
ordered: boolean
|
||||
orderedNumber: number | null
|
||||
/** Whether the item's checkbox is ticked, or null when it is not a task at all. */
|
||||
task: boolean | null
|
||||
text: string
|
||||
children: ParsedListItem[]
|
||||
}
|
||||
|
||||
/** A tab indents as far as four spaces, so mixed indentation still nests the way it looks. */
|
||||
export function indentationWidth(value: string): number {
|
||||
return value.replace(/\t/g, ' ').length
|
||||
}
|
||||
|
||||
export function parseListLine(line: string): ParsedListItem | null {
|
||||
const match = line.match(/^(\s*)((?:[-*+])|(?:\d+[.)]))\s+(.+)$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const marker = match[2] ?? ''
|
||||
const rawText = match[3] ?? ''
|
||||
const task = rawText.match(/^\[([ xX])\]\s+(.+)$/)
|
||||
const ordered = /^\d/.test(marker)
|
||||
return {
|
||||
indent: indentationWidth(match[1] ?? ''),
|
||||
ordered,
|
||||
orderedNumber: ordered ? Number.parseInt(marker, 10) : null,
|
||||
task: task ? task[1]!.toLowerCase() === 'x' : null,
|
||||
text: task ? task[2]! : rawText,
|
||||
children: []
|
||||
}
|
||||
}
|
||||
|
||||
/** Which of the three list shapes an item belongs to; a run of one kind becomes one list. */
|
||||
export function listKind(item: ParsedListItem): 'task' | 'ol' | 'ul' {
|
||||
if (item.task !== null) {
|
||||
return 'task'
|
||||
}
|
||||
return item.ordered ? 'ol' : 'ul'
|
||||
}
|
||||
|
||||
/**
|
||||
* The run of list lines starting at an index, as a tree, and where the run ended.
|
||||
*
|
||||
* A stack rather than recursion because indentation can drop by more than one level at a time, and
|
||||
* the caller needs the index the run stopped at to carry on reading blocks after it.
|
||||
*/
|
||||
export function parseListTree(
|
||||
lines: string[],
|
||||
startIndex: number
|
||||
): { items: ParsedListItem[]; nextIndex: number } {
|
||||
type ListLevel = { indent: number; children: ParsedListItem[] }
|
||||
const root: ListLevel = { indent: -1, children: [] }
|
||||
const stack: ListLevel[] = [root]
|
||||
let index = startIndex
|
||||
while (index < lines.length) {
|
||||
const item = parseListLine(lines[index] ?? '')
|
||||
if (!item) {
|
||||
break
|
||||
}
|
||||
while (stack.length > 1 && item.indent <= stack[stack.length - 1]!.indent) {
|
||||
stack.pop()
|
||||
}
|
||||
stack[stack.length - 1]!.children.push(item)
|
||||
stack.push(item)
|
||||
index += 1
|
||||
}
|
||||
return { items: root.children, nextIndex: index }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { renderInline } from './markdown-inline-render'
|
||||
import { listKind, type ParsedListItem } from './markdown-list-parse'
|
||||
import type { RichMarkdownEditorScope } from './document-scope'
|
||||
|
||||
/**
|
||||
* A parsed list tree as markup, one list element per run of a single kind.
|
||||
*
|
||||
* A task item's checkbox mirrors the surface's own editability, because a checkbox left enabled
|
||||
* under a read-only document is a control the user can move and the document will not record.
|
||||
*/
|
||||
export function renderListItems(scope: RichMarkdownEditorScope, items: ParsedListItem[]): string {
|
||||
const html: string[] = []
|
||||
let index = 0
|
||||
while (index < items.length) {
|
||||
const kind = listKind(items[index]!)
|
||||
const group: ParsedListItem[] = []
|
||||
while (index < items.length && listKind(items[index]!) === kind) {
|
||||
group.push(items[index]!)
|
||||
index += 1
|
||||
}
|
||||
const tag = kind === 'ol' ? 'ol' : 'ul'
|
||||
const attrs =
|
||||
kind === 'task'
|
||||
? ' data-type="taskList"'
|
||||
: kind === 'ol' && group[0]!.orderedNumber !== null
|
||||
? ` start="${group[0]!.orderedNumber}"`
|
||||
: ''
|
||||
const rendered = group
|
||||
.map((item) => {
|
||||
const children = item.children.length ? renderListItems(scope, item.children) : ''
|
||||
if (kind === 'task') {
|
||||
const checked = item.task === true
|
||||
return (
|
||||
`<li data-checked="${String(checked)}"><label contenteditable="false">` +
|
||||
`<input type="checkbox" ${checked ? 'checked ' : ''}${scope.editable ? '' : 'disabled '}/>` +
|
||||
`</label><div><p>${renderInline(item.text)}</p>${children}</div></li>`
|
||||
)
|
||||
}
|
||||
const orderedAttrs =
|
||||
kind === 'ol' && item.orderedNumber !== null
|
||||
? ` value="${item.orderedNumber}" data-list-number="${item.orderedNumber}"`
|
||||
: ''
|
||||
return `<li${orderedAttrs}><p>${renderInline(item.text)}</p>${children}</li>`
|
||||
})
|
||||
.join('')
|
||||
html.push(`<${tag}${attrs}>${rendered}</${tag}>`)
|
||||
}
|
||||
return html.join('')
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createRichMarkdownEditorScope } from './document-scope'
|
||||
import { listMarkdown } from './html-list-markdown'
|
||||
import { markdownToHtml } from './markdown-to-html'
|
||||
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
|
||||
import { currentMarkdown } from './editor-content'
|
||||
import { startEditorSurface } from './editor-surface'
|
||||
|
||||
/**
|
||||
* Markdown into the surface and back out of it, over a real document.
|
||||
*
|
||||
* These are the document's two halves and they are only correct together: a renderer that loses an
|
||||
* ordered list's start and a serializer that renumbers from one agree with each other and lose the
|
||||
* user's text. So each case renders, then reads the markup back, and the source it began with is
|
||||
* the assertion.
|
||||
*
|
||||
* Against real elements rather than shaped objects. The serializer reads `closest`, `children`,
|
||||
* `cloneNode` and a checkbox's `checked`, and a stand-in for those is a stand-in for the thing
|
||||
* being tested — which is how the old fixtures could describe a list no renderer would produce.
|
||||
*/
|
||||
function surface(markdown: string, options: { editable?: boolean } = {}) {
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
const scope = createRichMarkdownEditorScope()
|
||||
scope.editable = options.editable ?? true
|
||||
startEditorSurface(scope)
|
||||
const editor = document.getElementById('editor')!
|
||||
editor.innerHTML = markdownToHtml(scope, markdown)
|
||||
return { scope, editor, html: editor.innerHTML }
|
||||
}
|
||||
|
||||
describe('the editor document, from markdown and back', () => {
|
||||
it('renders and serializes nested bullet, ordered and task lists with indentation intact', () => {
|
||||
const markdown = [
|
||||
'- Parent',
|
||||
' 1. Ordered child',
|
||||
' - [x] Done task',
|
||||
' - [ ] Open task',
|
||||
'- Sibling'
|
||||
].join('\n')
|
||||
|
||||
const { scope, html } = surface(markdown)
|
||||
|
||||
expect(html).toContain(
|
||||
'<ul><li><p>Parent</p><ol start="1"><li value="1" data-list-number="1"><p>Ordered child</p>'
|
||||
)
|
||||
expect(html).toContain('<ul data-type="taskList">')
|
||||
expect(html).toContain('<li><p>Sibling</p></li></ul>')
|
||||
expect(currentMarkdown(scope)).toBe(markdown)
|
||||
})
|
||||
|
||||
it('renders markdown entities as characters without double-escaping them', () => {
|
||||
const { html } = surface('R&D & Sales and <tag>')
|
||||
|
||||
expect(html).toContain('R&D & Sales and <tag>')
|
||||
expect(html).not.toContain('&amp;')
|
||||
})
|
||||
|
||||
it('preserves explicit ordered-list numbering through the round trip', () => {
|
||||
const markdown = ['3. Third step', '4. Fourth step'].join('\n')
|
||||
const { scope, html } = surface(markdown)
|
||||
|
||||
expect(html).toContain('<ol start="3">')
|
||||
expect(html).toContain('data-list-number="3"')
|
||||
expect(currentMarkdown(scope)).toBe(markdown)
|
||||
})
|
||||
|
||||
it('serializes ordered lists from the parent start when item metadata is missing', () => {
|
||||
// What a paste leaves behind: the list carries a start and its items carry nothing, which is
|
||||
// the one case where position rather than an attribute decides the number.
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
const editor = document.getElementById('editor')!
|
||||
editor.innerHTML = '<ol start="8"><li><p>Pasted step</p></li><li><p>Inserted step</p></li></ol>'
|
||||
|
||||
expect(listMarkdown(editor.firstElementChild!, 0)).toBe(
|
||||
['8. Pasted step', '9. Inserted step'].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('renders task checkboxes as disabled while the surface is read-only', () => {
|
||||
expect(surface('- [ ] Read-only task', { editable: false }).html).toContain(
|
||||
'type="checkbox" disabled'
|
||||
)
|
||||
expect(surface('- [ ] Editable task').html).not.toContain('disabled')
|
||||
})
|
||||
|
||||
it('round-trips every block the toolbar can produce', () => {
|
||||
const markdown = [
|
||||
'# Title',
|
||||
'',
|
||||
'Body with **bold**, *italic*, ~~strike~~ and `code`.',
|
||||
'',
|
||||
'> Quoted line',
|
||||
'',
|
||||
'| a | b |',
|
||||
'| --- | --- |',
|
||||
'| 1 | 2 |',
|
||||
'',
|
||||
'```ts',
|
||||
'const x = 1',
|
||||
'```',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'[docs](https://example.com/docs)',
|
||||
'',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
expect(currentMarkdown(surface(markdown).scope)).toBe(markdown)
|
||||
})
|
||||
|
||||
it('makes progress on a marker with nothing after it, rather than reading it forever', () => {
|
||||
// `isBlockStart` admits `# ` and the list test admits `- `, but the heading reader needs text
|
||||
// after the hashes and `parseListLine` needs text after the marker, so neither consumed the
|
||||
// line and the index never moved: `markdownToHtml` looped forever on a one-line source the
|
||||
// host could hand it from any file. Bare markers are text.
|
||||
expect(markdownToHtml(createRichMarkdownEditorScope(), '# ')).toBe('<p># </p>')
|
||||
expect(markdownToHtml(createRichMarkdownEditorScope(), '- ')).toBe('<p>- </p>')
|
||||
expect(markdownToHtml(createRichMarkdownEditorScope(), '1. ')).toBe('<p>1. </p>')
|
||||
// The control, so the guard is not swallowing the readers it falls back from.
|
||||
expect(markdownToHtml(createRichMarkdownEditorScope(), '# ok')).toBe('<h1>ok</h1>')
|
||||
expect(markdownToHtml(createRichMarkdownEditorScope(), '- ok')).toBe(
|
||||
'<ul><li><p>ok</p></li></ul>'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders no link for a javascript: URL, which is the one scheme it filters', () => {
|
||||
// The refused token falls through to the emphasis branch, so the URL survives as inert text
|
||||
// rather than disappearing. What must not survive is an element that can be tapped.
|
||||
const { html } = surface('[tap](javascript:alert(1))')
|
||||
expect(html).not.toContain('<a')
|
||||
expect(html).not.toContain('href')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
/** A table row's cells, with the optional leading and trailing pipes taken off. */
|
||||
export function splitTableRow(line: string): string[] {
|
||||
return line
|
||||
.trim()
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim())
|
||||
}
|
||||
|
||||
/** The dashed row under a header, which is what makes the line above it a table rather than text. */
|
||||
export function isTableSeparator(line: string): boolean {
|
||||
const cells = splitTableRow(line)
|
||||
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell))
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { escapeAttr, escapeHtml } from './markdown-escaping'
|
||||
import { renderInline } from './markdown-inline-render'
|
||||
import { renderListItems } from './markdown-list-render'
|
||||
import { parseListTree } from './markdown-list-parse'
|
||||
import { isTableSeparator, splitTableRow } from './markdown-table-rows'
|
||||
import type { RichMarkdownEditorScope } from './document-scope'
|
||||
|
||||
/** Whether a line opens a block of its own, which is what ends the paragraph being gathered. */
|
||||
export function isBlockStart(line: string): boolean {
|
||||
return /^(```|#{1,6}\s+|>\s?|\s*(?:[-*+]|\d+[.)])\s+|\s*(-{3,}|\*{3,}|_{3,})\s*$)/.test(line)
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown as the markup the editable surface holds.
|
||||
*
|
||||
* Block by block rather than by one pass of replacements, because fenced code, tables and lists
|
||||
* each consume a run of lines whose length only their own reader knows. An empty source still
|
||||
* renders a paragraph, which is what carries the placeholder.
|
||||
*/
|
||||
export function markdownToHtml(scope: RichMarkdownEditorScope, markdown: string): string {
|
||||
const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
|
||||
const html: string[] = []
|
||||
let index = 0
|
||||
while (index < lines.length) {
|
||||
const line = lines[index] ?? ''
|
||||
if (!line.trim()) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
const fence = line.match(/^```([^\s`]*)\s*$/)
|
||||
if (fence) {
|
||||
index += 1
|
||||
const code: string[] = []
|
||||
while (index < lines.length && !/^```\s*$/.test(lines[index] ?? '')) {
|
||||
code.push(lines[index] ?? '')
|
||||
index += 1
|
||||
}
|
||||
if (index < lines.length) {
|
||||
index += 1
|
||||
}
|
||||
html.push(
|
||||
`<pre data-language="${escapeAttr(fence[1] ?? '')}"><code>${escapeHtml(code.join('\n'))}</code></pre>`
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
||||
html.push('<hr />')
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (
|
||||
line.includes('|') &&
|
||||
index + 1 < lines.length &&
|
||||
isTableSeparator(lines[index + 1] ?? '')
|
||||
) {
|
||||
const headers = splitTableRow(line)
|
||||
index += 2
|
||||
const rows: string[][] = []
|
||||
while (
|
||||
index < lines.length &&
|
||||
(lines[index] ?? '').includes('|') &&
|
||||
(lines[index] ?? '').trim()
|
||||
) {
|
||||
rows.push(splitTableRow(lines[index] ?? ''))
|
||||
index += 1
|
||||
}
|
||||
const head = headers.map((cell) => `<th>${renderInline(cell)}</th>`).join('')
|
||||
const body = rows
|
||||
.map(
|
||||
(row) =>
|
||||
`<tr>${headers.map((_, cellIndex) => `<td>${renderInline(row[cellIndex] ?? '')}</td>`).join('')}</tr>`
|
||||
)
|
||||
.join('')
|
||||
html.push(`<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`)
|
||||
continue
|
||||
}
|
||||
const heading = line.match(/^(#{1,6})\s+(.+)$/)
|
||||
if (heading) {
|
||||
const level = heading[1]!.length
|
||||
html.push(`<h${level}>${renderInline(heading[2]!.trim())}</h${level}>`)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (/^>\s?/.test(line)) {
|
||||
const quote: string[] = []
|
||||
while (index < lines.length && /^>\s?/.test(lines[index] ?? '')) {
|
||||
quote.push((lines[index] ?? '').replace(/^>\s?/, ''))
|
||||
index += 1
|
||||
}
|
||||
html.push(
|
||||
`<blockquote><p>${renderInline(quote.join('\n').trim()).replace(/\n/g, '<br />')}</p></blockquote>`
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (/^\s*(?:[-*+]|\d+[.)])\s+/.test(line)) {
|
||||
const list = parseListTree(lines, index)
|
||||
// Why: a marker with nothing after it parses as no item, so the run is empty and the index
|
||||
// has not moved. Falling through rather than continuing makes the line the text it is.
|
||||
if (list.nextIndex > index) {
|
||||
html.push(renderListItems(scope, list.items))
|
||||
index = list.nextIndex
|
||||
continue
|
||||
}
|
||||
}
|
||||
const paragraph: string[] = []
|
||||
while (
|
||||
index < lines.length &&
|
||||
(lines[index] ?? '').trim() &&
|
||||
!isBlockStart(lines[index] ?? '') &&
|
||||
!(
|
||||
index + 1 < lines.length &&
|
||||
(lines[index] ?? '').includes('|') &&
|
||||
isTableSeparator(lines[index + 1] ?? '')
|
||||
)
|
||||
) {
|
||||
paragraph.push(lines[index] ?? '')
|
||||
index += 1
|
||||
}
|
||||
if (paragraph.length === 0) {
|
||||
// Why: a line that opens a block by `isBlockStart` but matches no block reader's own grammar
|
||||
// — `# `, `- `, a fence with a backtick in its language — is gathered by nothing, and the
|
||||
// loop would read it again forever. It is text.
|
||||
paragraph.push(lines[index] ?? '')
|
||||
index += 1
|
||||
}
|
||||
html.push(`<p>${renderInline(paragraph.join('\n')).replace(/\n/g, '<br />')}</p>`)
|
||||
}
|
||||
return html.join('\n') || '<p class="is-empty"><br /></p>'
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { richMarkdownEditorBundle } from '../../../scripts/build-rich-markdown-editor-script.mjs'
|
||||
import { RICH_MARKDOWN_EDITOR_DOCUMENT_SCRIPT } from '../rich-markdown-editor-document-script.generated'
|
||||
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
|
||||
import { escapeInjectedJavaScriptString } from '../mobile-rich-markdown-editor-script-string'
|
||||
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
bundleDigestBuiltFrom,
|
||||
machinePathCommentsIn
|
||||
} from '../../test-support/webview-document-bundle-digest'
|
||||
|
||||
/**
|
||||
* The bundle runs, and it is the same document.
|
||||
*
|
||||
* The script the WebView loads is no longer a string a concatenator wrote, so it cannot be
|
||||
* compared line by line with a golden — and esbuild renames what collides, which would make every
|
||||
* text assertion against it a match on a rename. What replaces the byte pin is this: the bundle is
|
||||
* executed exactly as the WebView executes it, with the same globals its HTML declares, and then
|
||||
* driven through the injected handle the native component reaches it by.
|
||||
*
|
||||
* That covers the whole path no text assertion ever touched — the entry, the start sequence, the
|
||||
* surface read, the listeners and the global install — and it is the one thing that says the
|
||||
* bundle is a working document rather than a well-formed string. The module tests beside it say
|
||||
* each part does its job.
|
||||
*/
|
||||
const evaluated: (() => void)[] = []
|
||||
|
||||
type Recorded = { command: string; value: string | undefined }
|
||||
|
||||
/**
|
||||
* The WebView's own act: the globals its page carries, then the script.
|
||||
*
|
||||
* `execCommand`, `prompt` and `visualViewport` are the WebView's and happy-dom implements none of
|
||||
* them, so a case that wants the document's answer has to supply the browser's half. Recording
|
||||
* rather than applying: what the document decides is which verb and value go to the engine.
|
||||
*/
|
||||
function evaluateBundle(options: { prompt?: string | null } = {}) {
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
const posted: MobileRichMarkdownEditorMessage[] = []
|
||||
const commands: Recorded[] = []
|
||||
const viewportListeners: string[] = []
|
||||
const viewport = {
|
||||
height: 500,
|
||||
offsetTop: 20,
|
||||
addEventListener: (name: string) => viewportListeners.push(name),
|
||||
removeEventListener: () => {}
|
||||
}
|
||||
Object.assign(globalThis, {
|
||||
ReactNativeWebView: {
|
||||
postMessage: (message: string) => posted.push(JSON.parse(message))
|
||||
},
|
||||
prompt: () => options.prompt ?? null,
|
||||
visualViewport: viewport,
|
||||
innerHeight: 800
|
||||
})
|
||||
document.execCommand = (command: string, _showUI?: boolean, value?: string) => {
|
||||
commands.push({ command, value })
|
||||
return true
|
||||
}
|
||||
|
||||
new Function(RICH_MARKDOWN_EDITOR_DOCUMENT_SCRIPT)()
|
||||
const handle = window.__orcaRichMarkdown!
|
||||
evaluated.push(() => {
|
||||
Reflect.deleteProperty(globalThis, '__orcaRichMarkdown')
|
||||
})
|
||||
return { posted, commands, viewportListeners, handle }
|
||||
}
|
||||
|
||||
/** The native component's transport: a script evaluated in the document's own page. */
|
||||
function inject(script: string) {
|
||||
new Function(`${script}\ntrue;`)()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (evaluated.length > 0) {
|
||||
evaluated.pop()!()
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('the bundled rich Markdown editor document', () => {
|
||||
it('starts, measures the keyboard, and reports itself ready in that order', () => {
|
||||
const { posted, viewportListeners, handle } = evaluateBundle()
|
||||
expect(posted).toEqual([{ type: 'keyboardInset', bottom: 280 }, { type: 'ready' }])
|
||||
expect(viewportListeners).toEqual(['resize', 'scroll'])
|
||||
// The five members the native component reaches through `injectJavaScript`.
|
||||
expect(Object.keys(handle).sort()).toEqual([
|
||||
'currentMarkdown',
|
||||
'dismissKeyboard',
|
||||
'runCommand',
|
||||
'setEditable',
|
||||
'setMarkdown'
|
||||
])
|
||||
})
|
||||
|
||||
it('takes markdown through the injected handle and gives back the source it was given', () => {
|
||||
const { handle } = evaluateBundle()
|
||||
const markdown = [
|
||||
'# Title',
|
||||
'',
|
||||
'A paragraph with **bold**, *italic* and `code`.',
|
||||
'',
|
||||
'- Parent',
|
||||
' 1. Ordered child',
|
||||
' - [x] Done task',
|
||||
' - [ ] Open task',
|
||||
'- Sibling',
|
||||
'',
|
||||
'> Quoted',
|
||||
'',
|
||||
'| a | b |',
|
||||
'| --- | --- |',
|
||||
'| 1 | 2 |',
|
||||
'',
|
||||
'```ts',
|
||||
'const x = 1',
|
||||
'```',
|
||||
'',
|
||||
'---'
|
||||
].join('\n')
|
||||
// Through the transport the native component uses, escaping included.
|
||||
inject(`window.__orcaRichMarkdown.setMarkdown(${escapeInjectedJavaScriptString(markdown)}, 7);`)
|
||||
expect(handle.currentMarkdown()).toBe(markdown)
|
||||
})
|
||||
|
||||
it('reports an edit under the generation the host set, and stops when told it is read-only', () => {
|
||||
const { posted, handle } = evaluateBundle()
|
||||
handle.setMarkdown('first', 4)
|
||||
posted.length = 0
|
||||
document.getElementById('editor')!.dispatchEvent(new Event('input'))
|
||||
expect(posted).toEqual([{ type: 'change', markdown: 'first', generation: 4 }])
|
||||
|
||||
posted.length = 0
|
||||
handle.setEditable(false)
|
||||
document.getElementById('editor')!.dispatchEvent(new Event('input'))
|
||||
expect(posted).toEqual([])
|
||||
expect(document.getElementById('editor')!.getAttribute('contenteditable')).toBe('false')
|
||||
})
|
||||
|
||||
it('sends every toolbar command to the engine, and asks for the URL the two need', async () => {
|
||||
const { commands, handle } = evaluateBundle({ prompt: 'https://example.com/a' })
|
||||
handle.setMarkdown('body', 1)
|
||||
for (const command of [
|
||||
'paragraph',
|
||||
'heading1',
|
||||
'heading2',
|
||||
'heading3',
|
||||
'bold',
|
||||
'italic',
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'taskList',
|
||||
'quote',
|
||||
'codeBlock',
|
||||
'link',
|
||||
'image'
|
||||
] as const) {
|
||||
await handle.runCommand(command)
|
||||
}
|
||||
expect(commands.map((entry) => entry.command)).toEqual([
|
||||
'formatBlock',
|
||||
'formatBlock',
|
||||
'formatBlock',
|
||||
'formatBlock',
|
||||
'bold',
|
||||
'italic',
|
||||
'strikeThrough',
|
||||
'insertUnorderedList',
|
||||
'insertOrderedList',
|
||||
'insertHTML',
|
||||
'formatBlock',
|
||||
'insertHTML',
|
||||
'createLink',
|
||||
'insertImage'
|
||||
])
|
||||
expect(commands.slice(-2).map((entry) => entry.value)).toEqual([
|
||||
'https://example.com/a',
|
||||
'https://example.com/a'
|
||||
])
|
||||
// The fifteenth needs a selection to wrap and reaches no engine verb at all.
|
||||
expect(commands.map((entry) => entry.command)).not.toContain('inlineCode')
|
||||
})
|
||||
|
||||
it('refuses a javascript: URL from the dialog, which is the one scheme the document filters', async () => {
|
||||
const { commands, handle } = evaluateBundle({ prompt: 'javascript:alert(1)' })
|
||||
await handle.runCommand('link')
|
||||
expect(commands).toEqual([])
|
||||
})
|
||||
|
||||
it('opens a tapped link through the host rather than navigating', () => {
|
||||
const { posted, handle } = evaluateBundle()
|
||||
handle.setMarkdown('[docs](https://example.com/docs)', 1)
|
||||
posted.length = 0
|
||||
document.querySelector('#editor a')!.dispatchEvent(new Event('click', { bubbles: true }))
|
||||
expect(posted).toContainEqual({ type: 'openLink', url: 'https://example.com/docs' })
|
||||
})
|
||||
|
||||
it('is the same bytes wherever its generator was run from', () => {
|
||||
// The artifact is committed by a postinstall run whose working directory is whatever the
|
||||
// installer happened to be in, and every case above compares it with a build made here. So the
|
||||
// build has to be cwd-independent, which is what `absWorkingDir` buys: without it this digest
|
||||
// and the one from the temp directory differ, and the artifact carries a machine path.
|
||||
// `import.meta.dirname`, because a case in the DOM environment has no file URL to convert.
|
||||
const generator = join(
|
||||
import.meta.dirname,
|
||||
'../../../scripts/build-rich-markdown-editor-script.mjs'
|
||||
)
|
||||
const here = createHash('sha256').update(RICH_MARKDOWN_EDITOR_DOCUMENT_SCRIPT).digest('hex')
|
||||
expect(bundleDigestBuiltFrom(tmpdir(), generator, 'richMarkdownEditorBundle')).toBe(here)
|
||||
expect(machinePathCommentsIn(RICH_MARKDOWN_EDITOR_DOCUMENT_SCRIPT)).toEqual([])
|
||||
}, 30_000)
|
||||
|
||||
it('carries the document and nothing else: no dependency rides into the WebView', async () => {
|
||||
// The document imports ordinary modules now, so an import added anywhere in its graph reaches
|
||||
// the phone's script. One build, read four ways; the last assertion is what makes the other
|
||||
// three about the artifact that ships rather than about a bundle this case built for itself.
|
||||
const { script, inputs } = await richMarkdownEditorBundle()
|
||||
expect(inputs.filter((input) => input.includes('node_modules'))).toEqual([])
|
||||
expect(inputs).toHaveLength(22)
|
||||
expect(script).not.toContain('__commonJS')
|
||||
// `__esm` wrappers are esbuild's answer to a cycle, and a cycle would make a module's top level
|
||||
// run at first import rather than where the bundle places it.
|
||||
expect(script).not.toContain('__esm(')
|
||||
expect(RICH_MARKDOWN_EDITOR_DOCUMENT_SCRIPT).toBe(script)
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
|
||||
|
||||
/**
|
||||
* The WebView's document: one call, no host, and the handle hung where the host can reach it.
|
||||
*
|
||||
* Inside the WebView every seam is the window read the document always did, so the host argument
|
||||
* is empty and the defaults answer. The host's transport is `injectJavaScript`, which is a script
|
||||
* evaluated in this page rather than a message, so the handle has to be a global — and `stop` is
|
||||
* dropped on purpose, because there the document outlives nothing.
|
||||
*
|
||||
* This file exists to be bundled. It is the entry `build-rich-markdown-editor-script.mjs` hands to
|
||||
* esbuild, and the only module in the document with a statement at its top level.
|
||||
*/
|
||||
window.__orcaRichMarkdown = createRichMarkdownEditorDocument().send
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
documentModuleNames,
|
||||
documentModuleSource,
|
||||
exportedLifecycleFunctions,
|
||||
moduleLevelMutableBindings,
|
||||
parseTimeEffects,
|
||||
sequenceCalls,
|
||||
topLevelDeclarationsReachAnElement
|
||||
} from '../../test-support/webview-document-census'
|
||||
|
||||
/**
|
||||
* Rulings 20 and 21 over the editor's document, checked by the readers the terminal's census uses.
|
||||
*
|
||||
* The hand-written script could read `#editor` and install its listeners as it was parsed, because
|
||||
* the WebView re-parses the whole document on every load. These modules are imported, and an ES
|
||||
* module body runs once per page: a read or a listener left at a module's top level would hand the
|
||||
* page's second mount the first mount's element and install nothing, which is a dead editor that
|
||||
* reports itself ready.
|
||||
*
|
||||
* So every effect lives in a start function both hosts call, and every mutable binding lives on
|
||||
* the scope rather than in a module, which is what makes two editors on one page two editors.
|
||||
*/
|
||||
const DIRECTORY = import.meta.dirname
|
||||
|
||||
/** The entry is the one file allowed a statement at its top level — it is the call. */
|
||||
const ENTRY = 'native-document-entry'
|
||||
|
||||
/** The sequence that calls the starts, which is not a module with a start of its own. */
|
||||
const THE_SEQUENCE = 'create-rich-markdown-editor-document'
|
||||
|
||||
const MODULES = documentModuleNames(DIRECTORY, [ENTRY])
|
||||
|
||||
const moduleSource = (name: string) => documentModuleSource(DIRECTORY, name)
|
||||
|
||||
const sequenceCallsTo = (functionName: string) =>
|
||||
sequenceCalls(moduleSource(THE_SEQUENCE), functionName, [
|
||||
'startRichMarkdownEditorDocument',
|
||||
'stopRichMarkdownEditorDocument'
|
||||
])
|
||||
|
||||
describe('the rich Markdown editor document at parse time', () => {
|
||||
it('does no work: every effect is in a start function the hosts call', () => {
|
||||
expect(MODULES.length).toBeGreaterThan(15)
|
||||
expect(MODULES.flatMap((name) => parseTimeEffects(name, moduleSource(name)))).toEqual([])
|
||||
})
|
||||
|
||||
it('declares nothing that reaches an element', () => {
|
||||
for (const name of MODULES) {
|
||||
expect({
|
||||
name,
|
||||
reaches: topLevelDeclarationsReachAnElement(name, moduleSource(name))
|
||||
}).toEqual({ name, reaches: false })
|
||||
}
|
||||
})
|
||||
|
||||
it('would report work in every declaration that runs as the module is evaluated', () => {
|
||||
// Three shapes, because a reader that knew only the first would accept the other two and the
|
||||
// empty list above would be about nothing. A statement-kind filter waves all three through:
|
||||
// each is a declaration by shape and parse-time work by effect.
|
||||
const planted: [string, string, string][] = [
|
||||
[
|
||||
'a const read from the document',
|
||||
"const editor = document.getElementById('editor')\n",
|
||||
"planted: editor = document.getElementById('editor')"
|
||||
],
|
||||
[
|
||||
'a static class member',
|
||||
'class Reporter {\n static installed = install()\n}\n',
|
||||
'planted: static installed = install()'
|
||||
],
|
||||
['a default export that is an expression', 'export default install()\n', 'planted: install()']
|
||||
]
|
||||
expect(
|
||||
planted.map(([written, source]) => [written, parseTimeEffects('planted', source)])
|
||||
).toEqual(planted.map(([written, , named]) => [written, [named]]))
|
||||
})
|
||||
|
||||
it('leaves declarations that only declare alone, so the empty list is a measurement', () => {
|
||||
// The other direction, and the two that look like the shapes above but are not: an instance
|
||||
// field runs per `new`, and nothing in a document is ever constructed at parse; a default
|
||||
// export of a function declares a body that runs when something calls it.
|
||||
const inert = [
|
||||
'const options = { capture: true, passive: false }\n',
|
||||
'class Reporter {\n pending = install()\n}\n',
|
||||
'export default function () {\n return install()\n}\n'
|
||||
]
|
||||
expect(inert.map((source) => parseTimeEffects('inert', source))).toEqual([[], [], []])
|
||||
})
|
||||
|
||||
it('holds no mutable binding of its own: every one is a field of the scope', () => {
|
||||
// Ruling 21. The factory gives each call its own scope, so a `let` in a module would be the one
|
||||
// thing two editors on one page still shared — the second mount would inherit the first's
|
||||
// generation, its remembered caret and its last reported inset.
|
||||
expect(MODULES.flatMap((name) => moduleLevelMutableBindings(name, moduleSource(name)))).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('would name one in every shape a module can write it', () => {
|
||||
// The precondition, and it is per shape rather than one sample: a line match would have caught
|
||||
// only the first of these four, and the other three are the same shared binding.
|
||||
const planted: [string, string, string][] = [
|
||||
['bare', 'let pending = null\n', 'planted: let pending'],
|
||||
['var', 'var pending = null\n', 'planted: var pending'],
|
||||
['exported', 'export let pending = null\n', 'planted: let pending'],
|
||||
['in a block', 'if (true) {\n let pending = null\n}\n', 'planted: let pending'],
|
||||
[
|
||||
'in a loop head',
|
||||
'for (let pending = 0; pending < 1; pending++) {\n}\n',
|
||||
'planted: let pending'
|
||||
]
|
||||
]
|
||||
expect(
|
||||
planted.map(([syntax, source]) => [syntax, moduleLevelMutableBindings('planted', source)])
|
||||
).toEqual(planted.map(([syntax, , named]) => [syntax, [named]]))
|
||||
})
|
||||
|
||||
it('leaves a const and a function-local let alone, so the empty list is a measurement', () => {
|
||||
// The other direction: a reader that refused every declaration would agree with the empty
|
||||
// expectation just as happily. A binding one call owns is not module state.
|
||||
const inert =
|
||||
'const options = { capture: true }\n' +
|
||||
'export function n() {\n let index = 0\n for (var step = 0; step < 2; step++) {\n' +
|
||||
' index += step\n }\n return index + (options.capture ? 1 : 0)\n}\n'
|
||||
expect(moduleLevelMutableBindings('inert', inert)).toEqual([])
|
||||
})
|
||||
|
||||
it('starts every module there is, and undoes in reverse the ones that can be undone', () => {
|
||||
const exported = (keyword: 'start' | 'stop') =>
|
||||
MODULES.filter((name) => name !== THE_SEQUENCE).flatMap((name) =>
|
||||
exportedLifecycleFunctions(moduleSource(name), keyword, 'RichMarkdownEditorScope')
|
||||
)
|
||||
const started = sequenceCallsTo('startRichMarkdownEditorDocument')
|
||||
const stopped = sequenceCallsTo('stopRichMarkdownEditorDocument')
|
||||
expect([...started].sort()).toEqual(exported('start').sort())
|
||||
expect([...stopped].sort()).toEqual(exported('stop').sort())
|
||||
|
||||
// The surface is read before anything reaches for it, and the host is told last, after the
|
||||
// inset the host lifts its bar by has been measured.
|
||||
expect(started[0]).toBe('startEditorSurface')
|
||||
expect(started.at(-1)).toBe('startHostBridge')
|
||||
|
||||
const paired = started.filter((name) => stopped.includes(name.replace(/^start/, 'stop')))
|
||||
expect(paired.map((name) => name.replace(/^start/, 'stop'))).toEqual(
|
||||
stopped.filter((name) => paired.includes(name.replace(/^stop/, 'start'))).toReversed()
|
||||
)
|
||||
})
|
||||
|
||||
it('finds a lifecycle export however it is written, and only when it is one', () => {
|
||||
// The precondition for the comparison above, and the reason it is read from the tree: a
|
||||
// pattern over the source needed one exact spelling, so `async`, a return type or a parameter
|
||||
// list the formatter wrapped made a real start disappear — and a start missing from both
|
||||
// lists makes them agree, which is the silent version of the failure they exist to catch.
|
||||
const spellings: [string, string][] = [
|
||||
['plain', 'export function startKeyboardInset(scope: RichMarkdownEditorScope) {\n}\n'],
|
||||
['async', 'export async function startKeyboardInset(scope: RichMarkdownEditorScope) {\n}\n'],
|
||||
[
|
||||
'with a return type',
|
||||
'export function startKeyboardInset(scope: RichMarkdownEditorScope): void {\n}\n'
|
||||
],
|
||||
[
|
||||
'wrapped parameters',
|
||||
'export function startKeyboardInset(\n scope: RichMarkdownEditorScope\n) {\n}\n'
|
||||
]
|
||||
]
|
||||
expect(
|
||||
spellings.map(([spelling, source]) => [
|
||||
spelling,
|
||||
exportedLifecycleFunctions(source, 'start', 'RichMarkdownEditorScope')
|
||||
])
|
||||
).toEqual(spellings.map(([spelling]) => [spelling, ['startKeyboardInset']]))
|
||||
|
||||
// And only when it is one. A start that takes more than the scope is an act the document
|
||||
// performs, not a module's lifecycle (ruling 20), and a start over another document's scope
|
||||
// belongs to that document.
|
||||
const refused = [
|
||||
'export function startEdgeScroll(scope: RichMarkdownEditorScope, dir: number) {\n}\n',
|
||||
'export function startTapDispatch(scope: TerminalDocumentScope) {\n}\n',
|
||||
'function startKeyboardInset(scope: RichMarkdownEditorScope) {\n}\n'
|
||||
]
|
||||
expect(
|
||||
refused.map((source) =>
|
||||
exportedLifecycleFunctions(source, 'start', 'RichMarkdownEditorScope')
|
||||
)
|
||||
).toEqual([[], [], []])
|
||||
})
|
||||
|
||||
it('would name a start the sequence forgot, which is what the comparison above is for', () => {
|
||||
const planted = sequenceCallsTo('startRichMarkdownEditorDocument')
|
||||
expect(planted).not.toContain('startEditorCommands')
|
||||
expect([...planted, 'startEditorCommands'].sort()).not.toEqual(planted.slice().sort())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,251 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
|
||||
import { createRichMarkdownEditorScope } from './document-scope'
|
||||
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
|
||||
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
|
||||
import type { RichMarkdownEditorDocument, RichMarkdownEditorHost } from './document-host-seams'
|
||||
|
||||
/**
|
||||
* The six host seams the page sets, and the window reads and writes they default to.
|
||||
*
|
||||
* The script reached its host through `window.ReactNativeWebView`, asked for a URL with
|
||||
* `window.prompt`, measured the keyboard from `visualViewport` and took its ranges, its elements
|
||||
* and `execCommand` from the global `document`. On the page none of those means what it means in
|
||||
* the WebView: that bridge object is the *shell's*, so an editor message posted through it would
|
||||
* put editor JSON into the bridge's own channel; the prompt was measured to return null in both
|
||||
* shells, because neither implements the delegate the dialog needs; and the page's screen already
|
||||
* measures the same viewport with the same formula, so a second report would lift its bar twice.
|
||||
*
|
||||
* Both halves are asserted here, because a seam whose default quietly stopped reading the window
|
||||
* would leave the native document mute with every other editor test still green — they drive the
|
||||
* modules directly and would be stubbing nothing.
|
||||
*/
|
||||
const startedDocuments: RichMarkdownEditorDocument[] = []
|
||||
|
||||
/** A started document over markup the case owns, with the hooks it wants as the host argument. */
|
||||
function startedDocument(host: RichMarkdownEditorHost = {}): RichMarkdownEditorDocument {
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
const started = createRichMarkdownEditorDocument({
|
||||
// The one the sequence would otherwise answer with the window: these cases are not the
|
||||
// shell's, so nothing observes a viewport unless the case says so.
|
||||
keyboardInsetSource: () => null,
|
||||
...host
|
||||
})
|
||||
startedDocuments.push(started)
|
||||
return started
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (startedDocuments.length > 0) {
|
||||
startedDocuments.pop()!.stop()
|
||||
}
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('the editor document host seams, by default', () => {
|
||||
it('posts to the React Native bridge, reading it at call time', () => {
|
||||
const postMessage = vi.fn<(data: string) => void>()
|
||||
// Built before the global exists: the default must read the window when it posts, not when the
|
||||
// scope was created, because the document's scope is built as its script is parsed.
|
||||
const scope = createRichMarkdownEditorScope()
|
||||
vi.stubGlobal('ReactNativeWebView', { postMessage })
|
||||
scope.postToHost({ type: 'ready' })
|
||||
expect(postMessage.mock.calls).toEqual([['{"type":"ready"}']])
|
||||
})
|
||||
|
||||
it('posts nothing when there is no bridge, which is the guard the script carried', () => {
|
||||
expect(() => createRichMarkdownEditorScope().postToHost({ type: 'ready' })).not.toThrow()
|
||||
})
|
||||
|
||||
it('asks the window for a URL, under the label each command carried', async () => {
|
||||
const prompt = vi.fn<(label?: string) => string | null>(() => 'https://example.com/a')
|
||||
const scope = createRichMarkdownEditorScope()
|
||||
vi.stubGlobal('prompt', prompt)
|
||||
await expect(scope.promptForUrl('link')).resolves.toBe('https://example.com/a')
|
||||
await expect(scope.promptForUrl('image')).resolves.toBe('https://example.com/a')
|
||||
expect(prompt.mock.calls).toEqual([['Link URL'], ['Image URL']])
|
||||
})
|
||||
|
||||
it('answers the cancelled dialog as no URL rather than as a failure', async () => {
|
||||
vi.stubGlobal('prompt', () => null)
|
||||
await expect(createRichMarkdownEditorScope().promptForUrl('link')).resolves.toBe(null)
|
||||
})
|
||||
|
||||
it('measures the covered height from the visual viewport, and observes both its events', () => {
|
||||
const listeners: string[] = []
|
||||
const removed: string[] = []
|
||||
const viewport = {
|
||||
height: 500,
|
||||
offsetTop: 20,
|
||||
addEventListener: (name: string) => listeners.push(name),
|
||||
removeEventListener: (name: string) => removed.push(name)
|
||||
}
|
||||
const scope = createRichMarkdownEditorScope()
|
||||
// Null before the viewport exists and a reader after it, which is what "read at call time"
|
||||
// means for the one seam whose answer is an object.
|
||||
expect(scope.keyboardInsetSource()).toBe(null)
|
||||
vi.stubGlobal('visualViewport', viewport)
|
||||
vi.stubGlobal('innerHeight', 800)
|
||||
const source = scope.keyboardInsetSource()!
|
||||
expect(source.measure()).toBe(280)
|
||||
viewport.height = 900
|
||||
// Clamped: a viewport taller than the window covers nothing rather than a negative height.
|
||||
expect(source.measure()).toBe(0)
|
||||
const uninstall = source.observe(() => {})
|
||||
expect(listeners).toEqual(['resize', 'scroll'])
|
||||
uninstall()
|
||||
expect(removed).toEqual(['resize', 'scroll'])
|
||||
})
|
||||
|
||||
it('clears a real timer, and a handle that was never set', () => {
|
||||
const scope = createRichMarkdownEditorScope()
|
||||
const fired = vi.fn()
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
scope.clearTimer(window.setTimeout(fired, 0))
|
||||
vi.runAllTimers()
|
||||
expect(fired).not.toHaveBeenCalled()
|
||||
expect(() => scope.clearTimer(null)).not.toThrow()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('takes the selection and the page from the window the document is running in', () => {
|
||||
const scope = createRichMarkdownEditorScope()
|
||||
expect(scope.getDocument()).toBe(document)
|
||||
expect(scope.getSelection()).toBe(window.getSelection())
|
||||
})
|
||||
})
|
||||
|
||||
describe('the editor document host seams, once the page sets them', () => {
|
||||
it('routes every message to the field and nothing to the bridge', () => {
|
||||
const postMessage = vi.fn<(data: string) => void>()
|
||||
vi.stubGlobal('ReactNativeWebView', { postMessage })
|
||||
const posted: MobileRichMarkdownEditorMessage[] = []
|
||||
const started = startedDocument({ postToHost: (message) => posted.push(message) })
|
||||
expect(posted).toEqual([{ type: 'ready' }])
|
||||
posted.length = 0
|
||||
started.send.setEditable(true)
|
||||
started.send.setMarkdown('# Title', 3)
|
||||
document.getElementById('editor')!.dispatchEvent(new Event('input'))
|
||||
expect(posted).toEqual([{ type: 'change', markdown: '# Title', generation: 3 }])
|
||||
// The whole reason the seam exists: on the page this object belongs to the shell.
|
||||
expect(postMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('takes the URL from the host rather than from a dialog the shell never shows', async () => {
|
||||
const commands: [string, boolean, string | undefined][] = []
|
||||
const started = startedDocument({
|
||||
promptForUrl: (kind) => Promise.resolve(`https://example.com/${kind}`),
|
||||
getDocument: () => hostDocument(commands)
|
||||
})
|
||||
await started.send.runCommand('link')
|
||||
await started.send.runCommand('image')
|
||||
expect(commands).toEqual([
|
||||
['createLink', false, 'https://example.com/link'],
|
||||
['insertImage', false, 'https://example.com/image']
|
||||
])
|
||||
})
|
||||
|
||||
it('reports no inset at all when the host has no source, and observes nothing', () => {
|
||||
const posted: MobileRichMarkdownEditorMessage[] = []
|
||||
const viewport = {
|
||||
height: 500,
|
||||
offsetTop: 0,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
vi.stubGlobal('visualViewport', viewport)
|
||||
vi.stubGlobal('innerHeight', 800)
|
||||
startedDocument({ keyboardInsetSource: () => null, postToHost: (m) => posted.push(m) })
|
||||
expect(posted.map((message) => message.type)).toEqual(['ready'])
|
||||
expect(viewport.addEventListener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the inset from the source the host names, before it reports ready', () => {
|
||||
const posted: MobileRichMarkdownEditorMessage[] = []
|
||||
startedDocument({
|
||||
keyboardInsetSource: () => ({ measure: () => 291.4, observe: () => () => {} }),
|
||||
postToHost: (message) => posted.push(message)
|
||||
})
|
||||
expect(posted).toEqual([{ type: 'keyboardInset', bottom: 291 }, { type: 'ready' }])
|
||||
})
|
||||
|
||||
it('takes the caret from the selection the host names, not the window one', async () => {
|
||||
// The seam the page needs most. A document mounted inside a screen shares `window` with every
|
||||
// other field on it, so the caret the editor saves and restores has to come from the object
|
||||
// its host hands over — and the window's must be left alone, because it belongs to whatever
|
||||
// else has focus.
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
const editor = document.getElementById('editor')!
|
||||
editor.innerHTML = '<p id="only">text</p>'
|
||||
const ranges: Range[] = []
|
||||
const windowSelection = window.getSelection()!
|
||||
windowSelection.removeAllRanges()
|
||||
// WebKit's own behaviour, and the reason the document saves a caret at all.
|
||||
editor.addEventListener('blur', () => {
|
||||
ranges.length = 0
|
||||
})
|
||||
|
||||
const hostSelection: Selection = Object.create(windowSelection)
|
||||
Object.defineProperty(hostSelection, 'rangeCount', { get: () => ranges.length })
|
||||
hostSelection.getRangeAt = (index: number) => ranges[index]!
|
||||
hostSelection.removeAllRanges = () => {
|
||||
ranges.length = 0
|
||||
}
|
||||
hostSelection.addRange = (range: Range) => {
|
||||
ranges.push(range)
|
||||
}
|
||||
|
||||
const caret = document.createRange()
|
||||
caret.selectNodeContents(document.getElementById('only')!)
|
||||
caret.collapse(true)
|
||||
ranges.push(caret)
|
||||
|
||||
const started = createRichMarkdownEditorDocument({
|
||||
getSelection: () => hostSelection,
|
||||
getDocument: () => hostDocument([]),
|
||||
keyboardInsetSource: () => null,
|
||||
postToHost: () => {}
|
||||
})
|
||||
startedDocuments.push(started)
|
||||
|
||||
// Saved out of the host's selection, which the blur then empties.
|
||||
editor.focus()
|
||||
started.send.dismissKeyboard()
|
||||
expect(ranges).toEqual([])
|
||||
// And restored into the host's selection rather than the window's.
|
||||
await started.send.runCommand('bold')
|
||||
expect(
|
||||
ranges.map((range) => {
|
||||
const container = range.commonAncestorContainer
|
||||
return (container instanceof Element ? container : container.parentElement)?.id
|
||||
})
|
||||
).toEqual(['only'])
|
||||
expect(windowSelection.rangeCount).toBe(0)
|
||||
})
|
||||
|
||||
it('runs its selection and its commands against the page the host names', () => {
|
||||
const commands: [string, boolean, string | undefined][] = []
|
||||
const started = startedDocument({ getDocument: () => hostDocument(commands) })
|
||||
started.send.runCommand('bold')
|
||||
expect(commands).toEqual([['bold', false, undefined]])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The page a host hands over: the real one, with `execCommand` recorded.
|
||||
*
|
||||
* happy-dom implements no `execCommand`, and the point of the seam is that the document never
|
||||
* reaches for a global one, so a host that answers with its own is exactly what a case needs.
|
||||
*/
|
||||
function hostDocument(commands: [string, boolean, string | undefined][]): Document {
|
||||
const page: Document = Object.create(document)
|
||||
page.execCommand = (command: string, showUI?: boolean, value?: string) => {
|
||||
commands.push([command, showUI === true, value])
|
||||
return true
|
||||
}
|
||||
return page
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset-script'
|
||||
import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset'
|
||||
import type {
|
||||
MobileRichMarkdownCommand,
|
||||
MobileRichMarkdownEditorMessage,
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { parseSync } from 'oxc-parser'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DECLARATION_KINDS,
|
||||
documentModuleNames,
|
||||
documentModuleSource,
|
||||
exportedLifecycleFunctions,
|
||||
parseModule,
|
||||
parseTimeEffects,
|
||||
readsTheDocument,
|
||||
sequenceCalls,
|
||||
topLevelDeclarationsReachAnElement
|
||||
} from '../../test-support/webview-document-census'
|
||||
|
||||
/**
|
||||
* Rulings 20 and 21: no module in the document does work as it is parsed, and none owns state.
|
||||
@@ -13,8 +23,9 @@ import { describe, expect, it } from 'vitest'
|
||||
*
|
||||
* So the rule is structural rather than behavioural, and it is checked structurally. Every
|
||||
* emitted module may declare; none may run. What used to run lives in that module's start
|
||||
* function, which both hosts call — the generated script once at the foot of the document, the
|
||||
* page once per mount.
|
||||
* function, which both hosts call — the bundle once at the foot of the document, the page once per
|
||||
* mount. The readers are shared with the rich Markdown editor's document, which is the same rule
|
||||
* over a second set of modules.
|
||||
*
|
||||
* Ruling 21's half of this — no module-level `let`, because a second mount inherited a spent error
|
||||
* budget and the first terminal's momentum loop — is not checked any more, and ruling 22 is why. A
|
||||
@@ -23,200 +34,40 @@ import { describe, expect, it } from 'vitest'
|
||||
* would run at the position its module is emitted rather than in the start sequence, so no stop
|
||||
* would undo it and every call would leak another one.
|
||||
*/
|
||||
/**
|
||||
* Every module of the document, read from the directory.
|
||||
*
|
||||
* From the directory rather than from a list: the bundler walks imports from the entry, so there is
|
||||
* no order to pin any more, and a module that this census cannot see is a module the rule does not
|
||||
* cover. The entry itself is the one file allowed a statement at its top level — it is the call.
|
||||
*/
|
||||
const DIRECTORY = import.meta.dirname
|
||||
|
||||
/** The entry is the one file allowed a statement at its top level — it is the call. */
|
||||
const ENTRY = 'native-document-entry'
|
||||
|
||||
/** The sequence that calls the starts, which is not a module with a start of its own. */
|
||||
const THE_SEQUENCE = 'create-terminal-document'
|
||||
|
||||
const MODULES = readdirSync(new URL('.', import.meta.url))
|
||||
.filter((name) => name.endsWith('.ts') && !name.includes('.test'))
|
||||
.map((name) => name.replace(/\.ts$/, ''))
|
||||
.filter((name) => name !== ENTRY)
|
||||
.sort()
|
||||
const MODULES = documentModuleNames(DIRECTORY, [ENTRY])
|
||||
|
||||
/** Statement kinds that only declare. Anything else at the top level is work. */
|
||||
const DECLARATION_KINDS = new Set([
|
||||
'ImportDeclaration',
|
||||
'ExportNamedDeclaration',
|
||||
'ExportDefaultDeclaration',
|
||||
'ExportAllDeclaration',
|
||||
'FunctionDeclaration',
|
||||
'ClassDeclaration',
|
||||
'VariableDeclaration',
|
||||
'TSTypeAliasDeclaration',
|
||||
'TSInterfaceDeclaration',
|
||||
'TSEnumDeclaration',
|
||||
'TSModuleDeclaration',
|
||||
'TSDeclareFunction',
|
||||
'TSImportEqualsDeclaration',
|
||||
'EmptyStatement'
|
||||
])
|
||||
const moduleSource = (name: string) => documentModuleSource(DIRECTORY, name)
|
||||
|
||||
function moduleSource(name: string): string {
|
||||
return readFileSync(new URL(`./${name}.ts`, import.meta.url), 'utf8')
|
||||
}
|
||||
|
||||
/** A node's own properties, or nothing when it is not one. Read rather than asserted. */
|
||||
function fieldsOf(node: unknown): [string, unknown][] {
|
||||
return node !== null && typeof node === 'object' && !Array.isArray(node)
|
||||
? Object.entries(node)
|
||||
: []
|
||||
}
|
||||
|
||||
function stringField(node: unknown, key: string): string {
|
||||
const found = fieldsOf(node).find(([name]) => name === key)?.[1]
|
||||
return typeof found === 'string' ? found : ''
|
||||
}
|
||||
|
||||
function field(node: unknown, key: string): unknown {
|
||||
return fieldsOf(node).find(([name]) => name === key)?.[1]
|
||||
}
|
||||
|
||||
const RUNS_NOW = new Set([
|
||||
'CallExpression',
|
||||
'NewExpression',
|
||||
'AwaitExpression',
|
||||
'TaggedTemplateExpression'
|
||||
])
|
||||
/** What an initialiser *is* rather than what it does: its body runs later, not now. */
|
||||
const RUNS_LATER = new Set(['FunctionExpression', 'ArrowFunctionExpression', 'ClassExpression'])
|
||||
|
||||
function isElementGlobal(node: unknown): boolean {
|
||||
const name = stringField(node, 'name')
|
||||
return stringField(node, 'type') === 'Identifier' && (name === 'document' || name === 'window')
|
||||
}
|
||||
|
||||
function initialiserRuns(node: unknown): boolean {
|
||||
if (Array.isArray(node)) {
|
||||
return node.some(initialiserRuns)
|
||||
}
|
||||
const type = stringField(node, 'type')
|
||||
if (RUNS_NOW.has(type)) {
|
||||
return true
|
||||
}
|
||||
if (RUNS_LATER.has(type)) {
|
||||
return false
|
||||
}
|
||||
if (type === 'MemberExpression' && isElementGlobal(field(node, 'object'))) {
|
||||
return true
|
||||
}
|
||||
return fieldsOf(node).some(([key, value]) => key !== 'type' && initialiserRuns(value))
|
||||
}
|
||||
|
||||
/** Whether anything at a module's top level reaches an element, at any depth. */
|
||||
function readsTheDocument(node: unknown): boolean {
|
||||
if (Array.isArray(node)) {
|
||||
return node.some(readsTheDocument)
|
||||
}
|
||||
if (isElementGlobal(node)) {
|
||||
return true
|
||||
}
|
||||
return fieldsOf(node).some(([key, value]) => key !== 'type' && readsTheDocument(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level statements that are not declarations, and the initialisers that run something.
|
||||
*
|
||||
* A declaration counts as work when its initialiser calls, constructs, awaits, or reaches into
|
||||
* `document` or `window`: `const scrollIndicator = document.getElementById(...)` is a declaration
|
||||
* by shape and a parse-time element read by effect, and it is the exact form that survived a
|
||||
* remount still holding the first mount's node. Object and regex literals are not work, which is
|
||||
* why this reads the tree rather than the text.
|
||||
*/
|
||||
function parseTimeEffects(name: string): string[] {
|
||||
return parseTimeEffectsIn(name, moduleSource(name))
|
||||
}
|
||||
|
||||
function parseTimeEffectsIn(name: string, source: string): string[] {
|
||||
const { program, errors } = parseSync(`${name}.ts`, source, { lang: 'ts' })
|
||||
expect(errors).toEqual([])
|
||||
const effects: string[] = []
|
||||
for (const statement of program.body) {
|
||||
if (!DECLARATION_KINDS.has(statement.type)) {
|
||||
effects.push(`${name}: ${statement.type}`)
|
||||
continue
|
||||
}
|
||||
const declaration =
|
||||
statement.type === 'ExportNamedDeclaration' ? (statement.declaration ?? statement) : statement
|
||||
if (declaration.type !== 'VariableDeclaration') {
|
||||
continue
|
||||
}
|
||||
for (const declarator of declaration.declarations) {
|
||||
if (declarator.init && initialiserRuns(declarator.init)) {
|
||||
effects.push(`${name}: ${source.slice(declarator.start, declarator.end)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return effects
|
||||
}
|
||||
|
||||
/**
|
||||
* The names one function of the sequence calls, in the order it calls them.
|
||||
*
|
||||
* Read from the tree rather than the text, because the order is the thing being asserted and a
|
||||
* regex over the file would also match the sequence's own name in `startTerminalDocument`'s catch —
|
||||
* which is the unwind, not a module's start.
|
||||
*/
|
||||
function sequenceCalls(functionName: string): string[] {
|
||||
const { program } = parseSync(`${THE_SEQUENCE}.ts`, moduleSource(THE_SEQUENCE), { lang: 'ts' })
|
||||
const declaration = program.body
|
||||
.map((statement) =>
|
||||
statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement
|
||||
)
|
||||
.find(
|
||||
(node) =>
|
||||
node?.type === 'FunctionDeclaration' &&
|
||||
stringField(field(node, 'id'), 'name') === functionName
|
||||
)
|
||||
const called: string[] = []
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (stringField(node, 'type') === 'CallExpression') {
|
||||
const name = stringField(field(node, 'callee'), 'name')
|
||||
if (name !== '' && name !== 'startTerminalDocument' && name !== 'stopTerminalDocument') {
|
||||
called.push(name)
|
||||
}
|
||||
}
|
||||
fieldsOf(node).forEach(([key, value]) => {
|
||||
if (key !== 'type') {
|
||||
walk(value)
|
||||
}
|
||||
})
|
||||
}
|
||||
walk(declaration)
|
||||
return called
|
||||
}
|
||||
const sequenceCallsTo = (functionName: string) =>
|
||||
sequenceCalls(moduleSource(THE_SEQUENCE), functionName, [
|
||||
'startTerminalDocument',
|
||||
'stopTerminalDocument'
|
||||
])
|
||||
|
||||
describe('the document modules at parse time', () => {
|
||||
it('do no work: every effect is in a start function the hosts call', () => {
|
||||
// Every module, with no exception left: the scope is built by a call now, and the constants the
|
||||
// modules own are declarations rather than the substituted literals a generator wrote.
|
||||
expect(MODULES.length).toBeGreaterThan(30)
|
||||
expect(MODULES.flatMap(parseTimeEffects)).toEqual([])
|
||||
expect(MODULES.flatMap((name) => parseTimeEffects(name, moduleSource(name)))).toEqual([])
|
||||
})
|
||||
|
||||
it('declare nothing that reaches an element', () => {
|
||||
// The stricter half, and the one the remount defect was: a declaration whose initialiser reads
|
||||
// an element is work by effect whatever its shape, so the same reader runs over every module.
|
||||
for (const name of MODULES) {
|
||||
const { program } = parseSync(`${name}.ts`, moduleSource(name), { lang: 'ts' })
|
||||
const topLevel = program.body.filter(
|
||||
(statement) =>
|
||||
statement.type === 'VariableDeclaration' ||
|
||||
(statement.type === 'ExportNamedDeclaration' &&
|
||||
statement.declaration?.type === 'VariableDeclaration')
|
||||
)
|
||||
expect({ name, reaches: topLevel.some(readsTheDocument) }).toEqual({ name, reaches: false })
|
||||
expect({
|
||||
name,
|
||||
reaches: topLevelDeclarationsReachAnElement(name, moduleSource(name))
|
||||
}).toEqual({ name, reaches: false })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -228,19 +79,18 @@ describe('the document modules at parse time', () => {
|
||||
"import { scope } from './document-scope'\n" +
|
||||
"const indicator = document.getElementById('scroll-indicator')\n" +
|
||||
'export function n() {\n return indicator ?? scope.term\n}\n'
|
||||
expect(parseTimeEffectsIn('planted', planted)).toEqual([
|
||||
expect(parseTimeEffects('planted', planted)).toEqual([
|
||||
"planted: indicator = document.getElementById('scroll-indicator')"
|
||||
])
|
||||
// And the element reader the case above spends on every module: the same plant, seen by it.
|
||||
const { program } = parseSync('planted.ts', planted, { lang: 'ts' })
|
||||
expect(program.body.some(readsTheDocument)).toBe(true)
|
||||
expect(topLevelDeclarationsReachAnElement('planted', planted)).toBe(true)
|
||||
// And the other direction, because a reader that flagged every initialiser would agree with
|
||||
// the empty list above only by refusing everything: a plain literal is not work.
|
||||
const inert =
|
||||
"import { scope } from './document-scope'\n" +
|
||||
'const options = { capture: true, passive: false }\n' +
|
||||
'export function n() {\n return options.capture && scope.term !== null\n}\n'
|
||||
expect(parseTimeEffectsIn('inert', inert)).toEqual([])
|
||||
expect(parseTimeEffects('inert', inert)).toEqual([])
|
||||
})
|
||||
|
||||
it('would report one, so the empty list above is a measurement', () => {
|
||||
@@ -251,9 +101,11 @@ describe('the document modules at parse time', () => {
|
||||
new URL('./document-parse-time-effects.test.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const { program } = parseSync('probe.ts', source, { lang: 'ts' })
|
||||
const running = program.body.filter((statement) => !DECLARATION_KINDS.has(statement.type))
|
||||
const running = parseModule('probe', source).body.filter(
|
||||
(statement) => !DECLARATION_KINDS.has(statement.type)
|
||||
)
|
||||
expect(running.length).toBeGreaterThan(0)
|
||||
expect(readsTheDocument({ type: 'Identifier', name: 'document' })).toBe(true)
|
||||
})
|
||||
|
||||
it('start and stop: the sequence calls every one there is, and undoes them in reverse', () => {
|
||||
@@ -266,23 +118,16 @@ describe('the document modules at parse time', () => {
|
||||
// through `stopSelectionOverlay`, which is asserted here rather than waved through.
|
||||
const exported = (keyword: 'start' | 'stop') =>
|
||||
MODULES.filter((name) => name !== THE_SEQUENCE).flatMap((name) =>
|
||||
[
|
||||
...moduleSource(name).matchAll(
|
||||
new RegExp(
|
||||
`^export function (${keyword}[A-Za-z]+)\\(scope: TerminalDocumentScope\\) \\{$`,
|
||||
'gm'
|
||||
)
|
||||
)
|
||||
].map((match) => match[1]!)
|
||||
exportedLifecycleFunctions(moduleSource(name), keyword, 'TerminalDocumentScope')
|
||||
)
|
||||
expect(moduleSource('selection-overlay')).toContain(
|
||||
'export function stopSelectionOverlay(scope: TerminalDocumentScope) {\n stopEdgeScroll(scope)'
|
||||
)
|
||||
|
||||
const started = sequenceCalls('startTerminalDocument')
|
||||
const started = sequenceCallsTo('startTerminalDocument')
|
||||
// `cancelDocumentFrames` is the frame registry's undo rather than a module's stop, and it is
|
||||
// asserted below by its position: last, after every stop that might still hold a frame.
|
||||
const stopped = sequenceCalls('stopTerminalDocument').filter(
|
||||
const stopped = sequenceCallsTo('stopTerminalDocument').filter(
|
||||
(name) => name !== 'cancelDocumentFrames'
|
||||
)
|
||||
expect([...started].sort()).toEqual(exported('start').sort())
|
||||
@@ -298,13 +143,13 @@ describe('the document modules at parse time', () => {
|
||||
expect(paired.map((name) => name.replace(/^start/, 'stop'))).toEqual(
|
||||
stopped.filter((name) => paired.includes(name.replace(/^stop/, 'start'))).toReversed()
|
||||
)
|
||||
expect(sequenceCalls('stopTerminalDocument').at(-1)).toBe('cancelDocumentFrames')
|
||||
expect(sequenceCallsTo('stopTerminalDocument').at(-1)).toBe('cancelDocumentFrames')
|
||||
})
|
||||
|
||||
it('would name a start the sequence forgot, which is what the comparison above is for', () => {
|
||||
// The precondition, planted rather than argued: a module that exports a start nobody calls is
|
||||
// the failure the set comparison exists to catch, and the reader has to say its name.
|
||||
const planted = sequenceCalls('startTerminalDocument')
|
||||
const planted = sequenceCallsTo('startTerminalDocument')
|
||||
expect(planted).not.toContain('startReflow')
|
||||
expect([...planted, 'startReflow'].sort()).not.toEqual(planted.slice().sort())
|
||||
})
|
||||
|
||||
@@ -4,6 +4,13 @@ import { terminalDocumentBundle } from '../../../scripts/build-terminal-document
|
||||
import { createTerminalDocument } from './create-terminal-document'
|
||||
import { TERMINAL_DOCUMENT_SCRIPT } from '../terminal-webview-document-script.generated'
|
||||
import { TERMINAL_DOCUMENT_MARKUP } from '../terminal-webview-html'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
bundleDigestBuiltFrom,
|
||||
machinePathCommentsIn
|
||||
} from '../../test-support/webview-document-bundle-digest'
|
||||
|
||||
/**
|
||||
* The bundle runs, and it is the same document.
|
||||
@@ -279,6 +286,21 @@ describe('the bundled native document', () => {
|
||||
expect(pageListeners).toEqual([])
|
||||
})
|
||||
|
||||
it('is the same bytes wherever its generator was run from', () => {
|
||||
// The artifact is committed by a postinstall run whose working directory is whatever the
|
||||
// installer happened to be in, and the case below compares it with a build made here. So the
|
||||
// build has to be cwd-independent, which is what `absWorkingDir` buys: without it this digest
|
||||
// and the one from the temp directory differ, and the artifact carries a machine path.
|
||||
// `import.meta.dirname`, because a case in the DOM environment has no file URL to convert.
|
||||
const generator = join(
|
||||
import.meta.dirname,
|
||||
'../../../scripts/build-terminal-document-script.mjs'
|
||||
)
|
||||
const here = createHash('sha256').update(TERMINAL_DOCUMENT_SCRIPT).digest('hex')
|
||||
expect(bundleDigestBuiltFrom(tmpdir(), generator, 'terminalDocumentBundle')).toBe(here)
|
||||
expect(machinePathCommentsIn(TERMINAL_DOCUMENT_SCRIPT)).toEqual([])
|
||||
}, 30_000)
|
||||
|
||||
it('carries the document and nothing else: no dependency rides into the WebView', async () => {
|
||||
// The document imports ordinary modules now, so an import added anywhere in its graph reaches
|
||||
// the phone's script. `storage/preferences` did: one constant pulled AsyncStorage and its two
|
||||
|
||||
@@ -4,15 +4,20 @@ import { join } from 'node:path'
|
||||
/**
|
||||
* Build output, which a source census reads as source and must not.
|
||||
*
|
||||
* `mobile/.gitignore` is the list: six `*.generated.ts` files under `mobile/src`, written by the
|
||||
* four postinstall generators. Two are vendored engines — 3.7 MB of mermaid for the native WebView
|
||||
* and 3.5 MB of it for the page — and 7.9 MB of what a walk over this tree returns is generated. A
|
||||
* census that parses them parses minified third-party code looking for call sites nobody in this
|
||||
* repo wrote and nobody can move, and pays the whole parse to find them: five of those files is
|
||||
* what took `rpc-params-contract-type-only-boundary` from 1.5 s to over its 5 s timeout in CI.
|
||||
* The lists of record are `mobile/package.json`'s postinstall, which names every generator, and
|
||||
* `mobile/.gitignore`, which names every file they write. Both grow — C7.10 C1 added the rich
|
||||
* Markdown editor's document — so the shape is what this rule is about and the count below is a
|
||||
* reading rather than a fence. At this one: five generators, six `*.generated.ts` files under
|
||||
* `mobile/src`.
|
||||
*
|
||||
* The sixth is the page's copy of the terminal document (C7.5b), which is this repo's own emitted
|
||||
* text rather than a vendored bundle — and is walked as source at every one of its 38 modules.
|
||||
* Two of the six are vendored engines — 3.7 MB of mermaid for the native WebView and 3.5 MB of it
|
||||
* for the page — and most of what a walk over this tree returns by weight is generated. A census
|
||||
* that parses them parses minified third-party code looking for call sites nobody in this repo
|
||||
* wrote and nobody can move, and pays the whole parse to find them: five of those files is what
|
||||
* took `rpc-params-contract-type-only-boundary` from 1.5 s to over its 5 s timeout in CI.
|
||||
*
|
||||
* The two document bundles are this repo's own emitted text rather than vendored code, and their
|
||||
* sources are walked as the ordinary TypeScript modules they are built from.
|
||||
*
|
||||
* The generator that writes each one is ordinary source and is still walked, which is where a real
|
||||
* reach into whatever a census is fencing would be.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
/**
|
||||
* A WebView document bundle, built from somewhere else entirely, as a digest.
|
||||
*
|
||||
* esbuild writes each module's path into the bundle as a comment, relative to the working
|
||||
* directory, so the artifact's bytes depend on where its generator ran unless the generator pins
|
||||
* `absWorkingDir`. Three cwds gave three digests before it was pinned, and from outside the repo
|
||||
* the comments carried an absolute path with the builder's home directory in it — which is a
|
||||
* machine path in a file every test compares against the committed artifact.
|
||||
*
|
||||
* In a child process because that is the only way to ask the question: a vitest worker cannot
|
||||
* change its own working directory, so a case run from `mobile/` can only see the one answer.
|
||||
*/
|
||||
export function bundleDigestBuiltFrom(cwd: string, generator: string, member: string): string {
|
||||
return execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`Promise.all([import('node:crypto'), import(${JSON.stringify(generator)})]).then(` +
|
||||
`async ([crypto, generator]) => {` +
|
||||
`const { script } = await generator.${member}();` +
|
||||
`process.stdout.write(crypto.createHash('sha256').update(script).digest('hex'))` +
|
||||
`})`
|
||||
],
|
||||
{ cwd, encoding: 'utf8' }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every line of a bundle that names a directory only the machine that built it has.
|
||||
*
|
||||
* Both shapes esbuild can emit when the working directory is not the one the sources live under: a
|
||||
* comment that is an absolute path, and one that climbs out with `../`.
|
||||
*/
|
||||
export function machinePathCommentsIn(script: string): string[] {
|
||||
return script
|
||||
.split('\n')
|
||||
.filter((line) => /^\s*\/\/ (\/|\.\.\/)/.test(line) || line.includes('/Users/'))
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { parseSync } from 'oxc-parser'
|
||||
|
||||
/**
|
||||
* The readers behind rulings 20 and 21, for every document that runs inside a WebView.
|
||||
*
|
||||
* There are two of them now — the terminal's and the rich Markdown editor's — and both answer the
|
||||
* same question: does a module do work as it is parsed? An ES module body runs once per page, so
|
||||
* an element read, a listener or an install left in a module body keeps the *first* mount's
|
||||
* elements forever. That is a rule about a shape rather than about a terminal, so it is checked
|
||||
* once here and pointed at each document's directory.
|
||||
*
|
||||
* The readers walk the tree rather than the text, because a declaration can be a parse-time effect
|
||||
* by what it *does* — `const editor = document.getElementById('editor')` is a declaration by shape
|
||||
* — and an object or regex literal is not work however it reads.
|
||||
*/
|
||||
|
||||
/** Statement kinds that only declare. Anything else at a module's top level is work. */
|
||||
export const DECLARATION_KINDS = new Set([
|
||||
'ImportDeclaration',
|
||||
'ExportNamedDeclaration',
|
||||
'ExportDefaultDeclaration',
|
||||
'ExportAllDeclaration',
|
||||
'FunctionDeclaration',
|
||||
'ClassDeclaration',
|
||||
'VariableDeclaration',
|
||||
'TSTypeAliasDeclaration',
|
||||
'TSInterfaceDeclaration',
|
||||
'TSEnumDeclaration',
|
||||
'TSModuleDeclaration',
|
||||
'TSDeclareFunction',
|
||||
'TSImportEqualsDeclaration',
|
||||
'EmptyStatement'
|
||||
])
|
||||
|
||||
const RUNS_NOW = new Set([
|
||||
'CallExpression',
|
||||
'NewExpression',
|
||||
'AwaitExpression',
|
||||
'TaggedTemplateExpression'
|
||||
])
|
||||
|
||||
/** What an initialiser *is* rather than what it does: its body runs later, not now. */
|
||||
const RUNS_LATER = new Set(['FunctionExpression', 'ArrowFunctionExpression', 'ClassExpression'])
|
||||
|
||||
/** A node's own properties, or nothing when it is not one. Read rather than asserted. */
|
||||
function fieldsOf(node: unknown): [string, unknown][] {
|
||||
return node !== null && typeof node === 'object' && !Array.isArray(node)
|
||||
? Object.entries(node)
|
||||
: []
|
||||
}
|
||||
|
||||
function stringField(node: unknown, key: string): string {
|
||||
const found = fieldsOf(node).find(([name]) => name === key)?.[1]
|
||||
return typeof found === 'string' ? found : ''
|
||||
}
|
||||
|
||||
function field(node: unknown, key: string): unknown {
|
||||
return fieldsOf(node).find(([name]) => name === key)?.[1]
|
||||
}
|
||||
|
||||
function isElementGlobal(node: unknown): boolean {
|
||||
const name = stringField(node, 'name')
|
||||
return stringField(node, 'type') === 'Identifier' && (name === 'document' || name === 'window')
|
||||
}
|
||||
|
||||
function initialiserRuns(node: unknown): boolean {
|
||||
if (Array.isArray(node)) {
|
||||
return node.some(initialiserRuns)
|
||||
}
|
||||
const type = stringField(node, 'type')
|
||||
if (RUNS_NOW.has(type)) {
|
||||
return true
|
||||
}
|
||||
if (RUNS_LATER.has(type)) {
|
||||
return false
|
||||
}
|
||||
if (type === 'MemberExpression' && isElementGlobal(field(node, 'object'))) {
|
||||
return true
|
||||
}
|
||||
return fieldsOf(node).some(([key, value]) => key !== 'type' && initialiserRuns(value))
|
||||
}
|
||||
|
||||
/** Whether anything at a module's top level reaches an element, at any depth. */
|
||||
export function readsTheDocument(node: unknown): boolean {
|
||||
if (Array.isArray(node)) {
|
||||
return node.some(readsTheDocument)
|
||||
}
|
||||
if (isElementGlobal(node)) {
|
||||
return true
|
||||
}
|
||||
return fieldsOf(node).some(([key, value]) => key !== 'type' && readsTheDocument(value))
|
||||
}
|
||||
|
||||
export function parseModule(name: string, source: string) {
|
||||
const { program, errors } = parseSync(`${name}.ts`, source, { lang: 'ts' })
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`[webview-document-census] ${name}.ts did not parse: ${errors[0]?.message}`)
|
||||
}
|
||||
return program
|
||||
}
|
||||
|
||||
/**
|
||||
* A statement kind whose own body is not evaluated when the module is.
|
||||
*
|
||||
* `export default function () {}` declares a function; the calls inside it run when something
|
||||
* calls it. Without this the default-export reader below would walk into that body and report the
|
||||
* first call it found there, which is every module with a default export.
|
||||
*/
|
||||
const DECLARES_WITHOUT_RUNNING = new Set(['FunctionDeclaration', 'TSDeclareFunction'])
|
||||
|
||||
/**
|
||||
* The top-level statements that are not declarations, and the declarations that run something.
|
||||
*
|
||||
* A declaration counts as work when it calls, constructs, awaits, or reaches into `document` or
|
||||
* `window` as the module is evaluated, which is the exact form that survived a remount still
|
||||
* holding the first mount's node. There are three shapes of it, and a reader that knew only the
|
||||
* first would accept the other two:
|
||||
*
|
||||
* - `const editor = document.getElementById('editor')` — a declaration by shape.
|
||||
* - `class A { static value = install() }` — a static member is evaluated with the class.
|
||||
* - `export default install()` — the default export is an expression, evaluated where it is.
|
||||
*/
|
||||
export function parseTimeEffects(name: string, source: string): string[] {
|
||||
const effects: string[] = []
|
||||
const report = (node: unknown) => {
|
||||
effects.push(`${name}: ${source.slice(numberField(node, 'start'), numberField(node, 'end'))}`)
|
||||
}
|
||||
|
||||
for (const statement of parseModule(name, source).body) {
|
||||
if (!DECLARATION_KINDS.has(statement.type)) {
|
||||
effects.push(`${name}: ${statement.type}`)
|
||||
continue
|
||||
}
|
||||
const exported =
|
||||
statement.type === 'ExportNamedDeclaration' || statement.type === 'ExportDefaultDeclaration'
|
||||
const declared = exported ? (field(statement, 'declaration') ?? statement) : statement
|
||||
const declaredType = stringField(declared, 'type')
|
||||
|
||||
if (declaredType === 'VariableDeclaration') {
|
||||
for (const declarator of arrayField(declared, 'declarations')) {
|
||||
const init = field(declarator, 'init')
|
||||
if (init && initialiserRuns(init)) {
|
||||
report(declarator)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (declaredType === 'ClassDeclaration' || declaredType === 'ClassExpression') {
|
||||
for (const member of arrayField(field(declared, 'body'), 'body')) {
|
||||
// A static block is parse-time work by construction; a static field is when its value runs.
|
||||
// An instance field is not: it runs per `new`, and nothing here is ever constructed.
|
||||
if (stringField(member, 'type') === 'StaticBlock') {
|
||||
report(member)
|
||||
} else if (field(member, 'static') === true && initialiserRuns(field(member, 'value'))) {
|
||||
report(member)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
statement.type === 'ExportDefaultDeclaration' &&
|
||||
!DECLARES_WITHOUT_RUNNING.has(declaredType) &&
|
||||
initialiserRuns(declared)
|
||||
) {
|
||||
report(declared)
|
||||
}
|
||||
}
|
||||
return effects
|
||||
}
|
||||
|
||||
/** Whether a module declares anything at its top level whose initialiser reaches an element. */
|
||||
export function topLevelDeclarationsReachAnElement(name: string, source: string): boolean {
|
||||
return parseModule(name, source)
|
||||
.body.filter(
|
||||
(statement) =>
|
||||
statement.type === 'VariableDeclaration' ||
|
||||
(statement.type === 'ExportNamedDeclaration' &&
|
||||
statement.declaration?.type === 'VariableDeclaration')
|
||||
)
|
||||
.some(readsTheDocument)
|
||||
}
|
||||
|
||||
/**
|
||||
* The names one function of a start/stop sequence calls, in the order it calls them.
|
||||
*
|
||||
* Read from the tree rather than the text, because the order is the thing being asserted and a
|
||||
* regex over the file would also match the sequence's own name in its `catch` — which is the
|
||||
* unwind, not a module's start.
|
||||
*/
|
||||
export function sequenceCalls(source: string, functionName: string, ignore: string[]): string[] {
|
||||
const declaration = parseModule('sequence', source)
|
||||
.body.map((statement) =>
|
||||
statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement
|
||||
)
|
||||
.find(
|
||||
(node) =>
|
||||
node?.type === 'FunctionDeclaration' &&
|
||||
stringField(field(node, 'id'), 'name') === functionName
|
||||
)
|
||||
const called: string[] = []
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (stringField(node, 'type') === 'CallExpression') {
|
||||
const name = stringField(field(node, 'callee'), 'name')
|
||||
if (name !== '' && !ignore.includes(name)) {
|
||||
called.push(name)
|
||||
}
|
||||
}
|
||||
fieldsOf(node).forEach(([key, value]) => {
|
||||
if (key !== 'type') {
|
||||
walk(value)
|
||||
}
|
||||
})
|
||||
}
|
||||
walk(declaration)
|
||||
return called
|
||||
}
|
||||
|
||||
/**
|
||||
* What a binding *belongs to* rather than where it is written: a function's own bindings are one
|
||||
* per call, so the walk stops at every body and never at a block.
|
||||
*/
|
||||
const OWNS_ITS_BINDINGS = new Set([
|
||||
'FunctionDeclaration',
|
||||
'FunctionExpression',
|
||||
'ArrowFunctionExpression',
|
||||
'ClassDeclaration',
|
||||
'ClassExpression',
|
||||
'TSDeclareFunction'
|
||||
])
|
||||
|
||||
function arrayField(node: unknown, key: string): unknown[] {
|
||||
const found = field(node, key)
|
||||
return Array.isArray(found) ? found : []
|
||||
}
|
||||
|
||||
function numberField(node: unknown, key: string): number {
|
||||
const found = field(node, key)
|
||||
return typeof found === 'number' ? found : -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `let` and `var` a module owns, whatever shape it is written in.
|
||||
*
|
||||
* Ruling 21, and the reason it is a tree walk rather than a line match: `export let`, a declaration
|
||||
* indented inside a top-level block, and a `for (let …)` at the top level are all one binding
|
||||
* shared by every document the module serves, and none of them starts a line with the keyword. A
|
||||
* `let` inside a function body is the opposite — one binding per call — so the walk stops there.
|
||||
*/
|
||||
export function moduleLevelMutableBindings(name: string, source: string): string[] {
|
||||
const found: string[] = []
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
const type = stringField(node, 'type')
|
||||
if (OWNS_ITS_BINDINGS.has(type)) {
|
||||
return
|
||||
}
|
||||
if (type === 'VariableDeclaration') {
|
||||
const kind = stringField(node, 'kind')
|
||||
if (kind === 'let' || kind === 'var') {
|
||||
const declarations = field(node, 'declarations')
|
||||
for (const declarator of Array.isArray(declarations) ? declarations : []) {
|
||||
const id = field(declarator, 'id')
|
||||
found.push(
|
||||
`${name}: ${kind} ${source.slice(numberField(id, 'start'), numberField(id, 'end'))}`
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
fieldsOf(node).forEach(([key, value]) => {
|
||||
if (key !== 'type') {
|
||||
walk(value)
|
||||
}
|
||||
})
|
||||
}
|
||||
walk(parseModule(name, source).body)
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Every lifecycle function a module exports, by name, read from the tree.
|
||||
*
|
||||
* A regular expression over the source needed one exact spelling — `export function`, one line,
|
||||
* the scope parameter, no return type — so `export async function startX(` or a parameter list the
|
||||
* formatter wrapped would vanish from this list. That failure is silent in the worst way: the
|
||||
* comparison this feeds is a set against the names the sequence calls, so a function missing from
|
||||
* *both* lists makes them agree, and a start nobody runs reads as a start nobody needs.
|
||||
*
|
||||
* A function qualifies by what it is rather than how it is written: exported, named for the
|
||||
* lifecycle it belongs to, and taking the document's scope as its first parameter.
|
||||
*/
|
||||
export function exportedLifecycleFunctions(
|
||||
source: string,
|
||||
keyword: 'start' | 'stop',
|
||||
scopeType: string
|
||||
): string[] {
|
||||
const names: string[] = []
|
||||
for (const statement of parseModule('lifecycle', source).body) {
|
||||
if (statement.type !== 'ExportNamedDeclaration') {
|
||||
continue
|
||||
}
|
||||
const declared = field(statement, 'declaration')
|
||||
if (stringField(declared, 'type') !== 'FunctionDeclaration') {
|
||||
continue
|
||||
}
|
||||
const name = stringField(field(declared, 'id'), 'name')
|
||||
if (!name.startsWith(keyword)) {
|
||||
continue
|
||||
}
|
||||
// Exactly one parameter, which is ruling 20's own wording: a start takes nothing the scope
|
||||
// does not already carry. `startEdgeScroll(scope, dir)` takes a direction, so it is the
|
||||
// overlay's own act for a drag rather than a module's lifecycle.
|
||||
const params = arrayField(declared, 'params')
|
||||
const [first] = params
|
||||
if (params.length !== 1 || stringField(first, 'name') !== 'scope') {
|
||||
continue
|
||||
}
|
||||
const annotation = field(first, 'typeAnnotation')
|
||||
const written = source
|
||||
.slice(numberField(annotation, 'start'), numberField(annotation, 'end'))
|
||||
.replace(/^:\s*/, '')
|
||||
if (written === scopeType) {
|
||||
names.push(name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/**
|
||||
* Every module of a document, read from its directory.
|
||||
*
|
||||
* From the directory rather than from a list: the bundler walks imports from the entry, so there
|
||||
* is no order to pin, and a module this census cannot see is a module the rule does not cover.
|
||||
*/
|
||||
export function documentModuleNames(directory: string, except: string[]): string[] {
|
||||
return readdirSync(directory)
|
||||
.filter((name) => name.endsWith('.ts') && !name.includes('.test'))
|
||||
.map((name) => name.replace(/\.ts$/, ''))
|
||||
.filter((name) => !except.includes(name))
|
||||
.sort()
|
||||
}
|
||||
|
||||
export function documentModuleSource(directory: string, name: string): string {
|
||||
return readFileSync(join(directory, `${name}.ts`), 'utf8')
|
||||
}
|
||||
Reference in New Issue
Block a user