| 1 | import { decodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; |
| 2 | import { |
| 3 | collectComposerContextReferences, |
| 4 | formatComposerContextHref, |
| 5 | formatComposerContextReference, |
| 6 | } from "@t3tools/shared/composerContextReferences"; |
| 7 | import { markdownWithHtml } from "../lib/markdownDocument"; |
| 8 | import { useCallback, useMemo, useState } from "react"; |
| 9 | import { ActionSheetIOS, Platform, Share, StyleSheet, View } from "react-native"; |
| 10 | import { |
| 11 | EnrichedMarkdownText, |
| 12 | type DocumentAssetsEvent, |
| 13 | type MarkdownStyle, |
| 14 | type MarkdownMediaAsset, |
| 15 | } from "react-native-enriched-markdown"; |
| 16 | import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; |
| 17 | |
| 18 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 19 | import { AndroidAnchoredMenu } from "../components/AndroidAnchoredMenu"; |
| 20 | import { useEnrichedLinkVariants } from "./useEnrichedLinkVariants"; |
| 21 | import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; |
| 22 | import { |
| 23 | ENRICHED_INLINE_FILE_LINK_REGEX, |
| 24 | enrichedSkillLinkRegex, |
| 25 | enrichedLinkVariantPattern, |
| 26 | } from "../lib/enrichedLinkPresentation"; |
| 27 | |
| 28 | function enrichedStyle( |
| 29 | style: SelectableMarkdownTextProps["textStyle"], |
| 30 | dark: boolean, |
| 31 | ): MarkdownStyle { |
| 32 | const monospace = Platform.OS === "ios" ? "Menlo" : "monospace"; |
| 33 | // Keep the radius modest: iOS clips square table-cell borders without stroking the corner arcs. |
| 34 | const block = { |
| 35 | marginTop: 4, |
| 36 | marginBottom: 12, |
| 37 | borderRadius: 8, |
| 38 | borderWidth: StyleSheet.hairlineWidth, |
| 39 | borderColor: style.dividerColor, |
| 40 | }; |
| 41 | const body = { |
| 42 | color: style.color, |
| 43 | fontFamily: style.fontFamily, |
| 44 | fontSize: style.fontSize, |
| 45 | lineHeight: style.lineHeight, |
| 46 | marginTop: 0, |
| 47 | marginBottom: 8, |
| 48 | }; |
| 49 | const heading = (level: number) => { |
| 50 | const fontSize = style.headingFontSizes?.[level - 1] ?? style.fontSize * (1.6 - level * 0.1); |
| 51 | return { |
| 52 | ...body, |
| 53 | color: style.strongColor, |
| 54 | fontFamily: style.headingFontFamily, |
| 55 | fontSize, |
| 56 | lineHeight: fontSize * 1.35, |
| 57 | marginTop: 8, |
| 58 | }; |
| 59 | }; |
| 60 | return { |
| 61 | paragraph: body, |
| 62 | h1: heading(1), |
| 63 | h2: heading(2), |
| 64 | h3: heading(3), |
| 65 | h4: heading(4), |
| 66 | h5: heading(5), |
| 67 | h6: heading(6), |
| 68 | strong: { color: style.strongColor, fontFamily: style.boldFontFamily, fontWeight: "normal" }, |
| 69 | link: { color: style.linkColor, underline: false }, |
| 70 | code: { |
| 71 | fontFamily: monospace, |
| 72 | color: style.inlineCodeColor, |
| 73 | backgroundColor: "transparent", |
| 74 | borderColor: "transparent", |
| 75 | fontSize: style.fontSize * 0.9, |
| 76 | }, |
| 77 | codeBlock: { |
| 78 | ...body, |
| 79 | color: style.codeColor, |
| 80 | backgroundColor: style.codeBlockBackgroundColor, |
| 81 | fontFamily: monospace, |
| 82 | fontSize: style.fontSize * 0.9, |
| 83 | ...block, |
| 84 | padding: 12, |
| 85 | syntaxColors: dark |
| 86 | ? { |
| 87 | keyword: "#ff7b72", |
| 88 | string: "#a5d6ff", |
| 89 | number: "#79c0ff", |
| 90 | constant: "#79c0ff", |
| 91 | comment: "#8b949e", |
| 92 | function: "#d2a8ff", |
| 93 | type: "#ffa657", |
| 94 | property: "#79c0ff", |
| 95 | tag: "#7ee787", |
| 96 | attribute: "#79c0ff", |
| 97 | } |
| 98 | : { |
| 99 | keyword: "#cf222e", |
| 100 | string: "#0a3069", |
| 101 | number: "#0550ae", |
| 102 | constant: "#0550ae", |
| 103 | comment: "#6e7781", |
| 104 | function: "#8250df", |
| 105 | type: "#953800", |
| 106 | property: "#0550ae", |
| 107 | tag: "#116329", |
| 108 | attribute: "#0550ae", |
| 109 | }, |
| 110 | }, |
| 111 | blockquote: { |
| 112 | ...body, |
| 113 | color: style.mutedColor, |
| 114 | backgroundColor: "transparent", |
| 115 | borderColor: style.quoteMarkerColor, |
| 116 | borderWidth: 2, |
| 117 | gapWidth: 12, |
| 118 | }, |
| 119 | list: { ...body, bulletColor: style.mutedColor, markerColor: style.mutedColor, gapWidth: 6 }, |
| 120 | thematicBreak: { color: style.dividerColor, height: 1, marginTop: 8, marginBottom: 8 }, |
| 121 | table: { |
| 122 | ...body, |
| 123 | ...block, |
| 124 | headerFontFamily: style.boldFontFamily, |
| 125 | headerTextColor: style.strongColor, |
| 126 | headerBackgroundColor: style.codeBlockBackgroundColor, |
| 127 | rowEvenBackgroundColor: "transparent", |
| 128 | rowOddBackgroundColor: "transparent", |
| 129 | cellPaddingHorizontal: 10, |
| 130 | cellPaddingVertical: 7, |
| 131 | }, |
| 132 | taskList: { |
| 133 | checkedColor: style.linkColor, |
| 134 | checkedTextColor: style.color, |
| 135 | borderColor: style.mutedColor, |
| 136 | checkedStrikethrough: false, |
| 137 | }, |
| 138 | }; |
| 139 | } |
| 140 | |
| 141 | export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { |
| 142 | const { renderImage, resolveImageSource } = props; |
| 143 | const markdown = useMemo(() => markdownWithHtml(props.markdown), [props.markdown]); |
| 144 | const { themeAppearance } = useAppearancePreferences(); |
| 145 | const [documentAssets, setDocumentAssets] = useState<DocumentAssetsEvent["assets"]>([]); |
| 146 | const onDocumentAssets = useCallback(({ assets }: DocumentAssetsEvent) => { |
| 147 | setDocumentAssets((previous) => |
| 148 | previous.length === assets.length && |
| 149 | previous.every((asset, index) => { |
| 150 | const next = assets[index]!; |
| 151 | return ( |
| 152 | asset.id === next.id && |
| 153 | asset.kind === next.kind && |
| 154 | asset.url === next.url && |
| 155 | asset.altText === next.altText && |
| 156 | asset.title === next.title && |
| 157 | asset.placement === next.placement && |
| 158 | asset.eligible === next.eligible |
| 159 | ); |
| 160 | }) |
| 161 | ? previous |
| 162 | : assets, |
| 163 | ); |
| 164 | }, []); |
| 165 | const { linkVariants, linkPillContent } = useEnrichedLinkVariants(documentAssets, props); |
| 166 | const linkRecognition = useMemo( |
| 167 | () => ({ |
| 168 | text: enrichedSkillLinkRegex(props.skills) ?? undefined, |
| 169 | inlineCode: ENRICHED_INLINE_FILE_LINK_REGEX, |
| 170 | }), |
| 171 | [props.skills], |
| 172 | ); |
| 173 | const [activeMenu, setActiveMenu] = useState<{ |
| 174 | url: string; |
| 175 | menu: NonNullable<ReturnType<NonNullable<typeof props.fileContextMenu>>>; |
| 176 | } | null>(null); |
| 177 | const { fileContextMenu, onLinkPress, onFileContextMenuAction } = props; |
| 178 | const performMenuAction = useCallback( |
| 179 | (url: string, actionId: string) => { |
| 180 | if (actionId === "enriched-open-link") onLinkPress?.(url); |
| 181 | else if (actionId === "enriched-copy-link") copyTextWithHaptic(url); |
| 182 | else if (actionId === "enriched-share-link") void Share.share({ message: url }); |
| 183 | else onFileContextMenuAction?.(url, actionId); |
| 184 | }, |
| 185 | [onLinkPress, onFileContextMenuAction], |
| 186 | ); |
| 187 | const linkContextMenuItems = useMemo( |
| 188 | () => |
| 189 | Platform.OS === "ios" && (fileContextMenu || onLinkPress) |
| 190 | ? Object.fromEntries( |
| 191 | [ |
| 192 | ...new Set( |
| 193 | documentAssets.filter((asset) => asset.kind === "link").map((asset) => asset.url), |
| 194 | ), |
| 195 | ].map((url) => { |
| 196 | const menu = fileContextMenu?.(url) ?? { |
| 197 | title: url, |
| 198 | actions: [ |
| 199 | { id: "enriched-open-link", title: "Open link" }, |
| 200 | { id: "enriched-copy-link", title: "Copy link" }, |
| 201 | { id: "enriched-share-link", title: "Share link" }, |
| 202 | ], |
| 203 | }; |
| 204 | return [ |
| 205 | enrichedLinkVariantPattern(url), |
| 206 | menu.actions.map((action) => ({ |
| 207 | text: action.title, |
| 208 | disabled: "disabled" in action && action.disabled, |
| 209 | onPress: () => performMenuAction(url, action.id), |
| 210 | })), |
| 211 | ]; |
| 212 | }), |
| 213 | ) |
| 214 | : undefined, |
| 215 | [documentAssets, fileContextMenu, onLinkPress, performMenuAction], |
| 216 | ); |
| 217 | const selectionClipboard = useMemo(() => { |
| 218 | const fragment = decodeComposerContextFragment(props.contextClipboardFragment); |
| 219 | if (!fragment) return undefined; |
| 220 | const references = collectComposerContextReferences(props.markdown); |
| 221 | const linkTextByUrl = Object.fromEntries( |
| 222 | references.flatMap((reference) => { |
| 223 | const record = fragment.records.find((entry) => entry.contextId === reference.contextId); |
| 224 | return record |
| 225 | ? [ |
| 226 | [ |
| 227 | formatComposerContextHref(reference.kind, reference.contextId), |
| 228 | formatComposerContextReference({ ...reference, label: record.label }), |
| 229 | ], |
| 230 | ] |
| 231 | : []; |
| 232 | }), |
| 233 | ); |
| 234 | return { |
| 235 | linkTextByUrl, |
| 236 | htmlAttributes: { |
| 237 | "data-t3-context-fragment": encodeURIComponent(props.contextClipboardFragment!), |
| 238 | }, |
| 239 | mimeTypes: { "app.t3.context-fragment": props.contextClipboardFragment! }, |
| 240 | }; |
| 241 | }, [props.contextClipboardFragment, props.markdown]); |
| 242 | const markdownStyle = useMemo( |
| 243 | () => ({ ...enrichedStyle(props.textStyle, themeAppearance === "dark"), linkVariants }), |
| 244 | [props.textStyle, themeAppearance, linkVariants], |
| 245 | ); |
| 246 | const renderMedia = useCallback( |
| 247 | (asset: MarkdownMediaAsset) => |
| 248 | renderImage?.({ |
| 249 | href: asset.url, |
| 250 | alt: asset.altText || null, |
| 251 | title: asset.title || null, |
| 252 | }) ?? null, |
| 253 | [renderImage], |
| 254 | ); |
| 255 | const resolveNativeImageSource = useCallback( |
| 256 | (asset: MarkdownMediaAsset) => |
| 257 | resolveImageSource?.({ |
| 258 | href: asset.url, |
| 259 | alt: asset.altText || null, |
| 260 | title: asset.title || null, |
| 261 | }) ?? null, |
| 262 | [resolveImageSource], |
| 263 | ); |
| 264 | const renderMarkdown = (openAndroidMenu?: () => void) => { |
| 265 | const onLinkLongPress = props.fileContextMenu |
| 266 | ? ({ url }: { url: string }) => { |
| 267 | const menu = props.fileContextMenu?.(url) ?? { |
| 268 | title: url, |
| 269 | actions: [ |
| 270 | { id: "enriched-open-link", title: "Open link" }, |
| 271 | { id: "enriched-copy-link", title: "Copy link" }, |
| 272 | ], |
| 273 | }; |
| 274 | const actions = menu.actions; |
| 275 | if (Platform.OS === "ios") { |
| 276 | ActionSheetIOS.showActionSheetWithOptions( |
| 277 | { |
| 278 | title: menu.title, |
| 279 | options: [...actions.map((action) => action.title), "Cancel"], |
| 280 | cancelButtonIndex: actions.length, |
| 281 | disabledButtonIndices: actions.flatMap((action, index) => |
| 282 | action.disabled ? [index] : [], |
| 283 | ), |
| 284 | }, |
| 285 | (index) => { |
| 286 | const action = actions[index]; |
| 287 | if (action && !action.disabled) performMenuAction(url, action.id); |
| 288 | }, |
| 289 | ); |
| 290 | } else { |
| 291 | setActiveMenu({ url, menu }); |
| 292 | openAndroidMenu?.(); |
| 293 | } |
| 294 | } |
| 295 | : undefined; |
| 296 | |
| 297 | return ( |
| 298 | <View |
| 299 | style={{ |
| 300 | flexShrink: 1, |
| 301 | minWidth: 0, |
| 302 | marginTop: props.marginTop, |
| 303 | marginBottom: props.marginBottom, |
| 304 | }} |
| 305 | > |
| 306 | <EnrichedMarkdownText |
| 307 | markdown={markdown} |
| 308 | markdownStyle={markdownStyle} |
| 309 | onDocumentAssets={onDocumentAssets} |
| 310 | renderMedia={renderImage ? renderMedia : undefined} |
| 311 | resolveImageSource={resolveImageSource ? resolveNativeImageSource : undefined} |
| 312 | onImagePress={ |
| 313 | props.onImagePress || props.onLinkPress |
| 314 | ? ({ url }) => (props.onImagePress ?? props.onLinkPress)?.(url) |
| 315 | : undefined |
| 316 | } |
| 317 | linkRecognition={linkRecognition} |
| 318 | containerStyle={{ flexShrink: 1, minWidth: 0 }} |
| 319 | flavor="github" |
| 320 | selectable |
| 321 | selectionClipboard={selectionClipboard} |
| 322 | selectionColor={props.textStyle.selectionColor} |
| 323 | selectionHandleColor={props.textStyle.selectionHandleColor} |
| 324 | md4cFlags={{ |
| 325 | latexMath: false, |
| 326 | hardSoftBreaks: props.preserveSoftBreaks ?? false, |
| 327 | admonitions: true, |
| 328 | }} |
| 329 | enableTaskListItemToggle={false} |
| 330 | spoilerOverlay="solid" |
| 331 | onLinkPress={props.onLinkPress ? ({ url }) => props.onLinkPress?.(url) : undefined} |
| 332 | onLinkLongPress={onLinkLongPress} |
| 333 | linkContextMenuItems={linkContextMenuItems} |
| 334 | linkPillContent={linkPillContent} |
| 335 | /> |
| 336 | </View> |
| 337 | ); |
| 338 | }; |
| 339 | |
| 340 | return Platform.OS === "android" && props.fileContextMenu ? ( |
| 341 | <AndroidAnchoredMenu |
| 342 | title={activeMenu?.menu.title} |
| 343 | actions={ |
| 344 | activeMenu?.menu.actions.map((action) => ({ |
| 345 | id: action.id, |
| 346 | title: action.title, |
| 347 | attributes: { disabled: action.disabled }, |
| 348 | })) ?? [] |
| 349 | } |
| 350 | onPressAction={({ nativeEvent }) => { |
| 351 | if (activeMenu) performMenuAction(activeMenu.url, nativeEvent.event); |
| 352 | }} |
| 353 | style={{ flexShrink: 1, minWidth: 0 }} |
| 354 | > |
| 355 | {renderMarkdown} |
| 356 | </AndroidAnchoredMenu> |
| 357 | ) : ( |
| 358 | renderMarkdown() |
| 359 | ); |
| 360 | } |