Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/mobile/modules/t3-markdown-text/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"./markdown": "./src/nativeMarkdownText.ts",
"./primitive": "./src/MarkdownTextPrimitive.tsx",
"./renderer": "./src/SelectableMarkdownText.tsx",
"./types": "./src/SelectableMarkdownText.types.ts"
"./types": "./src/SelectableMarkdownText.types.ts",
"./fence-key": "./src/stableFenceKey.ts"
},
"peerDependencies": {
"@t3tools/client-runtime": "*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
NativeMarkdownTextStyle,
SelectableMarkdownSkill,
} from "./SelectableMarkdownText.types";
import { useStableFenceKey } from "./stableFenceKey";
import { useHighlightedCode, type HighlightedCode } from "./useHighlightedCode";

/** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */
Expand Down Expand Up @@ -156,6 +157,7 @@ function NativeCodeBlock(props: {
const theme = colorScheme === "dark" ? "dark" : "light";
const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode);
const languageLabel = props.node.language?.toUpperCase() ?? "CODE";
const fenceKey = useStableFenceKey(languageLabel, content);
return (
<View
style={{
Expand Down Expand Up @@ -194,6 +196,7 @@ function NativeCodeBlock(props: {
{languageLabel}
</MarkdownTextPrimitive>
<CopyTextButton
key={fenceKey}
accessibilityLabel={`Copy ${languageLabel.toLowerCase()} code`}
text={content}
tintColor={props.textStyle.mutedColor}
Expand All @@ -205,6 +208,7 @@ function NativeCodeBlock(props: {
/>
</View>
<ScrollView
key={fenceKey}
horizontal
bounces={false}
nestedScrollEnabled={Platform.OS === "android"}
Expand Down
36 changes: 36 additions & 0 deletions apps/mobile/modules/t3-markdown-text/src/stableFenceKey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { expect, it } from "vite-plus/test";

import { nextFenceKey } from "./stableFenceKey";

it("gives settled fences with the same opening a different key", () => {
const language = "ts";
const shared = `const value = 1;\n${"x".repeat(24)}`;
const first = `${shared}${"a".repeat(40)}`;
const second = `${shared}${"b".repeat(40)}`;
expect(first.length).toBe(second.length);
expect(first.slice(0, 32)).toBe(second.slice(0, 32));
expect(first).not.toBe(second);

const afterFirst = nextFenceKey(null, language, first);
const afterSecond = nextFenceKey(afterFirst, language, second);

expect(afterFirst.key).toBe(`${language}:${first}`);
expect(afterSecond.key).toBe(`${language}:${second}`);
expect(afterSecond.key).not.toBe(afterFirst.key);
});

it("keeps the key while the same fence streams longer", () => {
let state = nextFenceKey(null, "ts", "const");
const initialKey = state.key;
state = nextFenceKey(state, "ts", "const value");
state = nextFenceKey(state, "ts", "const value = 1;");
expect(state.key).toBe(initialKey);
});

it("replaces the key when a streamed fence is swapped for another", () => {
let streamed = nextFenceKey(null, "ts", "const");
streamed = nextFenceKey(streamed, "ts", "const value = 1;\nreturn value;");
const swapped = nextFenceKey(streamed, "ts", "let other = 2;\nreturn other;");
expect(swapped.key).toBe("ts:let other = 2;\nreturn other;");
expect(swapped.key).not.toBe(streamed.key);
});
45 changes: 45 additions & 0 deletions apps/mobile/modules/t3-markdown-text/src/stableFenceKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useState } from "react";

export interface FenceKeyState {
readonly languageLabel: string;
readonly content: string;
readonly key: string;
}

/**
* Identity for a fence's horizontal scroller and copy button.
*
* The key includes the full fence text when the fence changes, so a recycled
* row cannot keep another block's scroll offset or copied state just because
* the language, length, and opening characters match. Streaming appends keep
* the previous key; otherwise every chunk would remount the token tree.
*/
export function nextFenceKey(
previous: FenceKeyState | null,
languageLabel: string,
content: string,
): FenceKeyState {
if (
previous !== null &&
previous.languageLabel === languageLabel &&
content.startsWith(previous.content)
) {
return { languageLabel, content, key: previous.key };
}
return { languageLabel, content, key: `${languageLabel}:${content}` };
}

/** Remembers the last fence drawn in this container. */
export function useStableFenceKey(languageLabel: string, content: string): string {
const [stored, setStored] = useState<FenceKeyState | null>(null);
const next = nextFenceKey(stored, languageLabel, content);
if (
stored === null ||
stored.languageLabel !== languageLabel ||
stored.content !== content ||
stored.key !== next.key
) {
setStored(next);
}
return next.key;
}
160 changes: 160 additions & 0 deletions apps/mobile/src/features/threads/AndroidTranscriptCodeSlice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { memo } from "react";
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";

import type { NativeMarkdownTextStyle } from "@t3tools/mobile-markdown-text/types";

import { CopyTextButton } from "../../components/CopyTextButton";
import type { AndroidTranscriptCodePart } from "./androidTranscriptSlices";

const MONO_FONT_FAMILY = Platform.select({
ios: "ui-monospace",
android: "monospace",
default: "monospace",
});

/**
* Code inside a transcript slice scales with the body size, matching the
* highlighted fence (12pt at the default 15pt body).
*/
function codeSliceFontSize(textStyle: NativeMarkdownTextStyle): number {
return Math.max(10, Math.round(textStyle.fontSize * 0.8));
}

/**
* Line height for a plain code window. Kept identical to the highlighted fence
* so a windowed block does not jump when the reader stops on it.
*/
function codeSliceLineHeight(textStyle: NativeMarkdownTextStyle): number {
return codeSliceFontSize(textStyle) + 6;
}

/**
* Corner radii for one window of a fence. Middle and trailing windows stay
* square on the joined edge so the windows read as a single card.
*/
function codeSliceRadius(part: AndroidTranscriptCodePart): {
readonly borderTopLeftRadius: number;
readonly borderTopRightRadius: number;
readonly borderBottomLeftRadius: number;
readonly borderBottomRightRadius: number;
} {
const radius = part === "start" || part === "end" ? 10 : 0;
return {
borderTopLeftRadius: part === "start" ? radius : 0,
borderTopRightRadius: part === "start" ? radius : 0,
borderBottomLeftRadius: part === "end" ? radius : 0,
borderBottomRightRadius: part === "end" ? radius : 0,
};
}

/**
* One plain-text window of a long fenced block.
*
* Highlighted fences mount a `Text` per Shiki token. On Android that tree is
* created on the UI thread in the frame the row enters, which is the settled-
* thread hitch. A window is a single non-selectable `Text`; the header copies
* the whole fence, not just the lines in view.
*/
export const AndroidTranscriptCodeSlice = memo(function AndroidTranscriptCodeSlice(props: {
readonly text: string;
readonly language: string | null;
readonly part: Exclude<AndroidTranscriptCodePart, "only">;
readonly fullCode: string;
readonly textStyle: NativeMarkdownTextStyle;
}) {
const fontSize = codeSliceFontSize(props.textStyle);
const lineHeight = codeSliceLineHeight(props.textStyle);
const showHeader = props.part === "start";
const languageLabel = props.language?.toUpperCase() ?? "CODE";
return (
<View
style={[
styles.card,
codeSliceRadius(props.part),
{
backgroundColor: props.textStyle.codeBlockBackgroundColor,
borderColor: props.textStyle.dividerColor,
borderTopWidth: showHeader ? 1 : 0,
borderBottomWidth: props.part === "end" ? 1 : 0,
},
]}
>
{showHeader ? (
<View
style={[
styles.header,
{
borderBottomColor: props.textStyle.dividerColor,
},
]}
>
<Text
numberOfLines={1}
style={{
flex: 1,
color: props.textStyle.mutedColor,
fontFamily: MONO_FONT_FAMILY,
fontSize,
...(Platform.OS === "android" ? { includeFontPadding: false } : null),
}}
>
{languageLabel}
</Text>
<CopyTextButton
accessibilityLabel={`Copy ${languageLabel.toLowerCase()} code`}
text={props.fullCode}
tintColor={props.textStyle.mutedColor}
copiedTintColor={props.textStyle.linkColor}
backgroundColor={props.textStyle.codeBackgroundColor}
borderColor={props.textStyle.dividerColor}
buttonSize={34}
iconSize={14}
/>
</View>
) : null}
<ScrollView
horizontal
bounces={false}
nestedScrollEnabled={Platform.OS === "android"}
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.body}
>
<Text
selectable={false}
style={{
color: props.textStyle.codeColor,
fontFamily: MONO_FONT_FAMILY,
fontSize,
lineHeight,
...(Platform.OS === "android"
? { includeFontPadding: false, textBreakStrategy: "simple" as const }
: null),
}}
>
{props.text}
</Text>
</ScrollView>
</View>
);
});

const styles = StyleSheet.create({
card: {
borderCurve: "continuous",
borderWidth: 1,
overflow: "hidden",
},
header: {
minHeight: 42,
borderBottomWidth: 1,
paddingLeft: 14,
paddingRight: 6,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
body: {
paddingHorizontal: 14,
paddingVertical: 12,
},
});
Loading
Loading