diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx index 0ae9a0f7b178..cbc9aff5f6c5 100644 --- a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx @@ -60,11 +60,8 @@ function MarkdownTextPrimitiveChild({ () => [true, flattenedStyle], [flattenedStyle], ); - let childPosition = 0; - const nativeChildren = React.Children.toArray(children).map((child) => { - const position = childPosition; - childPosition += 1; - + // Children.map keeps each text child's position key while its text grows. + const nativeChildren = React.Children.map(children, (child) => { if (React.isValidElement(child)) { return child; } @@ -75,12 +72,7 @@ function MarkdownTextPrimitiveChild({ const text = child.toString(); return ( // @ts-expect-error The generated run props do not include inherited Text props. - + ); }); diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index 348a3c489a2c..d9f1da9c2cd1 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -4,7 +4,11 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import { CopyTextButton } from "./CopyTextButton"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; -import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText"; +import { + nativeMarkdownDocumentRuns, + nativeMarkdownListItemBlocks, + nativeMarkdownNodeKey, +} from "./nativeMarkdownText"; import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; import type { MarkdownCodeHighlighter, @@ -28,10 +32,6 @@ const MONO_FONT_FAMILY = Platform.select({ default: "monospace", }); -function nodeKey(node: MarkdownNode, index: number): string { - return `${node.type}:${node.beg ?? index}:${node.end ?? index}`; -} - /** Code inside markdown scales with the base text size (12pt at the default 15pt body). */ function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number { return Math.max(10, Math.round(textStyle.fontSize * 0.8)); @@ -193,14 +193,14 @@ function HighlightedCodeText(props: { const tokens = line.map((token) => { const start = sourceOffset; sourceOffset += token.content.length; - const signature = `${start}:${token.content}:${token.color ?? ""}:${token.fontStyle ?? ""}`; + const signature = `${start}:${token.color ?? ""}:${token.fontStyle ?? ""}`; const occurrence = keyOccurrences.get(signature) ?? 0; keyOccurrences.set(signature, occurrence + 1); return { key: `${signature}:${occurrence}`, token }; }); sourceOffset += 1; return { - key: `line:${lineStart}:${line.map((token) => token.content).join("")}`, + key: `line:${lineStart}`, tokens, }; }); @@ -353,7 +353,7 @@ function NativeTable(props: { > {rows.map((row, rowIndex) => ( {(row.children ?? []).map((cell, cellIndex) => ( child.type === "image" ? ( ) : ( {nativeMarkdownListItemBlocks(item).map((child, childIndex) => ( {(props.node.children ?? []).map((child, index) => ( {(props.node.children ?? []).map((child, index) => ( {(props.node.children ?? []).map((child, index) => ( (); const prefixedExternalLinks = new Set(); - const keyedRuns = props.runs.map((run) => { - const signature = runKeySignature(run); - const occurrence = occurrences.get(signature) ?? 0; - occurrences.set(signature, occurrence + 1); - + const keyedRuns = props.runs.map((run, index) => { let text = run.text; if (run.fileIcon && Platform.OS === "ios") { text = `${INLINE_ATTACHMENT_PREFIX}${text}`; @@ -210,7 +183,7 @@ export function NativeMarkdownSelectableText(props: { text = `${EXTERNAL_LINK_PREFIX}${text}`; } - return { key: `${signature}:${occurrence}`, run, text }; + return { key: nativeMarkdownTextRunKey(run, index), run, text }; }); // T3MarkdownText only rebuilds its attributed string during native layout. A // color-only child update can otherwise leave the previous appearance cached. diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 2b39ac201599..822d5a095170 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -36,6 +36,34 @@ export interface NativeMarkdownTextRun { readonly paragraphSpacing?: number; } +/** Keep text updates in the same run. Attribute changes still replace the run. */ +export function nativeMarkdownTextRunKey(run: NativeMarkdownTextRun, index: number): string { + return [ + index, + run.bold, + run.italic, + run.strikethrough, + run.code, + run.href, + run.externalHost, + run.fileIcon, + run.skillName, + run.skillLabel, + run.role, + run.headingLevel, + run.depth, + run.spacing, + run.firstLineHeadIndent, + run.headIndent, + run.paragraphSpacing, + ].join(":"); +} + +/** The block's end moves during streaming. Its type and start identify the block. */ +export function nativeMarkdownNodeKey(node: MarkdownNode, index: number): string { + return `${node.type}:${node.beg ?? `index:${index}`}`; +} + export type NativeMarkdownDocumentChunk = | { readonly kind: "selectable"; @@ -698,16 +726,16 @@ export function nativeMarkdownDocumentChunks( ): ReadonlyArray { const chunks: NativeMarkdownDocumentChunk[] = []; let selectableNodes: MarkdownNode[] = []; + let selectableStartIndex = 0; const flushSelectable = () => { - if (selectableNodes.length === 0) { + const first = selectableNodes[0]; + if (!first) { return; } - const first = selectableNodes[0]; - const last = selectableNodes.at(-1); chunks.push({ kind: "selectable", - key: `selectable:${first?.beg ?? "start"}:${last?.end ?? "end"}`, + key: `selectable:${nativeMarkdownNodeKey(first, selectableStartIndex)}`, node: { type: "document", children: selectableNodes, @@ -718,6 +746,9 @@ export function nativeMarkdownDocumentChunks( for (const [index, child] of (document.children ?? []).entries()) { if (!containsRichBlock(child)) { + if (selectableNodes.length === 0) { + selectableStartIndex = index; + } selectableNodes.push(child); continue; } @@ -725,7 +756,7 @@ export function nativeMarkdownDocumentChunks( flushSelectable(); chunks.push({ kind: "rich", - key: `rich:${child.type}:${child.beg ?? index}:${child.end ?? index}`, + key: `rich:${nativeMarkdownNodeKey(child, index)}`, node: child, }); } diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 1e7cb5f3164e..0274ee08964f 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -6,11 +6,42 @@ import { nativeMarkdownDocumentChunks, nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks, + nativeMarkdownTextRunKey, nativeMarkdownTextRuns, nativeMarkdownWithPreservedSoftBreaks, } from "@t3tools/mobile-markdown-text/markdown"; describe("nativeMarkdownTextRuns", () => { + it("keeps distinct run identities when the streaming tail grows", () => { + const before = nativeMarkdownTextRuns({ + type: "paragraph", + children: [ + { type: "text", content: "Before " }, + { type: "bold", children: [{ type: "text", content: "bold" }] }, + { type: "text", content: " and after" }, + ], + }); + const after = before.map((run, index) => + index === before.length - 1 ? { ...run, text: `${run.text} more text` } : run, + ); + const beforeKeys = before.map(nativeMarkdownTextRunKey); + const afterKeys = after.map(nativeMarkdownTextRunKey); + + expect(afterKeys).toEqual(beforeKeys); + expect(new Set(afterKeys).size).toBe(after.length); + expect(after.map((run) => run.text).join("")).toBe("Before bold and after more text"); + }); + + it("changes run identity when its attributes change", () => { + const run = { text: "Docs", href: "https://example.com/docs" }; + const key = nativeMarkdownTextRunKey(run, 0); + + expect(nativeMarkdownTextRunKey({ ...run, bold: true }, 0)).not.toBe(key); + expect(nativeMarkdownTextRunKey({ ...run, href: "https://example.com/other" }, 0)).not.toBe( + key, + ); + }); + it("links a path-shaped code span without changing the same path in prose", () => { expect( nativeMarkdownTextRuns({ @@ -486,6 +517,81 @@ describe("nativeMarkdownListItemBlocks", () => { }); describe("nativeMarkdownDocumentChunks", () => { + it.each(["paragraph", "code_block"] as const)( + "keeps a streaming %s identity while its end and contents change", + (type) => { + const before: MarkdownNode = { + type, + beg: 0, + end: 5, + children: [{ type: "text", content: "Hello" }], + }; + const after: MarkdownNode = { + ...before, + end: 11, + children: [{ type: "text", content: "Hello world" }], + }; + const [beforeChunk] = nativeMarkdownDocumentChunks({ type: "document", children: [before] }); + const [afterChunk] = nativeMarkdownDocumentChunks({ type: "document", children: [after] }); + + expect(afterChunk?.key).toBe(beforeChunk?.key); + expect( + nativeMarkdownDocumentRuns(afterChunk!.node) + .map((run) => run.text) + .join(""), + ).toBe("Hello world"); + }, + ); + + it("keeps the selectable container when a new paragraph extends it", () => { + const first: MarkdownNode = { + type: "paragraph", + beg: 0, + end: 5, + children: [{ type: "text", content: "Hello" }], + }; + const before = nativeMarkdownDocumentChunks({ type: "document", children: [first] }); + const after = nativeMarkdownDocumentChunks({ + type: "document", + children: [ + first, + { + type: "paragraph", + beg: 7, + end: 12, + children: [{ type: "text", content: "World" }], + }, + ], + }); + + expect(after).toHaveLength(1); + expect(after[0]?.key).toBe(before[0]?.key); + expect( + nativeMarkdownDocumentRuns(after[0]!.node) + .map((run) => run.text) + .join(""), + ).toBe("Hello\n\nWorld"); + }); + + it.each([ + ["paragraph", "heading"], + ["code_block", "blockquote"], + ] as const)("replaces a %s when it becomes a %s", (beforeType, afterType) => { + const node: MarkdownNode = { + type: beforeType, + beg: 0, + end: 5, + children: [{ type: "text", content: "Hello" }], + }; + const before = nativeMarkdownDocumentChunks({ type: "document", children: [node] }); + const after = nativeMarkdownDocumentChunks({ + type: "document", + children: [{ ...node, type: afterType }], + }); + + expect(after[0]?.key).not.toBe(before[0]?.key); + }); + it("renders plain blockquotes as rich blocks so their marker spans wrapped lines", () => { const blockquote: MarkdownNode = { type: "blockquote", @@ -513,7 +619,7 @@ describe("nativeMarkdownDocumentChunks", () => { ).toEqual([ { kind: "rich", - key: "rich:blockquote:0:120", + key: "rich:blockquote:0", node: blockquote, }, ]); @@ -640,10 +746,22 @@ describe("nativeMarkdownDocumentChunks", () => { expect(chunks[0]).toMatchObject({ kind: "selectable" }); expect(chunks[1]).toEqual({ kind: "rich", - key: "rich:code_block:11:35", + key: "rich:code_block:11", node: document.children?.[1], }); expect(chunks[2]).toMatchObject({ kind: "selectable" }); + + const growingTail: MarkdownNode = { + type: "paragraph", + beg: 37, + end: 53, + children: [{ type: "text", content: "Done. More text." }], + }; + const after = nativeMarkdownDocumentChunks({ + ...document, + children: [...document.children!.slice(0, 2), growingTail], + }); + expect(after.map((chunk) => chunk.key)).toEqual(chunks.map((chunk) => chunk.key)); }); it("keeps a list containing fenced code as one rich AST container", () => { @@ -677,7 +795,7 @@ describe("nativeMarkdownDocumentChunks", () => { expect(nativeMarkdownDocumentChunks(document)).toEqual([ { kind: "rich", - key: "rich:list:0:45", + key: "rich:list:0", node: document.children?.[0], }, ]); @@ -705,10 +823,11 @@ describe("nativeMarkdownDocumentChunks", () => { expect(chunks[0]).toMatchObject({ kind: "selectable" }); expect(chunks[1]).toEqual({ kind: "rich", - key: "rich:horizontal_rule:1:1", + key: "rich:horizontal_rule:index:1", node: document.children?.[1], }); expect(chunks[2]).toMatchObject({ kind: "selectable" }); + expect(new Set(chunks.map((chunk) => chunk.key)).size).toBe(chunks.length); }); it("keeps offset-free structural lists isolated without promoting the whole document", () => { @@ -751,7 +870,7 @@ describe("nativeMarkdownDocumentChunks", () => { expect(chunks[0]).toMatchObject({ kind: "selectable" }); expect(chunks[1]).toEqual({ kind: "rich", - key: "rich:list:1:1", + key: "rich:list:index:1", node: document.children?.[1], }); expect(chunks[2]).toMatchObject({ kind: "selectable" });