Skip to content
Merged
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
20 changes: 20 additions & 0 deletions apps/mobile/modules/t3-markdown-text/android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.kotlin.android'

group = 'com.t3tools.markdowntext'
version = '0.0.0'

android {
namespace 'expo.modules.t3markdowntext'
compileSdk rootProject.ext.compileSdkVersion

defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
}
}

dependencies {
implementation project(':expo-modules-core')
implementation 'com.facebook.react:react-android'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package expo.modules.t3markdowntext

import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.text.Spanned
import android.text.style.ReplacementSpan
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.widget.TextView
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.UIManagerHelper
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import kotlin.math.max
import kotlin.math.min

private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC"

private fun copyTextWithoutInlineImages(
text: CharSequence,
start: Int,
end: Int
): String {
if (text !is Spanned) return text.subSequence(start, end).toString()

return buildString {
for (index in start until end) {
val isInlineImage =
text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&
text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()
if (!isInlineImage) append(text[index])
}
}
}

private class SanitizingSelectionActionModeCallback(
private val textView: TextView,
private val delegate: ActionMode.Callback?
) : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean =
delegate?.onCreateActionMode(mode, menu) ?: true

override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean =
delegate?.onPrepareActionMode(mode, menu) ?: false

override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
if (item.itemId == android.R.id.copy) {
val start = min(textView.selectionStart, textView.selectionEnd)
val end = max(textView.selectionStart, textView.selectionEnd)
if (start >= 0 && end > start) {
val originalText = textView.text.subSequence(start, end).toString()
val selectedText = copyTextWithoutInlineImages(textView.text, start, end)
if (selectedText != originalText) {
val clipboard =
textView.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(null, selectedText))
mode.finish()
return true
}
}
}
return delegate?.onActionItemClicked(mode, item) ?: false
}

override fun onDestroyActionMode(mode: ActionMode) {
delegate?.onDestroyActionMode(mode)
}
}

class T3MarkdownTextSelectionModule : Module() {
override fun definition() = ModuleDefinition {
Name("T3MarkdownTextSelection")

Function("installCopySanitizer") { reactTag: Int ->
val reactContext = appContext.reactContext as? ReactContext ?: return@Function
reactContext.runOnUiQueueThread {
val textView =
runCatching {
UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag)
}
.getOrNull() as? TextView ?: return@runOnUiQueueThread
val currentCallback = textView.customSelectionActionModeCallback
if (currentCallback is SanitizingSelectionActionModeCallback) {
return@runOnUiQueueThread
}
textView.customSelectionActionModeCallback =
SanitizingSelectionActionModeCallback(textView, currentCallback)
}
}
}
}
6 changes: 6 additions & 0 deletions apps/mobile/modules/t3-markdown-text/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.t3markdowntext.T3MarkdownTextSelectionModule"]
}
}
3 changes: 3 additions & 0 deletions apps/mobile/modules/t3-markdown-text/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"private": true,
"source": "./index.ts",
"files": [
"android",
"assets",
"expo-module.config.json",
"ios",
"src",
"index.ts",
Expand All @@ -28,6 +30,7 @@
"peerDependencies": {
"@t3tools/client-runtime": "*",
"@t3tools/shared": "*",
"expo": "*",
"expo-asset": "*",
"expo-clipboard": "*",
"expo-haptics": "*",
Expand Down
23 changes: 17 additions & 6 deletions apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from "react";
import React, { type Ref } from "react";
import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native";
import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent";
import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent";
Expand Down Expand Up @@ -28,7 +28,12 @@ export type ContextMenuActionEvent = {
nativeEvent: { target: number; actionIdentifier: string };
};

export type MarkdownTextPrimitiveProps = TextProps & {
/**
* `onTextLayout` is not offered: the native view reports plain line strings
* while the React Native Text fallback reports measured `TextLayoutLine`s.
*/
export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & {
nativeTextRef?: Ref<RNText>;
uiTextView?: boolean;
contextMenuConfig?: string;
onContextMenuAction?: (event: ContextMenuActionEvent) => void;
Expand All @@ -41,7 +46,12 @@ export type MarkdownTextPrimitiveProps = TextProps & {
onSelectionChange?: (event: SelectionChangeEvent) => void;
};

function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPrimitiveProps) {
function MarkdownTextPrimitiveChild({
style,
children,
nativeTextRef: _nativeTextRef,
...rest
}: MarkdownTextPrimitiveProps) {
const [isAncestor, rootStyle] = useTextAncestorContext();

// Flatten the styles, and apply the root styles when needed
Expand Down Expand Up @@ -95,21 +105,22 @@ function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPr
return <>{nativeChildren}</>;
}

function MarkdownTextPrimitiveInner(props: MarkdownTextPrimitiveProps) {
function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPrimitiveProps) {
const [isAncestor] = useTextAncestorContext();

// Even if the uiTextView prop is set, we can still default to using
// normal selection (i.e. base RN text) if the text doesn't need to be
// selectable
if ((!props.selectable || !props.uiTextView) && !isAncestor) {
return <RNText {...props} />;
return <RNText ref={nativeTextRef} {...props} />;
}
return <MarkdownTextPrimitiveChild {...props} />;
}

export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) {
if (Platform.OS !== "ios") {
return <RNText {...props} />;
const { nativeTextRef, ...textProps } = props;
return <RNText ref={nativeTextRef} {...textProps} />;
}
return <MarkdownTextPrimitiveInner {...props} />;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createContext, useContext, useEffect, useState } from "react";
import { Image, ScrollView, Text, useColorScheme, View } from "react-native";
import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native";
import type { MarkdownNode } from "react-native-nitro-markdown/headless";

import { CopyTextButton } from "./CopyTextButton";
Expand All @@ -22,6 +22,11 @@ type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>;
const highlightedCodeCache = new Map<string, HighlightedCode>();
const highlightedCodePromiseCache = new Map<string, Promise<HighlightedCode>>();
const HIGHLIGHTED_CODE_CACHE_LIMIT = 64;
const MONO_FONT_FAMILY = Platform.select({
ios: "ui-monospace",
android: "monospace",
default: "monospace",
});

function nodeKey(node: MarkdownNode, index: number): string {
return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;
Expand Down Expand Up @@ -171,7 +176,7 @@ function HighlightedCodeText(props: {
selectable
style={{
color: props.textStyle.codeColor,
fontFamily: "ui-monospace",
fontFamily: MONO_FONT_FAMILY,
fontSize: codeBlockFontSize(props.textStyle),
lineHeight: codeBlockLineHeight(props.textStyle),
}}
Expand Down Expand Up @@ -206,7 +211,7 @@ function HighlightedCodeText(props: {
selectable
style={{
color: props.textStyle.codeColor,
fontFamily: "ui-monospace",
fontFamily: MONO_FONT_FAMILY,
fontSize: codeBlockFontSize(props.textStyle),
lineHeight: codeBlockLineHeight(props.textStyle),
}}
Expand All @@ -218,7 +223,7 @@ function HighlightedCodeText(props: {
key={key}
style={{
color: token.color ?? props.textStyle.codeColor,
fontFamily: "ui-monospace",
fontFamily: MONO_FONT_FAMILY,
fontStyle:
token.fontStyle !== null && (token.fontStyle & 1) === 1 ? "italic" : "normal",
fontWeight: token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400",
Expand Down Expand Up @@ -274,7 +279,7 @@ function NativeCodeBlock(props: {
style={{
flex: 1,
color: props.textStyle.mutedColor,
fontFamily: "ui-monospace",
fontFamily: MONO_FONT_FAMILY,
fontSize: codeBlockFontSize(props.textStyle),
}}
>
Expand All @@ -294,6 +299,7 @@ function NativeCodeBlock(props: {
<ScrollView
horizontal
bounces={false}
nestedScrollEnabled={Platform.OS === "android"}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 12 }}
>
Expand Down Expand Up @@ -330,7 +336,12 @@ function NativeTable(props: {
}) {
const rows = collectTableRows(props.node);
return (
<ScrollView horizontal bounces={false} showsHorizontalScrollIndicator={false}>
<ScrollView
horizontal
bounces={false}
nestedScrollEnabled={Platform.OS === "android"}
showsHorizontalScrollIndicator={false}
>
<View
style={{
borderColor: props.textStyle.dividerColor,
Expand Down
Loading
Loading