refactor(mobile): organize markdown editor and preview owners (#42428)

This commit is contained in:
Neil
2026-11-14 08:20:20 -08:00
committed by GitHub
parent 4672f7906c
commit 5bf36a80c5
165 changed files with 341 additions and 531 deletions
@@ -133,14 +133,14 @@
"count": 1
},
{
"filePath": "mobile/src/components/mobile-markdown-blocks.tsx",
"filePath": "mobile/src/components/markdown/blocks/mobile-markdown-blocks.tsx",
"kind": "jsx-expression",
"text": "[x]",
"dynamic": false,
"count": 1
},
{
"filePath": "mobile/src/components/MobileRichMarkdownEditor.tsx",
"filePath": "mobile/src/components/rich-markdown/MobileRichMarkdownEditor.tsx",
"kind": "jsx-attribute:placeholder",
"text": "https://",
"dynamic": false,
@@ -300,14 +300,12 @@ for (const engine of ['chromium', 'webkit']) {
if (engine === 'chromium') {
expect(shown.links?.cursor).toBe('pointer')
expect(hidden.links?.cursor).not.toBe('pointer')
} else {
expect(shown.links?.cursor).toBe(hidden.links?.cursor)
}
expect(shown.mountedSandbox).toBe('allow-top-navigation-by-user-activation')
}, 240_000)
// Parser round-trip must preserve artifact rendering beyond links; real browser catches doctype/newline losses happy-dom misses. Granted arm is unchanged control.
it('changes nothing an engine renders except that links are not links', async (ctx) => {
// Check painted fixtures, granted document content and inert fragment targets.
it('paints both artifact variants and keeps hidden fragment links inert', async (ctx) => {
// A doctype with a public identifier and no system identifier, which is quirks. The bare
// name is not, so a pass that rewrote one as the other moves the whole artifact between
// layout modes -- which is what this arm exists to catch.
@@ -318,21 +316,18 @@ for (const engine of ['chromium', 'webkit']) {
doctype: quirks,
grants: ['navigate', 'storage']
})
// Both painted, so every comparison below is between two rendered documents.
// Both fixture variants painted.
expect(shown.pixel).toBe(ARTIFACT_RGB)
expect(hidden.pixel).toBe(ARTIFACT_RGB)
// Doctype public/system IDs discriminate preservation where inherited compatMode cannot.
// Known granted doctype remains distinct from the bare-doctype control.
expect(shown.inside?.doctypePublicId).toBe('-//W3C//DTD HTML 4.01 Transitional//EN')
expect(hidden.inside?.doctypePublicId).toBe(shown.inside?.doctypePublicId)
expect(hidden.inside?.doctypeSystemId).toBe(shown.inside?.doctypeSystemId)
// Bare-doctype control proves ID reader can distinguish lost identifier.
const bare = await open(browser(), { signal: ctx.signal, grants: ['navigate', 'storage'] })
expect(bare.inside?.doctypePublicId).toBe('')
// Browser parser drops leading pre newline; inert-link serializer must restore it.
// Granted fixture retains the leading pre newline.
expect(shown.inside?.preText).toBe('\nkept')
expect(hidden.inside?.preText).toBe(shown.inside?.preText)
// Granted fragment remains known broken navigation; hidden arm must strip it rather than preserve false scroll affordance.
expect(shown.inside?.fragmentHref).toBe('#fragtarget')
@@ -14,7 +14,7 @@ import {
MobileHtmlPreview,
MOBILE_HTML_PREVIEW_SANDBOX,
MOBILE_HTML_PREVIEW_SEALED_SANDBOX
} from './MobileHtmlPreview'
} from '../files/preview/documents/MobileHtmlPreview'
window.__sandbox = MOBILE_HTML_PREVIEW_SANDBOX
window.__sealedSandbox = MOBILE_HTML_PREVIEW_SEALED_SANDBOX
@@ -52,7 +52,7 @@ const PAGE_ENTRY = `
import { createElement, useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { MobileRichMarkdownEditor } from './MobileRichMarkdownEditor'
import { MobileRichMarkdownEditor } from './rich-markdown/MobileRichMarkdownEditor'
import { applyMobileColorScheme } from '../theme/mobile-theme'
/**
@@ -5,7 +5,8 @@ import { dirname, resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { build } from 'esbuild'
const sourcePath = 'mobile/src/components/mobile-markdown-preview-html.ts'
const currentSourcePath = 'mobile/src/components/markdown/parsing/mobile-markdown-preview-html.ts'
const legacyBaselineSourcePath = 'mobile/src/components/mobile-markdown-preview-html.ts'
const baselineRef = process.argv[2]
if (!baselineRef) {
throw new Error(
@@ -14,7 +15,7 @@ if (!baselineRef) {
}
async function load(source) {
const result = await build({
stdin: { contents: source, resolveDir: dirname(resolve(sourcePath)), loader: 'ts' },
stdin: { contents: source, resolveDir: dirname(resolve(currentSourcePath)), loader: 'ts' },
bundle: true,
write: false,
platform: 'node',
@@ -26,10 +27,25 @@ async function load(source) {
)
).normalizeMobileMarkdownPreviewHtml
}
const before = await load(
execFileSync('git', ['show', `${baselineRef}:${sourcePath}`], { encoding: 'utf8' })
const baselinePaths = execFileSync(
'git',
['ls-tree', '-r', '--name-only', baselineRef, '--', currentSourcePath, legacyBaselineSourcePath],
{ encoding: 'utf8' }
)
const after = await load(readFileSync(sourcePath, 'utf8'))
.trim()
.split('\n')
const baselineSourcePath = baselinePaths.includes(currentSourcePath)
? currentSourcePath
: baselinePaths.includes(legacyBaselineSourcePath)
? legacyBaselineSourcePath
: null
if (baselineSourcePath === null) {
throw new Error(`Baseline contains neither Markdown preview source path: ${baselineRef}`)
}
const before = await load(
execFileSync('git', ['show', `${baselineRef}:${baselineSourcePath}`], { encoding: 'utf8' })
)
const after = await load(readFileSync(currentSourcePath, 'utf8'))
function measure(fn, input, repeats) {
const samples = []
for (let run = 0; run < repeats; run++) {
+1 -1
View File
@@ -3,7 +3,7 @@ src/terminal/document/terminal-webview-engine.generated.ts
src/terminal/document/terminal-webview-engine-css.generated.ts
src/terminal/document/terminal-webview-document-script.generated.ts
src/components/mermaid/mermaid-page-engine.generated.ts
src/components/katex-page-style.generated.ts
src/components/markdown/math/katex-page-style.generated.ts
.expo/
dist/
/android/
+2 -2
View File
@@ -2,10 +2,10 @@
"$schema": "./node_modules/oxlint/configuration_schema.json",
"extends": ["../.oxlintrc.json"],
"ignorePatterns": [
"src/components/mobile-markdown-mdast-nodes.d.ts",
"src/components/markdown/mobile-markdown-mdast-nodes.d.ts",
"src/terminal/document/terminal-webview-engine.generated.ts",
"src/components/mermaid/mermaid-page-engine.generated.ts",
"src/components/katex-page-style.generated.ts"
"src/components/markdown/math/katex-page-style.generated.ts"
],
"rules": {
"react-hooks/exhaustive-deps": "off",
+8 -1
View File
@@ -4,7 +4,14 @@ import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const mobileRoot = path.resolve(import.meta.dirname, '..')
const outputPath = path.join(mobileRoot, 'src', 'components', 'katex-page-style.generated.ts')
const outputPath = path.join(
mobileRoot,
'src',
'components',
'markdown',
'math',
'katex-page-style.generated.ts'
)
// KaTeX's stylesheet for the page, woff2 only: every shell WebView reads it, and the bundle
// emits each imported font as a same-origin asset, which the shell's font-src allows.
@@ -1,12 +1,12 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { Alert, Platform, Pressable, Text } from 'react-native'
import { Check, Copy } from 'lucide-react-native'
import { translate } from '../i18n/mobile-i18n'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { useClipboardWriter } from '../platform/clipboard/clipboard'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { translate } from '../../i18n/mobile-i18n'
import { useMobileTranslation } from '../../i18n/use-mobile-translation'
import { useClipboardWriter } from '../../platform/clipboard/clipboard'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import { themedStyleSheet } from '../../theme/themed-styles'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
type CopyFeedback = { status: 'copied'; text: string } | { status: 'failed' }
@@ -2,10 +2,10 @@ import { Text, type TextStyle } from 'react-native'
import type {
MobileSyntaxSegment,
MobileSyntaxTokenKind
} from '../files/session-documents/mobile-file-syntax'
import { colors } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
} from '../../files/session-documents/mobile-file-syntax'
import { colors } from '../../theme/mobile-theme'
import { themedStyleSheet } from '../../theme/themed-styles'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
export function MobileSyntaxSegments({ segments }: { segments: MobileSyntaxSegment[] }) {
useMobileColorScheme()
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { normalizeMobileMarkdownPreviewHtml } from './parsing/mobile-markdown-preview-html'
describe('normalizeMobileMarkdownPreviewHtml', () => {
it('normalizes common README HTML into readable Markdown preview text', () => {
@@ -1,19 +1,19 @@
import { INLINE_TEXT_SELECTION } from './inline-text-selection'
import { INLINE_TEXT_SELECTION } from '../../platform/text-selection/inline-text-selection'
import { memo, useContext, useMemo, type ReactNode } from 'react'
import { Text as NativeText, View } from 'react-native'
import { MARKDOWN_TONES, scaledMarkdownText, styles } from './mobile-markdown-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { renderBlocks, type MarkdownBlockContext } from './mobile-markdown-blocks'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
import { renderBlocks, type MarkdownBlockContext } from './blocks/mobile-markdown-blocks'
import { MarkdownText } from './mobile-markdown-text'
import { MobileMarkdownRemoteImage } from './MobileMarkdownRemoteImage'
import { MobileMarkdownRemoteImage } from './blocks/MobileMarkdownRemoteImage'
import { MarkdownTextContext, type MarkdownTextSetup } from './mobile-markdown-text-setup'
import { MarkdownLinkHandlerContext } from './markdown-link-handler'
import { useMobileMarkdownTree } from './use-mobile-markdown-tree'
import { MarkdownLinkHandlerContext } from './links/markdown-link-handler'
import { useMobileMarkdownTree } from './parsing/use-mobile-markdown-tree'
import {
MobileMarkdownLinksContext,
useNativeChatFileLinkExistsFrom
} from './mobile-markdown-links-context'
import type { NativeChatVisualDirective } from '../../../src/shared/native-chat/native-chat-visual-directive'
} from './links/mobile-markdown-links-context'
import type { NativeChatVisualDirective } from '../../../../src/shared/native-chat/native-chat-visual-directive'
type Props = {
content?: string
@@ -7,11 +7,11 @@ import {
OctagonAlert,
TriangleAlert
} from 'lucide-react-native'
import type { GitHubCalloutKind } from '../../../src/shared/github/markdown/github-callouts'
import { translate } from '../i18n/mobile-i18n'
import { styles } from './mobile-markdown-styles'
import { colors } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import type { GitHubCalloutKind } from '../../../../../src/shared/github/markdown/github-callouts'
import { translate } from '../../../i18n/mobile-i18n'
import { styles } from '../mobile-markdown-styles'
import { colors } from '../../../theme/mobile-theme'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
// Desktop's markdown-github-callout.tsx in the phone's tokens.
const CALLOUTS: Record<GitHubCalloutKind, { color: () => string; label: () => string }> = {
@@ -1,19 +1,19 @@
import { TextWrap } from 'lucide-react-native'
import { memo, useContext, useMemo } from 'react'
import { Pressable, ScrollView, Text, View } from 'react-native'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { highlightMobileCode } from '../files/session-documents/mobile-file-syntax'
import { useMobileTranslation } from '../../../i18n/use-mobile-translation'
import { highlightMobileCode } from '../../../files/session-documents/mobile-file-syntax'
import {
MobileNativeChatDisclosureContext,
MobileNativeChatMarkdownRowKeyContext,
useMobileNativeChatDisclosure
} from '../session/native-chat/transcript/viewport/mobile-native-chat-disclosure-store'
import { colors, pressedStyle } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { MobileSyntaxSegments } from './MobileSyntaxSegments'
import { MobileSourceCopyButton } from './MobileSourceCopyButton'
import { scaledMarkdownText, styles } from './mobile-markdown-styles'
import { MarkdownText } from './mobile-markdown-text'
} from '../../../session/native-chat/transcript/viewport/mobile-native-chat-disclosure-store'
import { colors, pressedStyle } from '../../../theme/mobile-theme'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
import { MobileSyntaxSegments } from '../../code-presentation/MobileSyntaxSegments'
import { MobileSourceCopyButton } from '../../code-presentation/MobileSourceCopyButton'
import { scaledMarkdownText, styles } from '../mobile-markdown-styles'
import { MarkdownText } from '../mobile-markdown-text'
export const MobileMarkdownCodeBlock = memo(function MobileMarkdownCodeBlock({
code,
@@ -1,10 +1,10 @@
import { useState, type ReactNode } from 'react'
import { Pressable, Text, View, type StyleProp, type TextStyle } from 'react-native'
import { ChevronRight } from 'lucide-react-native'
import { DisclosureChevron } from './DisclosureChevron'
import { styles } from './mobile-markdown-styles'
import { colors, pressedStyle } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { DisclosureChevron } from '../../DisclosureChevron'
import { styles } from '../mobile-markdown-styles'
import { colors, pressedStyle } from '../../../theme/mobile-theme'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
/** A `<details>` section: closed unless authored `open`, like desktop and GitHub. */
export function MobileMarkdownDetails({
@@ -1,9 +1,9 @@
import { useContext, useEffect, useState } from 'react'
import { Image, Pressable, StyleSheet, Text } from 'react-native'
import { MarkdownLinkHandlerContext } from './markdown-link-handler'
import { styles as markdownStyles } from './mobile-markdown-styles'
import { pressedStyle } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { MarkdownLinkHandlerContext } from '../links/markdown-link-handler'
import { styles as markdownStyles } from '../mobile-markdown-styles'
import { pressedStyle } from '../../../theme/mobile-theme'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
type Size = { width: number; height: number }
@@ -15,28 +15,28 @@ import {
type StyleProp,
type TextStyle
} from 'react-native'
import type { NativeChatVisualDirective } from '../../../src/shared/native-chat/native-chat-visual-directive'
import type { NativeChatVisualDirective } from '../../../../../src/shared/native-chat/native-chat-visual-directive'
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
import { mobileMathMeasureText } from './mobile-markdown-math-nodes'
import { mobileMathMeasureText } from '../math/mobile-markdown-math-nodes'
import { mobileMarkdownTableColumnWidths } from './mobile-markdown-table-columns'
import { mobileMarkdownVisualPlaceholderIndex } from './mobile-markdown-visual-lines'
import {
openMarkdownHref,
renderInline,
type MarkdownInlineContext
} from './mobile-markdown-inline'
import { MARKDOWN_TONES, scaledMarkdownText, styles } from './mobile-markdown-styles'
import { MarkdownText } from './mobile-markdown-text'
} from '../parsing/mobile-markdown-inline'
import { MARKDOWN_TONES, scaledMarkdownText, styles } from '../mobile-markdown-styles'
import { MarkdownText } from '../mobile-markdown-text'
import { codeFenceClosed } from './mobile-markdown-code-nodes'
import { plainText } from './mobile-markdown-plain-text'
import { MermaidDiagram } from './mermaid/MermaidDiagram'
import { plainText } from '../parsing/mobile-markdown-plain-text'
import { MermaidDiagram } from '../../mermaid/MermaidDiagram'
import { MobileMarkdownCallout } from './MobileMarkdownCallout'
import { MobileMarkdownCodeBlock } from './MobileMarkdownCodeBlock'
import { MobileMarkdownDetails } from './MobileMarkdownDetails'
import { MobileMarkdownMath } from './MobileMarkdownMath'
import { MobileMarkdownMath } from '../math/MobileMarkdownMath'
import { drawnBlock } from './mobile-markdown-drawn-blocks'
import { translate } from '../i18n/mobile-i18n'
import { colors, pressedStyle } from '../theme/mobile-theme'
import { translate } from '../../../i18n/mobile-i18n'
import { colors, pressedStyle } from '../../../theme/mobile-theme'
/** Prose base size — passed to MermaidDiagram fallback mono text. */
const MERMAID_BASE = 13
@@ -1,7 +1,7 @@
import type { RootContent } from 'mdast'
import type { ReactNode } from 'react'
import { getMobileUiRevision } from '../i18n/mobile-i18n'
import { getMobileColorScheme } from '../theme/mobile-theme'
import { getMobileUiRevision } from '../../../i18n/mobile-i18n'
import { getMobileColorScheme } from '../../../theme/mobile-theme'
import type { MarkdownBlockContext, ParagraphText } from './mobile-markdown-blocks'
type DrawnBlock = {
@@ -1,9 +1,9 @@
import type { Image, PhrasingContent } from 'mdast'
import { Fragment, type ReactNode } from 'react'
import { View } from 'react-native'
import { MarkdownText } from './mobile-markdown-text'
import { renderInline } from './mobile-markdown-inline'
import { styles } from './mobile-markdown-styles'
import { MarkdownText } from '../mobile-markdown-text'
import { renderInline } from '../parsing/mobile-markdown-inline'
import { styles } from '../mobile-markdown-styles'
import type { MarkdownBlockContext, ParagraphText } from './mobile-markdown-blocks'
function isBlankInline(node: PhrasingContent): boolean {
@@ -1,4 +1,4 @@
import { spacing } from '../theme/mobile-theme'
import { spacing } from '../../../theme/mobile-theme'
// Measured 12px advances: 6.4 regular, 6.9 bold, up to 8.7 per bold glyph. Estimation affects wrapping, never column alignment.
const AVERAGE_GLYPH_WIDTH = 7
@@ -3,8 +3,8 @@ import {
NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE,
parseNativeChatVisualDirectiveLine,
type NativeChatVisualDirective
} from '../../../src/shared/native-chat/native-chat-visual-directive'
import { mobileMarkdownCodeLines } from './mobile-markdown-tree'
} from '../../../../../src/shared/native-chat/native-chat-visual-directive'
import { mobileMarkdownCodeLines } from '../parsing/mobile-markdown-tree'
// Private-use delimiters, as the preview normalizer's own code placeholders use.
const PLACEHOLDER_PREFIX = '\uE000ORCA_VISUAL_'
const PLACEHOLDER = /^\uE000ORCA_VISUAL_(\d+)\uE000$/
@@ -1,6 +1,6 @@
// Prefer precise chat file paths over recall: missed link is tolerable, false prose/version tap is broken navigation.
import { parseFileLinkLocation } from '../../../src/shared/file-links/file-link-location'
import { parseFileLinkLocation } from '../../../../../src/shared/file-links/file-link-location'
export type FilePathSegment =
| { type: 'text'; value: string }
@@ -1,4 +1,4 @@
import { routeNativeChatHref } from '../../../src/shared/native-chat/native-chat-href-routing'
import { routeNativeChatHref } from '../../../../../src/shared/native-chat/native-chat-href-routing'
export type MarkdownHrefRoute =
| { kind: 'web'; url: string }
@@ -1,5 +1,5 @@
import { createContext } from 'react'
import { openExternalLink } from '../platform/external-links/external-link'
import { openExternalLink } from '../../../platform/external-links/external-link'
// Screens that offer choices for a tapped web link provide this; elsewhere the system browser opens it.
export const MarkdownLinkHandlerContext = createContext<(url: string) => void>(openExternalLink)
@@ -1,7 +1,7 @@
import { createContext, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
import { createNativeChatFileLinkHooks } from '../../../src/shared/native-chat/native-chat-file-link-hooks'
import type { NativeChatFileLinkExistence } from '../../../src/shared/native-chat/native-chat-file-link-existence'
import type { GitHubRepoReference } from '../../../src/shared/github/markdown/github-reference-links'
import { createNativeChatFileLinkHooks } from '../../../../../src/shared/native-chat/native-chat-file-link-hooks'
import type { NativeChatFileLinkExistence } from '../../../../../src/shared/native-chat/native-chat-file-link-existence'
import type { GitHubRepoReference } from '../../../../../src/shared/github/markdown/github-reference-links'
/** What a chat knows about its workspace for links in its text; absent outside chat. */
export type MobileMarkdownLinks = {
@@ -1,6 +1,9 @@
import type { Code, Parent, PhrasingContent, RootContent } from 'mdast'
import { isDisplayMathOpener, markdownMathCloser } from '../../../src/shared/markdown-math-rules'
import type { Token } from './mobile-markdown-code-nodes'
import {
isDisplayMathOpener,
markdownMathCloser
} from '../../../../../src/shared/markdown-math-rules'
import type { Token } from '../blocks/mobile-markdown-code-nodes'
// The mdast nodes mobile-markdown-math's tokens become, shaped like desktop chat's math nodes.
@@ -1,6 +1,6 @@
import { BoundedMap } from '../../../src/shared/bounded-map'
import { MARKDOWN_MATH_KATEX_SETTINGS } from '../../../src/shared/markdown-math-rules'
import { copyMarkdownMathSelection } from '../../../src/shared/markdown-math-selection'
import { BoundedMap } from '../../../../../src/shared/bounded-map'
import { MARKDOWN_MATH_KATEX_SETTINGS } from '../../../../../src/shared/markdown-math-rules'
import { copyMarkdownMathSelection } from '../../../../../src/shared/markdown-math-selection'
import { KATEX_PAGE_STYLE } from './katex-page-style.generated'
import { GITHUB_MATH_SIZE_MACROS } from './mobile-markdown-math-nodes'
import type { MobileMathTypesetter } from './mobile-markdown-math-typesetter'
@@ -1,6 +1,6 @@
import type { Nodes } from 'mdast'
import { describe, expect, it } from 'vitest'
import { parseMobileMarkdownTree } from './mobile-markdown-tree'
import { parseMobileMarkdownTree } from '../parsing/mobile-markdown-tree'
/** Formulas as `D:` (display) or `I:` (inline) plus the text drawn around them. */
function drawn(source: string, chatMath = true): { math: string[]; text: string } {
@@ -8,7 +8,7 @@ import {
endsDollarAmount,
rejectsSingleDollarClose,
rejectsSingleDollarOpen
} from '../../../src/shared/markdown-math-rules'
} from '../../../../../src/shared/markdown-math-rules'
// Desktop chat's math grammar (src/renderer/src/components/sidebar/chat-markdown-math.ts) for markdown-it.
@@ -1,5 +1,5 @@
import type { BlockContent, DefinitionContent, Literal, Parent, PhrasingContent } from 'mdast'
import type { GitHubCalloutKind } from '../../../src/shared/github/markdown/github-callouts'
import type { GitHubCalloutKind } from '../../../../src/shared/github/markdown/github-callouts'
// The node types MobileMarkdown adds to mdast, registered by declaration merging. Merging needs
// interfaces, which React Doctor's root rules flag; mobile's lint config ignores this file because
@@ -1,6 +1,6 @@
import { StyleSheet, type TextStyle } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet, themedStyles } from '../theme/themed-styles'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import { themedStyleSheet, themedStyles } from '../../theme/themed-styles'
/** Lifts the 13pt base to 14pt body text for documents (file preview, PR bodies); headings scale with it. */
export const MOBILE_MARKDOWN_DOCUMENT_SCALE = 14 / 13
@@ -1,5 +1,8 @@
import type MarkdownIt from 'markdown-it'
import { findProseUrlEnd, splitAtProseUrlTerminators } from '../../../src/shared/prose-url-boundary'
import {
findProseUrlEnd,
splitAtProseUrlTerminators
} from '../../../../../src/shared/prose-url-boundary'
type Linkify = MarkdownIt['linkify']
@@ -1,7 +1,7 @@
import type { Root, RootContent } from 'mdast'
import type { Token } from './mobile-markdown-code-nodes'
import { hasDetailsSectionTag } from './mobile-markdown-details'
import type { MathEnv } from './mobile-markdown-math'
import type { Token } from '../blocks/mobile-markdown-code-nodes'
import { hasDetailsSectionTag } from '../blocks/mobile-markdown-details'
import type { MathEnv } from '../math/mobile-markdown-math'
import { markdownTokens, parseMobileMarkdownTree } from './mobile-markdown-tree'
/** One parse's finished blocks, kept for the next parse of the same text grown longer. */
@@ -1,5 +1,5 @@
import type { PhrasingContent } from 'mdast'
import type { Token } from './mobile-markdown-code-nodes'
import type { Token } from '../blocks/mobile-markdown-code-nodes'
import { plainText } from './mobile-markdown-plain-text'
import { attrValue } from './mobile-markdown-preview-html'
@@ -1,17 +1,17 @@
import type { PhrasingContent } from 'mdast'
import { Fragment, type ReactNode } from 'react'
import type { TextProps } from 'react-native'
import { openExternalLink } from '../platform/external-links/external-link'
import { openExternalLink } from '../../../platform/external-links/external-link'
import {
detectFilePathSegments,
isFilePathCodeSpan,
normalizeFilePath
} from './markdown-file-path-detection'
import { routeMarkdownHref } from './markdown-href-routing'
import { styles, type MarkdownTone } from './mobile-markdown-styles'
import { MarkdownText } from './mobile-markdown-text'
} from '../links/markdown-file-path-detection'
import { routeMarkdownHref } from '../links/markdown-href-routing'
import { styles, type MarkdownTone } from '../mobile-markdown-styles'
import { MarkdownText } from '../mobile-markdown-text'
import { plainText } from './mobile-markdown-plain-text'
import { MobileMarkdownMath } from './MobileMarkdownMath'
import { MobileMarkdownMath } from '../math/MobileMarkdownMath'
export type MarkdownInlineContext = {
// File targets call this when viewer exists; omit to keep paths plain.
@@ -3,13 +3,13 @@ import cjkFriendly from 'markdown-it-cjk-friendly'
import footnote from 'markdown-it-footnote'
import type { AlignType, Parent, PhrasingContent, Root, RootContent, Table } from 'mdast'
import remend from 'remend'
import { forEachGitHubCallout } from '../../../src/shared/github/markdown/github-callouts'
import { codeNode, type Token } from './mobile-markdown-code-nodes'
import { forEachGitHubCallout } from '../../../../../src/shared/github/markdown/github-callouts'
import { codeNode, type Token } from '../blocks/mobile-markdown-code-nodes'
import { inlineTagNode, inlineTagPairs } from './mobile-markdown-inline-html'
import { readTaskMarker } from './mobile-markdown-task-items'
import { plainText } from './mobile-markdown-plain-text'
import { appendHtmlBlock, noteInlineHtml, popContainer } from './mobile-markdown-details'
import { mobileMarkdownMath, type MathEnv } from './mobile-markdown-math'
import { appendHtmlBlock, noteInlineHtml, popContainer } from '../blocks/mobile-markdown-details'
import { mobileMarkdownMath, type MathEnv } from '../math/mobile-markdown-math'
import {
displayFormulas,
inlineMathNode,
@@ -17,7 +17,7 @@ import {
mathClosed,
mathFenceNode,
withoutMath
} from './mobile-markdown-math-nodes'
} from '../math/mobile-markdown-math-nodes'
import { withImagesSplitOut } from './mobile-markdown-html-images'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { endLinkifyUrlsAtProsePunctuation } from './markdown-linkify-prose-boundary'
@@ -3,17 +3,17 @@ import { useMemo, useRef } from 'react'
import {
linkGitHubReferences,
type GitHubRepoReference
} from '../../../src/shared/github/markdown/github-reference-links'
} from '../../../../../src/shared/github/markdown/github-reference-links'
import {
linkNativeChatFilePaths,
type FileLinkExists
} from '../../../src/shared/native-chat/native-chat-file-link-tree'
import type { NativeChatVisualDirective } from '../../../src/shared/native-chat/native-chat-visual-directive'
} from '../../../../../src/shared/native-chat/native-chat-file-link-tree'
import type { NativeChatVisualDirective } from '../../../../../src/shared/native-chat/native-chat-visual-directive'
import {
parseGrowingMobileMarkdownTree,
type MobileMarkdownTreeCache
} from './mobile-markdown-growing-tree'
import { protectMobileMarkdownVisualLines } from './mobile-markdown-visual-lines'
import { protectMobileMarkdownVisualLines } from '../blocks/mobile-markdown-visual-lines'
const NO_DIRECTIVES: NativeChatVisualDirective[] = []
@@ -11,24 +11,24 @@ import {
type ForwardedRef
} from 'react'
import { View, type HostInstance } from 'react-native'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { colors } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { openExternalLink } from '../platform/external-links/external-link'
import { useMobileTranslation } from '../../i18n/use-mobile-translation'
import { colors } from '../../theme/mobile-theme'
import { themedStyleSheet } from '../../theme/themed-styles'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
import { openExternalLink } from '../../platform/external-links/external-link'
import { MobileRichMarkdownToolbar } from './MobileRichMarkdownToolbar'
import { TextInputModal } from './TextInputModal'
import { TextInputModal } from '../TextInputModal'
import {
ensureDocumentStyle,
mountRichMarkdownWebDocument,
type RichMarkdownWebDocument
} from './rich-markdown/rich-markdown-web-document-mount'
} from './document/rich-markdown-web-document-mount'
import { useMobileRichMarkdownEditorController } from './use-mobile-rich-markdown-editor-controller'
import {
noWorkspaceImage,
type RichMarkdownEditorApi,
type RichMarkdownUrlPromptKind
} from './rich-markdown/document-host-seams'
} from './document/document-host-seams'
import type {
MobileRichMarkdownEditorMessage,
MobileRichMarkdownEditorProps
@@ -17,11 +17,11 @@ import {
Quote,
Strikethrough
} from 'lucide-react-native'
import { colors, radii, spacing } from '../theme/mobile-theme'
import type { Translate } from '../i18n/mobile-i18n'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { colors, radii, spacing } from '../../theme/mobile-theme'
import type { Translate } from '../../i18n/mobile-i18n'
import { useMobileTranslation } from '../../i18n/use-mobile-translation'
import { themedStyleSheet } from '../../theme/themed-styles'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
import type { MobileRichMarkdownCommand } from './mobile-rich-markdown-editor-contract'
type ToolbarItem = {
@@ -1,13 +1,21 @@
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'
import { createRichMarkdownEditorScope } from './document/document-scope'
import {
currentMarkdown,
setEditable,
setMarkdown,
stopEditorContent
} from './editing/editor-content'
import { startEditorListeners, stopEditorListeners } from './editing/editor-listeners'
import { startEditorSurface } from './editing/editor-surface'
import { startHostBridge } from './document/host-bridge'
import { dismissKeyboard } from './editing/keyboard-dismiss'
import { runCommand } from './editing/editor-commands'
import { startKeyboardInset, stopKeyboardInset } from './editing/keyboard-inset'
import type { RichMarkdownEditorScope } from './document/document-scope'
import type {
RichMarkdownEditorDocument,
RichMarkdownEditorHost
} from './document/document-host-seams'
// Each mount owns local editor scope so overlapping editors/listeners never read another mount.
// Start in order: read surface, install listeners, start keyboard measurement, then announce ready.
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
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'
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from 'vitest'
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
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'
@@ -64,15 +64,6 @@ describe('an editor document that is stopped', () => {
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()
@@ -6,12 +6,6 @@ import {
type RichMarkdownEditorHost,
type RichMarkdownEditorHostSeams
} from './document-host-seams'
export type {
RichMarkdownEditorApi,
RichMarkdownEditorDocument,
RichMarkdownEditorHost,
RichMarkdownEditorHostSeams
} from './document-host-seams'
// Own all mutable editor state per factory call; module let would leak generation, caret and inset between overlapping mounts (ruling 21).
export type RichMarkdownEditorState = {
@@ -1,5 +1,5 @@
import { colors, getMobileColorScheme } from '../../theme/mobile-theme'
import { TEXT_INPUT_FONT_SIZE } from '../../platform/text-input/text-input-font-size'
import { colors, getMobileColorScheme } from '../../../theme/mobile-theme'
import { TEXT_INPUT_FONT_SIZE } from '../../../platform/text-input/text-input-font-size'
// Read theme at build time; native embeds sheet, page scopes it to editor host.
// #editor scrolls itself: iOS WebKit stops drawing selection ~2 screens into an editable scrolled by a separate box (#34472).
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { scopeDocumentStyleToHost } from '../../style-scoping/document-style-scoping'
import { scopeDocumentStyleToHost } from '../../../style-scoping/document-style-scoping'
const PREFIX = '.orca-rich-markdown-document-host'
@@ -1,8 +1,8 @@
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
import { createRichMarkdownEditorDocument } from '../create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
import { richMarkdownEditorStyle } from './document-style'
import { getMobileColorScheme } from '../../theme/mobile-theme'
import { scopeDocumentStyleToHost } from '../../style-scoping/document-style-scoping'
import { getMobileColorScheme } from '../../../theme/mobile-theme'
import { scopeDocumentStyleToHost } from '../../../style-scoping/document-style-scoping'
import type { RichMarkdownEditorApi, RichMarkdownUrlPromptKind } from './document-host-seams'
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
@@ -6,10 +6,10 @@ import {
wrapSelection
} from './editor-selection'
import { editorElement } from './editor-surface'
import { isSafeUrl } from './markdown-escaping'
import { isSafeUrl } from '../serialization/markdown-escaping'
import type { MobileRichMarkdownCommand } from '../mobile-rich-markdown-editor-contract'
import type { RichMarkdownEditorScope } from './document-scope'
import type { RichMarkdownUrlPromptKind } from './document-host-seams'
import type { RichMarkdownEditorScope } from '../document/document-scope'
import type { RichMarkdownUrlPromptKind } from '../document/document-host-seams'
const TASK_LIST_HTML =
'<ul data-type="taskList"><li data-checked="false"><label contenteditable="false">' +
@@ -1,10 +1,10 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
import { quietRichMarkdownEditorHost } from '../document/rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from '../create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from '../document/document-markup'
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
import type { RichMarkdownEditorDocument } from './document-host-seams'
import type { RichMarkdownEditorDocument } from '../document/document-host-seams'
// Round-trip host generation unchanged so pre-replacement changes can be discarded; suppress document’s own rewrites to avoid echo.
const started: RichMarkdownEditorDocument[] = []
@@ -76,15 +76,9 @@ describe('the editor document content', () => {
})
it('reports one change for a checkbox tap, not one per event the tap raises', () => {
// Assert click/input/change all bubble for one tick, then prove only one report; missing events cannot satisfy count accidentally.
const editor = editorDocument()
editor.handle.setMarkdown('- [ ] Open', 1)
const reached: string[] = []
for (const name of ['click', 'input', 'change']) {
editor.editor.addEventListener(name, (event) => reached.push(event.type))
}
editor.editor.querySelector('input')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
expect(reached).toEqual(['click', 'input', 'change'])
expect(editor.editor.querySelector('li')!.getAttribute('data-checked')).toBe('true')
expect(editor.posted).toEqual([{ type: 'change', markdown: '- [x] Open', generation: 1 }])
})
@@ -1,9 +1,9 @@
import { blockMarkdown } from './html-block-markdown'
import { markdownToHtml } from './markdown-to-html'
import { WORKSPACE_IMAGE_PATH_ATTRIBUTE } from './markdown-inline-render'
import { post } from './host-bridge'
import { blockMarkdown } from '../serialization/html-block-markdown'
import { markdownToHtml } from '../serialization/markdown-to-html'
import { WORKSPACE_IMAGE_PATH_ATTRIBUTE } from '../serialization/markdown-inline-render'
import { post } from '../document/host-bridge'
import { editorElement } from './editor-surface'
import type { RichMarkdownEditorScope } from './document-scope'
import type { RichMarkdownEditorScope } from '../document/document-scope'
/** What the surface holds right now, as markdown: every block, blank ones dropped. */
export function currentMarkdown(scope: RichMarkdownEditorScope): string {
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from '../create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from '../document/document-markup'
import { quietRichMarkdownEditorHost } from '../document/rich-markdown-editor-host.test-support'
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
const stopDocuments: (() => void)[] = []
@@ -98,11 +98,9 @@ it('keeps an image nested inside inline formatting', async () => {
it('leaves existing code blocks intact when selecting surrounding paragraphs', async () => {
const editor = mount('Before\n\n```ts\nconst n = 1\n```\n\nAfter')
const original = editor.surface.querySelector('pre')!.outerHTML
const range = document.createRange()
range.selectNodeContents(editor.surface)
select(range)
await editor.send.runCommand('inlineCode')
expect(editor.surface.querySelector('pre')!.outerHTML).toBe(original)
expect(editor.send.currentMarkdown()).toBe('`Before`\n\n```ts\nconst n = 1\n```\n\n`After`')
})
@@ -1,9 +1,9 @@
import { emitChange } from './editor-content'
import { applySelectionRange, caretRangeAtPoint, focusEditor } from './editor-selection'
import { editorElement } from './editor-surface'
import { post } from './host-bridge'
import { post } from '../document/host-bridge'
import { runCommand } from './editor-commands'
import type { RichMarkdownEditorScope } from './document-scope'
import type { RichMarkdownEditorScope } from '../document/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 {
@@ -1,9 +1,9 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from 'vitest'
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
import type { RichMarkdownEditorDocument } from './document-host-seams'
import { quietRichMarkdownEditorHost } from '../document/rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from '../create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from '../document/document-markup'
import type { RichMarkdownEditorDocument } from '../document/document-host-seams'
// Simulate WebKit dropping DOM ranges on blur; restore saved caret for toolbar command instead of inserting at document end. A preserving browser would hide failure.
const started: RichMarkdownEditorDocument[] = []
@@ -1,5 +1,5 @@
import { editorElement } from './editor-surface'
import type { RichMarkdownEditorScope } from './document-scope'
import type { RichMarkdownEditorScope } from '../document/document-scope'
export function focusEditor(scope: RichMarkdownEditorScope) {
editorElement(scope).focus()
@@ -1,5 +1,5 @@
import { RICH_MARKDOWN_EDITOR_ELEMENT_ID } from './document-markup'
import type { RichMarkdownEditorScope } from './document-scope'
import { RICH_MARKDOWN_EDITOR_ELEMENT_ID } from '../document/document-markup'
import type { RichMarkdownEditorScope } from '../document/document-scope'
// Factory starts before exposing API, so surface is non-null when reachable; missing markup fails first property read like native unguarded script.
export function editorElement(scope: RichMarkdownEditorScope): HTMLElement {
@@ -1,6 +1,6 @@
import { rememberSelection } from './editor-selection'
import { editorElement } from './editor-surface'
import type { RichMarkdownEditorScope } from './document-scope'
import type { RichMarkdownEditorScope } from '../document/document-scope'
// Save caret before blur closes keyboard because WebKit drops DOM selection; flag tells next command to restore saved range.
export function dismissKeyboard(scope: RichMarkdownEditorScope) {
@@ -1,6 +1,6 @@
import { post } from './host-bridge'
import type { RichMarkdownKeyboardInsetReader } from './document-host-seams'
import type { RichMarkdownEditorScope } from './document-scope'
import { post } from '../document/host-bridge'
import type { RichMarkdownKeyboardInsetReader } from '../document/document-host-seams'
import type { RichMarkdownEditorScope } from '../document/document-scope'
/** Posts the covered height, and only when it has actually moved. */
function reportKeyboardInset(
@@ -41,7 +41,7 @@ vi.mock('lucide-react-native', () => ({
Strikethrough: () => null
}))
vi.mock('./TextInputModal', async () => {
vi.mock('../TextInputModal', async () => {
const React = await import('react')
return {
TextInputModal: (props: Record<string, unknown>) => React.createElement('TextInputModal', props)
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from '../create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from '../document/document-markup'
import { quietRichMarkdownEditorHost } from '../document/rich-markdown-editor-host.test-support'
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
const stopDocuments: (() => void)[] = []
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from '../create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from '../document/document-markup'
import { quietRichMarkdownEditorHost } from '../document/rich-markdown-editor-host.test-support'
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
const stopDocuments: (() => void)[] = []
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from '../create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from '../document/document-markup'
import { quietRichMarkdownEditorHost } from '../document/rich-markdown-editor-host.test-support'
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
const stopDocuments: (() => void)[] = []
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from '../create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from '../document/document-markup'
import { quietRichMarkdownEditorHost } from '../document/rich-markdown-editor-host.test-support'
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
const stopDocuments: (() => void)[] = []
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from '../create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from '../document/document-markup'
import { quietRichMarkdownEditorHost } from '../document/rich-markdown-editor-host.test-support'
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
const stopDocuments: (() => void)[] = []
@@ -1,7 +1,7 @@
import {
createRawMarkdownCodeSpanScanner,
type MarkdownCodeSpanScanner
} from '../../../../src/shared/markdown-code-span-runs'
} from '../../../../../src/shared/markdown-code-span-runs'
import {
decodeMarkdownEntities,
escapeAttr,
@@ -9,7 +9,7 @@ import {
escapeLiteralHtml,
isSafeUrl
} from './markdown-escaping'
import { routeMarkdownHref } from '../markdown-href-routing'
import { routeMarkdownHref } from '../../markdown/links/markdown-href-routing'
/** Holds a workspace image's markdown path; the page resolves it, so the host loads `src` instead. */
export const WORKSPACE_IMAGE_PATH_ATTRIBUTE = 'data-workspace-path'
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from '../create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from '../document/document-markup'
import { quietRichMarkdownEditorHost } from '../document/rich-markdown-editor-host.test-support'
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
const stopDocuments: (() => void)[] = []
@@ -39,26 +39,14 @@ for (const [kind, source, selector] of lists) {
const { editorDocument, editor, messages } = surface(source)
const body = editor.querySelector(selector)!
body.innerHTML = html
const live = body.outerHTML
const contents = Array.from(editor.querySelectorAll('li')).map((item) => item.textContent)
const checks = Array.from(editor.querySelectorAll<HTMLInputElement>('input')).map(
(input) => input.checked
)
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertParagraph' }))
const markdown = source.replace('First', text)
expect(body.outerHTML).toBe(live)
expect(messages.filter((message) => message.type === 'change')).toEqual([
{ type: 'change', generation: 7, markdown }
])
editorDocument.send.setMarkdown(markdown, 8)
expect(editor.querySelector(selector)!.querySelector('p')!.innerHTML).toBe(inline)
expect(Array.from(editor.querySelectorAll('li')).map((item) => item.textContent)).toEqual(
contents
)
expect(
Array.from(editor.querySelectorAll<HTMLInputElement>('input')).map((input) => input.checked)
).toEqual(checks)
expect(editor.querySelector(':scope > p')?.textContent).toBe('Outside')
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
}
@@ -74,11 +62,9 @@ it.each([
const { editorDocument, editor } = surface('- First\n- Next')
const item = editor.querySelector('li')!
item.innerHTML = html
const live = item.outerHTML
const markdown = `- ${text}\n- Next`
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
expect(item.outerHTML).toBe(live)
editorDocument.send.setMarkdown(markdown, 8)
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
})
@@ -87,11 +73,9 @@ it('retains nested lists and checkbox labels while serializing the owning item o
const { editorDocument, editor } = surface('- [x] First\n 8. Child\n 9. Next\n- [ ] Last')
const paragraph = editor.querySelector('li > div > p')!
paragraph.insertAdjacentHTML('afterend', '<p>Second</p>')
const live = editor.innerHTML
const markdown = '- [x] First<br />Second\n 8. Child\n 9. Next\n- [ ] Last'
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
expect(editor.innerHTML).toBe(live)
editorDocument.send.setMarkdown(markdown, 8)
expect(editor.querySelectorAll('li').length).toBe(4)
expect(editor.querySelectorAll('ol li').length).toBe(2)
@@ -1,9 +1,9 @@
// @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 { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import type { RichMarkdownEditorDocument } from './document-host-seams'
import { createRichMarkdownEditorDocument } from '../create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from '../document/document-markup'
import { quietRichMarkdownEditorHost } from '../document/rich-markdown-editor-host.test-support'
import type { RichMarkdownEditorDocument } from '../document/document-host-seams'
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
let mounted: RichMarkdownEditorDocument | undefined
@@ -40,19 +40,16 @@ describe('mobile list code blocks', () => {
it.each(contexts)(
'preserves literal code and continuation paragraphs in $name items',
({ source, selector }) => {
const { editorDocument, messages, editor } = open(source)
const { editorDocument, editor } = open(source)
const body = editor.querySelector(selector)!
body.innerHTML =
'<p>Before <strong>bold</strong><br>continued</p><pre data-language="js"><code></code></pre><p>After <em>tail</em></p>'
const code = ' indented\n\ttab\n\t\n \n\n````\n<tag>&amp; &#60;'
body.querySelector('code')!.textContent = code
const items = editor.querySelectorAll('li').length
const before = body.innerHTML
messages.length = 0
editor.dispatchEvent(new Event('input'))
const saved = editorDocument.send.currentMarkdown()
expect(body.innerHTML).toBe(before)
expect(messages).toEqual([{ type: 'change', markdown: saved, generation: 7 }])
editorDocument.send.setMarkdown(saved, 8)
const reopened = editor.querySelector(selector)!
expect(reopened.querySelector('pre')?.textContent).toBe(code)
@@ -64,9 +61,7 @@ describe('mobile list code blocks', () => {
expect(reopened.querySelector('p br')).not.toBeNull()
expect(reopened.querySelector('strong')?.textContent).toBe('bold')
expect(reopened.querySelector('em')?.textContent).toBe('tail')
expect(editor.querySelectorAll('li').length).toBe(items)
expect(editor.querySelector(':scope > p')?.textContent).toBe('Outside')
expect(editorDocument.send.currentMarkdown()).toBe(saved)
}
)
@@ -107,7 +102,6 @@ describe('mobile list code blocks', () => {
expect(saved).toContain('### Sub')
expect(saved).toContain('- # Title')
editorDocument.send.setMarkdown(saved, 8)
expect(editorDocument.send.currentMarkdown()).toBe(saved)
})
it('writes a code block inserted into an item as a fence its reader keeps in place', () => {
@@ -131,6 +125,5 @@ describe('mobile list code blocks', () => {
editorDocument.send.setMarkdown(saved, 8)
expect(editor.querySelector<HTMLInputElement>('li > label input')?.checked).toBe(true)
expect(editor.querySelector('li pre')?.textContent).toBe('literal')
expect(editorDocument.send.currentMarkdown()).toBe(saved)
})
})
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from '../create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from '../document/document-markup'
import { quietRichMarkdownEditorHost } from '../document/rich-markdown-editor-host.test-support'
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
const stopDocuments: (() => void)[] = []
@@ -35,23 +35,13 @@ for (const [kind, source, selector] of lists) {
])(`keeps ${kind} item soft break inside its list through host save: %s`, (html, text) => {
const { editorDocument, editor, messages } = surface(source)
editor.querySelector(selector)!.innerHTML = html
const live = editor.querySelector(selector)!.outerHTML
const items = Array.from(editor.querySelectorAll('li')).map((item) => item.textContent)
const checkboxes = Array.from(editor.querySelectorAll<HTMLInputElement>('input')).map(
(input) => input.checked
)
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' }))
const markdown = source.replace('First', text)
expect(editor.querySelector(selector)!.outerHTML).toBe(live)
expect(messages.filter((message) => message.type === 'change')).toEqual([
{ type: 'change', generation: 7, markdown }
])
editorDocument.send.setMarkdown(markdown, 8)
expect(Array.from(editor.querySelectorAll('li')).map((item) => item.textContent)).toEqual(items)
expect(
Array.from(editor.querySelectorAll<HTMLInputElement>('input')).map((input) => input.checked)
).toEqual(checkboxes)
expect(editor.querySelector(selector)?.innerHTML).toBe(html)
expect(editor.querySelector(':scope > p')?.textContent).toBe('Outside')
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
@@ -71,7 +61,6 @@ it.each([
expect(editor.querySelector(selector)?.textContent).toBe(text)
expect(editor.querySelector(selector)?.children.length).toBe(0)
expect(editor.querySelectorAll('li').length).toBe(2)
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
})
it('recognizes attribute-free list breaks while keeping code and paragraph markup literal', () => {
@@ -101,7 +90,6 @@ it.each([
'',
'Last'
])
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
})
it.each([
@@ -115,11 +103,9 @@ it.each([
const { editorDocument, editor } = surface('- First\n- Next\n\nOutside')
const item = editor.querySelector('li')!
item.innerHTML = html
const live = item.outerHTML
const markdown = `- ${text}\n- Next\n\nOutside`
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
expect(item.outerHTML).toBe(live)
editorDocument.send.setMarkdown(markdown, 8)
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
})
@@ -1,7 +1,7 @@
import { openingFence } from './markdown-code-fence'
import { renderInline } from './markdown-inline-render'
import { listKind, type ParsedListItem } from './markdown-list-parse'
import type { RichMarkdownEditorScope } from './document-scope'
import type { RichMarkdownEditorScope } from '../document/document-scope'
// The leading lines stay one inline paragraph so block syntax on the marker line (`- # Title`) reads as text.
function itemContent(item: ParsedListItem, renderBlocks: (markdown: string) => string): string {
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from '../create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from '../document/document-markup'
import { quietRichMarkdownEditorHost } from '../document/rich-markdown-editor-host.test-support'
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
const stopDocuments: (() => void)[] = []
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorDocument } from '../create-rich-markdown-editor-document'
import { RICH_MARKDOWN_EDITOR_MARKUP } from '../document/document-markup'
import { quietRichMarkdownEditorHost } from '../document/rich-markdown-editor-host.test-support'
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
const stopDocuments: (() => void)[] = []
@@ -1,13 +1,13 @@
// @vitest-environment happy-dom
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorScope } from './document-scope'
import { quietRichMarkdownEditorHost } from '../document/rich-markdown-editor-host.test-support'
import { createRichMarkdownEditorScope } from '../document/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'
import { RICH_MARKDOWN_EDITOR_MARKUP } from '../document/document-markup'
import { currentMarkdown } from '../editing/editor-content'
import { startEditorSurface } from '../editing/editor-surface'
// Render then serialize real DOM and compare original source: two implementations can agree while both lose numbering.
// Real closest/children/cloneNode/checked semantics prevent fixtures inventing structures renderer never produces.

Some files were not shown because too many files have changed in this diff Show More