Tighten markdown list spacing
This commit is contained in:
@@ -49,7 +49,7 @@ import {
|
|||||||
FileSymlink,
|
FileSymlink,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { SPACING, type Theme } from "@/styles/theme";
|
import { type Theme } from "@/styles/theme";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -67,7 +67,7 @@ import type { AgentAttachment } from "@server/shared/messages";
|
|||||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||||
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list";
|
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||||
@@ -1554,39 +1554,14 @@ function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphVi
|
|||||||
return <View style={style}>{children}</View>;
|
return <View style={style}>{children}</View>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// List spacing in markdown:
|
|
||||||
// - p -> list and list -> p use a slightly larger gap than p -> p, so lists
|
|
||||||
// read as their own section against surrounding prose.
|
|
||||||
// - list -> list keeps the normal p-to-p gap; back-to-back lists are
|
|
||||||
// continuous content, not section breaks.
|
|
||||||
//
|
|
||||||
// Paragraph's marginBottom is SPACING[3] = 12 (and marginTop is 0). To produce
|
|
||||||
// 16px gaps on p<->list transitions and 12px on list<->list, we add a constant
|
|
||||||
// marginTop on lists (4) and switch marginBottom by next-sibling type:
|
|
||||||
// p -> list = p.marginBottom(12) + list.marginTop(4) = 16
|
|
||||||
// list -> p = list.marginBottom(16) + p.marginTop(0) = 16
|
|
||||||
// list -> list = list.marginBottom(8) + list.marginTop(4) = 12
|
|
||||||
const MARKDOWN_LIST_MARGIN_TOP = SPACING[1]; // 4
|
|
||||||
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE = SPACING[4]; // 16
|
|
||||||
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST = SPACING[2]; // 8
|
|
||||||
|
|
||||||
function getMarkdownListContextMarginBottom(node: ASTNode, parent: ASTNode[]): number {
|
|
||||||
const nextType = getMarkdownNextSiblingType(node, parent);
|
|
||||||
const nextIsList = nextType === "bullet_list" || nextType === "ordered_list";
|
|
||||||
return nextIsList ? MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST : MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MarkdownListViewProps {
|
interface MarkdownListViewProps {
|
||||||
baseStyle: ViewStyle;
|
baseStyle: ViewStyle;
|
||||||
marginBottom: number;
|
spacing: { marginTop: number; marginBottom: number };
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListViewProps) {
|
function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) {
|
||||||
const style = useMemo(
|
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
|
||||||
() => [baseStyle, { marginTop: MARKDOWN_LIST_MARGIN_TOP, marginBottom }],
|
|
||||||
[baseStyle, marginBottom],
|
|
||||||
);
|
|
||||||
return <View style={style}>{children}</View>;
|
return <View style={style}>{children}</View>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1748,7 +1723,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
<MarkdownListView
|
<MarkdownListView
|
||||||
key={node.key}
|
key={node.key}
|
||||||
baseStyle={styles.bullet_list}
|
baseStyle={styles.bullet_list}
|
||||||
marginBottom={getMarkdownListContextMarginBottom(node, parent)}
|
spacing={getMarkdownListSpacing(node, parent)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</MarkdownListView>
|
</MarkdownListView>
|
||||||
@@ -1762,7 +1737,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
<MarkdownListView
|
<MarkdownListView
|
||||||
key={node.key}
|
key={node.key}
|
||||||
baseStyle={styles.ordered_list}
|
baseStyle={styles.ordered_list}
|
||||||
marginBottom={getMarkdownListContextMarginBottom(node, parent)}
|
spacing={getMarkdownListSpacing(node, parent)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</MarkdownListView>
|
</MarkdownListView>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { getMarkdownListMarker } from "./markdown-list";
|
import { getMarkdownListMarker, getMarkdownListSpacing } from "./markdown-list";
|
||||||
|
|
||||||
describe("getMarkdownListMarker", () => {
|
describe("getMarkdownListMarker", () => {
|
||||||
it("returns a bullet marker for unordered list items", () => {
|
it("returns a bullet marker for unordered list items", () => {
|
||||||
@@ -39,3 +39,49 @@ describe("getMarkdownListMarker", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getMarkdownListSpacing", () => {
|
||||||
|
it("keeps top-level list spacing as a section boundary", () => {
|
||||||
|
const paragraph = { type: "paragraph" };
|
||||||
|
const list = { type: "bullet_list" };
|
||||||
|
const body = { type: "body", children: [list, paragraph] };
|
||||||
|
|
||||||
|
expect(getMarkdownListSpacing(list, [body])).toEqual({
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 16,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add bottom spacing after a list at the end of a markdown block", () => {
|
||||||
|
const list = { type: "bullet_list" };
|
||||||
|
const body = { type: "body", children: [list] };
|
||||||
|
|
||||||
|
expect(getMarkdownListSpacing(list, [body])).toEqual({
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a smaller gap between adjacent top-level lists", () => {
|
||||||
|
const list = { type: "bullet_list" };
|
||||||
|
const body = { type: "body", children: [list, { type: "ordered_list" }] };
|
||||||
|
|
||||||
|
expect(getMarkdownListSpacing(list, [body])).toEqual({
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add section spacing after a nested list", () => {
|
||||||
|
expect(
|
||||||
|
getMarkdownListSpacing({ type: "bullet_list" }, [
|
||||||
|
{ type: "list_item" },
|
||||||
|
{ type: "bullet_list" },
|
||||||
|
{ type: "body" },
|
||||||
|
]),
|
||||||
|
).toEqual({
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { SPACING } from "@/styles/theme";
|
||||||
|
|
||||||
interface MarkdownNode {
|
interface MarkdownNode {
|
||||||
type?: string;
|
type?: string;
|
||||||
index?: number;
|
index?: number;
|
||||||
@@ -10,6 +12,11 @@ interface MarkdownNode {
|
|||||||
|
|
||||||
const LIST_BULLET = "•";
|
const LIST_BULLET = "•";
|
||||||
const DEFAULT_ORDERED_LIST_MARKUP = ".";
|
const DEFAULT_ORDERED_LIST_MARKUP = ".";
|
||||||
|
const MARKDOWN_LIST_MARGIN_TOP = SPACING[1];
|
||||||
|
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE = SPACING[4];
|
||||||
|
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST = SPACING[2];
|
||||||
|
const MARKDOWN_NESTED_LIST_MARGIN_BOTTOM = 0;
|
||||||
|
const MARKDOWN_TERMINAL_LIST_MARGIN_BOTTOM = 0;
|
||||||
|
|
||||||
function toParentNodes(parent: unknown): MarkdownNode[] {
|
function toParentNodes(parent: unknown): MarkdownNode[] {
|
||||||
if (Array.isArray(parent)) {
|
if (Array.isArray(parent)) {
|
||||||
@@ -76,6 +83,41 @@ export function getMarkdownNextSiblingType(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isListType(type: string | undefined): boolean {
|
||||||
|
return type === "bullet_list" || type === "ordered_list";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasListItemAncestor(parent: unknown): boolean {
|
||||||
|
return toParentNodes(parent).some((ancestor) => ancestor?.type === "list_item");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMarkdownListSpacing(
|
||||||
|
node: MarkdownNode,
|
||||||
|
parent: unknown,
|
||||||
|
): { marginTop: number; marginBottom: number } {
|
||||||
|
if (hasListItemAncestor(parent)) {
|
||||||
|
return {
|
||||||
|
marginTop: MARKDOWN_LIST_MARGIN_TOP,
|
||||||
|
marginBottom: MARKDOWN_NESTED_LIST_MARGIN_BOTTOM,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextType = getMarkdownNextSiblingType(node, parent);
|
||||||
|
if (!nextType) {
|
||||||
|
return {
|
||||||
|
marginTop: MARKDOWN_LIST_MARGIN_TOP,
|
||||||
|
marginBottom: MARKDOWN_TERMINAL_LIST_MARGIN_BOTTOM,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
marginTop: MARKDOWN_LIST_MARGIN_TOP,
|
||||||
|
marginBottom: isListType(nextType)
|
||||||
|
? MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST
|
||||||
|
: MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getMarkdownListMarker(
|
export function getMarkdownListMarker(
|
||||||
node: MarkdownNode,
|
node: MarkdownNode,
|
||||||
parent: unknown,
|
parent: unknown,
|
||||||
|
|||||||
Reference in New Issue
Block a user