mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
fix(app): preserve Markdown containers before grouping prose
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
The shared `components/markdown/renderer.tsx` renders native prose with `react-native-enriched-markdown` 1.1.1 on iOS and Android. Native selection belongs to one text view, so separate paragraph rows prevent dragging a selection between them.
|
||||
|
||||
Every `MarkdownRenderer` consumer gets prose runs without surface-specific wiring: chat, Markdown file previews, plan cards, pull request activity and changelogs. The renderer owns classification, native preparation and the platform component under `components/markdown/`. Its pure `parts` boundary groups adjacent paragraphs, headings, lists and horizontal rules; code, tables, images and HTML-like content retain Paseo's block renderers. Keep whole containers when splitting around an image would lose formatting or list context.
|
||||
Every `MarkdownRenderer` consumer gets prose runs without surface-specific wiring: chat, Markdown file previews, plan cards, pull request activity and changelogs. The renderer owns classification, native preparation and the platform component under `components/markdown/`. Its pure `parts` boundary groups adjacent paragraphs, headings, lists and horizontal rules; code, tables, images and HTML-like content retain Paseo's block renderers. Parse HTML-like containers and image/text layout before grouping prose; blank-line splitting of the raw document severs `<details>` from its body. Expanded bodies use the same renderer. Keep whole containers when splitting around an image would lose formatting or list context.
|
||||
|
||||
Chat uses the same grouper to create virtualized display rows. Presentation owns message-to-row ids, promoted/history identity and the growing last source block. It passes an already grouped part to `MarkdownRenderer`; the growing block uses the existing streaming parser and joins the preceding run when another block starts or the turn ends. Selection across that paragraph becomes available at completion. Chat supplies its file-link actions; native prose does not require a chat provider. Other surfaces keep their parser's link policy and compact typography.
|
||||
|
||||
|
||||
@@ -14,4 +14,4 @@ export {
|
||||
type AssistantFileLinkResolverProviderProps,
|
||||
} from "./provider";
|
||||
export type { AssistantFileLinkSource } from "./resolver";
|
||||
export { useAssistantFileLinkActions } from "./use-file-link";
|
||||
export { useAssistantFileLinkActions, type AssistantFileLinkActions } from "./use-file-link";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MarkdownDisplayPart } from "./html-ish";
|
||||
import { groupMarkdownParts } from "./part-groups";
|
||||
import { groupMarkdownParts, prepareMarkdownGroups } from "./part-groups";
|
||||
import { parseMarkdownPreviewDocument } from "@/file-pane/markdown-preview/document";
|
||||
import { createProsePreparer } from "./prose/prepare";
|
||||
import { splitMarkdownProse } from "./parts";
|
||||
@@ -130,3 +130,25 @@ describe("Markdown file preview document", () => {
|
||||
expect(parts[1]?.references?.PIC?.href).toBe("image.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderer container ordering", () => {
|
||||
it("retains a collapsible before preparing its prose", () => {
|
||||
const groups = prepareMarkdownGroups({
|
||||
text: "Before.\n\n<details>\n<summary>More</summary>\n\nFirst paragraph.\n\nSecond paragraph.\n\n</details>\n\nAfter.",
|
||||
enableHtmlish: true,
|
||||
});
|
||||
expect(groups.map((group) => (group.kind === "part" ? group.part.kind : group.kind))).toEqual([
|
||||
"markdown",
|
||||
"details",
|
||||
"markdown",
|
||||
]);
|
||||
const details = groups[1];
|
||||
expect(details?.kind).toBe("part");
|
||||
if (details?.kind !== "part" || details.part.kind !== "details")
|
||||
throw new Error("Missing collapsible");
|
||||
expect(details.part.summary).toBe("More");
|
||||
expect(splitMarkdownProse(details.part.body).map((part) => part.markdownKind)).toEqual([
|
||||
"prose",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import type { MarkdownDisplayPart, MarkdownInlineImagePart } from "./html-ish";
|
||||
import {
|
||||
splitHtmlishMarkdown,
|
||||
type MarkdownDisplayPart,
|
||||
type MarkdownInlineImagePart,
|
||||
} from "./html-ish";
|
||||
|
||||
export type MarkdownPartGroup =
|
||||
| { kind: "part"; part: MarkdownDisplayPart }
|
||||
| { kind: "imageText"; images: MarkdownInlineImagePart[]; lead: string; rest: string };
|
||||
|
||||
/** Parse containers before splitting their Markdown text into selectable runs. */
|
||||
export function prepareMarkdownGroups({
|
||||
text,
|
||||
enableHtmlish,
|
||||
}: {
|
||||
text: string;
|
||||
enableHtmlish: boolean;
|
||||
}): MarkdownPartGroup[] {
|
||||
const parts = enableHtmlish ? splitHtmlishMarkdown(text) : [{ kind: "markdown" as const, text }];
|
||||
return groupMarkdownParts(parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairs a run of inline images that flow with text (small status images at the
|
||||
* start of a line, GitHub-style) with the lead paragraph of the following
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { AssistantFileLinkActions } from "@/assistant-file-links/use-file-link";
|
||||
import type { AssistantFileLinkActions } from "@/assistant-file-links";
|
||||
|
||||
import type { MarkdownReferences } from "../parts";
|
||||
|
||||
|
||||
@@ -33,22 +33,18 @@ import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/mark
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import {
|
||||
splitHtmlishMarkdown,
|
||||
type MarkdownDisplayPart,
|
||||
type MarkdownInlineImagePart,
|
||||
} from "./html-ish";
|
||||
import { type MarkdownDisplayPart, type MarkdownInlineImagePart } from "./html-ish";
|
||||
import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size";
|
||||
import {
|
||||
getAssistantImageMetadata,
|
||||
setAssistantImageMetadata,
|
||||
} from "@/utils/assistant-image-metadata";
|
||||
import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups";
|
||||
import { groupMarkdownParts, prepareMarkdownGroups, type MarkdownPartGroup } from "./part-groups";
|
||||
import { colorMarkdownLinkChildren } from "./link-children";
|
||||
import { MarkdownLinkText } from "./link-text";
|
||||
import { MarkdownProse } from "./prose/renderer";
|
||||
import { splitMarkdownProse, type MarkdownProsePart } from "./parts";
|
||||
import type { AssistantFileLinkActions } from "@/assistant-file-links/use-file-link";
|
||||
import type { AssistantFileLinkActions } from "@/assistant-file-links";
|
||||
|
||||
export type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
@@ -94,12 +90,17 @@ export interface MarkdownRendererProps {
|
||||
}
|
||||
|
||||
export function MarkdownRenderer(props: MarkdownRendererProps) {
|
||||
if (isNative && props.part) return <MarkdownRuns {...props} />;
|
||||
return <MarkdownBlockRenderer {...props} />;
|
||||
}
|
||||
|
||||
const BLOCK_PART = { markdownKind: "block" as const };
|
||||
|
||||
function MarkdownRuns(props: MarkdownRendererProps) {
|
||||
const parts = useMemo<MarkdownProsePart[]>(() => {
|
||||
if (!isNative) return [];
|
||||
if (props.part) return [{ ...props.part, text: props.text, id: "part" }];
|
||||
return splitMarkdownProse(props.text);
|
||||
}, [props.part, props.text]);
|
||||
if (!isNative) return <MarkdownBlockRenderer {...props} />;
|
||||
const parser = props.markdownit ?? defaultMarkdownParser;
|
||||
return (
|
||||
<>
|
||||
@@ -108,7 +109,7 @@ export function MarkdownRenderer(props: MarkdownRendererProps) {
|
||||
key={part.id}
|
||||
text={part.text}
|
||||
sourceBlocks={part.markdownProseBlocks ?? [part.text]}
|
||||
kind={part.markdownKind ?? "block"}
|
||||
kind={part.markdownKind}
|
||||
compact={props.compact}
|
||||
blockSpacing={props.blockSpacing}
|
||||
fileLinks={props.fileLinks}
|
||||
@@ -117,7 +118,12 @@ export function MarkdownRenderer(props: MarkdownRendererProps) {
|
||||
allowFileLinks={parser.validateLink("file:///file")}
|
||||
onLinkPress={props.onLinkPress ?? openMarkdownLink}
|
||||
>
|
||||
<MarkdownScopedBlock {...props} text={part.text} references={part.references} />
|
||||
<MarkdownScopedBlock
|
||||
{...props}
|
||||
part={BLOCK_PART}
|
||||
text={part.text}
|
||||
references={part.references}
|
||||
/>
|
||||
</MarkdownProse>
|
||||
))}
|
||||
</>
|
||||
@@ -162,10 +168,11 @@ function MarkdownBlockRenderer({
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
enableHtmlish = true,
|
||||
part,
|
||||
}: MarkdownRendererProps) {
|
||||
const markdownRules = useMemo(() => rules ?? createSharedMarkdownRules(), [rules]);
|
||||
const parts = useMemo(
|
||||
() => (enableHtmlish ? splitHtmlishMarkdown(text) : [{ kind: "markdown" as const, text }]),
|
||||
const groups = useMemo(
|
||||
() => prepareMarkdownGroups({ text, enableHtmlish }),
|
||||
[enableHtmlish, text],
|
||||
);
|
||||
const rendererProps = useMemo(
|
||||
@@ -176,6 +183,7 @@ function MarkdownBlockRenderer({
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
part,
|
||||
}),
|
||||
[
|
||||
allowedImageHandlers,
|
||||
@@ -184,24 +192,25 @@ function MarkdownBlockRenderer({
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
topLevelMaxExceededItem,
|
||||
part,
|
||||
],
|
||||
);
|
||||
|
||||
return <MarkdownPartList parts={parts} rendererProps={rendererProps} />;
|
||||
return <MarkdownGroupList groups={groups} rendererProps={rendererProps} />;
|
||||
}
|
||||
|
||||
type MarkdownPartRendererProps = Omit<MarkdownRendererProps, "text" | "enableHtmlish"> & {
|
||||
rules: RenderRules;
|
||||
};
|
||||
|
||||
function MarkdownPartList({
|
||||
parts,
|
||||
function MarkdownGroupList({
|
||||
groups,
|
||||
rendererProps,
|
||||
}: {
|
||||
parts: MarkdownDisplayPart[];
|
||||
groups: MarkdownPartGroup[];
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
const keyedGroups = useMemo(() => keyMarkdownGroups(groupMarkdownParts(parts)), [parts]);
|
||||
const keyedGroups = useMemo(() => keyMarkdownGroups(groups), [groups]);
|
||||
return (
|
||||
<>
|
||||
{keyedGroups.map(({ key, group }) =>
|
||||
@@ -243,7 +252,12 @@ function MarkdownPart({
|
||||
return <MarkdownFragment text={part.text} {...rendererProps} />;
|
||||
}
|
||||
|
||||
function MarkdownFragment({
|
||||
function MarkdownFragment(props: MarkdownRendererProps & { rules: RenderRules }) {
|
||||
if (isNative && !props.part) return <MarkdownRuns {...props} enableHtmlish={false} />;
|
||||
return <MarkdownBlockFragment {...props} />;
|
||||
}
|
||||
|
||||
function MarkdownBlockFragment({
|
||||
text,
|
||||
compact,
|
||||
rules,
|
||||
@@ -443,8 +457,8 @@ function MarkdownDetails({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggle = useCallback(() => setOpen((current) => !current), []);
|
||||
const bodyParts = useMemo(
|
||||
() => part.bodyParts ?? [{ kind: "markdown" as const, text: part.body }],
|
||||
const bodyGroups = useMemo(
|
||||
() => groupMarkdownParts(part.bodyParts ?? [{ kind: "markdown" as const, text: part.body }]),
|
||||
[part.body, part.bodyParts],
|
||||
);
|
||||
return (
|
||||
@@ -459,7 +473,7 @@ function MarkdownDetails({
|
||||
</Pressable>
|
||||
{open ? (
|
||||
<View style={detailsStyles.body}>
|
||||
<MarkdownPartList parts={bodyParts} rendererProps={rendererProps} />
|
||||
<MarkdownGroupList groups={bodyGroups} rendererProps={rendererProps} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user