diff --git a/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx b/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx index 369fcf19ef55a..a17e48c2604c7 100644 --- a/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx +++ b/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx @@ -6,7 +6,10 @@ import { Pressable, ScrollView, View } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import { useHardwareBack } from "../../lib/backHandler"; -import { deriveMenuHorizontalPlacement } from "../../lib/menu-placement"; +import { + deriveMenuHorizontalPlacement, + deriveMenuVerticalPlacement, +} from "../../lib/menu-placement"; import { WindowHingeContext } from "../../native/window-hinge-context"; import { OverlayPortal } from "./OverlayPortal"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; @@ -41,10 +44,9 @@ export type AndroidAnchoredMenuProps = { }; export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { - const { scale, menuWidth: desiredMenuWidth } = useAndroidControlSizing(); + const { scale, menuWidth: desiredMenuWidth, menuItemHeight } = useAndroidControlSizing(); const [anchor, setAnchor] = useState(null); const [path, setPath] = useState([]); - const [rootHeight, setRootHeight] = useState(null); const [overlay, setOverlay] = useState(null); const windowHinge = use(WindowHingeContext); // State, not a ref: `open` reaches render-prop children, and the compiler @@ -58,7 +60,6 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { setAnchor(null); setPath([]); setOverlay(null); - setRootHeight(null); }; const open = () => { @@ -70,7 +71,6 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const measureOverlay = () => { overlayRef.current?.measureInWindow((x, y, width, height) => { setOverlay({ x, y, width, height }); - setRootHeight(height); }); }; @@ -110,17 +110,19 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { margin: SCREEN_MARGIN, hinge: windowHinge && { x: windowHinge.x - overlay.x, width: windowHinge.width }, }); - // This in-window menu preserves keyboard focus; subtract the IME area so its items remain tappable. - const usableBottom = - overlay === null ? 0 : overlay.height - (keyboardVisible ? keyboardHeight : 0); - const spaceBelow = + const verticalPlacement = local === null || overlay === null - ? 0 - : usableBottom - (local.y + local.height) - ANCHOR_GAP - SCREEN_MARGIN; - const spaceAbove = local === null ? 0 : local.y - ANCHOR_GAP - SCREEN_MARGIN; - const opensDown = spaceBelow >= 280 || spaceBelow >= spaceAbove; - const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480); - const placeable = local !== null && rootHeight !== null; + ? null + : deriveMenuVerticalPlacement({ + anchorY: local.y, + anchorHeight: local.height, + overlayHeight: overlay.height, + // This in-window menu preserves keyboard focus; subtract the IME area so its items remain tappable. + keyboardInset: keyboardVisible ? keyboardHeight : 0, + gap: ANCHOR_GAP, + margin: SCREEN_MARGIN, + minHeight: 2 * menuItemHeight + 14 * scale, + }); const onPressItem = (action: MenuAction) => { if ((action.subactions?.length ?? 0) > 0) { @@ -167,7 +169,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { onLayout={measureOverlay} > - {!placeable || local === null ? null : !anchor.keyboardWasVisible ? ( + {local === null ? null : !anchor.keyboardWasVisible ? ( {/* Compose DropdownMenu takes focus; in-window editor menus preserve the keyboard. */} diff --git a/apps/mobile/src/lib/menu-placement.test.ts b/apps/mobile/src/lib/menu-placement.test.ts new file mode 100644 index 0000000000000..725a5c3ad879f --- /dev/null +++ b/apps/mobile/src/lib/menu-placement.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { deriveMenuVerticalPlacement } from "./menu-placement"; + +const base = { gap: 6, margin: 12, minHeight: 110 }; + +describe("deriveMenuVerticalPlacement", () => { + it.each([ + { + name: "anchor taller than the keyboard-free area stays on screen", + input: { anchorY: -100, anchorHeight: 1000, overlayHeight: 800, keyboardInset: 300 }, + expected: { maxHeight: 476, top: 12 }, + }, + { + name: "room below opens down", + input: { anchorY: 100, anchorHeight: 40, overlayHeight: 800, keyboardInset: 300 }, + expected: { maxHeight: 342, top: 146 }, + }, + { + name: "room above opens up", + input: { anchorY: 400, anchorHeight: 40, overlayHeight: 800, keyboardInset: 300 }, + expected: { maxHeight: 382, bottom: 406 }, + }, + ])("$name", ({ input, expected }) => { + expect(deriveMenuVerticalPlacement({ ...base, ...input })).toEqual(expected); + }); +}); diff --git a/apps/mobile/src/lib/menu-placement.ts b/apps/mobile/src/lib/menu-placement.ts index 8bd560e6a446b..eb7262a41eaa6 100644 --- a/apps/mobile/src/lib/menu-placement.ts +++ b/apps/mobile/src/lib/menu-placement.ts @@ -23,3 +23,31 @@ export function deriveMenuHorizontalPlacement(input: { const left = Math.min(Math.max(preferredLeft, start + input.margin), end - width - input.margin); return { left, width }; } + +/** In the overlay's coordinates; `bottom` is measured up from the overlay's bottom edge. */ +export function deriveMenuVerticalPlacement(input: { + readonly anchorY: number; + readonly anchorHeight: number; + readonly overlayHeight: number; + readonly keyboardInset: number; + readonly gap: number; + readonly margin: number; + readonly minHeight: number; +}) { + const usableBottom = input.overlayHeight - input.keyboardInset; + const spaceBelow = usableBottom - (input.anchorY + input.anchorHeight) - input.gap - input.margin; + const spaceAbove = input.anchorY - input.gap - input.margin; + const opensDown = spaceBelow >= 280 || spaceBelow >= spaceAbove; + const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480); + if (maxHeight < input.minHeight) { + // Neither side fits (e.g. a code block taller than the screen): overlap the anchor instead. + const top = Math.max( + input.margin, + Math.min(input.anchorY, usableBottom - input.margin - input.minHeight), + ); + return { maxHeight: Math.min(usableBottom - input.margin - top, 480), top }; + } + return opensDown + ? { maxHeight, top: input.anchorY + input.anchorHeight + input.gap } + : { maxHeight, bottom: input.overlayHeight - input.anchorY + input.gap }; +} diff --git a/apps/mobile/src/native/CodeBlockFrame.tsx b/apps/mobile/src/native/CodeBlockFrame.tsx new file mode 100644 index 0000000000000..1c80e759d4dbd --- /dev/null +++ b/apps/mobile/src/native/CodeBlockFrame.tsx @@ -0,0 +1,98 @@ +import type { ReactNode } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; + +import { SymbolView, type AppSymbolName } from "../components/ui/AppSymbol"; +import { themeColorWithAlpha } from "../lib/mobileTheme"; +import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; + +/** + * The native code block's border and header, for a fence drawn in React once it closes. + * The block slot already applies the code block's outer margins. + */ +export function CodeBlockFrame(props: { + readonly label: string; + readonly textStyle: NativeMarkdownTextStyle; + readonly actions: ReactNode; + readonly children: ReactNode; +}) { + const { textStyle } = props; + return ( + + + + {props.label} + + {props.actions} + + {props.children} + + ); +} + +/** A header icon; `active` draws it at full strength, as the native wrap toggle when on. */ +export function CodeBlockHeaderButton(props: { + readonly icon: AppSymbolName; + readonly label: string; + readonly textStyle: NativeMarkdownTextStyle; + readonly active?: boolean; + readonly onPress: () => void; +}) { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + block: { + borderRadius: 8, + borderWidth: StyleSheet.hairlineWidth, + overflow: "hidden", + }, + header: { + minHeight: 32, + paddingHorizontal: 12, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + borderBottomWidth: StyleSheet.hairlineWidth, + }, + label: { fontWeight: "500" }, + actions: { flexDirection: "row", alignItems: "center", gap: 14 }, +}); diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 16bab62f09372..86bf8021bdd0f 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -20,6 +20,8 @@ import { type MarkdownLinkOccurrence, type MarkdownStyle, } from "react-native-enriched-markdown"; +import { codeFenceRenderer } from "./codeBlockMarkdown"; +import { HighlightedCodeBlock } from "./HighlightedCodeBlock"; import { MermaidCodeBlock } from "./MermaidCodeBlock"; import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; @@ -29,6 +31,7 @@ import { useEnrichedLinkVariants } from "./useEnrichedLinkVariants"; import { useRetryingImageSource } from "./useRetryingImageSource"; import * as Dialog from "../components/ui/Dialog"; import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; +import type { HighlightScheme } from "../lib/highlights"; import { ENRICHED_INLINE_FILE_LINK_REGEX, enrichedTextLinkRegex, @@ -166,19 +169,25 @@ function enrichedStyle( }; } -/** Diagram and math fences, as web draws them. A fence streams as native code until it closes. */ +/** + * Diagram and math fences, as web draws them, and code enriched-markdown cannot colour. + * Diagrams and math stay native until the library reports them complete; fallback code keeps one block throughout. + */ function codeBlockElement( block: MarkdownCodeBlock, textStyle: SelectableMarkdownTextProps["textStyle"], markdownStyle: MarkdownStyle, mathRenderingEnabled: boolean, + scheme: HighlightScheme, ): ReactElement | undefined { - if (!block.isComplete) return undefined; - if (block.language === "mermaid") { - return ; + const renderer = codeFenceRenderer(block.language, mathRenderingEnabled); + if (renderer.kind === "mermaid") { + return block.isComplete ? ( + + ) : undefined; } - if (block.language === "math" && mathRenderingEnabled) { - return ( + if (renderer.kind === "math") { + return block.isComplete ? ( + ) : undefined; + } + if (renderer.kind === "highlight" && markdownStyle.codeBlock) { + return ( + ); } return undefined; @@ -347,7 +369,13 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { } : undefined, code: (block: MarkdownCodeBlock) => - codeBlockElement(block, props.textStyle, baseMarkdownStyle, mathRenderingEnabled), + codeBlockElement( + block, + props.textStyle, + baseMarkdownStyle, + mathRenderingEnabled, + themeAppearance, + ), }; const resolveNativeImageSource = useRetryingImageSource(resolveImageSource); const renderMarkdown = (openAndroidMenu?: () => void) => { diff --git a/apps/mobile/src/native/HighlightedCodeBlock.tsx b/apps/mobile/src/native/HighlightedCodeBlock.tsx new file mode 100644 index 0000000000000..b0c981d95e3fc --- /dev/null +++ b/apps/mobile/src/native/HighlightedCodeBlock.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { Pressable, ScrollView, Text } from "react-native"; +import type { MarkdownStyle } from "react-native-enriched-markdown"; + +import { Menu } from "../components/ui/Menu"; +import { useMarkdownCodeHighlight } from "../features/threads/markdownCodeHighlightState"; +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; +import type { HighlightScheme } from "../lib/highlights"; +import { CodeBlockFrame, CodeBlockHeaderButton } from "./CodeBlockFrame"; +import { fencedCodeMarkdown } from "./codeBlockMarkdown"; +import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; + +// Header names from enriched-markdown's CodeBlockLanguages.cpp that are not the fence capitalized. +const FENCE_DISPLAY_NAMES: Readonly> = { + cc: "C++", + cpp: "C++", + cxx: "C++", + cs: "C#", + csharp: "C#", + graphql: "GraphQL", + objc: "Objective-C", + objectivec: "Objective-C", + php: "PHP", + rb: "Ruby", + scss: "SCSS", + sql: "SQL", + toml: "TOML", + xml: "XML", +}; + +// The native block's long-press menu, with its labels and iOS symbols. +const CODE_MENU_ACTIONS = [ + { id: "copy-code", title: "Copy", image: "doc.on.doc" }, + { id: "copy-markdown", title: "Copy as Markdown", image: "doc.text" }, +]; + +/** A fence in a language enriched-markdown cannot colour, drawn like its native block. */ +export function HighlightedCodeBlock(props: { + readonly code: string; + readonly fence: string; + readonly language: string; + /** False only while the library withholds an unclosed fence (`streamingAnimation`, which T3 never enables, so always true today); highlighting waits for it. */ + readonly complete: boolean; + readonly scheme: HighlightScheme; + readonly textStyle: NativeMarkdownTextStyle; + readonly codeStyle: NonNullable; +}) { + const { code, codeStyle, textStyle } = props; + const [wrapped, setWrapped] = useState(false); + const lines = useMarkdownCodeHighlight({ + code, + language: props.language, + theme: props.scheme, + enabled: props.complete, + }); + const fence = props.fence.toLowerCase(); + let offset = 0; + const text = ( + // Not selectable: as in the native block, a long-press opens the copy menu instead. + + {lines?.flatMap((line, lineIndex) => { + const separator = lineIndex > 0 ? "\n" : ""; + offset += separator.length; + return [ + separator, + ...line.map((token) => { + const start = offset; + offset += token.content.length; + return ( + + {token.content} + + ); + }), + ]; + }) ?? code} + + ); + // Inside the scroller: on iOS an ancestor JS responder disables a ScrollView's panning. + const content = ( + { + copyTextWithHaptic( + nativeEvent.event === "copy-markdown" ? fencedCodeMarkdown(code, props.fence) : code, + ); + }} + > + {text} + + ); + return ( + + setWrapped((value) => !value)} + /> + copyTextWithHaptic(code)} + /> + + } + > + {wrapped ? ( + content + ) : ( + + {content} + + )} + + ); +} diff --git a/apps/mobile/src/native/MermaidCodeBlock.tsx b/apps/mobile/src/native/MermaidCodeBlock.tsx index 345f99b1b6355..154c7e11ee174 100644 --- a/apps/mobile/src/native/MermaidCodeBlock.tsx +++ b/apps/mobile/src/native/MermaidCodeBlock.tsx @@ -7,10 +7,10 @@ import { PixelRatio, Platform, Pressable, StyleSheet, Text, View } from "react-n import { parse, Svg, SvgXml } from "react-native-svg"; import { FilePreviewModal } from "../components/ui/FilePreviewModal"; -import { SymbolView, type AppSymbolName } from "../components/ui/AppSymbol"; import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { mermaidSvgForNative, withDiagramBackground } from "../lib/mermaidSvg"; import { themeColorWithAlpha } from "../lib/mobileTheme"; +import { CodeBlockFrame, CodeBlockHeaderButton } from "./CodeBlockFrame"; import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; type Pele = typeof import("pele/lazy"); @@ -191,43 +191,29 @@ function MermaidBlock(props: MermaidCodeBlockProps & { readonly diagram: Diagram ); }; - // The header copies the native code block's, which this block replaces once the fence closes. return ( - - - - Mermaid - - + {svg ? ( - setShowCode((value) => !value)} /> ) : null} - copyTextWithHaptic(code)} /> - - + + } + > {diagram?.status === "error" ? ( @@ -296,45 +282,11 @@ function MermaidBlock(props: MermaidCodeBlockProps & { readonly diagram: Diagram onRequestClose={() => setExpansion(null)} /> ) : null} - - ); -} - -function HeaderButton(props: { - readonly icon: AppSymbolName; - readonly label: string; - readonly color: string; - readonly onPress: () => void; -}) { - return ( - - - + ); } -// The block slot already applies the code block's outer margins. const styles = StyleSheet.create({ - block: { - borderRadius: 8, - borderWidth: StyleSheet.hairlineWidth, - overflow: "hidden", - }, - header: { - height: 32, - paddingHorizontal: 12, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - borderBottomWidth: StyleSheet.hairlineWidth, - }, - language: { fontWeight: "500" }, - actions: { flexDirection: "row", alignItems: "center", gap: 14 }, content: { padding: 12 }, diagramRow: { flexDirection: "row", justifyContent: "center" }, error: { marginBottom: 8 }, diff --git a/apps/mobile/src/native/codeBlockMarkdown.test.ts b/apps/mobile/src/native/codeBlockMarkdown.test.ts new file mode 100644 index 0000000000000..1b254ed420ed8 --- /dev/null +++ b/apps/mobile/src/native/codeBlockMarkdown.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { codeFenceRenderer, fencedCodeMarkdown } from "./codeBlockMarkdown"; + +describe("fencedCodeMarkdown", () => { + it.each([ + ["a plain fence", "fun main() {}", "kotlin", "```kotlin\nfun main() {}\n```"], + ["a longer fence than the code's", "```\nx\n```", "md", "````md\n```\nx\n```\n````"], + ["no language", "x", "", "```\nx\n```"], + ])("writes %s", (_, code, language, expected) => { + expect(fencedCodeMarkdown(code, language)).toBe(expected); + }); +}); + +describe("codeFenceRenderer", () => { + it.each([ + ["mermaid", true, { kind: "mermaid" }], + ["math", true, { kind: "math" }], + ["math", false, { kind: "native" }], + ["kotlin", true, { kind: "highlight", language: "kotlin" }], + ["KOTLIN", true, { kind: "highlight", language: "kotlin" }], + ["diff", true, { kind: "highlight", language: "diff" }], + ["ts", true, { kind: "native" }], + ["python", true, { kind: "native" }], + ["", true, { kind: "native" }], + ["text", true, { kind: "native" }], + ])("draws %j with math rendering %s as %j", (fence, mathRenderingEnabled, expected) => { + expect(codeFenceRenderer(fence, mathRenderingEnabled)).toEqual(expected); + }); +}); diff --git a/apps/mobile/src/native/codeBlockMarkdown.ts b/apps/mobile/src/native/codeBlockMarkdown.ts new file mode 100644 index 0000000000000..b4be181a2ba8f --- /dev/null +++ b/apps/mobile/src/native/codeBlockMarkdown.ts @@ -0,0 +1,54 @@ +import { resolveHighlightLanguage } from "../lib/highlightLanguage"; + +// Fences enriched-markdown colours itself: cpp/highlight/CodeBlockLanguages.cpp entries with a default grammar. +const NATIVE_HIGHLIGHT_FENCES = new Set([ + "bash", + "c", + "css", + "go", + "golang", + "html", + "java", + "javascript", + "js", + "json", + "jsx", + "markdown", + "md", + "py", + "python", + "rs", + "rust", + "sh", + "shell", + "ts", + "tsx", + "typescript", + "yaml", + "yml", + "zsh", +]); + +/** A fenced code block for `code`, with a backtick fence longer than any backtick run inside it. */ +export function fencedCodeMarkdown(code: string, language: string): string { + let longestRun = 0; + for (const [run] of code.matchAll(/`+/g)) longestRun = Math.max(longestRun, run.length); + const fence = "`".repeat(Math.max(3, longestRun + 1)); + return `${fence}${language}\n${code}\n${fence}`; +} + +type CodeFenceRenderer = + | { readonly kind: "mermaid" } + | { readonly kind: "math" } + | { readonly kind: "highlight"; readonly language: string } + | { readonly kind: "native" }; + +/** Mermaid and math fences first: highlights knows `mermaid` too, and would claim it as code. */ +export function codeFenceRenderer(fence: string, mathRenderingEnabled: boolean): CodeFenceRenderer { + if (fence === "mermaid") return { kind: "mermaid" }; + if (fence === "math" && mathRenderingEnabled) return { kind: "math" }; + const name = fence.toLowerCase(); + if (NATIVE_HIGHLIGHT_FENCES.has(name)) return { kind: "native" }; + const language = resolveHighlightLanguage({ language: name }); + return language === "text" ? { kind: "native" } : { kind: "highlight", language }; +}