mobile: render markdown text consistent with desktop
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
import { ScrollView, Text, View } from "react-native";
|
import { Platform, ScrollView, Text, View, type ViewStyle } from "react-native";
|
||||||
import { useMarkdown, type MarkedStyles } from "react-native-marked";
|
import { Renderer, useMarkdown, type MarkedStyles } from "react-native-marked";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
||||||
@@ -40,30 +40,103 @@ function getImmersiveStyle(length: number) {
|
|||||||
|
|
||||||
// Mirrors desktop's text-particle-view: short plain notes get the immersive
|
// Mirrors desktop's text-particle-view: short plain notes get the immersive
|
||||||
// centered treatment; anything with markdown syntax renders formatted instead
|
// centered treatment; anything with markdown syntax renders formatted instead
|
||||||
// of showing raw `**asterisks**`.
|
// of showing raw `**asterisks**`. Covers the full GFM set desktop's Crepe
|
||||||
|
// engine handles — including ~~strikethrough~~ and tables — so short formatted
|
||||||
|
// messages drop to the rendered card rather than showing raw syntax.
|
||||||
function hasMarkdownFormatting(content: string): boolean {
|
function hasMarkdownFormatting(content: string): boolean {
|
||||||
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test(
|
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|~~|^>|\|.*\|/m.test(
|
||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dark theme + base typography for rendered markdown. Defined at module scope
|
// react-native-marked doesn't render GFM task-list checkboxes (marked strips
|
||||||
// so the references stay stable — `useMarkdown` re-parses only when these or
|
// the `[ ]`/`[x]` into token flags the parser ignores), so a write/read drift
|
||||||
// the content change.
|
// shows up as bullets with no box. Swap the marker for a checkbox glyph before
|
||||||
|
// parsing — read-only, matching desktop's bullet-free checkboxes.
|
||||||
|
const TASK_ITEM_RE = /^(\s*)[-*+] \[([ xX])\] /gm;
|
||||||
|
|
||||||
|
function withTaskCheckboxes(markdown: string): string {
|
||||||
|
return markdown.replace(
|
||||||
|
TASK_ITEM_RE,
|
||||||
|
(_match, indent: string, mark: string) =>
|
||||||
|
`${indent}${mark === " " ? "☐" : "☑"} `,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror the desktop Crepe palette (markdown-editor.css `--crepe-*`) so a
|
||||||
|
// message reads the same on both surfaces: white-on-transparent text, a blue
|
||||||
|
// accent, pink inline code, and a near-opaque dark surface behind code blocks
|
||||||
|
// and tables. Defined at module scope so the references stay stable —
|
||||||
|
// `useMarkdown` re-parses only when these or the content change.
|
||||||
|
//
|
||||||
|
// Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe
|
||||||
|
// uses CodeMirror; react-native-marked only exposes the language tag). They
|
||||||
|
// render as plain monospace on the dark surface, which is acceptable for v1.
|
||||||
|
const TEXT_COLOR = "rgba(255,255,255,0.92)";
|
||||||
|
const ACCENT = "#60a5fa";
|
||||||
|
const SURFACE = "rgba(24,24,28,0.96)";
|
||||||
|
const OUTLINE = "rgba(255,255,255,0.2)";
|
||||||
|
const MONO = Platform.OS === "ios" ? "Menlo" : "monospace";
|
||||||
|
|
||||||
const MARKDOWN_THEME = {
|
const MARKDOWN_THEME = {
|
||||||
colors: {
|
colors: {
|
||||||
text: "#ffffff",
|
text: TEXT_COLOR,
|
||||||
link: "#60a5fa",
|
link: ACCENT,
|
||||||
code: "rgba(255,255,255,0.1)",
|
code: SURFACE,
|
||||||
border: "rgba(255,255,255,0.2)",
|
border: OUTLINE,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const MARKDOWN_STYLES: MarkedStyles = {
|
const MARKDOWN_STYLES: MarkedStyles = {
|
||||||
text: { color: "#ffffff", fontSize: 18, lineHeight: 28 },
|
text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
|
||||||
codespan: { color: "#ffffff" },
|
li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
|
||||||
|
strong: { fontWeight: "700" },
|
||||||
|
em: { fontStyle: "italic" },
|
||||||
|
strikethrough: {
|
||||||
|
textDecorationLine: "line-through",
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
},
|
||||||
|
// fontStyle "normal" cancels react-native-marked's italic-by-default for
|
||||||
|
// links and inline code (desktop renders neither italic).
|
||||||
|
link: { color: ACCENT, fontStyle: "normal" },
|
||||||
|
// borderBottomWidth 0 removes the library's default heading underline rule,
|
||||||
|
// which desktop's headings don't have.
|
||||||
|
h1: { color: "#ffffff", fontSize: 28, lineHeight: 34, fontWeight: "700", marginTop: 8, marginBottom: 8, borderBottomWidth: 0 },
|
||||||
|
h2: { color: "#ffffff", fontSize: 24, lineHeight: 30, fontWeight: "700", marginTop: 8, marginBottom: 6, borderBottomWidth: 0 },
|
||||||
|
h3: { color: "#ffffff", fontSize: 20, lineHeight: 26, fontWeight: "600", marginTop: 6, marginBottom: 4 },
|
||||||
|
h4: { color: "#ffffff", fontSize: 18, lineHeight: 24, fontWeight: "600", marginTop: 6, marginBottom: 4 },
|
||||||
|
h5: { color: "#ffffff", fontSize: 16, lineHeight: 22, fontWeight: "600", marginTop: 4, marginBottom: 2 },
|
||||||
|
h6: { color: "rgba(255,255,255,0.7)", fontSize: 15, lineHeight: 20, fontWeight: "600", marginTop: 4, marginBottom: 2 },
|
||||||
|
codespan: { color: "#fca5a5", fontFamily: MONO, fontStyle: "normal", backgroundColor: "rgba(255,255,255,0.1)" },
|
||||||
|
code: { backgroundColor: SURFACE, borderColor: OUTLINE, borderWidth: 1, borderRadius: 8, padding: 12, marginVertical: 6 },
|
||||||
|
blockquote: { borderLeftWidth: 3, borderLeftColor: OUTLINE, paddingLeft: 12, marginVertical: 6, opacity: 0.85 },
|
||||||
|
// hr is left to the library default, which already draws a 1px rule in the
|
||||||
|
// themed border color (OUTLINE).
|
||||||
|
table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 },
|
||||||
|
tableRow: { borderColor: OUTLINE },
|
||||||
|
tableCell: { borderColor: OUTLINE, padding: 8 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// react-native-marked feeds fenced code blocks the `em` (italic, proportional)
|
||||||
|
// text style, so out of the box code renders italic in the body font. Override
|
||||||
|
// `code` to apply a monospace, non-italic style instead — matching desktop's
|
||||||
|
// code blocks. Instantiated once at module scope to keep the reference stable
|
||||||
|
// for `useMarkdown`'s memoization.
|
||||||
|
const CODE_TEXT_STYLE = {
|
||||||
|
color: TEXT_COLOR,
|
||||||
|
fontFamily: MONO,
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 22,
|
||||||
|
};
|
||||||
|
|
||||||
|
class MarkdownRenderer extends Renderer {
|
||||||
|
code(text: string, language?: string, containerStyle?: ViewStyle): ReactNode {
|
||||||
|
return super.code(text, language, containerStyle, CODE_TEXT_STYLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKDOWN_RENDERER = new MarkdownRenderer();
|
||||||
|
|
||||||
export function TextParticleView({
|
export function TextParticleView({
|
||||||
particle,
|
particle,
|
||||||
paused,
|
paused,
|
||||||
@@ -75,7 +148,8 @@ export function TextParticleView({
|
|||||||
const durationS = computeReadDuration(content);
|
const durationS = computeReadDuration(content);
|
||||||
const elapsedRef = useRef(0);
|
const elapsedRef = useRef(0);
|
||||||
const safe = useStreamSafeArea();
|
const safe = useStreamSafeArea();
|
||||||
const markdownNodes = useMarkdown(content, {
|
const markdownNodes = useMarkdown(withTaskCheckboxes(content), {
|
||||||
|
renderer: MARKDOWN_RENDERER,
|
||||||
theme: MARKDOWN_THEME,
|
theme: MARKDOWN_THEME,
|
||||||
styles: MARKDOWN_STYLES,
|
styles: MARKDOWN_STYLES,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user