Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios
import type {
MarkdownCodeHighlighter,
MarkdownHighlightedToken,
MarkdownImageRenderer,
NativeMarkdownTextStyle,
SelectableMarkdownSkill,
} from "./SelectableMarkdownText.types";
Expand Down Expand Up @@ -378,6 +379,7 @@ function NativeMarkdownImage(props: {
readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
readonly textStyle: NativeMarkdownTextStyle;
readonly onLinkPress?: (href: string) => void;
readonly renderImage?: MarkdownImageRenderer;
}) {
const href = props.node.href;
if (!href) {
Expand All @@ -391,19 +393,24 @@ function NativeMarkdownImage(props: {
);
}

return (
const frameStyle = {
width: "100%",
aspectRatio: 16 / 9,
backgroundColor: props.textStyle.codeBackgroundColor,
borderRadius: 10,
} as const;
const renderDefault = (uri: string | undefined) => (
<View style={{ gap: 6 }}>
<Image
source={{ uri: href }}
resizeMode="contain"
accessibilityLabel={props.node.alt ?? props.node.title}
style={{
width: "100%",
aspectRatio: 16 / 9,
backgroundColor: props.textStyle.codeBackgroundColor,
borderRadius: 10,
}}
/>
{uri === undefined ? (
<View style={frameStyle} />
) : (
<Image
source={{ uri }}
resizeMode="contain"
accessibilityLabel={props.node.alt ?? props.node.title}
style={frameStyle}
/>
)}
{props.node.alt ? (
<Text
selectable
Expand All @@ -419,6 +426,15 @@ function NativeMarkdownImage(props: {
) : null}
</View>
);

if (props.renderImage) {
return (
<>
{props.renderImage({ href, alt: props.node.alt, title: props.node.title }, renderDefault)}
</>
);
}
return renderDefault(href);
}

function inlineGroups(nodes: ReadonlyArray<MarkdownNode>): MarkdownNode[] {
Expand Down Expand Up @@ -449,6 +465,7 @@ function NativeMixedParagraph(props: {
readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
readonly textStyle: NativeMarkdownTextStyle;
readonly onLinkPress?: (href: string) => void;
readonly renderImage?: MarkdownImageRenderer;
}) {
return (
<View style={{ gap: 8 }}>
Expand All @@ -460,6 +477,7 @@ function NativeMixedParagraph(props: {
skills={props.skills}
textStyle={props.textStyle}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
/>
) : (
<SelectableNode
Expand All @@ -481,6 +499,7 @@ function NativeList(props: {
readonly textStyle: NativeMarkdownTextStyle;
readonly highlightCode: MarkdownCodeHighlighter;
readonly onLinkPress?: (href: string) => void;
readonly renderImage?: MarkdownImageRenderer;
readonly depth: number;
}) {
const ordered = props.node.ordered ?? false;
Expand Down Expand Up @@ -543,6 +562,7 @@ function NativeList(props: {
textStyle={props.textStyle}
highlightCode={props.highlightCode}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
depth={props.depth + 1}
compact
/>
Expand All @@ -561,6 +581,7 @@ export function NativeMarkdownBlock(props: {
readonly textStyle: NativeMarkdownTextStyle;
readonly highlightCode: MarkdownCodeHighlighter;
readonly onLinkPress?: (href: string) => void;
readonly renderImage?: MarkdownImageRenderer;
readonly depth?: number;
readonly compact?: boolean;
}) {
Expand All @@ -577,6 +598,7 @@ export function NativeMarkdownBlock(props: {
textStyle={props.textStyle}
highlightCode={props.highlightCode}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
depth={depth}
/>
))}
Expand Down Expand Up @@ -607,6 +629,7 @@ export function NativeMarkdownBlock(props: {
skills={props.skills}
textStyle={props.textStyle}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
/>
);
case "horizontal_rule":
Expand Down Expand Up @@ -638,6 +661,7 @@ export function NativeMarkdownBlock(props: {
textStyle={props.textStyle}
highlightCode={props.highlightCode}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
depth={depth}
compact
/>
Expand All @@ -652,6 +676,7 @@ export function NativeMarkdownBlock(props: {
textStyle={props.textStyle}
highlightCode={props.highlightCode}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
depth={depth}
/>
);
Expand All @@ -662,6 +687,7 @@ export function NativeMarkdownBlock(props: {
skills={props.skills}
textStyle={props.textStyle}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
/>
) : (
<SelectableNode
Expand Down Expand Up @@ -709,6 +735,7 @@ export function NativeMarkdownBlock(props: {
textStyle={props.textStyle}
highlightCode={props.highlightCode}
onLinkPress={props.onLinkPress}
renderImage={props.renderImage}
depth={depth}
compact
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const EMPTY_SKILLS: ReadonlyArray<SelectableMarkdownSkill> = [];
export type {
MarkdownCodeHighlighter,
MarkdownHighlightedToken,
MarkdownImageRenderer,
MarkdownImageSource,
NativeMarkdownTextStyle,
SelectableMarkdownSkill,
SelectableMarkdownTextProps,
Expand All @@ -36,6 +38,7 @@ export function SelectableMarkdownText({
highlightCode,
preserveSoftBreaks = false,
onLinkPress,
renderImage,
marginTop = 0,
marginBottom = 0,
}: SelectableMarkdownTextProps) {
Expand Down Expand Up @@ -73,6 +76,7 @@ export function SelectableMarkdownText({
textStyle={textStyle}
highlightCode={highlightCode}
onLinkPress={onLinkPress}
renderImage={renderImage}
/>
) : (
<NativeMarkdownSelectableText
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types
export type {
MarkdownCodeHighlighter,
MarkdownHighlightedToken,
MarkdownImageRenderer,
MarkdownImageSource,
NativeMarkdownTextStyle,
SelectableMarkdownSkill,
SelectableMarkdownTextProps,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ReactNode } from "react";

export interface NativeMarkdownTextStyle {
readonly color: string;
readonly strongColor: string;
Expand Down Expand Up @@ -36,13 +38,31 @@ export interface SelectableMarkdownSkill {
readonly displayName?: string | null;
}

export interface MarkdownImageSource {
readonly href: string;
readonly alt?: string | undefined;
readonly title?: string | undefined;
}

/**
* Overrides how block images render. `renderDefault` draws the module's own
* image frame for a uri, so overrides that only swap the uri (e.g. a signed
* asset URL for a workspace path) keep the native look; passing undefined
* draws the empty frame as a placeholder.
*/
export type MarkdownImageRenderer = (
image: MarkdownImageSource,
renderDefault: (uri: string | undefined) => ReactNode,
) => ReactNode;

export interface SelectableMarkdownTextProps {
readonly markdown: string;
readonly textStyle: NativeMarkdownTextStyle;
readonly highlightCode: MarkdownCodeHighlighter;
readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
readonly preserveSoftBreaks?: boolean;
readonly onLinkPress?: (href: string) => void;
readonly renderImage?: MarkdownImageRenderer;
readonly marginTop?: number;
readonly marginBottom?: number;
}
113 changes: 113 additions & 0 deletions apps/mobile/src/components/MarkdownWorkspaceImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
import type { ReactNode } from "react";

import type { MarkdownImageRenderer } from "../native/SelectableMarkdownText";
import { useAssetUrl } from "../state/assets";

const WINDOWS_DRIVE_PATTERN = /^[A-Za-z]:[\\/]/;
const EXTERNAL_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/;

function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}

/**
* Filesystem path of a `file:` url, or null when it isn't one we can read.
* The url names a file on the server, not the phone, so it must go through
* the signed asset flow like any other workspace path.
*/
function parseFileUrlPath(value: string): string | null {
const match = value.match(/^file:\/\/([^/]*)(\/.*)$/i);
const host = match?.[1] ?? "";
const rawPath = match?.[2];
if (rawPath === undefined || (host.length > 0 && host.toLowerCase() !== "localhost")) {
return null;
}
const path = safeDecode(rawPath.split(/[?#]/, 1)[0] ?? rawPath);
// The url form of a windows drive path carries a leading slash: /C:/x.png.
return WINDOWS_DRIVE_PATTERN.test(path.slice(1)) ? path.slice(1) : path;
}

/**
* Workspace path for a markdown image src, or null when the src is external
* (scheme or protocol-relative) or not an image. Relative results stay
* workspace-relative; the server resolves them against the thread's workspace
* root. `baseDir` anchors document-relative srcs, e.g. a nested README's own
* folder in the file preview.
*/
export function resolveMarkdownImageWorkspacePath(src: string, baseDir?: string): string | null {
const trimmed = src.trim();
if (trimmed.length === 0 || trimmed.startsWith("#") || trimmed.startsWith("//")) return null;

const fileUrlPath = /^file:/i.test(trimmed) ? parseFileUrlPath(trimmed) : null;
if (fileUrlPath === null && /^file:/i.test(trimmed)) return null;
if (
fileUrlPath === null &&
!WINDOWS_DRIVE_PATTERN.test(trimmed) &&
EXTERNAL_SCHEME_PATTERN.test(trimmed)
) {
return null;
}

const path = fileUrlPath ?? safeDecode(trimmed.split(/[?#]/, 1)[0] ?? trimmed);
if (path.length === 0 || !isWorkspaceImagePreviewPath(path)) return null;
// RN's <Image> cannot decode SVG, so a signed URL would still draw nothing;
// leave SVG srcs to the default renderer instead of fetching one.
if (path.toLowerCase().endsWith(".svg")) return null;

const isAbsolute =
path.startsWith("/") || WINDOWS_DRIVE_PATTERN.test(path) || path.startsWith("\\\\");
if (isAbsolute || !baseDir) return path;
return `${baseDir.replace(/[\\/]+$/, "")}/${path}`;
}

/**
* A markdown image whose src points into the workspace. The phone cannot fetch
* the server's files by path, so the image waits for a signed asset URL; while
* loading — or if the server refuses the path — the module's default frame
* shows its empty placeholder, matching today's broken-image look.
*/
export function MarkdownWorkspaceImage(props: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly path: string;
readonly renderWithUri: (uri: string | undefined) => ReactNode;
}) {
const uri = useAssetUrl(props.environmentId, {
_tag: "workspace-file",
threadId: props.threadId,
path: props.path,
});
return <>{props.renderWithUri(uri ?? undefined)}</>;
}

/**
* Builds the `renderImage` override for markdown surfaces in a thread:
* workspace srcs go through the signed asset flow, everything else keeps the
* default rendering.
*/
export function createWorkspaceImageRenderer(context: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly baseDir?: string;
}): MarkdownImageRenderer {
return (image, renderDefault) => {
const path = resolveMarkdownImageWorkspacePath(image.href, context.baseDir);
if (path === null) {
return renderDefault(image.href);
}
return (
<MarkdownWorkspaceImage
environmentId={context.environmentId}
threadId={context.threadId}
path={path}
renderWithUri={renderDefault}
/>
);
};
}
3 changes: 3 additions & 0 deletions apps/mobile/src/features/files/FileMarkdownPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe
import {
hasNativeSelectableMarkdownText,
SelectableMarkdownText,
type MarkdownImageRenderer,
type NativeMarkdownTextStyle,
} from "../../native/SelectableMarkdownText";

Expand Down Expand Up @@ -173,6 +174,7 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
export function FileMarkdownPreview(props: {
readonly markdown: string;
readonly onRefresh?: () => Promise<void> | void;
readonly renderImage?: MarkdownImageRenderer;
}) {
const [isPullRefreshing, setIsPullRefreshing] = useState(false);
const handlePullToRefresh = useCallback(async () => {
Expand Down Expand Up @@ -210,6 +212,7 @@ export function FileMarkdownPreview(props: {
markdown={props.markdown}
onLinkPress={onLinkPress}
textStyle={styles.nativeTextStyle}
renderImage={props.renderImage}
/>
) : (
<Markdown
Expand Down
Loading
Loading