diff --git a/apps/mobile/assets/file-icons/pierre_agents.png b/apps/mobile/assets/file-icons/pierre_agents.png new file mode 100644 index 0000000000000000000000000000000000000000..4696e3fd37d9483796d1fbf661ec742c67611ebb Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_agents.png differ diff --git a/apps/mobile/assets/file-icons/pierre_astro.png b/apps/mobile/assets/file-icons/pierre_astro.png new file mode 100644 index 0000000000000000000000000000000000000000..0d348cb0a2e93cc1984e5ed20310fd86c4125799 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_astro.png differ diff --git a/apps/mobile/assets/file-icons/pierre_babel.png b/apps/mobile/assets/file-icons/pierre_babel.png new file mode 100644 index 0000000000000000000000000000000000000000..7481353a2596b2aa1891c66b7e06b03c7a9815d2 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_babel.png differ diff --git a/apps/mobile/assets/file-icons/pierre_bash.png b/apps/mobile/assets/file-icons/pierre_bash.png new file mode 100644 index 0000000000000000000000000000000000000000..da0441b96a484ca19861606b427adb1608a96c2b Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_bash.png differ diff --git a/apps/mobile/assets/file-icons/pierre_biome.png b/apps/mobile/assets/file-icons/pierre_biome.png new file mode 100644 index 0000000000000000000000000000000000000000..ba07d10d8fa9511ff0566300525fb05a9ab1fbd3 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_biome.png differ diff --git a/apps/mobile/assets/file-icons/pierre_bootstrap.png b/apps/mobile/assets/file-icons/pierre_bootstrap.png new file mode 100644 index 0000000000000000000000000000000000000000..32a8b227598fb73836da61cc3e0de95a2c5eaff1 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_bootstrap.png differ diff --git a/apps/mobile/assets/file-icons/pierre_browserslist.png b/apps/mobile/assets/file-icons/pierre_browserslist.png new file mode 100644 index 0000000000000000000000000000000000000000..4b15e51c463eebb5790144798ca73f216b0b999c Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_browserslist.png differ diff --git a/apps/mobile/assets/file-icons/pierre_bun.png b/apps/mobile/assets/file-icons/pierre_bun.png new file mode 100644 index 0000000000000000000000000000000000000000..7369c907148a05bc33406597344279eedfd52ad7 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_bun.png differ diff --git a/apps/mobile/assets/file-icons/pierre_c.png b/apps/mobile/assets/file-icons/pierre_c.png new file mode 100644 index 0000000000000000000000000000000000000000..adc17802ef1dda47f0b715be68905dc28b75ac11 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_c.png differ diff --git a/apps/mobile/assets/file-icons/pierre_claude.png b/apps/mobile/assets/file-icons/pierre_claude.png new file mode 100644 index 0000000000000000000000000000000000000000..90117bf0e5e54c079790e586c0771645843987b5 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_claude.png differ diff --git a/apps/mobile/assets/file-icons/pierre_cpp.png b/apps/mobile/assets/file-icons/pierre_cpp.png new file mode 100644 index 0000000000000000000000000000000000000000..adc17802ef1dda47f0b715be68905dc28b75ac11 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_cpp.png differ diff --git a/apps/mobile/assets/file-icons/pierre_css.png b/apps/mobile/assets/file-icons/pierre_css.png new file mode 100644 index 0000000000000000000000000000000000000000..0e15d2f96afb7101639478dd7170c3ca8c3898e3 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_css.png differ diff --git a/apps/mobile/assets/file-icons/pierre_database.png b/apps/mobile/assets/file-icons/pierre_database.png new file mode 100644 index 0000000000000000000000000000000000000000..19b31f09a1f7d6d376ad00fe2cb1af6ce784d26f Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_database.png differ diff --git a/apps/mobile/assets/file-icons/pierre_default.png b/apps/mobile/assets/file-icons/pierre_default.png new file mode 100644 index 0000000000000000000000000000000000000000..06bc23496a10f88821017354f53d7c3f1049e95d Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_default.png differ diff --git a/apps/mobile/assets/file-icons/pierre_docker.png b/apps/mobile/assets/file-icons/pierre_docker.png new file mode 100644 index 0000000000000000000000000000000000000000..66e899f28ebb981d193dbe0f9a2b3bf5707f633c Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_docker.png differ diff --git a/apps/mobile/assets/file-icons/pierre_eslint.png b/apps/mobile/assets/file-icons/pierre_eslint.png new file mode 100644 index 0000000000000000000000000000000000000000..e6a14ca533c78dfbe91b07c0566206b21baf646f Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_eslint.png differ diff --git a/apps/mobile/assets/file-icons/pierre_font.png b/apps/mobile/assets/file-icons/pierre_font.png new file mode 100644 index 0000000000000000000000000000000000000000..4bd7f4a45d5ccda7424ffca7b1da2c114c219a37 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_font.png differ diff --git a/apps/mobile/assets/file-icons/pierre_git.png b/apps/mobile/assets/file-icons/pierre_git.png new file mode 100644 index 0000000000000000000000000000000000000000..1efb11a3703ae8629b3ba97f0afacea3d28243d1 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_git.png differ diff --git a/apps/mobile/assets/file-icons/pierre_go.png b/apps/mobile/assets/file-icons/pierre_go.png new file mode 100644 index 0000000000000000000000000000000000000000..98fc3adcb9bede87d8947279c0b6f6530ed78ba5 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_go.png differ diff --git a/apps/mobile/assets/file-icons/pierre_graphql.png b/apps/mobile/assets/file-icons/pierre_graphql.png new file mode 100644 index 0000000000000000000000000000000000000000..3f2c97ea1ac5525fdbf95faf5a61bdfff7c8940a Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_graphql.png differ diff --git a/apps/mobile/assets/file-icons/pierre_html.png b/apps/mobile/assets/file-icons/pierre_html.png new file mode 100644 index 0000000000000000000000000000000000000000..2e63e9c485d5eca86d481bf6696d3a3f44bda119 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_html.png differ diff --git a/apps/mobile/assets/file-icons/pierre_image.png b/apps/mobile/assets/file-icons/pierre_image.png new file mode 100644 index 0000000000000000000000000000000000000000..9f6be84d09dc7747fd7a58bd00fa9e9427004f41 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_image.png differ diff --git a/apps/mobile/assets/file-icons/pierre_javascript.png b/apps/mobile/assets/file-icons/pierre_javascript.png new file mode 100644 index 0000000000000000000000000000000000000000..827d1feca36ea933f66c643d2a20bc7c3627c587 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_javascript.png differ diff --git a/apps/mobile/assets/file-icons/pierre_json.png b/apps/mobile/assets/file-icons/pierre_json.png new file mode 100644 index 0000000000000000000000000000000000000000..b2c4b3dcd89ca973318599a6d8aab4264d771aef Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_json.png differ diff --git a/apps/mobile/assets/file-icons/pierre_markdown.png b/apps/mobile/assets/file-icons/pierre_markdown.png new file mode 100644 index 0000000000000000000000000000000000000000..8742ea19308f60aa7bdaf761544bab65477a02e4 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_markdown.png differ diff --git a/apps/mobile/assets/file-icons/pierre_mcp.png b/apps/mobile/assets/file-icons/pierre_mcp.png new file mode 100644 index 0000000000000000000000000000000000000000..b31a9473235e2169d4be11ee1fc5200a9bcb3cc5 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_mcp.png differ diff --git a/apps/mobile/assets/file-icons/pierre_nextjs.png b/apps/mobile/assets/file-icons/pierre_nextjs.png new file mode 100644 index 0000000000000000000000000000000000000000..2fb339d4f97316c65a53e63c382d2e3339e94107 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_nextjs.png differ diff --git a/apps/mobile/assets/file-icons/pierre_npm.png b/apps/mobile/assets/file-icons/pierre_npm.png new file mode 100644 index 0000000000000000000000000000000000000000..070802b308af22fde01f7955636243031ab09193 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_npm.png differ diff --git a/apps/mobile/assets/file-icons/pierre_oxc.png b/apps/mobile/assets/file-icons/pierre_oxc.png new file mode 100644 index 0000000000000000000000000000000000000000..8353d6b7e4b275eb13c97d67aa784d70fd84a1f6 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_oxc.png differ diff --git a/apps/mobile/assets/file-icons/pierre_pnpm.png b/apps/mobile/assets/file-icons/pierre_pnpm.png new file mode 100644 index 0000000000000000000000000000000000000000..f5bde9929ff821c01f5876018d9c48a6561e377d Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_pnpm.png differ diff --git a/apps/mobile/assets/file-icons/pierre_postcss.png b/apps/mobile/assets/file-icons/pierre_postcss.png new file mode 100644 index 0000000000000000000000000000000000000000..856e70ac4415f3348db2ec816b0d60ba5c6ddfb5 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_postcss.png differ diff --git a/apps/mobile/assets/file-icons/pierre_prettier.png b/apps/mobile/assets/file-icons/pierre_prettier.png new file mode 100644 index 0000000000000000000000000000000000000000..cf805c1602c029dd30ac6b5602591e82dcad73a9 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_prettier.png differ diff --git a/apps/mobile/assets/file-icons/pierre_python.png b/apps/mobile/assets/file-icons/pierre_python.png new file mode 100644 index 0000000000000000000000000000000000000000..ae577548b71dc95930b69c21fdf6bc4f1685d84f Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_python.png differ diff --git a/apps/mobile/assets/file-icons/pierre_react.png b/apps/mobile/assets/file-icons/pierre_react.png new file mode 100644 index 0000000000000000000000000000000000000000..76e085bf60fa583b31f27e127c45dc56539884e7 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_react.png differ diff --git a/apps/mobile/assets/file-icons/pierre_ruby.png b/apps/mobile/assets/file-icons/pierre_ruby.png new file mode 100644 index 0000000000000000000000000000000000000000..93160389d9e7ad23e8488d1cc6cc034ef2463b67 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_ruby.png differ diff --git a/apps/mobile/assets/file-icons/pierre_rust.png b/apps/mobile/assets/file-icons/pierre_rust.png new file mode 100644 index 0000000000000000000000000000000000000000..b3de20632f201b7fa1d687fbed34e2c75c0237ef Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_rust.png differ diff --git a/apps/mobile/assets/file-icons/pierre_sass.png b/apps/mobile/assets/file-icons/pierre_sass.png new file mode 100644 index 0000000000000000000000000000000000000000..194bd8e456a946c92dacb7a0e6d9ee3b6b674a8b Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_sass.png differ diff --git a/apps/mobile/assets/file-icons/pierre_stylelint.png b/apps/mobile/assets/file-icons/pierre_stylelint.png new file mode 100644 index 0000000000000000000000000000000000000000..e0951fb7ad2a347ede22e57f6fb87edd9b746d28 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_stylelint.png differ diff --git a/apps/mobile/assets/file-icons/pierre_svelte.png b/apps/mobile/assets/file-icons/pierre_svelte.png new file mode 100644 index 0000000000000000000000000000000000000000..08381b8325b8ea054b0c11e827b5a9d81227f186 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_svelte.png differ diff --git a/apps/mobile/assets/file-icons/pierre_svg.png b/apps/mobile/assets/file-icons/pierre_svg.png new file mode 100644 index 0000000000000000000000000000000000000000..5c00f0e1887da8a0d55ab49e20729e98e1ed50b8 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_svg.png differ diff --git a/apps/mobile/assets/file-icons/pierre_svgo.png b/apps/mobile/assets/file-icons/pierre_svgo.png new file mode 100644 index 0000000000000000000000000000000000000000..77cbe960e551d7ff8f412b6648abf7db94a122b2 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_svgo.png differ diff --git a/apps/mobile/assets/file-icons/pierre_swift.png b/apps/mobile/assets/file-icons/pierre_swift.png new file mode 100644 index 0000000000000000000000000000000000000000..2bbd936a2d5aa69240eae8a14dd63e738665f2f7 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_swift.png differ diff --git a/apps/mobile/assets/file-icons/pierre_table.png b/apps/mobile/assets/file-icons/pierre_table.png new file mode 100644 index 0000000000000000000000000000000000000000..089b2091c98e7497836b575938209c7f5a3e1177 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_table.png differ diff --git a/apps/mobile/assets/file-icons/pierre_tailwind.png b/apps/mobile/assets/file-icons/pierre_tailwind.png new file mode 100644 index 0000000000000000000000000000000000000000..3a817538888dde8a43916807e241901bcec06e47 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_tailwind.png differ diff --git a/apps/mobile/assets/file-icons/pierre_terraform.png b/apps/mobile/assets/file-icons/pierre_terraform.png new file mode 100644 index 0000000000000000000000000000000000000000..52f29bc3a6201abaa35aa5cebe9d104228ff4fc0 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_terraform.png differ diff --git a/apps/mobile/assets/file-icons/pierre_text.png b/apps/mobile/assets/file-icons/pierre_text.png new file mode 100644 index 0000000000000000000000000000000000000000..adc79f280a48b37fdc23ed06110f5d48e3104825 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_text.png differ diff --git a/apps/mobile/assets/file-icons/pierre_typescript.png b/apps/mobile/assets/file-icons/pierre_typescript.png new file mode 100644 index 0000000000000000000000000000000000000000..006ac67149cfe5aa50b66b98e6e180fcfc90c40c Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_typescript.png differ diff --git a/apps/mobile/assets/file-icons/pierre_video.png b/apps/mobile/assets/file-icons/pierre_video.png new file mode 100644 index 0000000000000000000000000000000000000000..673c95d8b36a84ea316c04840d0018b0d3101175 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_video.png differ diff --git a/apps/mobile/assets/file-icons/pierre_vite.png b/apps/mobile/assets/file-icons/pierre_vite.png new file mode 100644 index 0000000000000000000000000000000000000000..7f3ac3015451d5201550f3d07a198b81701b9e12 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_vite.png differ diff --git a/apps/mobile/assets/file-icons/pierre_vscode.png b/apps/mobile/assets/file-icons/pierre_vscode.png new file mode 100644 index 0000000000000000000000000000000000000000..cce8c108a6ee85f2dbd6d3c9dd437ad9ee8704fd Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_vscode.png differ diff --git a/apps/mobile/assets/file-icons/pierre_vue.png b/apps/mobile/assets/file-icons/pierre_vue.png new file mode 100644 index 0000000000000000000000000000000000000000..252d22784966da1fe43d7431387ee1491579174a Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_vue.png differ diff --git a/apps/mobile/assets/file-icons/pierre_wasm.png b/apps/mobile/assets/file-icons/pierre_wasm.png new file mode 100644 index 0000000000000000000000000000000000000000..167002a94db9232f048bd875ef27dd63942ef728 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_wasm.png differ diff --git a/apps/mobile/assets/file-icons/pierre_webpack.png b/apps/mobile/assets/file-icons/pierre_webpack.png new file mode 100644 index 0000000000000000000000000000000000000000..838d008ff805627f0c1721d078eb1c93f07d7d12 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_webpack.png differ diff --git a/apps/mobile/assets/file-icons/pierre_yml.png b/apps/mobile/assets/file-icons/pierre_yml.png new file mode 100644 index 0000000000000000000000000000000000000000..09f01e445c7f8a98f36e34ac608d906b10e87da1 Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_yml.png differ diff --git a/apps/mobile/assets/file-icons/pierre_zig.png b/apps/mobile/assets/file-icons/pierre_zig.png new file mode 100644 index 0000000000000000000000000000000000000000..64b10efb9aaf174831924f7737502033b719341e Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_zig.png differ diff --git a/apps/mobile/assets/file-icons/pierre_zip.png b/apps/mobile/assets/file-icons/pierre_zip.png new file mode 100644 index 0000000000000000000000000000000000000000..4f668ea490f10c8f5d8118150e7a3f4f5b448dac Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_zip.png differ diff --git a/apps/mobile/assets/link-icons/github.png b/apps/mobile/assets/link-icons/github.png new file mode 100644 index 0000000000000000000000000000000000000000..87eab9f5218ffce249bd6019b59b55d9106f44f5 Binary files /dev/null and b/apps/mobile/assets/link-icons/github.png differ diff --git a/apps/mobile/deps/README.md b/apps/mobile/deps/README.md new file mode 100644 index 0000000000000000000000000000000000000000..6581b46dd09f73a67b3922aa3095ca0068a7bdff --- /dev/null +++ b/apps/mobile/deps/README.md @@ -0,0 +1,3 @@ +The Enriched Markdown tarball comes from the [`fork/t3-mobile` branch](https://github.com/juliusmarminge/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. File/skill link recognition, back navigation from horizontal blocks, table-image presses, and code-block headers use upstream implementations. The remaining fork adds clipboard metadata, parsed asset manifests, signed image source resolution, React media slots, and development-host autolinks. + +To replace it, commit the fork changes, run `yarn install --immutable` and `yarn workspace react-native-enriched-markdown prepare` in the fork checkout, then `node scripts/pack-t3.mjs /path/to/t3code/apps/mobile/deps`. Bump the fork package version for each replacement, update the tarball path in `apps/mobile/package.json` and `pnpm-workspace.yaml`'s `allowBuilds`, and run `vp i` to update the lockfile. Native clients need rebuilding when this dependency changes. diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz new file mode 100644 index 0000000000000000000000000000000000000000..ebcf848dc039df35cba4b6f2a94e07815f621240 Binary files /dev/null and b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz differ diff --git a/apps/mobile/modules/t3-composer-editor/android/build.gradle b/apps/mobile/modules/t3-composer-editor/android/build.gradle index 0a3e7ed7e17b17bcd122e030b639ec0cf2ef45d4..9425c8f7de36bccee5f953a5c8ab68bfab9560de 100644 --- a/apps/mobile/modules/t3-composer-editor/android/build.gradle +++ b/apps/mobile/modules/t3-composer-editor/android/build.gradle @@ -20,7 +20,6 @@ android { dependencies { implementation project(':expo-modules-core') - implementation project(':t3tools-mobile-markdown-text') testImplementation 'junit:junit:4.13.2' testImplementation 'org.robolectric:robolectric:4.16.1' } diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 1f0da6e444deae282a80e263bb7372a6c4d02b97..ca2426f8c023fef4e9d899763ea7be8b5cbedd25 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -29,7 +29,6 @@ import android.widget.EditText import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView -import expo.modules.t3markdowntext.T3ContextChip import org.json.JSONObject import kotlin.math.max diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt new file mode 100644 index 0000000000000000000000000000000000000000..d4f8312b72b3becab2483d70af699210c3017536 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt @@ -0,0 +1,184 @@ +package expo.modules.t3composereditor + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.Typeface +import android.text.TextPaint +import android.text.TextUtils +import kotlin.math.ceil +import kotlin.math.min + +/** Shared by editable spans and inline chat images so their metrics and colors agree. */ +class T3ContextChip( + content: Content, + fontSize: Float, + colors: Colors, + maximumWidth: Float, + private val density: Float +) { + /** What the chip says: its name, the size beside it, and the glyph that leads it. */ + data class Content(val label: String, val symbol: String, val detail: String = "") + + data class Colors(val accent: Int, val foreground: Int, val border: Int) + + private val symbol = content.symbol + + private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = fontSize + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + } + private val em = fontSize + + // The size reads as metadata beside the name, so it draws a step down from the label the + // way the web chip does. + private val detailPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = fontSize * 0.84f + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + } + private val detailText = if (content.detail.isEmpty()) "" else " ${content.detail}" + private val detailWidth = if (detailText.isEmpty()) 0f else detailPaint.measureText(detailText) + + // The border is stroked, and a stroke straddles the path it follows, so the box has to + // reserve a stroke on every side it insets. Reserving it once left the right edge a + // stroke short, and antialiasing shaved that curve flat against the span bounds. + private val strokeWidth = density + private val inset = strokeWidth * 2f + val width = ceil( + min( + maximumWidth.coerceAtLeast(em * 3), + paint.measureText(content.label) + detailWidth + em * 2.5f + inset + ) + ) + val height = ceil(em * 1.41f + inset) + private val text = TextUtils.ellipsize( + content.label, + paint, + (width - em * 2.5f - inset - detailWidth).coerceAtLeast(0f), + TextUtils.TruncateAt.MIDDLE + ).toString() + private val fill = Color.argb( + 28, + Color.red(colors.accent), + Color.green(colors.accent), + Color.blue(colors.accent) + ) + private val textColor = blend(colors.accent, colors.foreground, 0.22f) + private val borderColor = blend(colors.accent, colors.border, 0.34f) + + // Half a stroke keeps the border inside the box; the rest of the reserved margin is + // slack, so an antialiased edge fades out before it reaches the span bounds. + private val shape = + RectF(inset / 2f, inset / 2f, width - inset / 2f, height - inset / 2f) + private val icon = iconPath(symbol) + + fun draw(canvas: Canvas, x: Float, y: Float) { + canvas.save() + canvas.translate(x, y) + paint.style = Paint.Style.FILL + paint.color = fill + canvas.drawRoundRect(shape, em / 2, em / 2, paint) + paint.style = Paint.Style.STROKE + paint.strokeWidth = strokeWidth + paint.color = borderColor + canvas.drawRoundRect(shape, em / 2, em / 2, paint) + paint.color = textColor + canvas.save() + val iconSize = em * 1.17f + canvas.translate(em / 2 + inset / 2f, (height - iconSize) / 2) + canvas.scale(iconSize / 24, iconSize / 24) + paint.strokeWidth = 1.7f + paint.strokeJoin = Paint.Join.ROUND + paint.strokeCap = Paint.Cap.ROUND + canvas.drawPath(icon, paint) + canvas.restore() + paint.style = Paint.Style.FILL + val metrics = paint.fontMetrics + val baseline = (height - metrics.descent - metrics.ascent) / 2 + canvas.drawText(text, em * 2 + inset / 2f, baseline, paint) + if (detailText.isNotEmpty()) { + detailPaint.color = paint.color + canvas.drawText( + detailText, + em * 2 + inset / 2f + paint.measureText(text), + baseline, + detailPaint + ) + } + canvas.restore() + } + + companion object { + fun color( + value: String, + fallback: Int + ): Int = runCatching { + // React Native hex colors put alpha last; Android's parser puts it first. + val androidColor = if (value.length == 9 && value.startsWith("#")) { + "#${value.takeLast(2)}${value.substring(1, 7)}" + } else { + value + } + Color.parseColor(androidColor) + }.getOrDefault(fallback) + + private fun blend(accent: Int, base: Int, weight: Float): Int = Color.rgb( + (Color.red(accent) * weight + Color.red(base) * (1 - weight)).toInt(), + (Color.green(accent) * weight + Color.green(base) * (1 - weight)).toInt(), + (Color.blue(accent) * weight + Color.blue(base) * (1 - weight)).toInt(), + ) + + private fun iconPath(symbol: String) = Path().apply { + fun line(vararg points: Float) { + moveTo(points[0], points[1]) + for (index in 2 until points.size step 2) lineTo(points[index], points[index + 1]) + } + when (symbol) { + "cube" -> { + line(12f, 2f, 21f, 7f, 21f, 17f, 12f, 22f, 3f, 17f, 3f, 7f, 12f, 2f) + line(3f, 7f, 12f, 12f, 21f, 7f) + line(12f, 12f, 12f, 22f) + } + // lucide `git-pull-request`, the glyph web draws: two nodes, an elbow, and a stem. + "git-pull-request" -> { + addCircle(18f, 18f, 3f, Path.Direction.CW) + addCircle(6f, 6f, 3f, Path.Direction.CW) + moveTo(13f, 6f) + lineTo(16f, 6f) + cubicTo(17.1f, 6f, 18f, 6.9f, 18f, 8f) + lineTo(18f, 15f) + line(6f, 9f, 6f, 21f) + } + "cursorarrow.click" -> { + line(4f, 3f, 19f, 12f, 12f, 14f, 9f, 21f, 4f, 3f) + line(13f, 15f, 18f, 21f) + } + "text.bubble" -> { + line(3f, 4f, 21f, 4f, 21f, 17f, 10f, 17f, 5f, 21f, 5f, 17f, 3f, 17f, 3f, 4f) + line(7f, 8f, 17f, 8f) + line(7f, 12f, 14f, 12f) + } + "terminal", "play.rectangle", "photo" -> { + addRoundRect(2f, 4f, 22f, 20f, 2f, 2f, Path.Direction.CW) + when (symbol) { + "terminal" -> { + line(6f, 8f, 10f, 12f, 6f, 16f) + line(13f, 16f, 18f, 16f) + } + "play.rectangle" -> line(9f, 8f, 16f, 12f, 9f, 16f, 9f, 8f) + else -> { + addCircle(8f, 9f, 1.5f, Path.Direction.CW) + line(3f, 18f, 11f, 12f, 15f, 15f, 18f, 12f, 21f, 16f) + } + } + } + else -> { + line(5f, 2f, 14f, 2f, 20f, 8f, 20f, 22f, 5f, 22f, 5f, 2f) + line(14f, 2f, 14f, 8f, 20f, 8f) + } + } + } + } +} diff --git a/apps/mobile/modules/t3-markdown-text/LICENSE b/apps/mobile/modules/t3-markdown-text/LICENSE deleted file mode 100644 index 9aa27cb649d9a4c09a3a3fac60af7cd947297b2f..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -MIT License - -Copyright (c) 2024-25 Bluesky PBC -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec b/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec deleted file mode 100644 index 0ac471faf24d96fa8485211ccc2a68db23725706..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec +++ /dev/null @@ -1,25 +0,0 @@ -require "json" - -package = JSON.parse(File.read(File.join(__dir__, "package.json"))) -new_arch_enabled = ENV["RCT_NEW_ARCH_ENABLED"] == "1" - -Pod::Spec.new do |s| - s.name = "T3MarkdownText" - s.version = package["version"] - s.summary = "Native selectable markdown renderer for T3 Code mobile." - s.description = "Fabric-backed attributed text and markdown rendering primitives owned by T3 Code." - s.homepage = "https://t3tools.com" - s.license = { :type => "MIT", :file => "LICENSE" } - s.author = { "T3 Tools" => "hello@t3tools.com" } - s.platforms = { :ios => min_ios_version_supported } - s.source = { :path => "." } - s.source_files = "ios/**/*.{h,m,mm,cpp}" - - install_modules_dependencies(s) - - if ENV["USE_FRAMEWORKS"] != nil && new_arch_enabled - add_dependency(s, "React-FabricComponents", :additional_framework_paths => [ - "react/renderer/textlayoutmanager/platform/ios", - ]) - end -end diff --git a/apps/mobile/modules/t3-markdown-text/UPSTREAM.md b/apps/mobile/modules/t3-markdown-text/UPSTREAM.md deleted file mode 100644 index 0ddc7775a9ece0d56147ce19ed56bf2702c882f2..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/UPSTREAM.md +++ /dev/null @@ -1,12 +0,0 @@ -# Upstream Attribution - -The Fabric attributed-text component in this module originated from -[`bluesky-social/react-native-uitextview`](https://github.com/bluesky-social/react-native-uitextview), -version `2.2.0`, commit `addc08fea303608f070fe1eeba4bc075f181c4af`. - -The upstream project is Copyright (c) 2024-25 Bluesky PBC and licensed under -the MIT License included in this directory. - -T3 Code has substantially modified and renamed the implementation, integrated -its markdown renderer, and owns the resulting module going forward. This is not -an upstream package dependency or a compatibility fork. diff --git a/apps/mobile/modules/t3-markdown-text/android/build.gradle b/apps/mobile/modules/t3-markdown-text/android/build.gradle deleted file mode 100644 index 5b7e372006f307cd554d8ca7260ea807762a4f68..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/android/build.gradle +++ /dev/null @@ -1,32 +0,0 @@ -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 - - testOptions { - unitTests.includeAndroidResources = true - } - - defaultConfig { - minSdkVersion rootProject.ext.minSdkVersion - targetSdkVersion rootProject.ext.targetSdkVersion - } -} - -dependencies { - implementation project(':expo-modules-core') - implementation 'com.facebook.react:react-android' - testImplementation 'junit:junit:4.13.2' - testImplementation 'org.robolectric:robolectric:4.16.1' -} - -tasks.withType(Test).configureEach { - javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(21) - } -} diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3ContextChip.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3ContextChip.kt deleted file mode 100644 index df0dd88724e07b07d8feed47231bd8501bff3059..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3ContextChip.kt +++ /dev/null @@ -1,184 +0,0 @@ -package expo.modules.t3markdowntext - -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.Path -import android.graphics.RectF -import android.graphics.Typeface -import android.text.TextPaint -import android.text.TextUtils -import kotlin.math.ceil -import kotlin.math.min - -/** Shared by editable spans and inline chat images so their metrics and colors agree. */ -class T3ContextChip( - content: Content, - fontSize: Float, - colors: Colors, - maximumWidth: Float, - private val density: Float -) { - /** What the chip says: its name, the size beside it, and the glyph that leads it. */ - data class Content(val label: String, val symbol: String, val detail: String = "") - - data class Colors(val accent: Int, val foreground: Int, val border: Int) - - private val symbol = content.symbol - - private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { - textSize = fontSize - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - } - private val em = fontSize - - // The size reads as metadata beside the name, so it draws a step down from the label the - // way the web chip does. - private val detailPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { - textSize = fontSize * 0.84f - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - } - private val detailText = if (content.detail.isEmpty()) "" else " ${content.detail}" - private val detailWidth = if (detailText.isEmpty()) 0f else detailPaint.measureText(detailText) - - // The border is stroked, and a stroke straddles the path it follows, so the box has to - // reserve a stroke on every side it insets. Reserving it once left the right edge a - // stroke short, and antialiasing shaved that curve flat against the span bounds. - private val strokeWidth = density - private val inset = strokeWidth * 2f - val width = ceil( - min( - maximumWidth.coerceAtLeast(em * 3), - paint.measureText(content.label) + detailWidth + em * 2.5f + inset - ) - ) - val height = ceil(em * 1.41f + inset) - private val text = TextUtils.ellipsize( - content.label, - paint, - (width - em * 2.5f - inset - detailWidth).coerceAtLeast(0f), - TextUtils.TruncateAt.MIDDLE - ).toString() - private val fill = Color.argb( - 28, - Color.red(colors.accent), - Color.green(colors.accent), - Color.blue(colors.accent) - ) - private val textColor = blend(colors.accent, colors.foreground, 0.22f) - private val borderColor = blend(colors.accent, colors.border, 0.34f) - - // Half a stroke keeps the border inside the box; the rest of the reserved margin is - // slack, so an antialiased edge fades out before it reaches the span bounds. - private val shape = - RectF(inset / 2f, inset / 2f, width - inset / 2f, height - inset / 2f) - private val icon = iconPath(symbol) - - fun draw(canvas: Canvas, x: Float, y: Float) { - canvas.save() - canvas.translate(x, y) - paint.style = Paint.Style.FILL - paint.color = fill - canvas.drawRoundRect(shape, em / 2, em / 2, paint) - paint.style = Paint.Style.STROKE - paint.strokeWidth = strokeWidth - paint.color = borderColor - canvas.drawRoundRect(shape, em / 2, em / 2, paint) - paint.color = textColor - canvas.save() - val iconSize = em * 1.17f - canvas.translate(em / 2 + inset / 2f, (height - iconSize) / 2) - canvas.scale(iconSize / 24, iconSize / 24) - paint.strokeWidth = 1.7f - paint.strokeJoin = Paint.Join.ROUND - paint.strokeCap = Paint.Cap.ROUND - canvas.drawPath(icon, paint) - canvas.restore() - paint.style = Paint.Style.FILL - val metrics = paint.fontMetrics - val baseline = (height - metrics.descent - metrics.ascent) / 2 - canvas.drawText(text, em * 2 + inset / 2f, baseline, paint) - if (detailText.isNotEmpty()) { - detailPaint.color = paint.color - canvas.drawText( - detailText, - em * 2 + inset / 2f + paint.measureText(text), - baseline, - detailPaint - ) - } - canvas.restore() - } - - companion object { - fun color( - value: String, - fallback: Int - ): Int = runCatching { - // React Native hex colors put alpha last; Android's parser puts it first. - val androidColor = if (value.length == 9 && value.startsWith("#")) { - "#${value.takeLast(2)}${value.substring(1, 7)}" - } else { - value - } - Color.parseColor(androidColor) - }.getOrDefault(fallback) - - private fun blend(accent: Int, base: Int, weight: Float): Int = Color.rgb( - (Color.red(accent) * weight + Color.red(base) * (1 - weight)).toInt(), - (Color.green(accent) * weight + Color.green(base) * (1 - weight)).toInt(), - (Color.blue(accent) * weight + Color.blue(base) * (1 - weight)).toInt(), - ) - - private fun iconPath(symbol: String) = Path().apply { - fun line(vararg points: Float) { - moveTo(points[0], points[1]) - for (index in 2 until points.size step 2) lineTo(points[index], points[index + 1]) - } - when (symbol) { - "cube" -> { - line(12f, 2f, 21f, 7f, 21f, 17f, 12f, 22f, 3f, 17f, 3f, 7f, 12f, 2f) - line(3f, 7f, 12f, 12f, 21f, 7f) - line(12f, 12f, 12f, 22f) - } - // lucide `git-pull-request`, the glyph web draws: two nodes, an elbow, and a stem. - "git-pull-request" -> { - addCircle(18f, 18f, 3f, Path.Direction.CW) - addCircle(6f, 6f, 3f, Path.Direction.CW) - moveTo(13f, 6f) - lineTo(16f, 6f) - cubicTo(17.1f, 6f, 18f, 6.9f, 18f, 8f) - lineTo(18f, 15f) - line(6f, 9f, 6f, 21f) - } - "cursorarrow.click" -> { - line(4f, 3f, 19f, 12f, 12f, 14f, 9f, 21f, 4f, 3f) - line(13f, 15f, 18f, 21f) - } - "text.bubble" -> { - line(3f, 4f, 21f, 4f, 21f, 17f, 10f, 17f, 5f, 21f, 5f, 17f, 3f, 17f, 3f, 4f) - line(7f, 8f, 17f, 8f) - line(7f, 12f, 14f, 12f) - } - "terminal", "play.rectangle", "photo" -> { - addRoundRect(2f, 4f, 22f, 20f, 2f, 2f, Path.Direction.CW) - when (symbol) { - "terminal" -> { - line(6f, 8f, 10f, 12f, 6f, 16f) - line(13f, 16f, 18f, 16f) - } - "play.rectangle" -> line(9f, 8f, 16f, 12f, 9f, 16f, 9f, 8f) - else -> { - addCircle(8f, 9f, 1.5f, Path.Direction.CW) - line(3f, 18f, 11f, 12f, 15f, 15f, 18f, 12f, 21f, 16f) - } - } - } - else -> { - line(5f, 2f, 14f, 2f, 20f, 8f, 20f, 22f, 5f, 22f, 5f, 2f) - line(14f, 2f, 14f, 8f, 20f, 8f) - } - } - } - } -} diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt deleted file mode 100644 index bca3c6430304115473b39860a78086940519a336..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt +++ /dev/null @@ -1,302 +0,0 @@ -package expo.modules.t3markdowntext - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.Typeface -import android.os.Build -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.Spanned -import android.text.TextPaint -import android.text.style.ReplacementSpan -import android.view.ActionMode -import android.view.Menu -import android.view.MenuItem -import android.widget.TextView -import android.util.Base64 -import android.util.LruCache -import com.facebook.react.bridge.ReactContext -import com.facebook.react.common.assets.ReactFontManager -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 -import org.json.JSONObject -import org.json.JSONArray -import java.net.URLEncoder -import java.io.ByteArrayOutputStream -import kotlin.math.ceil - -private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC" - -// Match React Native's measurement buffer. Android orders tied line-height -// spans differently in SpannableString, shifting inline images once RN's -// span priorities are exhausted. -private object MarkdownSpannableFactory : Spannable.Factory() { - override fun newSpannable(source: CharSequence): Spannable = - SpannableStringBuilder(source) -} - -internal fun copyTextWithoutInlineImages( - text: CharSequence, - start: Int, - end: Int -): String { - if (text !is Spanned) return text.subSequence(start, end).toString() - - fun isInlineImage(index: Int): Boolean = - index >= 0 && text[index].toString() == OBJECT_REPLACEMENT_CHARACTER && - text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty() - - return buildString { - for (index in start until end) { - // The renderer inserts one NBSP after each image to keep its label on the same line. - // Inspect the original text even when selection starts after the image. - val isIconSpacer = text[index] == '\u00A0' && isInlineImage(index - 1) - if (!isInlineImage(index) && !isIconSpacer) append(text[index]) - } - } -} - -private fun canonicalSelection( - originalText: String, - start: Int, - end: Int, - ranges: JSONArray? -): String? { - if (ranges == null) return null - val canonical = StringBuilder(originalText) - var hasContext = false - for (index in ranges.length() - 1 downTo 0) { - val range = ranges.optJSONObject(index) ?: continue - val first = max(start, range.optInt("start")) - val last = min(end, range.optInt("end")) - if (last > first) { - canonical.replace(first - start, last - start, range.optString("text")) - hasContext = true - } - } - return if (hasContext) canonical.toString().replace(OBJECT_REPLACEMENT_CHARACTER, "") else null -} - -private fun selectedContextRecords(records: JSONArray, selectedText: String): JSONArray { - val ids = mutableSetOf() - for (index in 0 until records.length()) { - val record = records.getJSONObject(index) - if (selectedText.contains("/${record.optString("contextId")})")) { - ids.add(record.optString("contextId")) - if (record.has("screenshotContextId")) ids.add(record.getString("screenshotContextId")) - } - } - val copied = JSONArray() - for (index in 0 until records.length()) { - val record = records.getJSONObject(index) - if (ids.contains(record.optString("contextId"))) copied.put(record) - } - return copied -} - -private fun contextClipData(selectedText: String, fragment: String): ClipData { - val payload = runCatching { JSONObject(fragment) }.getOrNull() - val records = payload?.optJSONArray("records") - val copied = records?.let { selectedContextRecords(it, selectedText) } - if (payload == null || copied == null || copied.length() == 0) { - return ClipData.newPlainText(null, selectedText) - } - payload.put("records", copied) - val attribute = URLEncoder.encode(payload.toString(), "UTF-8").replace("+", "%20") - val escaped = selectedText.replace("&", "&").replace("<", "<").replace(">", ">") - return ClipData.newHtmlText( - null, - selectedText, - "
$escaped
" - ) -} - -private class SanitizingSelectionActionModeCallback( - private val textView: TextView, - private val delegate: ActionMode.Callback?, - var contextClipboardConfig: String -) : 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 && copySelection()) { - mode.finish() - return true - } - return delegate?.onActionItemClicked(mode, item) ?: false - } - - private fun copySelection(): Boolean { - val start = min(textView.selectionStart, textView.selectionEnd) - val end = max(textView.selectionStart, textView.selectionEnd) - if (start < 0 || end <= start) return false - val originalText = textView.text.subSequence(start, end).toString() - val config = runCatching { JSONObject(contextClipboardConfig) }.getOrNull() - val canonical = canonicalSelection(originalText, start, end, config?.optJSONArray("ranges")) - val selectedText = canonical ?: copyTextWithoutInlineImages(textView.text, start, end) - val handled = canonical != null || selectedText != originalText - if (handled) { - val clipboard = - textView.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = if (canonical != null) { - contextClipData(selectedText, config?.optString("fragment") ?: "") - } else { - ClipData.newPlainText(null, selectedText) - } - clipboard.setPrimaryClip(clip) - } - return handled - } - - override fun onDestroyActionMode(mode: ActionMode) { - delegate?.onDestroyActionMode(mode) - } -} - -internal fun applySelectionHandleColor(textView: TextView, color: Int) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return - textView.textSelectHandle?.mutate()?.apply { setTint(color) }?.let(textView::setTextSelectHandle) - textView.textSelectHandleLeft?.mutate()?.apply { - setTint(color) - }?.let(textView::setTextSelectHandleLeft) - textView.textSelectHandleRight?.mutate()?.apply { - setTint(color) - }?.let(textView::setTextSelectHandleRight) -} - -class T3MarkdownTextSelectionModule : Module() { - private val chipImages = LruCache>(128) - - /** - * Metrics of the paragraph font a chip sits in, so the inline box it reports can be sized - * from the same ascent and descent the surrounding text lays out with. - */ - private fun paragraphFontMetrics(text: JSONObject?, scale: Float): Paint.FontMetricsInt = - TextPaint(Paint.ANTI_ALIAS_FLAG).apply { - textSize = (text?.optDouble("fontSize", 15.0)?.toFloat() ?: 15f).coerceIn(6f, 80f) * scale - val fontFamily = text?.optString("fontFamily").orEmpty() - typeface = if (fontFamily.isEmpty()) { - Typeface.DEFAULT - } else { - ReactFontManager.getInstance() - .getTypeface(fontFamily, Typeface.NORMAL, appContext.reactContext?.assets) - } - }.fontMetricsInt - - private fun setSelectionHandleColor(reactTag: Int, color: Int) { - val reactContext = appContext.reactContext as? ReactContext ?: return - reactContext.runOnUiQueueThread { - val textView = runCatching { - UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag) - }.getOrNull() as? TextView ?: return@runOnUiQueueThread - applySelectionHandleColor(textView, color) - } - } - - override fun definition() = ModuleDefinition { - Name("T3MarkdownTextSelection") - - Function("setSelectionHandleColor") { reactTag: Int, color: Int -> - setSelectionHandleColor(reactTag, color) - } - - Function("renderContextChip") { payloadJson: String -> - val resources = appContext.reactContext?.resources ?: return@Function null - val metrics = resources.displayMetrics - val fontScale = resources.configuration.fontScale - val key = "${metrics.density}:$fontScale:${metrics.widthPixels}:$payloadJson" - chipImages.get(key)?.let { return@Function it } - val payload = JSONObject(payloadJson) - val chip = T3ContextChip( - content = T3ContextChip.Content( - label = payload.optString("label").take(4096), - symbol = payload.optString("symbol", "doc") - ), - // The line box around the chip is measured with `fontScale` below, so the chip has to - // carry it too, or it shrinks against the words beside it at a larger text size. - fontSize = - payload.optDouble("fontSize", 12.0).toFloat().coerceIn(10f, 40f) * - metrics.density * fontScale, - colors = T3ContextChip.Colors( - accent = T3ContextChip.color(payload.optString("accent"), Color.GRAY), - foreground = T3ContextChip.color(payload.optString("foreground"), Color.BLACK), - border = T3ContextChip.color(payload.optString("border"), Color.GRAY) - ), - maximumWidth = (metrics.widthPixels - 80 * metrics.density).coerceAtLeast(100f), - density = metrics.density, - ) - // toInt() truncates, so a fractional pixel of the chip would fall outside the bitmap - // and take the right-hand border with it. Round up: a spare column costs nothing. - // One spare pixel of transparency on each side. Whatever rounding happens between the - // bitmap's pixels and the box's dp then falls on padding instead of on the border. - val bleed = 1 - val bitmap = Bitmap.createBitmap( - ceil(chip.width).toInt() + bleed * 2, - ceil(chip.height).toInt() + bleed * 2, - Bitmap.Config.ARGB_8888 - ) - chip.draw(Canvas(bitmap), bleed.toFloat(), bleed.toFloat()) - val bytes = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes) - bitmap.recycle() - // React Native sits an inline view's box on the text baseline and grows the line's - // ascent to fit it, so a box as tall as the chip lifts the chip above the words and - // pushes the baseline down. Report a box no taller than the paragraph font's ascent, - // which leaves the line exactly as tall as a line of plain text, plus where inside - // that box the bitmap must sit so the chip centres on the font's ascent/descent box: - // the same rule the composer's ReplacementSpan uses to draw its chips. - val lineMetrics = paragraphFontMetrics( - payload.optJSONObject("text"), - fontScale * metrics.density, - ) - val ascent = -lineMetrics.ascent - val descent = lineMetrics.descent - val bitmapWidth = ceil(chip.width) + bleed * 2 - val bitmapHeight = ceil(chip.height) + bleed * 2 - val result = mapOf( - "uri" to - "data:image/png;base64,${Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP)}", - // Layout rounds dp back to whole pixels. Reporting a hair less than the bitmap lets - // that rounding land inside the image and crop its right-hand border, so round the - // box up: an extra fraction of a pixel is invisible, a missing border is not. - "width" to bitmapWidth / metrics.density, - "height" to bitmapHeight / metrics.density, - "boxHeight" to ascent / metrics.density, - "offsetY" to (ascent + descent - bitmapHeight) / 2f / metrics.density, - ) - chipImages.put(key, result) - result - } - - Function("installCopySanitizer") { reactTag: Int, contextClipboardConfig: String -> - 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) { - currentCallback.contextClipboardConfig = contextClipboardConfig - return@runOnUiQueueThread - } - textView.setSpannableFactory(MarkdownSpannableFactory) - textView.customSelectionActionModeCallback = - SanitizingSelectionActionModeCallback(textView, currentCallback, contextClipboardConfig) - } - } - } -} diff --git a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt deleted file mode 100644 index 81703090a3f08a92d0bf9e0cdd78dd7470866c03..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -package expo.modules.t3markdowntext - -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable -import android.widget.TextView -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [36], manifest = Config.NONE) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class MarkdownSelectionColorTest { - private fun textView() = TextView(RuntimeEnvironment.getApplication()).apply { - setTextSelectHandle(ColorDrawable(Color.WHITE)) - setTextSelectHandleLeft(ColorDrawable(Color.WHITE)) - setTextSelectHandleRight(ColorDrawable(Color.WHITE)) - } - - private fun renderedColor(drawable: Drawable?): Int { - requireNotNull(drawable) - val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888) - drawable.setBounds(0, 0, 4, 4) - drawable.draw(Canvas(bitmap)) - val color = bitmap.getPixel(2, 2) - bitmap.recycle() - return color - } - - @Test - fun retintsAllHandlesWhenTheThemeChangesWithoutChangingTheHighlight() { - val text = textView() - val highlight = 0x52FF0088 - text.highlightColor = highlight - - for (color in listOf(Color.MAGENTA, Color.GREEN, Color.MAGENTA)) { - applySelectionHandleColor(text, color) - assertEquals(color, renderedColor(text.textSelectHandle)) - assertEquals(color, renderedColor(text.textSelectHandleLeft)) - assertEquals(color, renderedColor(text.textSelectHandleRight)) - assertEquals(highlight, text.highlightColor) - } - } - - @Test - fun doesNotTintOtherTextViewsSharingDrawableState() { - val original = ColorDrawable(Color.WHITE) - val first = textView().apply { - setTextSelectHandleLeft(original.constantState!!.newDrawable()) - } - val second = textView().apply { - setTextSelectHandleLeft(original.constantState!!.newDrawable()) - } - - applySelectionHandleColor(first, Color.MAGENTA) - - assertEquals(Color.MAGENTA, renderedColor(first.textSelectHandleLeft)) - assertEquals(Color.WHITE, renderedColor(second.textSelectHandleLeft)) - } -} diff --git a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt deleted file mode 100644 index da9012ee166581cf049515e2b6f5ad19600eb77d..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package expo.modules.t3markdowntext - -import android.graphics.drawable.ColorDrawable -import android.text.SpannableString -import android.text.Spanned -import android.text.style.ImageSpan -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [36], manifest = Config.NONE) -class MarkdownSelectionCopyTest { - private fun withIcon(value: String): SpannableString = SpannableString(value).apply { - val index = value.indexOf('\uFFFC') - setSpan(ImageSpan(ColorDrawable()), index, index + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - } - - @Test - fun removesIconAndInjectedSpacer() { - val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.") - assertEquals("main.go:12 starts the server.", copyTextWithoutInlineImages(text, 0, text.length)) - } - - @Test - fun removesSpacerWhenSelectionStartsAfterIcon() { - val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.") - assertEquals("main.go:12", copyTextWithoutInlineImages(text, 1, 12)) - } - - @Test - fun preservesAuthoredWhitespaceAndLiteralObjectCharacters() { - val text = withIcon("before\u00A0 \uFFFC\u00A0\u00A0 main.go after\u00A0\uFFFC\u00A0") - assertEquals( - "before\u00A0 \u00A0 main.go after\u00A0\uFFFC\u00A0", - copyTextWithoutInlineImages(text, 0, text.length) - ) - } - - @Test - fun preservesTextWithoutImageSpans() { - val text = "\uFFFC\u00A0main.go" - assertEquals(text, copyTextWithoutInlineImages(text, 0, text.length)) - assertEquals(text, copyTextWithoutInlineImages(SpannableString(text), 0, text.length)) - } -} diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png deleted file mode 100644 index 4696e3fd37d9483796d1fbf661ec742c67611ebb..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png deleted file mode 100644 index 0d348cb0a2e93cc1984e5ed20310fd86c4125799..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png deleted file mode 100644 index 7481353a2596b2aa1891c66b7e06b03c7a9815d2..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png deleted file mode 100644 index da0441b96a484ca19861606b427adb1608a96c2b..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png deleted file mode 100644 index ba07d10d8fa9511ff0566300525fb05a9ab1fbd3..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png deleted file mode 100644 index 32a8b227598fb73836da61cc3e0de95a2c5eaff1..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png deleted file mode 100644 index 4b15e51c463eebb5790144798ca73f216b0b999c..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png deleted file mode 100644 index 7369c907148a05bc33406597344279eedfd52ad7..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png deleted file mode 100644 index adc17802ef1dda47f0b715be68905dc28b75ac11..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png deleted file mode 100644 index 90117bf0e5e54c079790e586c0771645843987b5..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png deleted file mode 100644 index adc17802ef1dda47f0b715be68905dc28b75ac11..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png deleted file mode 100644 index 0e15d2f96afb7101639478dd7170c3ca8c3898e3..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png deleted file mode 100644 index 19b31f09a1f7d6d376ad00fe2cb1af6ce784d26f..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png deleted file mode 100644 index 06bc23496a10f88821017354f53d7c3f1049e95d..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png deleted file mode 100644 index 66e899f28ebb981d193dbe0f9a2b3bf5707f633c..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png deleted file mode 100644 index e6a14ca533c78dfbe91b07c0566206b21baf646f..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png deleted file mode 100644 index 4bd7f4a45d5ccda7424ffca7b1da2c114c219a37..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png deleted file mode 100644 index 1efb11a3703ae8629b3ba97f0afacea3d28243d1..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png deleted file mode 100644 index 98fc3adcb9bede87d8947279c0b6f6530ed78ba5..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png deleted file mode 100644 index 3f2c97ea1ac5525fdbf95faf5a61bdfff7c8940a..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png deleted file mode 100644 index 2e63e9c485d5eca86d481bf6696d3a3f44bda119..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png deleted file mode 100644 index 9f6be84d09dc7747fd7a58bd00fa9e9427004f41..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png deleted file mode 100644 index 827d1feca36ea933f66c643d2a20bc7c3627c587..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png deleted file mode 100644 index b2c4b3dcd89ca973318599a6d8aab4264d771aef..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png deleted file mode 100644 index 8742ea19308f60aa7bdaf761544bab65477a02e4..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png deleted file mode 100644 index b31a9473235e2169d4be11ee1fc5200a9bcb3cc5..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png deleted file mode 100644 index 2fb339d4f97316c65a53e63c382d2e3339e94107..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png deleted file mode 100644 index 070802b308af22fde01f7955636243031ab09193..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png deleted file mode 100644 index 8353d6b7e4b275eb13c97d67aa784d70fd84a1f6..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png deleted file mode 100644 index f5bde9929ff821c01f5876018d9c48a6561e377d..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png deleted file mode 100644 index 856e70ac4415f3348db2ec816b0d60ba5c6ddfb5..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png deleted file mode 100644 index cf805c1602c029dd30ac6b5602591e82dcad73a9..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png deleted file mode 100644 index ae577548b71dc95930b69c21fdf6bc4f1685d84f..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png deleted file mode 100644 index 76e085bf60fa583b31f27e127c45dc56539884e7..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png deleted file mode 100644 index 93160389d9e7ad23e8488d1cc6cc034ef2463b67..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png deleted file mode 100644 index b3de20632f201b7fa1d687fbed34e2c75c0237ef..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png deleted file mode 100644 index 194bd8e456a946c92dacb7a0e6d9ee3b6b674a8b..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png deleted file mode 100644 index e0951fb7ad2a347ede22e57f6fb87edd9b746d28..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png deleted file mode 100644 index 08381b8325b8ea054b0c11e827b5a9d81227f186..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png deleted file mode 100644 index 5c00f0e1887da8a0d55ab49e20729e98e1ed50b8..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png deleted file mode 100644 index 77cbe960e551d7ff8f412b6648abf7db94a122b2..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png deleted file mode 100644 index 2bbd936a2d5aa69240eae8a14dd63e738665f2f7..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png deleted file mode 100644 index 089b2091c98e7497836b575938209c7f5a3e1177..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png deleted file mode 100644 index 3a817538888dde8a43916807e241901bcec06e47..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png deleted file mode 100644 index 52f29bc3a6201abaa35aa5cebe9d104228ff4fc0..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png deleted file mode 100644 index adc79f280a48b37fdc23ed06110f5d48e3104825..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png deleted file mode 100644 index 006ac67149cfe5aa50b66b98e6e180fcfc90c40c..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png deleted file mode 100644 index 673c95d8b36a84ea316c04840d0018b0d3101175..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png deleted file mode 100644 index 7f3ac3015451d5201550f3d07a198b81701b9e12..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png deleted file mode 100644 index cce8c108a6ee85f2dbd6d3c9dd437ad9ee8704fd..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png deleted file mode 100644 index 252d22784966da1fe43d7431387ee1491579174a..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png deleted file mode 100644 index 167002a94db9232f048bd875ef27dd63942ef728..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png deleted file mode 100644 index 838d008ff805627f0c1721d078eb1c93f07d7d12..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png deleted file mode 100644 index 09f01e445c7f8a98f36e34ac608d906b10e87da1..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png deleted file mode 100644 index 64b10efb9aaf174831924f7737502033b719341e..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png deleted file mode 100644 index 4f668ea490f10c8f5d8118150e7a3f4f5b448dac..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png b/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png deleted file mode 100644 index 87eab9f5218ffce249bd6019b59b55d9106f44f5..0000000000000000000000000000000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/expo-module.config.json b/apps/mobile/modules/t3-markdown-text/expo-module.config.json deleted file mode 100644 index f41f760ee7ca7202d19960cec2b974b6f39f75ab..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/expo-module.config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "platforms": ["android"], - "android": { - "modules": ["expo.modules.t3markdowntext.T3MarkdownTextSelectionModule"] - } -} diff --git a/apps/mobile/modules/t3-markdown-text/index.ts b/apps/mobile/modules/t3-markdown-text/index.ts deleted file mode 100644 index 81b5f13f28df7d771bc9bf43833a7f30dbf2eb66..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -export { markdownFileIconSource } from "./src/markdownFileIcons"; -export { - resolveMarkdownFileIcon, - resolveMarkdownLinkPresentation, - type MarkdownFileIcon, - type MarkdownLinkPresentation, -} from "./src/markdownLinks"; -export { - nativeMarkdownChunkSpacing, - nativeMarkdownDocumentChunks, - nativeMarkdownDocumentRuns, - nativeMarkdownListItemBlocks, - nativeMarkdownTextRuns, - type NativeMarkdownDocumentChunk, - type NativeMarkdownTextRun, -} from "./src/nativeMarkdownText"; -export { MarkdownTextPrimitive } from "./src/MarkdownTextPrimitive"; -export { - SelectableMarkdownText, - type MarkdownCodeHighlighter, - type MarkdownHighlightedToken, -} from "./src/SelectableMarkdownText"; -export type { - MarkdownFileContextMenu, - MarkdownFileContextMenuAction, - NativeMarkdownTextStyle, - SelectableMarkdownSkill, - SelectableMarkdownTextProps, -} from "./src/SelectableMarkdownText.types"; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h b/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h deleted file mode 100644 index 625b7ca97c7baf25dd84101ef1ad9ed2bddd17fd..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h +++ /dev/null @@ -1,162 +0,0 @@ -#pragma once - -#import - -// Used by both the shadow measurement and UITextView rendering paths. -static NSDictionary *T3ContextChipPayload(NSString *uri) -{ - if (![uri hasPrefix:@"chip:"]) return nil; - NSData *data = [[uri substringFromIndex:5] dataUsingEncoding:NSUTF8StringEncoding]; - id payload = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - if (![payload isKindOfClass:NSDictionary.class]) return nil; - // Every consumer draws or substitutes `label`; a missing or non-string one would raise inside - // `replaceCharactersInRange:withString:` while a message is rendering. - if (![payload[@"label"] isKindOfClass:NSString.class]) return nil; - return payload; -} - -static UIColor *T3ContextChipColor(NSString *hex) -{ - unsigned int rgb = 0; - if (![hex isKindOfClass:NSString.class] || hex.length != 7) return UIColor.labelColor; - [[NSScanner scannerWithString:[hex substringFromIndex:1]] scanHexInt:&rgb]; - return [UIColor colorWithRed:((rgb >> 16) & 255) / 255.0 - green:((rgb >> 8) & 255) / 255.0 - blue:(rgb & 255) / 255.0 alpha:1]; -} - -static UIColor *T3ContextChipBlend(UIColor *accent, UIColor *base, CGFloat weight) -{ - CGFloat ar = 0, ag = 0, ab = 0, aa = 0, br = 0, bg = 0, bb = 0, ba = 0; - [accent getRed:&ar green:&ag blue:&ab alpha:&aa]; - [base getRed:&br green:&bg blue:&bb alpha:&ba]; - return [UIColor colorWithRed:ar * weight + br * (1 - weight) - green:ag * weight + bg * (1 - weight) - blue:ab * weight + bb * (1 - weight) - alpha:aa * weight + ba * (1 - weight)]; -} - -// Some chip glyphs have no SF Symbol that reads correctly: the pull request one would land on -// `arrow.triangle.branch`, a road-sign fork that says "branch", not "pull request". Draw those -// from the same lucide geometry web and Android use so one chip looks alike on every surface. -static UIImage *T3ContextChipVectorIcon(NSString *symbol, CGFloat size, UIColor *color) -{ - if (![symbol isEqualToString:@"git-pull-request"]) return nil; - UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] - initWithSize:CGSizeMake(size, size)]; - return [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) { - CGFloat s = size / 24.0; // lucide authors on a 24pt grid. - UIBezierPath *path = [UIBezierPath bezierPath]; - [path appendPath:[UIBezierPath bezierPathWithArcCenter:CGPointMake(18 * s, 18 * s) - radius:3 * s startAngle:0 - endAngle:M_PI * 2 clockwise:YES]]; - [path appendPath:[UIBezierPath bezierPathWithArcCenter:CGPointMake(6 * s, 6 * s) - radius:3 * s startAngle:0 - endAngle:M_PI * 2 clockwise:YES]]; - [path moveToPoint:CGPointMake(13 * s, 6 * s)]; - [path addLineToPoint:CGPointMake(16 * s, 6 * s)]; - [path addCurveToPoint:CGPointMake(18 * s, 8 * s) - controlPoint1:CGPointMake(17.1 * s, 6 * s) - controlPoint2:CGPointMake(18 * s, 6.9 * s)]; - [path addLineToPoint:CGPointMake(18 * s, 15 * s)]; - [path moveToPoint:CGPointMake(6 * s, 9 * s)]; - [path addLineToPoint:CGPointMake(6 * s, 21 * s)]; - path.lineWidth = 2 * s; - path.lineCapStyle = kCGLineCapRound; - path.lineJoinStyle = kCGLineJoinRound; - [color setStroke]; - [path stroke]; - }]; -} - -// Centres the chip on the run font's ascent/descent box, the rule the composer span and -// Android use, so the chip lands in the same place beside the words on every surface. -static inline CGRect T3ContextChipBounds(UIFont *font, CGSize size) -{ - CGFloat y = font != nil ? (font.ascender + font.descender - size.height) / 2 : -3; - return CGRectMake(0, y, size.width, size.height); -} - -// A bare attachment string carries none of the run's attributes. Losing the paragraph -// style at a paragraph's first character drops its line height, and losing the font lets -// a chip-only line shrink to the bitmap, so the placeholder keeps both, plus the run -// colour so a later re-apply (after an image loads) still tints with it. -static inline NSAttributedString *T3MarkdownTextAttachmentString( - NSTextAttachment *attachment, NSDictionary *runAttributes) -{ - NSMutableAttributedString *string = - [[NSAttributedString attributedStringWithAttachment:attachment] mutableCopy]; - for (NSAttributedStringKey key in - @[ - NSFontAttributeName, NSParagraphStyleAttributeName, NSForegroundColorAttributeName, - NSBaselineOffsetAttributeName - ]) { - id value = runAttributes[key]; - if (value != nil) { - [string addAttribute:key value:value range:NSMakeRange(0, string.length)]; - } - } - return string; -} - -static UIFont *T3ContextChipFont(NSDictionary *payload) -{ - CGFloat size = MAX(10, MIN(40, [payload[@"fontSize"] doubleValue])); - size *= payload[@"fontSizeMultiplier"] != nil ? [payload[@"fontSizeMultiplier"] doubleValue] : 1; - return [UIFont fontWithName:@"DMSans-Medium" size:size] - ?: [UIFont systemFontOfSize:size weight:UIFontWeightMedium]; -} - -static inline CGSize T3ContextChipSize(NSDictionary *payload, CGFloat maximumWidth) -{ - UIFont *font = T3ContextChipFont(payload); - NSString *label = payload[@"label"]; - CGFloat textWidth = [label sizeWithAttributes:@{ NSFontAttributeName: font }].width; - return CGSizeMake(MIN(maximumWidth, ceil(textWidth + font.pointSize * 2.5)), - ceil(font.pointSize * 1.41)); -} - -static inline UIImage *T3ContextChipImage(NSDictionary *payload, CGSize size, UIImage *fileIcon) -{ - static NSCache *cache; - static dispatch_once_t once; - dispatch_once(&once, ^{ cache = [NSCache new]; cache.countLimit = 256; }); - NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:NSJSONWritingSortedKeys error:nil]; - NSString *key = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - key = [key stringByAppendingString:NSStringFromCGSize(size)]; - if (fileIcon != nil) key = [key stringByAppendingString:@":file-icon"]; - UIImage *cached = [cache objectForKey:key]; - if (cached) return cached; - UIFont *font = T3ContextChipFont(payload); - CGFloat em = font.pointSize; - UIColor *accent = T3ContextChipColor(payload[@"accent"]); - UIColor *foreground = T3ContextChipBlend(accent, T3ContextChipColor(payload[@"foreground"]), 0.22); - UIColor *border = T3ContextChipBlend(accent, T3ContextChipColor(payload[@"border"]), 0.34); - UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:size]; - UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) { - UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect: - CGRectInset(CGRectMake(0, 0, size.width, size.height), 0.5, 0.5) - cornerRadius:em * 0.5]; - [[accent colorWithAlphaComponent:0.11] setFill]; - [path fill]; - [border setStroke]; - path.lineWidth = 1; - [path stroke]; - CGFloat iconSize = em * 1.17; - UIImage *icon = fileIcon - ?: T3ContextChipVectorIcon(payload[@"symbol"], iconSize, foreground) - ?: [[UIImage systemImageNamed:payload[@"symbol"] - withConfiguration:[UIImageSymbolConfiguration configurationWithPointSize:em weight:UIImageSymbolWeightMedium]] - imageWithTintColor:foreground renderingMode:UIImageRenderingModeAlwaysOriginal]; - [icon drawInRect:CGRectMake(em * 0.5, (size.height - iconSize) / 2, iconSize, iconSize)]; - NSMutableParagraphStyle *paragraph = [NSMutableParagraphStyle new]; - paragraph.lineBreakMode = NSLineBreakByTruncatingMiddle; - CGFloat x = em * 2; - [payload[@"label"] drawInRect:CGRectMake(x, (size.height - font.lineHeight) / 2, - MAX(0, size.width - x - em * 0.5), font.lineHeight) - withAttributes:@{ NSFontAttributeName: font, NSForegroundColorAttributeName: foreground, - NSParagraphStyleAttributeName: paragraph }]; - }]; - [cache setObject:image forKey:key]; - return image; -} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h deleted file mode 100644 index f9c05a19819f87d93f49694e551eb59a2377c8dc..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h +++ /dev/null @@ -1,13 +0,0 @@ -#import -#import - -#ifndef T3MarkdownTextNativeComponent_h -#define T3MarkdownTextNativeComponent_h - -NS_ASSUME_NONNULL_BEGIN -@interface T3MarkdownText : RCTViewComponentView -@end - -NS_ASSUME_NONNULL_END - -#endif /* UitextviewViewNativeComponent_h */ diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm deleted file mode 100644 index 3115a5a34a0420a12dcc4c82b26738bf4a3d4e1f..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ /dev/null @@ -1,839 +0,0 @@ -#import "T3MarkdownText.h" -#import "T3MarkdownTextShadowNode.h" -#import "T3MarkdownTextComponentDescriptor.h" -#import "T3MarkdownTextRun.h" -#import "T3ContextChip.h" -#import -#import - -#import -#import -#import -#import -#import - -using namespace facebook::react; - -@interface T3ContextChipAccessibilityElement : UIAccessibilityElement -@property(nonatomic, weak) T3MarkdownTextRun *run; -@end - -@implementation T3ContextChipAccessibilityElement -- (BOOL)accessibilityActivate -{ - if (self.run == nil) return NO; - [self.run onPress]; - return YES; -} -@end - -/** Preserve canonical references and their payload when copying a native text selection. */ -@interface T3ContextCopyTextView : UITextView -@property(nonatomic, copy) NSDictionary *contextClipboardConfig; -@end - -@implementation T3ContextCopyTextView -// Read-only text still supports selecting the entire document after selecting a word. -- (BOOL)canPerformAction:(SEL)action withSender:(id)sender -{ - if (action == @selector(selectAll:)) { - return self.selectable && self.text.length > 0 && self.selectedRange.length < self.text.length; - } - return [super canPerformAction:action withSender:sender]; -} - -- (void)copy:(id)sender -{ - NSRange selected = self.selectedRange; - NSArray *ranges = self.contextClipboardConfig[@"ranges"]; - if (selected.location == NSNotFound || selected.length == 0 || NSMaxRange(selected) > self.text.length || ranges.count == 0) { - [super copy:sender]; - return; - } - NSMutableString *text = [[self.text substringWithRange:selected] mutableCopy]; - BOOL hasContext = NO; - for (NSDictionary *range in [ranges reverseObjectEnumerator]) { - NSUInteger start = [range[@"start"] unsignedIntegerValue]; - NSUInteger end = [range[@"end"] unsignedIntegerValue]; - if (end <= start || end > self.text.length) continue; - NSRange overlap = NSIntersectionRange(selected, NSMakeRange(start, end - start)); - if (overlap.length == 0 || ![range[@"text"] isKindOfClass:NSString.class]) continue; - [text replaceCharactersInRange:NSMakeRange(overlap.location - selected.location, overlap.length) withString:range[@"text"]]; - hasContext = YES; - } - if (!hasContext) { [super copy:sender]; return; } - [text replaceOccurrencesOfString:@"\uFFFC\u00A0" withString:@"" options:0 range:NSMakeRange(0, text.length)]; - NSString *fragment = self.contextClipboardConfig[@"fragment"]; - NSMutableDictionary *payload = [[NSJSONSerialization JSONObjectWithData:[fragment dataUsingEncoding:NSUTF8StringEncoding] options:NSJSONReadingMutableContainers error:nil] mutableCopy]; - NSArray *records = payload[@"records"]; - NSMutableArray *copied = [NSMutableArray array]; - NSMutableSet *screenshots = [NSMutableSet set]; - for (NSDictionary *record in records) { - if ([text containsString:[NSString stringWithFormat:@"/%@)", record[@"contextId"]]]) { - [copied addObject:record]; - if ([record[@"screenshotContextId"] isKindOfClass:NSString.class]) [screenshots addObject:record[@"screenshotContextId"]]; - } - } - for (NSDictionary *record in records) { - if ([screenshots containsObject:record[@"contextId"]] && ![copied containsObject:record]) [copied addObject:record]; - } - payload[@"records"] = copied; - NSData *encoded = payload ? [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil] : nil; - NSMutableDictionary *item = [@{@"public.utf8-plain-text": text} mutableCopy]; - if (encoded && copied.count > 0) { - NSString *raw = [[NSString alloc] initWithData:encoded encoding:NSUTF8StringEncoding]; - NSString *attribute = [raw stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.alphanumericCharacterSet]; - NSString *escaped = [[[text stringByReplacingOccurrencesOfString:@"&" withString:@"&"] stringByReplacingOccurrencesOfString:@"<" withString:@"<"] stringByReplacingOccurrencesOfString:@">" withString:@">"]; - item[@"app.t3.context-fragment"] = encoded; - item[@"public.html"] = [[NSString stringWithFormat:@"
%@
", attribute, escaped] dataUsingEncoding:NSUTF8StringEncoding]; - } - UIPasteboard.generalPasteboard.items = @[item]; -} -@end - -static void T3MarkdownTextApplyParagraphStyles( - NSMutableAttributedString *attributedString, - const std::vector &styleRanges) -{ - for (const auto &styleRange : styleRanges) { - if (styleRange.length == 0 || styleRange.location >= attributedString.length) { - continue; - } - - const NSRange markerRange = NSMakeRange( - styleRange.location, - MIN(styleRange.length, attributedString.length - styleRange.location)); - const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; - const NSParagraphStyle *existingStyle = - [attributedString attribute:NSParagraphStyleAttributeName - atIndex:paragraphRange.location - effectiveRange:nil]; - NSMutableParagraphStyle *paragraphStyle = - existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; - paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; - paragraphStyle.headIndent = styleRange.headIndent; - paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; - paragraphStyle.tabStops = @[ - [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft - location:styleRange.headIndent - options:@{}] - ]; - paragraphStyle.defaultTabInterval = styleRange.headIndent; - [attributedString addAttribute:NSParagraphStyleAttributeName - value:paragraphStyle - range:paragraphRange]; - } -} - -static void T3MarkdownTextApplyAttachments( - NSMutableAttributedString *attributedString, - const std::vector &attachmentRanges, - NSDictionary *images) -{ - for (const auto &attachmentRange : attachmentRanges) { - if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { - continue; - } - - NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; - NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; - UIImage *image = images[imageUri]; - const BOOL isSymbol = [imageUri hasPrefix:@"sf:"]; - if (isSymbol) { - image = [UIImage systemImageNamed:[imageUri substringFromIndex:3]]; - } - NSDictionary *runAttributes = - [attributedString attributesAtIndex:attachmentRange.location effectiveRange:nil]; - UIColor *foregroundColor = runAttributes[NSForegroundColorAttributeName]; - if (image != nil && (isSymbol || attachmentRange.tintWithForeground)) { - image = [image imageWithTintColor:foregroundColor ?: UIColor.labelColor - renderingMode:UIImageRenderingModeAlwaysOriginal]; - } - attachment.image = image ?: [[UIImage alloc] init]; - const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange); - attachment.bounds = CGRectMake( - 0, - T3MarkdownTextAttachmentBaselineOffset(attachmentRange), - attachmentSize, - attachmentSize); - NSDictionary *chip = T3ContextChipPayload(imageUri); - if (chip != nil) { - CGSize size = CGSizeMake(attachmentRange.chipWidth, attachmentRange.chipHeight); - attachment.bounds = T3ContextChipBounds(runAttributes[NSFontAttributeName], size); - NSString *iconUri = [chip[@"iconUri"] isKindOfClass:NSString.class] ? chip[@"iconUri"] : nil; - attachment.image = T3ContextChipImage(chip, size, iconUri ? images[iconUri] : nil); - } - const NSRange range = NSMakeRange( - attachmentRange.location, - MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); - [attributedString replaceCharactersInRange:range - withAttributedString:T3MarkdownTextAttachmentString(attachment, runAttributes)]; - } -} - -@protocol T3MarkdownOutsideTapTarget -- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView; -@end - -@interface T3MarkdownOutsideTapCoordinator : NSObject - -- (instancetype)initWithWindow:(UIWindow *)window; -- (void)addTarget:(id)target; -- (void)removeTarget:(id)target; - -@end - -static const void *T3MarkdownOutsideTapCoordinatorKey = - &T3MarkdownOutsideTapCoordinatorKey; - -@implementation T3MarkdownOutsideTapCoordinator { - __weak UIWindow *_window; - UITapGestureRecognizer *_recognizer; - NSHashTable> *_targets; -} - -- (instancetype)initWithWindow:(UIWindow *)window -{ - if (self = [super init]) { - _window = window; - _targets = [NSHashTable weakObjectsHashTable]; - _recognizer = [[UITapGestureRecognizer alloc] initWithTarget:self - action:@selector(handleTap:)]; - _recognizer.cancelsTouchesInView = NO; - _recognizer.delegate = self; - [window addGestureRecognizer:_recognizer]; - } - return self; -} - -- (void)addTarget:(id)target -{ - [_targets addObject:target]; -} - -- (void)removeTarget:(id)target -{ - [_targets removeObject:target]; - if (_targets.count > 0) { - return; - } - - UIWindow *window = _window; - [window removeGestureRecognizer:_recognizer]; - if (objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey) == self) { - objc_setAssociatedObject( - window, - T3MarkdownOutsideTapCoordinatorKey, - nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } -} - -- (void)handleTap:(UITapGestureRecognizer *)sender -{ - UIWindow *window = _window; - if (window == nil) { - return; - } - - UIView *hitView = [window hitTest:[sender locationInView:window] withEvent:nil]; - if (hitView == nil) { - return; - } - for (id target in _targets.allObjects) { - [target clearSelectionForOutsideTapWithHitView:hitView]; - } -} - -- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer - shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer -{ - return YES; -} - -@end - -static T3MarkdownOutsideTapCoordinator * -T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) -{ - T3MarkdownOutsideTapCoordinator *coordinator = - objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey); - if (coordinator == nil) { - coordinator = [[T3MarkdownOutsideTapCoordinator alloc] initWithWindow:window]; - objc_setAssociatedObject( - window, - T3MarkdownOutsideTapCoordinatorKey, - coordinator, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } - return coordinator; -} - -@interface T3MarkdownText () - -@end - -@interface T3MarkdownText () -@end - -@implementation T3MarkdownText { - UIView * _view; - T3ContextCopyTextView * _textView; - T3MarkdownTextShadowNode::ConcreteState::Shared _state; - __weak UIWindow * _outsideTapWindow; - BOOL _suppressSelectionChange; - NSMutableDictionary * _attachmentImages; - NSMutableSet * _pendingAttachmentUris; - UILongPressGestureRecognizer *_longPressGestureRecognizer; - UITapGestureRecognizer *_pressGestureRecognizer; - NSArray *_contextAccessibilityElements; - BOOL _textLayoutNeedsUpdate; -} - -+ (ComponentDescriptorProvider)componentDescriptorProvider -{ - return concreteComponentDescriptorProvider(); -} - -- (instancetype)initWithFrame:(CGRect)frame -{ - if (self = [super initWithFrame:frame]) { - static const auto defaultProps = std::make_shared(); - _props = defaultProps; - - _view = [[UIView alloc] init]; - self.contentView = _view; - self.clipsToBounds = true; - - _textView = [[T3ContextCopyTextView alloc] init]; - _attachmentImages = [[NSMutableDictionary alloc] init]; - _pendingAttachmentUris = [[NSMutableSet alloc] init]; - _textView.scrollEnabled = false; - _textView.editable = false; - _textView.textContainerInset = UIEdgeInsetsZero; - _textView.textContainer.lineFragmentPadding = 0; - // The shadow node already sized this view to fit every line. A container - // that tracks the frame can come out a fraction of a point shorter after - // pixel rounding, and TextKit then drops the last line. Size it by hand - // with unbounded height and let the view's bounds clip. - _textView.textContainer.widthTracksTextView = NO; - _textView.textContainer.heightTracksTextView = NO; - _textView.delegate = self; - // Chat text supports selection and contextual actions, but not drag-and-drop. - _textView.textDragInteraction.enabled = NO; - _textView.linkTextAttributes = @{}; - // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO. - _textView.layoutManager.usesFontLeading = NO; - [self addSubview:_textView]; - - _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self - action:@selector(handleLongPressIfNecessary:)]; - _longPressGestureRecognizer.delegate = self; - - _pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self - action:@selector(handlePressIfNecessary:)]; - _pressGestureRecognizer.delegate = self; - [_pressGestureRecognizer requireGestureRecognizerToFail:_longPressGestureRecognizer]; - - [_textView addGestureRecognizer:_pressGestureRecognizer]; - [_textView addGestureRecognizer:_longPressGestureRecognizer]; - } - - return self; -} - -- (void)didMoveToWindow -{ - [super didMoveToWindow]; - if (_outsideTapWindow == self.window) { - return; - } - if (_outsideTapWindow != nil) { - T3MarkdownOutsideTapCoordinator *coordinator = - objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); - [coordinator removeTarget:self]; - } - _outsideTapWindow = self.window; - if (_outsideTapWindow != nil) { - [T3MarkdownOutsideTapCoordinatorForWindow(_outsideTapWindow) addTarget:self]; - } -} - -- (void)dealloc -{ - T3MarkdownOutsideTapCoordinator *coordinator = - objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); - [coordinator removeTarget:self]; -} - -- (NSArray *)accessibilityElements -{ - return _contextAccessibilityElements ?: [super accessibilityElements]; -} - -// See RCTParagraphComponentView -- (void)prepareForRecycle -{ - [super prepareForRecycle]; - T3MarkdownOutsideTapCoordinator *coordinator = - objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); - [coordinator removeTarget:self]; - _outsideTapWindow = nil; - _state.reset(); - - // Reset the frame to zero so that when it properly lays out on the next use - _textView.frame = CGRectZero; - _textView.attributedText = nil; - _contextAccessibilityElements = nil; - _textLayoutNeedsUpdate = YES; -} - -- (void)layoutSubviews -{ - [super layoutSubviews]; - // Resize during UIKit's layout pass. Doing this in drawRect can leave the - // text view's drawing surface clipped to its previous width after a pane - // expands, even though TextKit has already rewrapped the lines. - if (!CGRectEqualToRect(_textView.frame, _view.frame)) { - _textView.frame = _view.frame; - _textLayoutNeedsUpdate = YES; - [self setNeedsDisplay]; - } -} - -- (void)drawRect:(CGRect)rect -{ - if (!_state) { - return; - } - - const auto &props = *std::static_pointer_cast(_props); - - const auto attrString = _state->getData().attributedString; - NSMutableAttributedString *convertedAttrString = - [RCTNSAttributedStringFromAttributedString(attrString) mutableCopy]; - T3MarkdownTextApplyParagraphStyles( - convertedAttrString, - _state->getData().paragraphStyleRanges); - T3MarkdownTextApplyAttachments( - convertedAttrString, - _state->getData().attachmentRanges, - _attachmentImages); - // Matches the shadow node so drawn lines sit where measurement put them. - RCTApplyBaselineOffset(convertedAttrString); - NSUInteger runLocation = 0; - for (UIView *child in self.subviews) { - if (![child isKindOfClass:[T3MarkdownTextRun class]]) { - continue; - } - - T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child; - const NSRange runRange = NSMakeRange(runLocation, textChild.text.length); - runLocation = NSMaxRange(runRange); - if (![textChild hasContextMenu] || runRange.length == 0 || - NSMaxRange(runRange) > convertedAttrString.length) { - continue; - } - - NSURL *link = [NSURL URLWithString: - [NSString stringWithFormat:@"t3-markdown-run://%ld", (long)textChild.tag]]; - if (link != nil) { - // A glyph must not be both a link and an attachment. UIKit caches them as - // different text-item classes and can send `attachment` to a cached link - // on a later tap. Attachment actions already use primaryActionForTextItem. - [convertedAttrString enumerateAttribute:NSAttachmentAttributeName - inRange:runRange - options:0 - usingBlock:^(id attachment, NSRange range, BOOL *stop) { - if (attachment == nil) { - [convertedAttrString addAttribute:NSLinkAttributeName value:link range:range]; - } - }]; - } - } - [self loadAttachmentImages:_state->getData().attachmentRanges]; - - // Setting attributedText clears any active text selection, and re-assigning - // the frame triggers a layout flush that has the same effect. Bail out - // entirely when nothing actually changed so a JS-side state update made in - // response to onSelectionChange doesn't deselect what the user is selecting. - const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; - const BOOL frameChanged = _textLayoutNeedsUpdate; - if (!textChanged && !frameChanged) { - return; - } - if (textChanged) { - // Reassigning attributedText clears any active selection. Save it and - // restore after, while suppressing the synthetic textViewDidChangeSelection - // events the clear-then-restore would otherwise produce — those would - // round-trip to JS and re-trigger this same path, causing a loop. - const NSRange savedRange = _textView.selectedRange; - _suppressSelectionChange = YES; - _textView.attributedText = convertedAttrString; - NSMutableString *accessibleText = [convertedAttrString.string mutableCopy]; - for (auto it = _state->getData().attachmentRanges.rbegin(); - it != _state->getData().attachmentRanges.rend(); ++it) { - NSDictionary *chip = T3ContextChipPayload([NSString stringWithUTF8String:it->imageUri.c_str()]); - if (chip != nil && it->location < accessibleText.length) { - [accessibleText replaceCharactersInRange:NSMakeRange(it->location, 1) withString:chip[@"label"]]; - } - } - _textView.accessibilityLabel = accessibleText; - if (savedRange.length > 0 && NSMaxRange(savedRange) <= _textView.attributedText.length) { - _textView.selectedRange = savedRange; - } - _suppressSelectionChange = NO; - } - if (frameChanged) { - _textView.textContainer.size = CGSizeMake(CGRectGetWidth(_view.frame), CGFLOAT_MAX); - } - _textLayoutNeedsUpdate = NO; - - // Text attachments have no native link element. Expose their existing runs - // at the measured glyph bounds, without inserting views into text layout. - NSMutableArray *accessibleElements = [NSMutableArray arrayWithObject:_textView]; - for (UIView *child in self.subviews) { - if (![child isKindOfClass:T3MarkdownTextRun.class]) continue; - T3MarkdownTextRun *run = (T3MarkdownTextRun *)child; - run.contextChipInteractive = NO; - } - for (const auto &attachmentRange : _state->getData().attachmentRanges) { - NSDictionary *chip = T3ContextChipPayload( - [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]); - if (![chip[@"interactive"] boolValue]) continue; - NSRange range = NSMakeRange(attachmentRange.location, 1); - T3MarkdownTextRun *run = [self childForCharacterRange:range]; - if (!run || NSMaxRange(range) > convertedAttrString.length) continue; - NSRange glyphRange = [_textView.layoutManager glyphRangeForCharacterRange:range actualCharacterRange:nil]; - CGRect bounds = [_textView.layoutManager boundingRectForGlyphRange:glyphRange - inTextContainer:_textView.textContainer]; - bounds = CGRectOffset(bounds, _textView.textContainerInset.left, _textView.textContainerInset.top); - run.contextChipInteractive = YES; - T3ContextChipAccessibilityElement *element = - [[T3ContextChipAccessibilityElement alloc] initWithAccessibilityContainer:self]; - element.run = run; - element.accessibilityLabel = chip[@"label"]; - element.accessibilityTraits = UIAccessibilityTraitButton; - element.accessibilityFrameInContainerSpace = [_textView convertRect:bounds toView:self]; - [accessibleElements addObject:element]; - } - _contextAccessibilityElements = accessibleElements; - - __block std::vector lines; - const int maxLines = props.numberOfLines; - [_textView.layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, convertedAttrString.string.length) usingBlock:^(CGRect rect, - CGRect usedRect, - NSTextContainer * _Nonnull textContainer, - NSRange glyphRange, - BOOL * _Nonnull stop) { - const auto charRange = [self->_textView.layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nil]; - const auto line = [self->_textView.text substringWithRange:charRange]; - lines.push_back(line.UTF8String); - // enumerateLineFragments overshoots maximumNumberOfLines by one on iOS - // 18, so cap explicitly. - if (maxLines > 0 && lines.size() >= (size_t)maxLines) { - *stop = YES; - } - }]; - - if (_eventEmitter != nullptr) { - std::dynamic_pointer_cast(_eventEmitter) - ->onTextLayout(facebook::react::T3MarkdownTextEventEmitter::OnTextLayout{static_cast(self.tag), lines}); - }; -} - -- (void)loadAttachmentImages:(const std::vector &)attachmentRanges -{ - for (const auto &attachmentRange : attachmentRanges) { - NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; - if ([imageUri hasPrefix:@"sf:"]) { - continue; - } - NSDictionary *chip = T3ContextChipPayload(imageUri); - if (chip != nil) { - imageUri = [chip[@"iconUri"] isKindOfClass:NSString.class] ? chip[@"iconUri"] : nil; - if (imageUri.length == 0) continue; - } - if (_attachmentImages[imageUri] != nil || [_pendingAttachmentUris containsObject:imageUri]) { - continue; - } - - NSURL *url = [NSURL URLWithString:imageUri]; - if (url == nil) { - continue; - } - if (url.isFileURL) { - UIImage *image = [UIImage imageWithContentsOfFile:url.path]; - if (image != nil) { - _attachmentImages[imageUri] = image; - dispatch_async(dispatch_get_main_queue(), ^{ - [self refreshDisplayedAttachments]; - }); - } - continue; - } - - [_pendingAttachmentUris addObject:imageUri]; - [[[NSURLSession sharedSession] dataTaskWithURL:url - completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { - UIImage *image = data == nil ? nil : [UIImage imageWithData:data]; - dispatch_async(dispatch_get_main_queue(), ^{ - [self->_pendingAttachmentUris removeObject:imageUri]; - if (image != nil) { - self->_attachmentImages[imageUri] = image; - [self refreshDisplayedAttachments]; - } - }); - }] resume]; - } -} - -- (void)refreshDisplayedAttachments -{ - if (!_state || _textView.attributedText == nil) { - return; - } - - NSMutableAttributedString *attributedText = [_textView.attributedText mutableCopy]; - T3MarkdownTextApplyAttachments( - attributedText, - _state->getData().attachmentRanges, - _attachmentImages); - - const NSRange savedRange = _textView.selectedRange; - _suppressSelectionChange = YES; - _textView.attributedText = attributedText; - if (savedRange.location != NSNotFound && - NSMaxRange(savedRange) <= _textView.attributedText.length) { - _textView.selectedRange = savedRange; - } - _suppressSelectionChange = NO; - [_textView setNeedsDisplay]; -} - -- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps -{ - const auto &oldViewProps = *std::static_pointer_cast(_props); - const auto &newViewProps = *std::static_pointer_cast(props); - if (oldViewProps.contextClipboardConfig != newViewProps.contextClipboardConfig) { - NSString *config = RCTNSStringFromString(newViewProps.contextClipboardConfig); - _textView.contextClipboardConfig = config.length ? [NSJSONSerialization JSONObjectWithData:[config dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil] : nil; - } - - if (oldViewProps.numberOfLines != newViewProps.numberOfLines) { - _textView.textContainer.maximumNumberOfLines = newViewProps.numberOfLines; - } - - if (oldViewProps.selectable != newViewProps.selectable) { - _textView.selectable = newViewProps.selectable; - } - - if (oldViewProps.allowFontScaling != newViewProps.allowFontScaling) { - if (@available(iOS 11.0, *)) { - _textView.adjustsFontForContentSizeCategory = newViewProps.allowFontScaling; - } - } - - if (oldViewProps.ellipsizeMode != newViewProps.ellipsizeMode) { - if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { - _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingHead; - } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { - _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingMiddle; - } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { - _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingTail; - } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Clip) { - _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByClipping; - } - } - - - // I'm not sure if this is really the right way to handle this style. This means that the entire _view_ the text - // is in will have this background color applied. To apply it just to a particular part of a string, you'd need - // to do Hello. - // This is how the base component works though, so we'll go with it for now. Can change later if we want. - if (oldViewProps.backgroundColor != newViewProps.backgroundColor) { - _textView.backgroundColor = RCTUIColorFromSharedColor(newViewProps.backgroundColor); - } - - [super updateProps:props oldProps:oldProps]; -} - -// See RCTParagraphComponentView -- (void)updateState:(const facebook::react::State::Shared &)state oldState:(const facebook::react::State::Shared &)oldState -{ - _state = std::static_pointer_cast(state); - [self setNeedsDisplay]; -} - -// MARK: - UIGestureRecognizerDelegate - -- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer -{ - return YES; -} - -- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer -{ - if (gestureRecognizer != _longPressGestureRecognizer && - gestureRecognizer != _pressGestureRecognizer) { - return YES; - } - - const auto location = [self getLocationOfPress:gestureRecognizer]; - const auto child = [self getTouchChild:location]; - return ![child hasContextMenu]; -} - -- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch -{ - return YES; -} - -- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView -{ - if ([hitView isDescendantOfView:self]) { - return; - } - // Defer past the current event loop turn so any in-flight edit-menu action - // (Copy / Define / Look Up / …) reads the live selection before we clear it. - UITextView *textView = _textView; - dispatch_async(dispatch_get_main_queue(), ^{ - UITextRange *range = textView.selectedTextRange; - if (range != nil && !range.isEmpty) { - textView.selectedTextRange = nil; - } - }); -} - -// MARK: - Touch handling - -- (nullable T3MarkdownTextRun *)childForCharacterRange:(NSRange)characterRange -{ - NSUInteger location = 0; - for (UIView *child in self.subviews) { - if (![child isKindOfClass:[T3MarkdownTextRun class]]) { - continue; - } - - T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child; - const NSRange range = NSMakeRange(location, textChild.text.length); - if (NSIntersectionRange(range, characterRange).length > 0) { - return textChild; - } - location = NSMaxRange(range); - } - return nil; -} - -- (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender -{ - return [sender locationInView:_textView]; -} - -- (T3MarkdownTextRun*)getTouchChild:(CGPoint)location -{ - const auto charIndex = [_textView.layoutManager characterIndexForPoint:location - inTextContainer:_textView.textContainer - fractionOfDistanceBetweenInsertionPoints:nil - ]; - - int currIndex = -1; - for (UIView* child in self.subviews) { - if (![child isKindOfClass:[T3MarkdownTextRun class]]) { - continue; - } - - T3MarkdownTextRun* textChild = (T3MarkdownTextRun*)child; - - // This is UTF16 code units!! - currIndex += textChild.text.length; - - if (charIndex <= currIndex) { - return textChild; - } - } - - return nil; -} - -- (void)handlePressIfNecessary:(UITapGestureRecognizer*)sender -{ - const auto location = [self getLocationOfPress:sender]; - const auto child = [self getTouchChild:location]; - - if (child) { - [child onPress]; - } -} - -- (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender -{ - if (sender.state != UIGestureRecognizerStateBegan) { - return; - } - - const auto location = [self getLocationOfPress:sender]; - const auto child = [self getTouchChild:location]; - - if (child) { - [child onLongPress]; - } -} - -// MARK: - UITextViewDelegate - -- (nullable UIAction *)textView:(UITextView *)textView - primaryActionForTextItem:(UITextItem *)textItem - defaultAction:(UIAction *)defaultAction API_AVAILABLE(ios(17.0)) -{ - T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range]; - if (![child hasContextMenu] && !child.contextChipInteractive) { - return defaultAction; - } - - __weak T3MarkdownTextRun *weakChild = child; - return [UIAction actionWithHandler:^(__kindof UIAction *action) { - [weakChild onPress]; - }]; -} - -- (nullable UITextItemMenuConfiguration *)textView:(UITextView *)textView - menuConfigurationForTextItem:(UITextItem *)textItem - defaultMenu:(UIMenu *)defaultMenu API_AVAILABLE(ios(17.0)) -{ - T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range]; - UIMenu *menu = [child contextMenu]; - if (child.contextChipInteractive && menu == nil) return nil; - return [UITextItemMenuConfiguration configurationWithMenu:menu ?: defaultMenu]; -} - -- (void)textViewDidChangeSelection:(UITextView *)textView -{ - if (_suppressSelectionChange) { - return; - } - if (_eventEmitter == nullptr) { - return; - } - - const NSRange selectedRange = textView.selectedRange; - if (selectedRange.location == NSNotFound) { - return; - } - - // Fires on programmatic selection changes too (e.g. the outside-tap clear - // in handleOutsideTap:), so JS will see a synthetic empty-range event then. - std::dynamic_pointer_cast(_eventEmitter) - ->onSelectionChange(facebook::react::T3MarkdownTextEventEmitter::OnSelectionChange{ - static_cast(self.tag), - static_cast(selectedRange.location), - static_cast(selectedRange.location + selectedRange.length), - }); -} - -Class T3MarkdownTextCls(void) -{ - return T3MarkdownText.class; -} - -@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h deleted file mode 100644 index 77e21d585100895904b6f39df4b71b103ef39725..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -#include "T3MarkdownTextShadowNode.h" - -#include -#include - -namespace facebook::react { -using T3MarkdownTextComponentDescriptor = ConcreteComponentDescriptor; - -void T3MarkdownTextSpec_registerComponentDescriptorsFromCodegen( - std::shared_ptr registry); -} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm deleted file mode 100644 index 93f1b2e4ba2a4b81806f5658c17a9b9329e15b4c..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm +++ /dev/null @@ -1,35 +0,0 @@ -#import -#import -#import "Utils.h" - -@interface T3MarkdownTextManager : RCTViewManager -@end - -@implementation T3MarkdownTextManager - -RCT_EXPORT_MODULE(T3MarkdownText) - -- (UIView *)view -{ - return [[UIView alloc] init]; -} - -RCT_CUSTOM_VIEW_PROPERTY(color, NSString, UIView) -{ -} - -@end - -@interface T3MarkdownTextRunManager : RCTViewManager -@end - -@implementation T3MarkdownTextRunManager - -RCT_EXPORT_MODULE(T3MarkdownTextRun) - -- (UIView *)view -{ - return nil; -} - -@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h deleted file mode 100644 index f5f812451b70be9ff126a4109c4e7f4fe57357d8..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h +++ /dev/null @@ -1,28 +0,0 @@ -// This guard prevent this file to be compiled in the old architecture. -#ifdef RCT_NEW_ARCH_ENABLED -#import -#import -#import - -#ifndef T3MarkdownTextRunNativeComponent_h -#define T3MarkdownTextRunNativeComponent_h - -NS_ASSUME_NONNULL_BEGIN - -@interface T3MarkdownTextRun : RCTViewComponentView - -@property (nonatomic, copy, nullable) NSString *text; -@property (nonatomic, assign) BOOL contextChipInteractive; - -- (nullable UIMenu *)contextMenu; -- (BOOL)hasContextMenu; -- (void)onContextMenuAction:(NSString *)actionIdentifier; -- (void)onPress; -- (void)onLongPress; - -@end - -NS_ASSUME_NONNULL_END - -#endif /* UitextviewViewNativeComponent_h */ -#endif /* RCT_NEW_ARCH_ENABLED */ diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm deleted file mode 100644 index 2cd9444f6d2274469aa4016c5ecb271a9036e267..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm +++ /dev/null @@ -1,140 +0,0 @@ -#import "T3MarkdownTextRun.h" -#import "T3MarkdownText.h" -#import "T3MarkdownTextRunComponentDescriptor.h" -#import -#import -#import -#import -#import "Utils.h" - -using namespace facebook::react; - -@interface T3MarkdownTextRun () - -@end - -@implementation T3MarkdownTextRun { - NSString * _text; - NSString * _contextMenuConfig; -} - -+ (ComponentDescriptorProvider)componentDescriptorProvider -{ - return concreteComponentDescriptorProvider(); -} - -- (instancetype)initWithFrame:(CGRect)frame -{ - if (self = [super initWithFrame:frame]) { - static const auto defaultProps = std::make_shared(); - _props = defaultProps; - } - return self; -} - -- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps -{ - const auto &oldViewProps = *std::static_pointer_cast(_props); - const auto &newViewProps = *std::static_pointer_cast(props); - - if (newViewProps.text != oldViewProps.text) { - NSString *text = [NSString stringWithUTF8String:newViewProps.text.c_str()]; - _text = text; - } - - if (newViewProps.contextMenuConfig != oldViewProps.contextMenuConfig) { - _contextMenuConfig = [NSString stringWithUTF8String:newViewProps.contextMenuConfig.c_str()]; - } - - [super updateProps:props oldProps:oldProps]; -} - -- (BOOL)hasContextMenu -{ - return _contextMenuConfig.length > 0; -} - -- (nullable UIMenu *)contextMenu -{ - if (_contextMenuConfig.length == 0) { - return nil; - } - - NSData *data = [_contextMenuConfig dataUsingEncoding:NSUTF8StringEncoding]; - NSDictionary *config = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - if (![config isKindOfClass:[NSDictionary class]]) { - return nil; - } - - NSArray *actionConfigs = config[@"actions"]; - if (![actionConfigs isKindOfClass:[NSArray class]] || actionConfigs.count == 0) { - return nil; - } - - NSMutableArray *actions = [NSMutableArray arrayWithCapacity:actionConfigs.count]; - __weak T3MarkdownTextRun *weakSelf = self; - for (NSDictionary *actionConfig in actionConfigs) { - if (![actionConfig isKindOfClass:[NSDictionary class]]) { - continue; - } - NSString *actionIdentifier = actionConfig[@"id"]; - NSString *title = actionConfig[@"title"]; - if (![actionIdentifier isKindOfClass:[NSString class]] || - ![title isKindOfClass:[NSString class]]) { - continue; - } - - UIAction *action = [UIAction actionWithTitle:title - image:nil - identifier:actionIdentifier - handler:^(__kindof UIAction *selectedAction) { - [weakSelf onContextMenuAction:selectedAction.identifier]; - }]; - if ([actionConfig[@"disabled"] boolValue]) { - action.attributes = UIMenuElementAttributesDisabled; - } - [actions addObject:action]; - } - - if (actions.count == 0) { - return nil; - } - NSString *title = [config[@"title"] isKindOfClass:[NSString class]] ? config[@"title"] : @""; - return [UIMenu menuWithTitle:title children:actions]; -} - -- (void)onContextMenuAction:(NSString *)actionIdentifier -{ - if (_eventEmitter != nullptr) { - std::dynamic_pointer_cast(_eventEmitter) - ->onContextMenuAction(facebook::react::T3MarkdownTextRunEventEmitter::OnContextMenuAction{ - static_cast(self.tag), - actionIdentifier.UTF8String, - }); - } -} - -- (void)onPress { - if (_eventEmitter != nullptr) { - std::dynamic_pointer_cast(_eventEmitter) - ->onPress(facebook::react::T3MarkdownTextRunEventEmitter::OnPress{}); - } -} - -- (void)onLongPress { - if (_eventEmitter != nullptr) { - std::dynamic_pointer_cast(_eventEmitter) - ->onLongPress(facebook::react::T3MarkdownTextRunEventEmitter::OnLongPress{}); - } -} - -+ (BOOL)shouldBeRecycled { - return NO; -} - -Class T3MarkdownTextRunCls(void) -{ - return T3MarkdownTextRun.class; -} - -@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h deleted file mode 100644 index 61f9e1a129ee072e213d6e3776bbeacd6f82db9e..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -#include "T3MarkdownTextRunShadowNode.h" - -#include -#include - -namespace facebook::react { -using T3MarkdownTextRunComponentDescriptor = ConcreteComponentDescriptor; - -void T3MarkdownTextRunSpec_registerComponentDescriptorsFromCodegen( - std::shared_ptr registry); -} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp deleted file mode 100644 index a1af619205dfee3ceda3c5d41f4bd1700ba947ec..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp +++ /dev/null @@ -1,6 +0,0 @@ -#include "T3MarkdownTextRunShadowNode.h" - -namespace facebook::react { - -extern const char T3MarkdownTextRunComponentName[] = "T3MarkdownTextRun"; -} // namespace facebook::react diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h deleted file mode 100644 index c00bd1f2407900954137f207fee5244354456b64..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace facebook::react { -extern const char T3MarkdownTextRunComponentName[]; - -using T3MarkdownTextRunShadowNode = ConcreteViewShadowNode< - T3MarkdownTextRunComponentName, - T3MarkdownTextRunProps, - T3MarkdownTextRunEventEmitter, - T3MarkdownTextRunState>; -} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h deleted file mode 100644 index 0f7b284594bb9b5cd13aa100e2c53a9b9f2457f9..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +++ /dev/null @@ -1,82 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include -#include - -namespace facebook::react { - -extern const char T3MarkdownTextComponentName[]; - -struct T3MarkdownTextParagraphStyleRange { - size_t location; - size_t length; - Float firstLineHeadIndent; - Float headIndent; - Float paragraphSpacing; -}; - -struct T3MarkdownTextAttachmentRange { - size_t location; - size_t length; - std::string imageUri; - /// Recolor the loaded image with the run's foreground color, like `sf:` symbols. - bool tintWithForeground; - Float chipWidth = 0; - Float chipHeight = 0; -}; - -inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { - return 14; -} - -inline Float T3MarkdownTextAttachmentBaselineOffset( - const T3MarkdownTextAttachmentRange &) { - return -2; -} - -class T3MarkdownTextStateReal final { - public: - AttributedString attributedString; - std::vector paragraphStyleRanges; - std::vector attachmentRanges; -}; - -class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode< -T3MarkdownTextComponentName, -T3MarkdownTextProps, -T3MarkdownTextEventEmitter, -T3MarkdownTextStateReal> { -public: - using ConcreteViewShadowNode::ConcreteViewShadowNode; - - T3MarkdownTextShadowNode( - const ShadowNode& sourceShadowNode, - const ShadowNodeFragment& fragment - ); - - static ShadowNodeTraits BaseTraits() { - auto traits = ConcreteViewShadowNode::BaseTraits(); - traits.set(ShadowNodeTraits::Trait::LeafYogaNode); - traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); - return traits; - } - - void layout(LayoutContext layoutContext) override; - - Size measureContent( - const LayoutContext& layoutContext, - const LayoutConstraints& layoutConstraints) const override; - -private: - mutable AttributedString _attributedString; - mutable std::vector _paragraphStyleRanges; - mutable std::vector _attachmentRanges; -}; -} // namespace facebook::React diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm deleted file mode 100644 index e1cc7c2046b2f6cadd26e7f78b1b2a665fe09de3..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ /dev/null @@ -1,303 +0,0 @@ -#include "T3MarkdownTextShadowNode.h" -#include "T3MarkdownTextRunShadowNode.h" -#import "T3ContextChip.h" -#include -#import - -#include -#include - -namespace facebook::react { - -static constexpr Float ParagraphStyleEncodingOffset = 1000; -static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:"; -static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:"; -static constexpr auto LinkAttachmentNativeIdPrefix = "t3-link:"; - -static void applyParagraphStyles( - NSMutableAttributedString *attributedString, - const std::vector &styleRanges) -{ - for (const auto &styleRange : styleRanges) { - if (styleRange.length == 0 || styleRange.location >= attributedString.length) { - continue; - } - - const NSRange markerRange = NSMakeRange( - styleRange.location, - MIN(styleRange.length, attributedString.length - styleRange.location)); - const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; - const NSParagraphStyle *existingStyle = - [attributedString attribute:NSParagraphStyleAttributeName - atIndex:paragraphRange.location - effectiveRange:nil]; - NSMutableParagraphStyle *paragraphStyle = - existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; - paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; - paragraphStyle.headIndent = styleRange.headIndent; - paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; - paragraphStyle.tabStops = @[ - [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft - location:styleRange.headIndent - options:@{}] - ]; - paragraphStyle.defaultTabInterval = styleRange.headIndent; - [attributedString addAttribute:NSParagraphStyleAttributeName - value:paragraphStyle - range:paragraphRange]; - } -} - -static void applyAttachments( - NSMutableAttributedString *attributedString, - const std::vector &attachmentRanges) -{ - for (const auto &attachmentRange : attachmentRanges) { - if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { - continue; - } - - NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; - attachment.image = [[UIImage alloc] init]; - const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange); - attachment.bounds = CGRectMake( - 0, - T3MarkdownTextAttachmentBaselineOffset(attachmentRange), - attachmentSize, - attachmentSize); - NSDictionary *runAttributes = - [attributedString attributesAtIndex:attachmentRange.location effectiveRange:nil]; - if (attachmentRange.chipWidth > 0) { - attachment.bounds = T3ContextChipBounds( - runAttributes[NSFontAttributeName], - CGSizeMake(attachmentRange.chipWidth, attachmentRange.chipHeight)); - } - const NSRange range = NSMakeRange( - attachmentRange.location, - MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); - [attributedString replaceCharactersInRange:range - withAttributedString:T3MarkdownTextAttachmentString(attachment, runAttributes)]; - } -} - -T3MarkdownTextShadowNode::T3MarkdownTextShadowNode( - const ShadowNode& sourceShadowNode, - const ShadowNodeFragment& fragment -) : ConcreteViewShadowNode(sourceShadowNode, fragment) { -}; - -Size T3MarkdownTextShadowNode::measureContent( - const LayoutContext& layoutContext, - const LayoutConstraints& layoutConstraints) const { - const auto &baseProps = getConcreteProps(); - - auto baseTextAttributes = TextAttributes::defaultTextAttributes(); - baseTextAttributes.backgroundColor = baseProps.backgroundColor; - baseTextAttributes.allowFontScaling = baseProps.allowFontScaling; - - Float fontSizeMultiplier = 1.0; - if (baseTextAttributes.allowFontScaling) { - fontSizeMultiplier = layoutContext.fontSizeMultiplier; - } - - auto baseAttributedString = AttributedString{}; - auto paragraphStyleRanges = std::vector{}; - auto attachmentRanges = std::vector{}; - size_t utf16Offset = 0; - const auto &children = getChildren(); - for (size_t i = 0; i < children.size(); i++) { - const auto child = children[i].get(); - if (auto textViewChild = dynamic_cast(child)) { - auto &props = textViewChild->getConcreteProps(); - auto fragment = AttributedString::Fragment{}; - auto textAttributes = TextAttributes::defaultTextAttributes(); - - textAttributes.allowFontScaling = baseProps.allowFontScaling; - textAttributes.backgroundColor = props.backgroundColor; - textAttributes.fontSize = props.fontSize * fontSizeMultiplier; - textAttributes.lineHeight = props.lineHeight * fontSizeMultiplier; - textAttributes.foregroundColor = props.color; - const bool hasParagraphStyle = props.shadowRadius >= ParagraphStyleEncodingOffset; - if (!hasParagraphStyle) { - textAttributes.textShadowColor = props.shadowColor; - textAttributes.textShadowOffset = props.shadowOffset; - textAttributes.textShadowRadius = props.shadowRadius; - } - textAttributes.letterSpacing = props.letterSpacing; - textAttributes.textDecorationColor = props.textDecorationColor; - textAttributes.fontFamily = props.fontFamily; - - if (props.fontStyle == T3MarkdownTextRunFontStyle::Italic) { - textAttributes.fontStyle = FontStyle::Italic; - } else { - textAttributes.fontStyle = FontStyle::Normal; - } - - if (props.fontWeight == T3MarkdownTextRunFontWeight::Bold) { - textAttributes.fontWeight = FontWeight::Bold; - } else if (props.fontWeight == T3MarkdownTextRunFontWeight::UltraLight) { - textAttributes.fontWeight = FontWeight::UltraLight; - } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Light) { - textAttributes.fontWeight = FontWeight::Light; - } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Medium) { - textAttributes.fontWeight = FontWeight::Medium; - } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Semibold) { - textAttributes.fontWeight = FontWeight::Semibold; - } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Heavy) { - textAttributes.fontWeight = FontWeight::Heavy; - } else { - textAttributes.fontWeight = FontWeight::Regular; - } - - if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::LineThrough) { - textAttributes.textDecorationLineType = TextDecorationLineType::Strikethrough; - } else if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::Underline) { - textAttributes.textDecorationLineType = TextDecorationLineType::Underline; - } else { - textAttributes.textDecorationLineType = TextDecorationLineType::None; - } - - if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Solid) { - textAttributes.textDecorationStyle = TextDecorationStyle::Solid; - } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dotted) { - textAttributes.textDecorationStyle = TextDecorationStyle::Dotted; - } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dashed) { - textAttributes.textDecorationStyle = TextDecorationStyle::Dashed; - } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Double) { - textAttributes.textDecorationStyle = TextDecorationStyle::Double; - } - - if (props.textAlign == T3MarkdownTextRunTextAlign::Left) { - textAttributes.alignment = TextAlignment::Left; - } else if (props.textAlign == T3MarkdownTextRunTextAlign::Right) { - textAttributes.alignment = TextAlignment::Right; - } else if (props.textAlign == T3MarkdownTextRunTextAlign::Center) { - textAttributes.alignment = TextAlignment::Center; - } else if (props.textAlign == T3MarkdownTextRunTextAlign::Justify) { - textAttributes.alignment = TextAlignment::Justified; - } else if (props.textAlign == T3MarkdownTextRunTextAlign::Auto) { - textAttributes.alignment = TextAlignment::Natural; - } - - textAttributes.backgroundColor = props.backgroundColor; - - fragment.string = props.text; - fragment.textAttributes = textAttributes; - - NSString *fragmentText = [NSString stringWithUTF8String:props.text.c_str()]; - const size_t fragmentLength = fragmentText.length; - if (hasParagraphStyle) { - paragraphStyleRanges.push_back(T3MarkdownTextParagraphStyleRange{ - utf16Offset, - fragmentLength, - props.shadowOffset.width, - props.shadowOffset.height, - props.shadowRadius - ParagraphStyleEncodingOffset, - }); - } - if (props.nativeId.rfind("t3-chip:", 0) == 0 && fragmentLength > 0) { - const std::string uri = props.nativeId.substr(3); - NSMutableDictionary *payload = - [T3ContextChipPayload([NSString stringWithUTF8String:uri.c_str()]) mutableCopy]; - // Chips must scale with the paragraph or smaller Dynamic Type sizes clip them. - // Store the scaled payload so measurement and the rendered bitmap use the same font. - payload[@"fontSizeMultiplier"] = @(fontSizeMultiplier); - NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; - NSString *scaledUri = [@"chip:" stringByAppendingString: - [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]]; - const CGFloat maxWidth = std::isfinite(layoutConstraints.maximumSize.width) - ? layoutConstraints.maximumSize.width : 320; - const CGSize size = T3ContextChipSize(payload, maxWidth); - attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ - utf16Offset, 1, std::string(scaledUri.UTF8String), false, - static_cast(size.width), static_cast(size.height), - }); - } else if (props.nativeId.rfind(FileAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { - attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ - utf16Offset, - 1, - props.nativeId.substr(std::char_traits::length(FileAttachmentNativeIdPrefix)), - false, - }); - } else if ( - props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { - attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ - utf16Offset, - 1, - props.nativeId.substr( - std::char_traits::length(SkillAttachmentNativeIdPrefix)), - false, - }); - } else if ( - props.nativeId.rfind(LinkAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { - attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ - utf16Offset, - 1, - props.nativeId.substr(std::char_traits::length(LinkAttachmentNativeIdPrefix)), - true, - }); - } - utf16Offset += fragmentLength; - baseAttributedString.appendFragment(std::move(fragment)); - } - } - - _attributedString = baseAttributedString; - _paragraphStyleRanges = paragraphStyleRanges; - _attachmentRanges = attachmentRanges; - - NSMutableAttributedString *convertedAttributedString = - [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; - applyParagraphStyles(convertedAttributedString, paragraphStyleRanges); - applyAttachments(convertedAttributedString, attachmentRanges); - // TextKit stacks a paragraph's extra line height above the glyphs. React Native's own - // layout manager centres them with a baseline offset; do the same, after attachments - // so chips shift with the words. - RCTApplyBaselineOffset(convertedAttributedString); - - const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width) - ? layoutConstraints.maximumSize.width - : CGFLOAT_MAX; - NSTextStorage *textStorage = - [[NSTextStorage alloc] initWithAttributedString:convertedAttributedString]; - NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; - layoutManager.usesFontLeading = NO; - NSTextContainer *textContainer = - [[NSTextContainer alloc] initWithSize:CGSizeMake(maximumWidth, CGFLOAT_MAX)]; - textContainer.lineFragmentPadding = 0; - textContainer.maximumNumberOfLines = baseProps.numberOfLines; - if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { - textContainer.lineBreakMode = NSLineBreakByTruncatingHead; - } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { - textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle; - } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { - textContainer.lineBreakMode = NSLineBreakByTruncatingTail; - } else { - textContainer.lineBreakMode = NSLineBreakByClipping; - } - [layoutManager addTextContainer:textContainer]; - [textStorage addLayoutManager:layoutManager]; - [layoutManager ensureLayoutForTextContainer:textContainer]; - const CGRect usedRect = [layoutManager usedRectForTextContainer:textContainer]; - - return { - std::clamp( - static_cast(std::ceil(usedRect.size.width)), - layoutConstraints.minimumSize.width, - layoutConstraints.maximumSize.width), - std::clamp( - static_cast(std::ceil(usedRect.size.height)), - layoutConstraints.minimumSize.height, - layoutConstraints.maximumSize.height), - }; -} - -void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) { - ensureUnsealed(); - setStateData(T3MarkdownTextStateReal{ - _attributedString, - _paragraphStyleRanges, - _attachmentRanges, - }); -} -} diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json deleted file mode 100644 index 376befbeb152c34ddcecee736fa17f51025940a8..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "@t3tools/mobile-markdown-text", - "version": "0.0.0", - "private": true, - "source": "./index.ts", - "files": [ - "android", - "assets", - "expo-module.config.json", - "ios", - "src", - "index.ts", - "LICENSE", - "UPSTREAM.md", - "T3MarkdownText.podspec", - "react-native.config.js" - ], - "main": "./index.ts", - "types": "./index.ts", - "react-native": "./index.ts", - "exports": { - ".": "./index.ts", - "./file-icons": "./src/markdownFileIcons.ts", - "./link-icons": "./src/markdownLinkIcons.ts", - "./links": "./src/markdownLinks.ts", - "./markdown": "./src/nativeMarkdownText.ts", - "./primitive": "./src/MarkdownTextPrimitive.tsx", - "./renderer": "./src/SelectableMarkdownText.tsx", - "./types": "./src/SelectableMarkdownText.types.ts" - }, - "peerDependencies": { - "@t3tools/client-runtime": "*", - "@t3tools/shared": "*", - "expo": "*", - "expo-asset": "*", - "expo-clipboard": "*", - "expo-haptics": "*", - "expo-symbols": "*", - "react": "*", - "react-native": "*", - "react-native-nitro-markdown": "*" - }, - "codegenConfig": { - "name": "T3MarkdownTextSpec", - "type": "all", - "jsSrcsDir": "src", - "ios": { - "componentProvider": { - "T3MarkdownText": "T3MarkdownText", - "T3MarkdownTextRun": "T3MarkdownTextRun" - } - }, - "outputDir": { - "ios": "ios/generated" - } - } -} diff --git a/apps/mobile/modules/t3-markdown-text/react-native.config.js b/apps/mobile/modules/t3-markdown-text/react-native.config.js deleted file mode 100644 index 6b10ea26eec9abbbed7c4458c5f36207c05da8fe..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/react-native.config.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - dependency: { - platforms: { - ios: { - podspecPath: "T3MarkdownText.podspec", - }, - android: null, - }, - }, -}; diff --git a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs deleted file mode 100644 index fdbb03382eef45ffd067b5462477243edfdc2f46..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs +++ /dev/null @@ -1,134 +0,0 @@ -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; - -import { getBuiltInSpriteSheet } from "@pierre/trees"; - -const scriptDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); -const moduleDirectory = NodePath.resolve(scriptDirectory, ".."); -const repositoryRoot = NodePath.resolve(moduleDirectory, "../../../.."); -const outputDirectory = NodePath.join(moduleDirectory, "assets/file-icons"); -const generatedModulePath = NodePath.join(moduleDirectory, "src/markdownFileIcons.generated.ts"); -const webIconSource = NodeFS.readFileSync( - NodePath.join(repositoryRoot, "apps/web/src/pierre-icons.ts"), - "utf8", -); -const customSprite = webIconSource.match(/const T3_FILE_ICON_SPRITE = `([\s\S]*?)`;/)?.[1]; - -if (!customSprite) { - throw new Error("Could not read the T3 Pierre icon sprite from apps/web/src/pierre-icons.ts"); -} - -const colors = { - astro: "#a631be", - babel: "#d5a910", - bash: "#199f43", - biome: "#1a85d4", - bootstrap: "#693acf", - browserslist: "#d5a910", - bun: "#594c5b", - c: "#1a85d4", - claude: "#d47628", - cpp: "#1a85d4", - css: "#693acf", - database: "#a631be", - default: "#84848a", - docker: "#1a85d4", - eslint: "#693acf", - font: "#84848a", - git: "#ff8c5b", - go: "#1ca1c7", - graphql: "#d32a61", - html: "#d47628", - image: "#d32a61", - javascript: "#d5a910", - json: "#d47628", - markdown: "#199f43", - mcp: "#17a5af", - nextjs: "#84848a", - npm: "#d52c36", - oxc: "#1ca1c7", - postcss: "#d52c36", - prettier: "#17a5af", - python: "#1a85d4", - react: "#1ca1c7", - ruby: "#d52c36", - rust: "#d47628", - sass: "#d32a61", - stylelint: "#84848a", - svelte: "#d52c36", - svg: "#d47628", - svgo: "#199f43", - swift: "#d47628", - table: "#17a5af", - tailwind: "#1ca1c7", - terraform: "#693acf", - text: "#84848a", - typescript: "#1a85d4", - video: "#a631be", - vite: "#a631be", - vscode: "#1a85d4", - vue: "#199f43", - wasm: "#693acf", - webpack: "#1a85d4", - yml: "#d52c36", - zig: "#d47628", - zip: "#d47628", -}; - -const customIcons = { - agents: "t3-file-icon-agents", - pnpm: "t3-file-icon-pnpm", - video: "t3-file-icon-video", -}; - -function symbolFromSprite(sprite, id) { - const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const match = sprite.match( - new RegExp(`]*)>([\\s\\S]*?)<\\/symbol>`), - ); - if (!match) throw new Error(`Missing Pierre icon symbol: ${id}`); - return { - body: match[2], - viewBox: match[1].match(/viewBox="([^"]+)"/)?.[1] ?? "0 0 16 16", - }; -} - -function renderIcon(token, symbol, color) { - const svgPath = NodePath.join(outputDirectory, `.pierre-${token}.svg`); - const pngPath = NodePath.join(outputDirectory, `pierre_${token}.png`); - NodeFS.writeFileSync( - svgPath, - `${symbol.body}`, - ); - NodeChildProcess.execFileSync("sips", ["-s", "format", "png", svgPath, "--out", pngPath], { - stdio: "ignore", - }); - NodeFS.rmSync(svgPath); -} - -NodeFS.rmSync(outputDirectory, { recursive: true, force: true }); -NodeFS.mkdirSync(outputDirectory, { recursive: true }); - -const builtInSprite = getBuiltInSpriteSheet("complete"); -const builtInTokens = [...builtInSprite.matchAll(/ match[1]) - .sort(); - -for (const token of builtInTokens) { - renderIcon( - token, - symbolFromSprite(builtInSprite, `file-tree-builtin-${token}`), - colors[token] ?? colors.default, - ); -} -for (const [token, symbolId] of Object.entries(customIcons)) { - renderIcon(token, symbolFromSprite(customSprite, symbolId), colors[token] ?? colors.default); -} - -const tokens = [...new Set([...builtInTokens, ...Object.keys(customIcons)])].sort(); -const generatedSource = `import type { ImageSourcePropType } from "react-native";\n\nexport const MARKDOWN_FILE_ICON_SOURCES = {\n${tokens - .map((token) => ` ${token}: require("../assets/file-icons/pierre_${token}.png"),`) - .join("\n")}\n} as const satisfies Readonly>;\n`; -NodeFS.writeFileSync(generatedModulePath, generatedSource); diff --git a/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx b/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx deleted file mode 100644 index ffbc0e2fcb6368e9b3adad9d8ec166e8e932c6ea..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { SymbolView } from "expo-symbols"; -import * as Clipboard from "expo-clipboard"; -import * as Haptics from "expo-haptics"; -import { memo, useEffect, useRef, useState } from "react"; -import { Pressable, type ColorValue } from "react-native"; - -const COPY_FEEDBACK_DURATION_MS = 1200; - -function copyTextWithHaptic(value: string): void { - void Clipboard.setStringAsync(value); - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); -} - -export const CopyTextButton = memo(function CopyTextButton(props: { - readonly accessibilityLabel: string; - readonly text: string; - readonly tintColor: ColorValue; - readonly copiedTintColor?: ColorValue; - readonly backgroundColor?: ColorValue; - readonly borderColor?: ColorValue; - readonly iconSize?: number; - readonly buttonSize?: number; -}) { - const [copied, setCopied] = useState(false); - const resetTimeoutRef = useRef | null>(null); - - useEffect( - () => () => { - if (resetTimeoutRef.current) { - clearTimeout(resetTimeoutRef.current); - } - }, - [], - ); - - return ( - { - copyTextWithHaptic(props.text); - setCopied(true); - if (resetTimeoutRef.current) { - clearTimeout(resetTimeoutRef.current); - } - resetTimeoutRef.current = setTimeout(() => { - setCopied(false); - resetTimeoutRef.current = null; - }, COPY_FEEDBACK_DURATION_MS); - }} - style={({ pressed }) => ({ - width: props.buttonSize ?? 30, - height: props.buttonSize ?? 30, - alignItems: "center", - justifyContent: "center", - borderRadius: 9, - borderWidth: props.borderColor ? 1 : 0, - borderColor: props.borderColor, - backgroundColor: props.backgroundColor, - opacity: pressed ? 0.52 : 1, - })} - > - - - ); -}); diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx deleted file mode 100644 index 2e8026f328288fc48069754f14e679231255b26b..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import React, { type Ref } from "react"; -import { - findNodeHandle, - Platform, - processColor, - StyleSheet, - Text as RNText, - type ColorValue, - type TextInstance, - type TextProps, - type ViewStyle, -} from "react-native"; -import { setMarkdownSelectionHandleColor } from "./T3MarkdownTextSelectionModule"; -import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; -import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; -import { flattenStyles } from "./util"; - -const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([ - false, - StyleSheet.create({}), -]); - -const textDefaults = { - allowFontScaling: true, - selectable: true, -} satisfies TextProps; - -const useTextAncestorContext = () => React.useContext(TextAncestorContext); - -/** - * Event fired by `onSelectionChange`. `start`/`end` are 0-based UTF-16 indices - * into the rendered string. `start === end` means the selection was cleared. - */ -export type SelectionChangeEvent = { - nativeEvent: { target: number; start: number; end: number }; -}; - -export type ContextMenuActionEvent = { - nativeEvent: { target: number; actionIdentifier: string }; -}; - -/** - * `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 & { - nativeTextRef?: Ref; - selectionHandleColor?: ColorValue; - uiTextView?: boolean; - contextMenuConfig?: string; - contextClipboardConfig?: string; - onContextMenuAction?: (event: ContextMenuActionEvent) => void; - /** - * Fired when the native text selection changes. Only fires on iOS when - * `uiTextView` is true. Note: fires on every selection-edge adjustment - * (e.g. dragging a selection handle), so consumers driving expensive work - * off this event should debounce. - */ - onSelectionChange?: (event: SelectionChangeEvent) => void; -}; - -function MarkdownTextPrimitiveChild({ - style, - children, - nativeTextRef: _nativeTextRef, - ...rest -}: MarkdownTextPrimitiveProps) { - const [isAncestor, rootStyle] = useTextAncestorContext(); - - // Flatten the styles, and apply the root styles when needed - const flattenedStyle = React.useMemo(() => flattenStyles(rootStyle, style), [rootStyle, style]); - const contextValue = React.useMemo<[boolean, ViewStyle]>( - () => [true, flattenedStyle], - [flattenedStyle], - ); - let childPosition = 0; - const nativeChildren = React.Children.toArray(children).map((child) => { - const position = childPosition; - childPosition += 1; - - if (React.isValidElement(child)) { - return child; - } - if (typeof child !== "string" && typeof child !== "number") { - return null; - } - - const text = child.toString(); - return ( - // @ts-expect-error The generated run props do not include inherited Text props. - - ); - }); - - if (!isAncestor) { - // Press handlers are delivered by the text runs; the container never sees them. - const { onPress: _onPress, onLongPress: _onLongPress, ...containerProps } = rest; - return ( - - - {nativeChildren} - - - ); - } - - return <>{nativeChildren}; -} - -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 ; - } - return ; -} - -function AndroidMarkdownText({ - nativeTextRef, - selectionHandleColor, - onLayout, - contextClipboardConfig: _contextClipboardConfig, - ...props -}: MarkdownTextPrimitiveProps) { - const textRef = React.useRef(null); - React.useImperativeHandle( - nativeTextRef, - () => textRef.current, - [], - ); - const color = processColor(selectionHandleColor); - const applyHandleColor = React.useCallback(() => { - if (!textRef.current || typeof color !== "number") return; - const reactTag = findNodeHandle(textRef.current); - if (reactTag != null) setMarkdownSelectionHandleColor(reactTag, color); - }, [color]); - - // RN's selectionColor only sets the highlight. Retint mounted handles when - // the theme changes, and after layout when the native view first exists. - React.useEffect(applyHandleColor, [applyHandleColor]); - - return ( - { - applyHandleColor(); - onLayout?.(event); - }} - /> - ); -} - -export function MarkdownTextPrimitive({ - selectionHandleColor, - ...props -}: MarkdownTextPrimitiveProps) { - if (Platform.OS === "android" && selectionHandleColor !== undefined) { - return ; - } - if (Platform.OS !== "ios") { - const { nativeTextRef, contextClipboardConfig: _contextClipboardConfig, ...textProps } = props; - return ; - } - return ; -} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx deleted file mode 100644 index 34f042471a73e060f8a8ebadb09918bb438ff896..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx +++ /dev/null @@ -1,687 +0,0 @@ -import { SymbolView } from "expo-symbols"; -import { githubAlert } from "./githubAlert"; -import { createContext, memo, useContext, useMemo } from "react"; -import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native"; -import type { MarkdownNode } from "react-native-nitro-markdown/headless"; - -import { CopyTextButton } from "./CopyTextButton"; -import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; -import { - nativeMarkdownDocumentRuns, - nativeMarkdownListItemBlocks, - nativeMarkdownNodePosition, -} from "./nativeMarkdownText"; -import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText"; -import type { - MarkdownCodeHighlighter, - MarkdownHighlightedToken, - MarkdownImageRenderer, - NativeMarkdownTextStyle, - SelectableMarkdownSkill, -} from "./SelectableMarkdownText.types"; -import { useHighlightedCode, type HighlightedCode } from "./useHighlightedCode"; - -/** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */ -export const MarkdownImageRendererContext = createContext(null); - -const MONO_FONT_FAMILY = Platform.select({ - ios: "ui-monospace", - android: "monospace", - default: "monospace", -}); - -function nodeKey(node: MarkdownNode, index: number): string { - return `${node.type}:${nativeMarkdownNodePosition(node, index)}`; -} - -/** Code inside markdown scales with the base text size (12pt at the default 15pt body). */ -function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number { - return Math.max(10, Math.round(textStyle.fontSize * 0.8)); -} - -function codeBlockLineHeight(textStyle: NativeMarkdownTextStyle): number { - return codeBlockFontSize(textStyle) + 6; -} - -function nodeText(node: MarkdownNode): string { - if (node.content !== undefined) { - return node.content; - } - return (node.children ?? []).map(nodeText).join(""); -} - -function documentFor(node: MarkdownNode): MarkdownNode { - return node.type === "document" ? node : { type: "document", children: [node] }; -} - -function SelectableNode(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly onLinkPress?: (href: string) => void; -}) { - return ( - - ); -} - -const HighlightedCodeLine = memo(function HighlightedCodeLine(props: { - readonly tokens: ReadonlyArray; - readonly color: string; - readonly newline: boolean; -}) { - let offset = 0; - const children = []; - for (const token of props.tokens) { - if (!token.content) continue; - children.push( - - {token.content} - , - ); - offset += token.content.length; - } - return ( - - {children} - {props.newline ? "\n" : ""} - - ); -}); - -function HighlightedCodeText(props: { - readonly content: string; - readonly highlighted: HighlightedCode | null; - readonly textStyle: NativeMarkdownTextStyle; -}) { - // The text root provides inherited styles through context. A new style object - // would rerender every token even when its completed line is unchanged. - const fontSize = codeBlockFontSize(props.textStyle); - const lineHeight = codeBlockLineHeight(props.textStyle); - const style = useMemo( - () => ({ - color: props.textStyle.codeColor, - fontFamily: MONO_FONT_FAMILY, - fontSize, - lineHeight, - }), - [props.textStyle.codeColor, fontSize, lineHeight], - ); - let offset = 0; - const lines = []; - if (props.highlighted) { - for (const tokens of props.highlighted) { - lines.push( - , - ); - offset += tokens.reduce((length, token) => length + token.content.length, 0) + 1; - } - } - return ( - - {props.highlighted ? lines : props.content} - - ); -} - -function NativeCodeBlock(props: { - readonly node: MarkdownNode; - readonly textStyle: NativeMarkdownTextStyle; - readonly highlightCode: MarkdownCodeHighlighter; - readonly compact?: boolean; -}) { - const content = nodeText(props.node).replace(/\n$/, ""); - const colorScheme = useColorScheme(); - const theme = colorScheme === "dark" ? "dark" : "light"; - const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode); - const languageLabel = props.node.language?.toUpperCase() ?? "CODE"; - return ( - - - - {languageLabel} - - - - - - - - ); -} - -function collectTableRows(node: MarkdownNode): MarkdownNode[] { - const rows: MarkdownNode[] = []; - const visit = (child: MarkdownNode) => { - if (child.type === "table_row") { - rows.push(child); - return; - } - for (const nested of child.children ?? []) { - visit(nested); - } - }; - visit(node); - return rows; -} - -function NativeTable(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly onLinkPress?: (href: string) => void; -}) { - const rows = collectTableRows(props.node); - return ( - - - {rows.map((row, rowIndex) => ( - - {(row.children ?? []).map((cell, cellIndex) => ( - - - rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, - )} - textStyle={props.textStyle} - onLinkPress={props.onLinkPress} - /> - - ))} - - ))} - - - ); -} - -function NativeMarkdownImage(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly onLinkPress?: (href: string) => void; -}) { - const renderImage = useContext(MarkdownImageRendererContext); - const href = props.node.href; - if (!href) { - return ( - - ); - } - - if (renderImage) { - const rendered = renderImage({ - href, - alt: props.node.alt ?? null, - title: props.node.title ?? null, - }); - if (rendered != null) { - return <>{rendered}; - } - } - - return ( - - - {props.node.alt ? ( - - {props.node.alt} - - ) : null} - - ); -} - -function inlineGroups(nodes: ReadonlyArray): MarkdownNode[] { - const groups: MarkdownNode[] = []; - let inline: MarkdownNode[] = []; - const flush = () => { - if (inline.length === 0) { - return; - } - groups.push({ type: "paragraph", children: inline }); - inline = []; - }; - - for (const node of nodes) { - if (node.type === "image") { - flush(); - groups.push(node); - } else { - inline.push(node); - } - } - flush(); - return groups; -} - -function NativeMixedParagraph(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly onLinkPress?: (href: string) => void; -}) { - return ( - - {inlineGroups(props.node.children ?? []).map((child, index) => - child.type === "image" ? ( - - ) : ( - - ), - )} - - ); -} - -function NativeList(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly highlightCode: MarkdownCodeHighlighter; - readonly onLinkPress?: (href: string) => void; - readonly depth: number; -}) { - const ordered = props.node.ordered ?? false; - const start = props.node.start ?? 1; - const nested = props.depth > 0; - return ( - - {(props.node.children ?? []).map((item, index) => { - const taskMarker = item.type === "task_list_item"; - const marker = taskMarker - ? item.checked - ? "☑︎" - : "☐︎" - : ordered - ? `${start + index}.` - : props.depth % 3 === 1 - ? "◦" - : props.depth % 3 === 2 - ? "▪︎" - : "•"; - const markerWidth = ordered ? 28 : taskMarker ? 20 : 18; - const markerOffset = taskMarker ? 3 : ordered ? 0 : 2; - return ( - - - - {marker} - - - - {nativeMarkdownListItemBlocks(item).map((child, childIndex) => ( - - ))} - - - ); - })} - - ); -} - -export function NativeMarkdownBlock(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly highlightCode: MarkdownCodeHighlighter; - readonly onLinkPress?: (href: string) => void; - readonly depth?: number; - readonly compact?: boolean; -}) { - const depth = props.depth ?? 0; - const dark = useColorScheme() === "dark"; - switch (props.node.type) { - case "document": - return ( - - {(props.node.children ?? []).map((child, index) => ( - - ))} - - ); - case "code_block": - return ( - - ); - case "table": - return ( - - ); - case "image": - return ( - - ); - case "horizontal_rule": - return ( - - ); - case "blockquote": { - const alert = githubAlert(props.node); - const alertColor = dark ? alert?.color : alert?.lightColor; - return ( - - {alert ? ( - - {Platform.OS === "ios" ? ( - - ) : null} - - {alert.title} - - - ) : null} - {(alert?.children ?? props.node.children ?? []).map((child, index) => ( - - ))} - - ); - } - case "list": - return ( - - ); - case "paragraph": - return (props.node.children ?? []).some((child) => child.type === "image") ? ( - - ) : ( - - ); - case "html_block": - case "math_block": - return ( - - - - ); - case "table_head": - case "table_body": - case "table_row": - case "table_cell": - case "list_item": - case "task_list_item": - return ( - - {(props.node.children ?? []).map((child, index) => ( - - ))} - - ); - default: - return ( - - ); - } -} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx deleted file mode 100644 index 5a01dd436bbc73e0227210add31f4281ffcbbb88..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx +++ /dev/null @@ -1,446 +0,0 @@ -import { createContext, useCallback, useContext, useMemo } from "react"; -import { decodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; -import { - findNodeHandle, - Image, - Linking, - Platform, - StyleSheet, - type TextInstance, - type TextStyle, - useColorScheme, - View, -} from "react-native"; - -import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; -import { markdownFileIconSource, markdownIconAssetUri } from "./markdownFileIcons"; -import { markdownLinkIconSource } from "./markdownLinkIcons"; -import { resolveMarkdownFileIcon, resolveMarkdownLinkIcon } from "./markdownLinks"; -import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; -import { nativeMarkdownContextCopyRanges } from "./nativeMarkdownText"; -import type { - MarkdownFileContextMenu, - NativeMarkdownTextStyle, -} from "./SelectableMarkdownText.types"; -import { - installMarkdownCopySanitizer, - renderAndroidContextChip, -} from "./T3MarkdownTextSelectionModule"; -import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; -import { contextChipPresentation } from "./nativeMarkdownText"; - -export const MarkdownContextClipboardContext = createContext(""); - -export interface MarkdownFileContextMenuHandlers { - readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; - readonly onFileContextMenuAction: (href: string, actionId: string) => void; -} - -/** Set by SelectableMarkdownText so file chips anywhere in the block tree get the same menu. */ -export const MarkdownFileContextMenuContext = createContext( - null, -); - -const EXTERNAL_LINK_PREFIX = "◉ "; -const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; -const SKILL_ICON_PLACEHOLDER = "\uFFFC"; -// React Native turns a run whose whole text is U+FFFC into a bare inline-view attachment -// with no font or paragraph style, so a chip opening a paragraph would drop its line -// height. Any other single character keeps the run's attributes; the native side swaps it -// for the chip attachment either way. -const IOS_CHIP_PLACEHOLDER = "\u200B"; -const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; -const MONO_FONT_FAMILY = Platform.select({ - ios: "ui-monospace", - android: "monospace", - default: "monospace", -}); -const styles = StyleSheet.create({ - inlineIcon: { - width: 14, - height: 14, - marginHorizontal: 3, - transform: [{ translateY: 2 }], - }, -}); - -function runKeySignature(run: NativeMarkdownTextRun): string { - return [ - run.text, - run.bold, - run.italic, - run.strikethrough, - run.code, - run.href, - run.externalHost, - run.fileIcon, - run.skillName, - run.skillLabel, - run.sourceText, - run.role, - run.headingLevel, - run.depth, - run.spacing, - run.firstLineHeadIndent, - run.headIndent, - run.paragraphSpacing, - ].join(":"); -} - -const DEFAULT_BODY_FONT_SIZE = 15; -const DEFAULT_HEADING_FONT_SIZES = [22, 19, 17, 16, 15, 15] as const; - -function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel: number): number { - const index = Math.max(0, Math.min(5, headingLevel - 1)); - const configured = textStyle.headingFontSizes?.[index]; - if (typeof configured === "number" && Number.isFinite(configured)) { - return configured; - } - - const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE; - return Math.max(12, Math.round((DEFAULT_HEADING_FONT_SIZES[index] ?? 15) * scale)); -} - -function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { - const isFile = run.fileIcon != null; - const isSkill = run.skillName != null; - const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1)); - const headingFontSize = resolveHeadingFontSize(textStyle, headingLevel); - const isHeading = run.role === "heading"; - const isCodeBlock = run.role === "code-block" || run.role === "code-language"; - const hasParagraphStyle = run.headIndent !== undefined; - const textDecorationLine = run.strikethrough - ? "line-through" - : run.href && !isFile - ? "underline" - : "none"; - - return { - color: isFile - ? textStyle.fileTextColor - : isSkill - ? textStyle.skillTextColor - : run.href - ? textStyle.linkColor - : isHeading - ? textStyle.strongColor - : run.role === "quote-marker" - ? textStyle.quoteMarkerColor - : run.role === "divider" - ? textStyle.dividerColor - : run.role === "code-language" - ? textStyle.mutedColor - : run.role === "list-marker" - ? textStyle.mutedColor - : isCodeBlock - ? textStyle.codeColor - : run.code - ? textStyle.inlineCodeColor - : run.bold - ? textStyle.strongColor - : textStyle.color, - fontFamily: - isFile || isSkill - ? textStyle.boldFontFamily - : run.code || isCodeBlock - ? MONO_FONT_FAMILY - : isHeading - ? textStyle.headingFontFamily - : run.bold - ? textStyle.boldFontFamily - : textStyle.fontFamily, - fontSize: - run.role === "spacer" - ? (run.spacing ?? 10) - : run.role === "list-break" - ? textStyle.fontSize - : isHeading - ? headingFontSize - : run.role === "code-language" - ? Math.max(10, Math.round(textStyle.fontSize * 0.73)) - : run.code || isCodeBlock - ? Math.max(12, textStyle.fontSize - 2) - : textStyle.fontSize, - lineHeight: - run.role === "spacer" - ? (run.spacing ?? 10) - : run.role === "list-break" - ? textStyle.lineHeight + (run.spacing ?? 0) - : isHeading - ? Math.max(headingFontSize + 6, textStyle.lineHeight + 2) - : isCodeBlock - ? Math.max(16, textStyle.lineHeight - 2) - : textStyle.lineHeight, - fontStyle: run.italic ? "italic" : "normal", - fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400", - textDecorationLine, - backgroundColor: isCodeBlock - ? textStyle.codeBlockBackgroundColor - : parseComposerContextHref(run.href ?? "") - ? textStyle.codeBackgroundColor - : undefined, - ...(hasParagraphStyle - ? { - shadowColor: "transparent", - shadowOffset: { - width: run.firstLineHeadIndent ?? 0, - height: run.headIndent, - }, - shadowRadius: PARAGRAPH_STYLE_ENCODING_OFFSET + (run.paragraphSpacing ?? 0), - } - : {}), - }; -} - -export function NativeMarkdownSelectableText(props: { - readonly runs: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly onLinkPress?: (href: string) => void; -}) { - const colorScheme = useColorScheme(); - const menu = useContext(MarkdownFileContextMenuContext); - const contextClipboardFragment = useContext(MarkdownContextClipboardContext); - const contextRecords = useMemo( - () => decodeComposerContextFragment(contextClipboardFragment)?.records ?? [], - [contextClipboardFragment], - ); - const containsInlineIcon = props.runs.some( - (run) => - run.fileIcon != null || - run.skillName != null || - parseComposerContextHref(run.href ?? "") !== null || - (run.externalHost != null && resolveMarkdownLinkIcon(run.externalHost) !== null), - ); - const keyedRuns = useMemo(() => { - const occurrences = new Map(); - const prefixedExternalLinks = new Set(); - return props.runs.map((run) => { - const signature = runKeySignature(run); - const occurrence = occurrences.get(signature) ?? 0; - occurrences.set(signature, occurrence + 1); - - let text = run.text; - let linkIcon = null; - const contextReference = parseComposerContextHref(run.href ?? ""); - const contextRecord = contextReference - ? contextRecords.find((record) => record.contextId === contextReference.contextId) - : undefined; - const chip = - contextReference || run.skillName || run.fileIcon - ? { - ...contextChipPresentation( - contextReference?.kind ?? (run.skillName ? "skill" : "mention"), - contextRecord, - ), - label: run.skillLabel ?? run.text, - interactive: Boolean(run.href), - iconUri: - !contextReference && run.fileIcon - ? markdownIconAssetUri(markdownFileIconSource(run.fileIcon)) - : contextRecord?.kind === "mention" && "path" in contextRecord - ? markdownIconAssetUri( - markdownFileIconSource(resolveMarkdownFileIcon(contextRecord.path)), - ) - : undefined, - fontSize: props.textStyle.fontSize * 0.8, - foreground: props.textStyle.color, - border: props.textStyle.contextChipBorderColor ?? props.textStyle.dividerColor, - } - : null; - // Android sizes the chip's inline box from the paragraph font so the line box stays - // the height of a plain text line; see renderContextChip. - const androidChip = - Platform.OS === "android" && chip - ? renderAndroidContextChip( - JSON.stringify({ - ...chip, - text: { - fontFamily: props.textStyle.fontFamily, - fontSize: props.textStyle.fontSize, - }, - }), - ) - : null; - if (androidChip) { - text = ""; - } else if (chip && Platform.OS === "ios") { - text = IOS_CHIP_PLACEHOLDER; - } else if (run.fileIcon && Platform.OS === "ios") { - text = `${INLINE_ATTACHMENT_PREFIX}${text}`; - } else if (run.skillName && run.skillLabel) { - text = - Platform.OS === "ios" - ? `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}` - : `$${run.skillName}`; - } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { - prefixedExternalLinks.add(run.href); - linkIcon = resolveMarkdownLinkIcon(run.externalHost); - if (linkIcon === null) { - text = `${EXTERNAL_LINK_PREFIX}${text}`; - } else if (Platform.OS === "ios") { - text = `${INLINE_ATTACHMENT_PREFIX}${text}`; - } - } - - return { key: `${signature}:${occurrence}`, run, text, linkIcon, chip, androidChip }; - }); - }, [props.runs, props.textStyle, contextRecords]); - const ranges = nativeMarkdownContextCopyRanges( - keyedRuns.map(({ run, text, linkIcon, androidChip }) => ({ - run, - text, - inlineImageLength: - Platform.OS === "android" && (androidChip || run.fileIcon || linkIcon) ? 1 : 0, - })), - ); - const contextClipboardConfig = ranges.length - ? JSON.stringify({ fragment: contextClipboardFragment, ranges }) - : ""; - const attachAndroidText = useCallback( - (textView: TextInstance | null) => { - if (Platform.OS !== "android" || !containsInlineIcon || !textView) return; - const reactTag = findNodeHandle(textView); - if (typeof reactTag === "number") { - installMarkdownCopySanitizer(reactTag, contextClipboardConfig); - } - }, - [containsInlineIcon, contextClipboardConfig], - ); - // T3MarkdownText only rebuilds its attributed string during native layout. A - // color-only child update can otherwise leave the previous appearance cached. - const appearanceKey = [ - colorScheme ?? "unspecified", - props.textStyle.fontSize, - props.textStyle.lineHeight, - props.textStyle.headingFontSizes?.join(","), - props.textStyle.color, - props.textStyle.strongColor, - props.textStyle.mutedColor, - props.textStyle.linkColor, - props.textStyle.inlineCodeColor, - props.textStyle.codeColor, - props.textStyle.codeBackgroundColor, - props.textStyle.codeBlockBackgroundColor, - props.textStyle.fileTextColor, - props.textStyle.skillTextColor, - props.textStyle.quoteMarkerColor, - props.textStyle.dividerColor, - props.textStyle.contextChipBorderColor, - ].join(":"); - - return ( - run.skillLabel ?? run.text).join("") - : undefined - } - uiTextView - selectable - selectionColor={props.textStyle.selectionColor} - selectionHandleColor={props.textStyle.selectionHandleColor} - style={{ - flexShrink: 1, - minWidth: 0, - color: props.textStyle.color, - fontFamily: props.textStyle.fontFamily, - fontSize: props.textStyle.fontSize, - lineHeight: props.textStyle.lineHeight, - }} - > - {keyedRuns.map(({ key, run, text, linkIcon, chip, androidChip }) => { - const href = run.href; - const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; - const onPress = href - ? () => { - if (props.onLinkPress) props.onLinkPress(href); - else void Linking.openURL(href); - } - : undefined; - const fileIconUri = run.fileIcon - ? markdownIconAssetUri(markdownFileIconSource(run.fileIcon)) - : undefined; - const linkIconUri = linkIcon - ? markdownIconAssetUri(markdownLinkIconSource(linkIcon)) - : undefined; - return ( - menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier) - : undefined - } - > - {androidChip ? ( - // The inline box sits on the baseline and is only as tall as the font's - // ascent, so it never changes the line's height. The bitmap hangs off that - // box (views in text are not clipped) to centre the chip on the text. - { - if (event.nativeEvent.actionName === "activate") onPress(); - } - : undefined - } - style={{ width: androidChip.width, height: androidChip.boxHeight }} - > - - - ) : Platform.OS === "android" && run.fileIcon ? ( - - ) : Platform.OS === "android" && linkIcon ? ( - - ) : null} - {text} - - ); - })} - - ); -} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx deleted file mode 100644 index 8b42b132b2d00f249f352632fc1d9b1d3d1b0f00..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { useMemo } from "react"; -import { View } from "react-native"; -import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; - -import { - nativeMarkdownWithHtml, - nativeMarkdownChunkSpacing, - nativeMarkdownDocumentChunks, - nativeMarkdownDocumentRuns, - nativeMarkdownWithAuthoredWindowsPaths, - nativeMarkdownWithPreservedSoftBreaks, -} from "./nativeMarkdownText"; -import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock"; -import { - MarkdownContextClipboardContext, - MarkdownFileContextMenuContext, - NativeMarkdownSelectableText, - type MarkdownFileContextMenuHandlers, -} from "./NativeMarkdownSelectableText"; -import type { - SelectableMarkdownSkill, - SelectableMarkdownTextProps, -} from "./SelectableMarkdownText.types"; - -const EMPTY_SKILLS: ReadonlyArray = []; - -export type { - MarkdownCodeHighlighter, - MarkdownHighlightedToken, - MarkdownImageRenderer, - MarkdownImageRequest, - NativeMarkdownTextStyle, - SelectableMarkdownSkill, - SelectableMarkdownTextProps, -} from "./SelectableMarkdownText.types"; - -export function hasNativeSelectableMarkdownText(): boolean { - return true; -} - -export function SelectableMarkdownText({ - markdown, - contextClipboardFragment, - skills = EMPTY_SKILLS, - textStyle, - highlightCode, - preserveSoftBreaks = false, - onLinkPress, - fileContextMenu, - onFileContextMenuAction, - renderImage, - marginTop = 0, - marginBottom = 0, -}: SelectableMarkdownTextProps) { - const chunks = useMemo(() => { - const parsedDocument = nativeMarkdownWithAuthoredWindowsPaths( - nativeMarkdownWithHtml( - parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false }), - (content) => parseMarkdownWithOptions(content, { gfm: true, html: false, math: false }), - ), - markdown, - ); - const document = preserveSoftBreaks - ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument) - : parsedDocument; - return nativeMarkdownDocumentChunks(document).map((chunk) => - chunk.kind === "selectable" - ? { - ...chunk, - runs: nativeMarkdownDocumentRuns(chunk.node, skills), - } - : chunk, - ); - }, [markdown, preserveSoftBreaks, skills]); - - const fileContextMenuHandlers = useMemo( - () => - fileContextMenu && onFileContextMenuAction - ? { fileContextMenu, onFileContextMenuAction } - : null, - [fileContextMenu, onFileContextMenuAction], - ); - - return ( - - - - {/* A percentage width here creates a cyclic intrinsic measurement inside - shrink-to-fit containers such as user-message bubbles. Yoga then gives - the native text node an unbounded second pass and the parent only clips - the resulting single-line width instead of reflowing it. */} - - {chunks.map((chunk, index) => { - const content = - chunk.kind === "rich" ? ( - - ) : ( - - ); - - return ( - - {content} - - ); - })} - - - - - ); -} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts deleted file mode 100644 index adfa34365af6171cb4b426565452e2383d00d024..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ /dev/null @@ -1,92 +0,0 @@ -export interface NativeMarkdownTextStyle { - readonly selectionColor?: string; - readonly selectionHandleColor?: string; - readonly color: string; - readonly strongColor: string; - readonly mutedColor: string; - readonly linkColor: string; - readonly inlineCodeColor: string; - readonly codeColor: string; - readonly codeBackgroundColor: string; - readonly codeBlockBackgroundColor: string; - readonly fileTextColor: string; - readonly skillTextColor: string; - readonly quoteMarkerColor: string; - readonly dividerColor: string; - readonly contextChipBorderColor?: string; - readonly fontSize: number; - readonly lineHeight: number; - readonly fontFamily: string; - readonly headingFontFamily: string; - readonly boldFontFamily: string; - readonly headingFontSizes?: ReadonlyArray; -} - -export interface MarkdownHighlightedToken { - readonly content: string; - readonly color: string | null; - readonly fontStyle: number | null; -} - -export interface MarkdownCodeHighlightInput { - /** Identity of the mounted code block, for incremental highlighting. */ - readonly session?: object; - readonly code: string; - readonly language?: string | null; - readonly theme: "light" | "dark"; -} -export interface MarkdownCodeHighlighter { - ( - input: MarkdownCodeHighlightInput, - ): Promise>>; - /** Optional synchronous result for a small append to an already warm block. */ - read?: ( - input: MarkdownCodeHighlightInput, - ) => ReadonlyArray> | undefined; -} - -export interface SelectableMarkdownSkill { - readonly name: string; - readonly displayName?: string | null; -} - -export interface MarkdownImageRequest { - readonly href: string; - readonly alt: string | null; - readonly title: string | null; -} - -/** - * App-supplied renderer for markdown images. The module cannot load - * workspace-relative image paths itself — the host app resolves them (for - * example through a signed asset URL) and returns the element to show. - * Returning null falls back to the module's plain remote-URI rendering. - */ -export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode; - -export interface MarkdownFileContextMenuAction { - readonly id: string; - readonly title: string; - readonly disabled?: boolean; -} - -export interface MarkdownFileContextMenu { - readonly title?: string; - readonly actions: ReadonlyArray; -} - -export interface SelectableMarkdownTextProps { - readonly markdown: string; - /** Opaque context payload supplied by the host for native selection copy. */ - readonly contextClipboardFragment?: string; - readonly textStyle: NativeMarkdownTextStyle; - readonly highlightCode: MarkdownCodeHighlighter; - readonly skills?: ReadonlyArray; - readonly preserveSoftBreaks?: boolean; - readonly onLinkPress?: (href: string) => void; - readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined; - readonly onFileContextMenuAction?: (href: string, actionId: string) => void; - readonly renderImage?: MarkdownImageRenderer; - readonly marginTop?: number; - readonly marginBottom?: number; -} diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts deleted file mode 100644 index d505efd1f05f4d3ab52034ece81a03fb8d035a8b..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { codegenNativeComponent, type CodegenTypes, type ViewProps } from "react-native"; - -interface TargetedEvent { - target: CodegenTypes.Int32; -} - -interface TextLayoutEvent extends TargetedEvent { - lines: string[]; -} - -/** - * Event fired when text selection changes in the MarkdownTextPrimitive. - * @property target - The view tag identifier - * @property start - The start index of the selected range (0-based) - * @property end - The end index of the selected range (0-based, exclusive) - */ -interface SelectionChangeEvent extends TargetedEvent { - start: CodegenTypes.Int32; - end: CodegenTypes.Int32; -} - -type EllipsizeMode = "head" | "middle" | "tail" | "clip"; - -interface NativeProps extends ViewProps { - contextClipboardConfig?: string; - numberOfLines?: CodegenTypes.Int32; - allowFontScaling?: CodegenTypes.WithDefault; - ellipsizeMode?: CodegenTypes.WithDefault; - selectable?: boolean; - onTextLayout?: CodegenTypes.BubblingEventHandler; - /** - * Callback fired when the text selection changes. - * - * @example - * ```tsx - * { - * console.log('Selection:', event.nativeEvent.start, event.nativeEvent.end); - * }} - * > - * Selectable text - * - * ``` - */ - onSelectionChange?: CodegenTypes.BubblingEventHandler; -} - -export default codegenNativeComponent("T3MarkdownText", { - excludedPlatforms: ["android"], -}); diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts deleted file mode 100644 index 6c28742256c653c536adb7b0123daa6a56f13b07..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - codegenNativeComponent, - type CodegenTypes, - type ColorValue, - type ViewProps, -} from "react-native"; - -interface TargetedEvent { - target: CodegenTypes.Int32; -} - -interface ContextMenuActionEvent extends TargetedEvent { - actionIdentifier: string; -} - -type TextDecorationLine = "none" | "underline" | "line-through"; - -type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed"; - -export type NativeFontWeight = - | "normal" - | "bold" - | "ultraLight" - | "light" - | "medium" - | "semibold" - | "heavy"; - -type FontStyle = "normal" | "italic"; - -type TextAlign = "auto" | "left" | "right" | "center" | "justify"; - -interface NativeProps extends ViewProps { - text: string; - color?: ColorValue; - fontSize?: CodegenTypes.Float; - fontStyle?: CodegenTypes.WithDefault; - fontWeight?: CodegenTypes.WithDefault; - fontFamily?: string; - letterSpacing?: CodegenTypes.Float; - lineHeight?: CodegenTypes.Float; - textDecorationLine?: CodegenTypes.WithDefault; - textDecorationStyle?: CodegenTypes.WithDefault; - textDecorationColor?: ColorValue; - textAlign?: CodegenTypes.WithDefault; - shadowRadius?: CodegenTypes.WithDefault; - contextMenuConfig?: string; - onPress?: CodegenTypes.BubblingEventHandler; - onLongPress?: CodegenTypes.BubblingEventHandler; - onContextMenuAction?: CodegenTypes.BubblingEventHandler; -} - -export default codegenNativeComponent("T3MarkdownTextRun", { - excludedPlatforms: ["android"], -}); diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts deleted file mode 100644 index 56d8f6d4e0f8824265e55bcba32dfa5f56010b5a..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { requireOptionalNativeModule } from "expo"; - -interface T3MarkdownTextSelectionNativeModule { - readonly setSelectionHandleColor?: (reactTag: number, color: number) => void; - readonly installCopySanitizer: (reactTag: number, contextClipboardConfig: string) => void; - readonly renderContextChip?: (payloadJson: string) => { - readonly uri: string; - readonly width: number; - readonly height: number; - /** Inline box height: the paragraph font's ascent, so the line box never grows. */ - readonly boxHeight: number; - /** Bitmap top relative to the box top; negative when the chip overhangs the box. */ - readonly offsetY: number; - } | null; -} - -const nativeModule = - requireOptionalNativeModule("T3MarkdownTextSelection"); - -export function installMarkdownCopySanitizer(reactTag: number, contextClipboardConfig = ""): void { - nativeModule?.installCopySanitizer(reactTag, contextClipboardConfig); -} - -export function setMarkdownSelectionHandleColor(reactTag: number, color: number): void { - nativeModule?.setSelectionHandleColor?.(reactTag, color); -} - -export function renderAndroidContextChip(payloadJson: string) { - return nativeModule?.renderContextChip?.(payloadJson) ?? null; -} diff --git a/apps/mobile/modules/t3-markdown-text/src/githubAlert.test.ts b/apps/mobile/modules/t3-markdown-text/src/githubAlert.test.ts deleted file mode 100644 index 6158fc1ef88c785c4bf8b0345a5c4154215a12cf..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/githubAlert.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import type { MarkdownNode } from "react-native-nitro-markdown/headless"; -import { githubAlert } from "./githubAlert"; - -const quote = (...children: MarkdownNode[]): MarkdownNode => ({ - type: "blockquote", - children: [{ type: "paragraph", children }], -}); - -describe("GitHub alerts in native Markdown", () => { - it.each(["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"])( - "recognizes %s and retains formatted content", - (marker) => { - const bold: MarkdownNode = { type: "bold", children: [{ type: "text", content: "Details" }] }; - const node = quote({ type: "text", content: `[!${marker}]` }, { type: "soft_break" }, bold); - const original = structuredClone(node); - const alert = githubAlert(node); - expect(alert?.title.toUpperCase()).toBe(marker); - expect(alert?.children[0]?.children).toEqual([bold]); - expect(node).toEqual(original); - }, - ); - it("handles text nodes containing the line break and following text", () => { - expect( - githubAlert(quote({ type: "text", content: "[!WARNING]\nKeep this text." }))?.children[0] - ?.children?.[0]?.content, - ).toBe("Keep this text."); - }); - it("leaves inline markers, code, and ordinary quotes alone", () => { - expect(githubAlert(quote({ type: "text", content: "[!WARNING] inline" }))).toBeNull(); - expect( - githubAlert( - quote( - { type: "text", content: "[!WARNING]" }, - { type: "bold", children: [{ type: "text", content: "inline" }] }, - ), - ), - ).toBeNull(); - expect(githubAlert({ type: "code_block", content: "> [!WARNING]" })).toBeNull(); - expect(githubAlert(quote({ type: "text", content: "Just a quote" }))).toBeNull(); - }); -}); diff --git a/apps/mobile/modules/t3-markdown-text/src/githubAlert.ts b/apps/mobile/modules/t3-markdown-text/src/githubAlert.ts deleted file mode 100644 index 8d3dd1bef075d95041c96f34c550533966f832f2..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/githubAlert.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { MarkdownNode } from "react-native-nitro-markdown/headless"; - -const ALERTS = { - NOTE: { title: "Note", color: "#4493f8", lightColor: "#0969da", symbol: "info.circle" }, - TIP: { title: "Tip", color: "#3fb950", lightColor: "#1a7f37", symbol: "lightbulb" }, - IMPORTANT: { - title: "Important", - color: "#ab7df8", - lightColor: "#8250df", - symbol: "exclamationmark.bubble", - }, - WARNING: { - title: "Warning", - color: "#d29922", - lightColor: "#9a6700", - symbol: "exclamationmark.triangle", - }, - CAUTION: { - title: "Caution", - color: "#f85149", - lightColor: "#cf222e", - symbol: "exclamationmark.octagon", - }, -} as const; - -/** GitHub alerts are blockquotes whose first line contains only the alert marker. */ -export function githubAlert(node: MarkdownNode) { - if (node.type !== "blockquote") return null; - const paragraph = node.children?.[0]; - const text = paragraph?.children?.[0]; - if (paragraph?.type !== "paragraph" || text?.type !== "text") return null; - const match = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](\r?\n|$)/i.exec(text.content ?? ""); - if (!match) return null; - const rest = (text.content ?? "").slice(match[0].length); - const siblings = paragraph.children?.slice(1) ?? []; - if ( - !rest && - !match[2] && - siblings.length && - !["soft_break", "line_break"].includes(siblings[0]!.type) - ) - return null; - if (!rest && ["soft_break", "line_break"].includes(siblings[0]?.type ?? "")) siblings.shift(); - const children = [...(rest ? [{ ...text, content: rest }] : []), ...siblings]; - return { - ...ALERTS[match[1]!.toUpperCase() as keyof typeof ALERTS], - children: [ - ...(children.length ? [{ ...paragraph, children }] : []), - ...(node.children?.slice(1) ?? []), - ], - }; -} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts deleted file mode 100644 index fb0209b6a990bbe749c698ce6209d3bbd9015baf..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { ImageSourcePropType } from "react-native"; - -export const MARKDOWN_FILE_ICON_SOURCES = { - agents: require("../assets/file-icons/pierre_agents.png"), - astro: require("../assets/file-icons/pierre_astro.png"), - babel: require("../assets/file-icons/pierre_babel.png"), - bash: require("../assets/file-icons/pierre_bash.png"), - biome: require("../assets/file-icons/pierre_biome.png"), - bootstrap: require("../assets/file-icons/pierre_bootstrap.png"), - browserslist: require("../assets/file-icons/pierre_browserslist.png"), - bun: require("../assets/file-icons/pierre_bun.png"), - c: require("../assets/file-icons/pierre_c.png"), - claude: require("../assets/file-icons/pierre_claude.png"), - cpp: require("../assets/file-icons/pierre_cpp.png"), - css: require("../assets/file-icons/pierre_css.png"), - database: require("../assets/file-icons/pierre_database.png"), - default: require("../assets/file-icons/pierre_default.png"), - docker: require("../assets/file-icons/pierre_docker.png"), - eslint: require("../assets/file-icons/pierre_eslint.png"), - font: require("../assets/file-icons/pierre_font.png"), - git: require("../assets/file-icons/pierre_git.png"), - go: require("../assets/file-icons/pierre_go.png"), - graphql: require("../assets/file-icons/pierre_graphql.png"), - html: require("../assets/file-icons/pierre_html.png"), - image: require("../assets/file-icons/pierre_image.png"), - javascript: require("../assets/file-icons/pierre_javascript.png"), - json: require("../assets/file-icons/pierre_json.png"), - markdown: require("../assets/file-icons/pierre_markdown.png"), - mcp: require("../assets/file-icons/pierre_mcp.png"), - nextjs: require("../assets/file-icons/pierre_nextjs.png"), - npm: require("../assets/file-icons/pierre_npm.png"), - oxc: require("../assets/file-icons/pierre_oxc.png"), - pnpm: require("../assets/file-icons/pierre_pnpm.png"), - postcss: require("../assets/file-icons/pierre_postcss.png"), - prettier: require("../assets/file-icons/pierre_prettier.png"), - python: require("../assets/file-icons/pierre_python.png"), - react: require("../assets/file-icons/pierre_react.png"), - ruby: require("../assets/file-icons/pierre_ruby.png"), - rust: require("../assets/file-icons/pierre_rust.png"), - sass: require("../assets/file-icons/pierre_sass.png"), - stylelint: require("../assets/file-icons/pierre_stylelint.png"), - svelte: require("../assets/file-icons/pierre_svelte.png"), - svg: require("../assets/file-icons/pierre_svg.png"), - svgo: require("../assets/file-icons/pierre_svgo.png"), - swift: require("../assets/file-icons/pierre_swift.png"), - table: require("../assets/file-icons/pierre_table.png"), - tailwind: require("../assets/file-icons/pierre_tailwind.png"), - terraform: require("../assets/file-icons/pierre_terraform.png"), - text: require("../assets/file-icons/pierre_text.png"), - typescript: require("../assets/file-icons/pierre_typescript.png"), - video: require("../assets/file-icons/pierre_video.png"), - vite: require("../assets/file-icons/pierre_vite.png"), - vscode: require("../assets/file-icons/pierre_vscode.png"), - vue: require("../assets/file-icons/pierre_vue.png"), - wasm: require("../assets/file-icons/pierre_wasm.png"), - webpack: require("../assets/file-icons/pierre_webpack.png"), - yml: require("../assets/file-icons/pierre_yml.png"), - zig: require("../assets/file-icons/pierre_zig.png"), - zip: require("../assets/file-icons/pierre_zip.png"), -} as const satisfies Readonly>; diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts deleted file mode 100644 index 99c902727dfe53bb3ca949dd0e64a70231871e8a..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Image, type ImageSourcePropType } from "react-native"; - -import type { MarkdownFileIcon } from "./markdownLinks"; -import { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; - -export function markdownFileIconSource(icon: MarkdownFileIcon): ImageSourcePropType { - return MARKDOWN_FILE_ICON_SOURCES[icon]; -} - -/** - * Bundled icon URI handed to native views that draw the icon themselves. The - * sources are static requires, so this only comes back undefined when the - * bundler dropped the asset; callers treat a missing icon as "no icon". - */ -export function markdownIconAssetUri(source: ImageSourcePropType): string | undefined { - return Image.resolveAssetSource(source)?.uri; -} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts deleted file mode 100644 index 568a510057985f5baf15734668eccabdfde12251..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ImageSourcePropType } from "react-native"; - -import type { MarkdownLinkIcon } from "./markdownLinks"; - -// Black-on-transparent marks; callers tint them with the link color. -const MARKDOWN_LINK_ICON_SOURCES = { - github: require("../assets/link-icons/github.png"), -} as const satisfies Readonly>; - -export function markdownLinkIconSource(icon: MarkdownLinkIcon): ImageSourcePropType { - return MARKDOWN_LINK_ICON_SOURCES[icon]; -} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts deleted file mode 100644 index 913f7cb37167a8652fe29459ecdfc26c8c7fc9aa..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { fileBasename } from "@t3tools/shared/path"; -import { formatFilePathPosition } from "@t3tools/shared/fileLinks"; -import { - inlineCodeFilePathCandidate, - normalizeMarkdownLinkDestination, - parseMarkdownFileLink, -} from "@t3tools/shared/markdownLinks"; -import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; -import { videoMimeType } from "@t3tools/shared/video"; - -import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; - -const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; - -export type MarkdownLinkPresentation = - | { - readonly kind: "external"; - readonly href: string; - readonly host: string; - } - | { - readonly kind: "file"; - readonly href: string; - readonly icon: MarkdownFileIcon; - readonly label: string; - readonly path: string; - readonly line?: number; - readonly column?: number; - } - | { - readonly kind: "link"; - readonly href: string | null; - }; - -export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; - -export type MarkdownLinkIcon = "github"; - -/** - * Sites whose brand mark replaces the generic external-link glyph. The marks - * are monochrome and tinted with the link color, so they follow the theme. - */ -export function resolveMarkdownLinkIcon(host: string): MarkdownLinkIcon | null { - const hostname = host.toLowerCase(); - if (hostname === "github.com" || hostname.endsWith(".github.com")) return "github"; - return null; -} - -const FILE_ICON_BY_NAME: Readonly> = { - ".babelrc": "babel", - ".babelrc.json": "babel", - ".bash_profile": "bash", - ".bashrc": "bash", - ".browserslistrc": "browserslist", - ".dockerignore": "docker", - ".eslintignore": "eslint", - ".eslintrc": "eslint", - ".eslintrc.cjs": "eslint", - ".eslintrc.js": "eslint", - ".eslintrc.json": "eslint", - ".eslintrc.yaml": "eslint", - ".eslintrc.yml": "eslint", - ".gitattributes": "git", - ".gitignore": "git", - ".gitkeep": "git", - ".gitmodules": "git", - ".oxlintrc.json": "oxc", - ".postcssrc": "postcss", - ".postcssrc.json": "postcss", - ".postcssrc.yaml": "postcss", - ".postcssrc.yml": "postcss", - ".prettierignore": "prettier", - ".prettierrc": "prettier", - ".prettierrc.json": "prettier", - ".prettierrc.cjs": "prettier", - ".prettierrc.js": "prettier", - ".prettierrc.mjs": "prettier", - ".prettierrc.toml": "prettier", - ".prettierrc.yaml": "prettier", - ".prettierrc.yml": "prettier", - ".stylelintignore": "stylelint", - ".stylelintrc": "stylelint", - ".stylelintrc.cjs": "stylelint", - ".stylelintrc.js": "stylelint", - ".stylelintrc.json": "stylelint", - ".stylelintrc.mjs": "stylelint", - ".stylelintrc.yaml": "stylelint", - ".stylelintrc.yml": "stylelint", - ".terraform.lock.hcl": "terraform", - ".zprofile": "bash", - ".zshenv": "bash", - ".zshrc": "bash", - "agents.md": "agents", - "babel.config.js": "babel", - "babel.config.cjs": "babel", - "babel.config.json": "babel", - "babel.config.mjs": "babel", - "biome.json": "biome", - "biome.jsonc": "biome", - "bun.lock": "bun", - "bun.lockb": "bun", - "bunfig.toml": "bun", - "claude.md": "claude", - "compose.yaml": "docker", - "compose.yml": "docker", - "docker-compose.yaml": "docker", - "docker-compose.yml": "docker", - "docker-compose.override.yml": "docker", - dockerfile: "docker", - "eslint.config.js": "eslint", - "eslint.config.cjs": "eslint", - "eslint.config.mjs": "eslint", - "eslint.config.mts": "eslint", - "eslint.config.ts": "eslint", - gemfile: "ruby", - "next.config.js": "nextjs", - "next.config.mjs": "nextjs", - "next.config.mts": "nextjs", - "next.config.ts": "nextjs", - "package.json": "npm", - "pnpm-lock.yaml": "pnpm", - "pnpm-workspace.yaml": "pnpm", - "postcss.config.js": "postcss", - "postcss.config.cjs": "postcss", - "postcss.config.mjs": "postcss", - "postcss.config.ts": "postcss", - "prettier.config.js": "prettier", - "prettier.config.cjs": "prettier", - "prettier.config.mjs": "prettier", - rakefile: "ruby", - "readme.md": "markdown", - "stylelint.config.js": "stylelint", - "stylelint.config.cjs": "stylelint", - "stylelint.config.mjs": "stylelint", - "svgo.config.js": "svgo", - "svgo.config.cjs": "svgo", - "svgo.config.mjs": "svgo", - "svgo.config.ts": "svgo", - "tailwind.config.js": "tailwind", - "tailwind.config.cjs": "tailwind", - "tailwind.config.mjs": "tailwind", - "tailwind.config.ts": "tailwind", - "tsconfig.json": "typescript", - "vite.config.js": "vite", - "vite.config.mjs": "vite", - "vite.config.mts": "vite", - "vite.config.ts": "vite", - "webpack.config.js": "webpack", - "webpack.config.babel.js": "webpack", - "webpack.config.cjs": "webpack", - "webpack.config.mjs": "webpack", - "webpack.config.ts": "webpack", -}; - -const FILE_ICON_BY_EXTENSION: Readonly> = { - "7z": "zip", - astro: "astro", - avif: "image", - "code-workspace": "vscode", - bash: "bash", - bmp: "image", - bz2: "zip", - c: "c", - cc: "cpp", - cpp: "cpp", - cxx: "cpp", - css: "css", - csv: "table", - cts: "typescript", - db: "database", - env: "text", - "env.development": "text", - "env.local": "text", - "env.production": "text", - eot: "font", - erb: "ruby", - fish: "bash", - gif: "image", - go: "go", - gql: "graphql", - graphql: "graphql", - gz: "zip", - h: "c", - hh: "cpp", - hpp: "cpp", - hxx: "cpp", - htm: "html", - html: "html", - ico: "image", - icns: "image", - ini: "text", - inl: "cpp", - jar: "zip", - jpeg: "image", - jpg: "image", - js: "javascript", - jsx: "react", - json: "json", - jsonc: "json", - less: "css", - md: "markdown", - mdx: "markdown", - "mdx.tsx": "markdown", - mjs: "javascript", - mts: "typescript", - png: "image", - postcss: "css", - py: "python", - pyi: "python", - pyw: "python", - pyx: "python", - rake: "ruby", - rar: "zip", - rb: "ruby", - rs: "rust", - sass: "sass", - scss: "sass", - sh: "bash", - sql: "database", - sqlite: "database", - sqlite3: "database", - svelte: "svelte", - svg: "svg", - swift: "swift", - tar: "zip", - tf: "terraform", - tfstate: "terraform", - tfvars: "terraform", - tgz: "zip", - ts: "typescript", - tsv: "table", - tsx: "react", - txt: "text", - woff: "font", - woff2: "font", - vue: "vue", - wasm: "wasm", - webp: "image", - yml: "yml", - yaml: "yml", - zig: "zig", - zip: "zip", - zsh: "bash", -}; - -/** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */ -export function normalizeNativeMarkdownUrl(value: string): string { - return value.startsWith("//") ? `https:${value}` : value; -} - -export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { - const basename = fileBasename(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); - if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video"; - const exactIcon = FILE_ICON_BY_NAME[basename]; - if (exactIcon) return exactIcon; - if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { - return "typescript"; - } - const segments = basename.split("."); - for (let index = 1; index < segments.length; index += 1) { - const icon = FILE_ICON_BY_EXTENSION[segments.slice(index).join(".")]; - if (icon) return icon; - } - return "default"; -} - -export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { - const normalized = normalizeMarkdownLinkDestination(href); - try { - const parsed = new URL(normalizeNativeMarkdownUrl(normalized)); - if (parsed.protocol === "http:" || parsed.protocol === "https:") { - return { - kind: "external", - href: parsed.toString(), - host: parsed.hostname, - }; - } - } catch { - // Relative paths and non-URL link destinations are handled below. - } - - const target = parseMarkdownFileLink(normalized); - if (target) { - return { - kind: "file", - href: normalized, - icon: resolveMarkdownFileIcon(target.path), - label: fileBasename(formatFilePathPosition(target)), - path: target.path, - ...(target.line ? { line: target.line } : {}), - ...(target.column ? { column: target.column } : {}), - }; - } - - return { - kind: "link", - // A thread link keeps its href so a press reaches the feed, which opens the thread. - href: - /^(?:mailto|tel):/i.test(normalized) || parseThreadLinkHref(normalized) !== null - ? normalized - : null, - }; -} - -/** Backticks become file references only when the shared path heuristic recognizes the whole span. */ -export function resolveMarkdownInlineCodePresentation( - content: string, -): Extract | null { - const candidate = inlineCodeFilePathCandidate(content); - if (candidate === null) return null; - const presentation = resolveMarkdownLinkPresentation(candidate); - return presentation.kind === "file" ? presentation : null; -} diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts deleted file mode 100644 index 5fa4a274f409856f28825bf06e552ccb3c57618a..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ /dev/null @@ -1,1159 +0,0 @@ -import type { MarkdownNode } from "react-native-nitro-markdown/headless"; -import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; -import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; -import { imageMimeType } from "@t3tools/shared/image"; -import { isWindowsAbsolutePath } from "@t3tools/shared/path"; -import { videoMimeType } from "@t3tools/shared/video"; -/** - * Every accent shares a lightness so no kind reads heavier than another; only hue carries - * identity. These are the sRGB form of the same OKLCH set web uses, so a chip looks the - * same on every surface. See `composerInlineChip.ts`. - */ -const CONTEXT_CHIP_PRESENTATIONS = { - image: { accent: "#d55665", symbol: "photo" }, - video: { accent: "#d06217", symbol: "play.rectangle" }, - file: { accent: "#0090cd", symbol: "doc" }, - mention: { accent: "#0096af", symbol: "doc" }, - terminal: { accent: "#009f6e", symbol: "terminal" }, - element: { accent: "#b87501", symbol: "cursorarrow.click" }, - "preview-annotation": { accent: "#b87501", symbol: "cursorarrow.click" }, - "review-comment": { accent: "#8a70dd", symbol: "text.bubble" }, - "pull-request": { accent: "#7079e4", symbol: "git-pull-request" }, - skill: { accent: "#b261be", symbol: "cube" }, - thread: { accent: "#009c96", symbol: "text.bubble" }, -} as const; - -/** - * The size an attachment chip reports beside its name, matching web. Rendered as its own - * smaller run, so it carries no separator. Only attachment-backed records have bytes. - */ -export function composerChipSizeSuffix(record?: { - readonly kind?: string; - readonly sizeBytes?: number; -}): string { - if (record?.kind !== "file" && record?.kind !== "image") return ""; - return typeof record.sizeBytes === "number" ? formatAttachmentSize(record.sizeBytes) : ""; -} - -/** - * A pull request chip is coloured by what the pull request *is*, the way web colours it and - * the way the forge itself does: green open, grey draft, purple merged, red closed. The glyph - * stays the same across all four, as it does on web — state is carried by colour alone. - */ -const PULL_REQUEST_CHIP_PRESENTATIONS = { - open: { accent: "#009f6e", symbol: "git-pull-request" }, - draft: { accent: "#7f8793", symbol: "git-pull-request" }, - merged: { accent: "#8a70dd", symbol: "git-pull-request" }, - closed: { accent: "#d55665", symbol: "git-pull-request" }, -} as const; - -export function contextChipPresentation( - kind: string, - record?: { - readonly kind?: string; - readonly name?: string; - readonly mimeType?: string; - readonly sectionId?: string; - readonly pullRequest?: { - readonly state?: string; - readonly isDraft?: boolean; - }; - }, -) { - const presentationKind = - kind === "file" && - videoMimeType({ - name: record?.name ?? "", - mimeType: record?.mimeType ?? "", - }) - ? "video" - : // A picture chosen through the file picker is typed `file`, but it is still a - // picture: it reads as one to the user and should not wear the generic file chip. - kind === "file" && - imageMimeType({ name: record?.name ?? "", mimeType: record?.mimeType ?? "" }) !== null - ? "image" - : kind === "review-comment" && record?.sectionId?.startsWith("pull-request:") - ? "pull-request" - : kind; - if (presentationKind === "pull-request") { - const pullRequest = record?.pullRequest; - const state = - pullRequest?.state === "open" && pullRequest.isDraft === true - ? "draft" - : (pullRequest?.state ?? ""); - if (Object.hasOwn(PULL_REQUEST_CHIP_PRESENTATIONS, state)) { - return PULL_REQUEST_CHIP_PRESENTATIONS[state as keyof typeof PULL_REQUEST_CHIP_PRESENTATIONS]; - } - } - return Object.hasOwn(CONTEXT_CHIP_PRESENTATIONS, presentationKind) - ? CONTEXT_CHIP_PRESENTATIONS[presentationKind as keyof typeof CONTEXT_CHIP_PRESENTATIONS] - : CONTEXT_CHIP_PRESENTATIONS.file; -} -import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; -import { - formatComposerContextReference, - parseComposerContextHref, -} from "@t3tools/shared/composerContextReferences"; - -/** Native selections count UTF-16 display units, including each inline image placeholder. */ -export function nativeMarkdownContextCopyRanges( - runs: ReadonlyArray<{ - readonly run: { - readonly href?: string; - readonly text: string; - readonly skillName?: string; - readonly fileIcon?: string; - readonly sourceText?: string; - }; - readonly text: string; - readonly inlineImageLength: number; - }>, -) { - let offset = 0; - const ranges: Array<{ start: number; end: number; text: string }> = []; - for (const [index, { run, text, inlineImageLength }] of runs.entries()) { - const start = offset; - offset += text.length + inlineImageLength; - const reference = parseComposerContextHref(run.href ?? ""); - const source = reference - ? formatComposerContextReference({ ...reference, label: run.text }) - : run.skillName - ? `$${run.skillName}` - : run.sourceText !== undefined - ? run.sourceText - : run.fileIcon && run.href - ? `[${run.text}](<${run.href}>)` - : null; - if (source === null) continue; - const previous = ranges.at(-1); - if ( - run.sourceText !== undefined && - !runs[index - 1]?.run.fileIcon && - previous?.end === start && - previous.text === source - ) { - previous.end = offset; - } else { - ranges.push({ start, end: offset, text: source }); - } - } - return ranges; -} - -import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; -import { - resolveMarkdownInlineCodePresentation, - resolveMarkdownLinkPresentation, - type MarkdownFileIcon, -} from "./markdownLinks"; - -export interface NativeMarkdownTextRun { - readonly text: string; - readonly bold?: boolean; - readonly italic?: boolean; - readonly strikethrough?: boolean; - readonly code?: boolean; - readonly href?: string; - readonly externalHost?: string; - readonly fileIcon?: MarkdownFileIcon; - readonly skillName?: string; - readonly skillLabel?: string; - readonly sourceText?: string; - readonly role?: - | "body" - | "heading" - | "list-marker" - | "list-break" - | "quote-marker" - | "code-block" - | "code-language" - | "divider" - | "spacer"; - readonly headingLevel?: number; - readonly depth?: number; - readonly spacing?: number; - readonly firstLineHeadIndent?: number; - readonly headIndent?: number; - readonly paragraphSpacing?: number; -} - -export type NativeMarkdownDocumentChunk = - | { - readonly kind: "selectable"; - readonly key: string; - readonly node: MarkdownNode; - } - | { - readonly kind: "rich"; - readonly key: string; - readonly node: MarkdownNode; - }; - -interface RunContext { - readonly bold: boolean; - readonly italic: boolean; - readonly strikethrough: boolean; - readonly code: boolean; - readonly href?: string; - readonly externalHost?: string; - readonly fileIcon?: MarkdownFileIcon; - readonly sourceText?: string; - readonly role?: NativeMarkdownTextRun["role"]; - readonly headingLevel?: number; - readonly depth?: number; - readonly spacing?: number; - readonly firstLineHeadIndent?: number; - readonly headIndent?: number; - readonly paragraphSpacing?: number; -} - -const EMPTY_CONTEXT: RunContext = { - bold: false, - italic: false, - strikethrough: false, - code: false, -}; - -const INLINE_HTML_TAG_PATTERN = /<\/?(?:kbd|mark|sub|sup|u)(?:\s[^>]*)?>/gi; - -function decodeCodePoint(codePoint: number, entity: string): string { - if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { - return entity; - } - return String.fromCodePoint(codePoint); -} - -function decodeHtmlEntitiesOnce(value: string): string { - return value.replace( - /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi, - (entity, decimal: string | undefined, hexadecimal: string | undefined) => { - if (decimal) { - return decodeCodePoint(Number.parseInt(decimal, 10), entity); - } - if (hexadecimal) { - return decodeCodePoint(Number.parseInt(hexadecimal, 16), entity); - } - switch (entity.toLowerCase()) { - case "&": - return "&"; - case "'": - return "'"; - case ">": - return ">"; - case "<": - return "<"; - case " ": - return "\u00a0"; - case """: - return '"'; - default: - return entity; - } - }, - ); -} - -function decodeHtmlEntities(value: string): string { - let decoded = value; - for (let pass = 0; pass < 2; pass += 1) { - const next = decodeHtmlEntitiesOnce(decoded); - if (next === decoded) { - break; - } - decoded = next; - } - return decoded; -} - -function textNodeContent(value: string): string { - return decodeHtmlEntities(value).replace(INLINE_HTML_TAG_PATTERN, ""); -} - -function inlineHtmlText(value: string): string { - if (/^$/i.test(value.trim())) { - return "\n"; - } - return decodeHtmlEntities(value.replace(/<[^>]+>/g, "")); -} - -function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun): boolean { - return ( - left.bold === right.bold && - left.italic === right.italic && - left.strikethrough === right.strikethrough && - left.code === right.code && - left.href === right.href && - left.externalHost === right.externalHost && - left.fileIcon === right.fileIcon && - left.sourceText === right.sourceText && - left.skillName === right.skillName && - left.skillLabel === right.skillLabel && - left.role === right.role && - left.headingLevel === right.headingLevel && - left.depth === right.depth && - left.spacing === right.spacing && - left.firstLineHeadIndent === right.firstLineHeadIndent && - left.headIndent === right.headIndent && - left.paragraphSpacing === right.paragraphSpacing - ); -} - -function appendRun( - runs: NativeMarkdownTextRun[], - text: string, - context: RunContext, -): NativeMarkdownTextRun[] { - if (text.length === 0) { - return runs; - } - - const run: NativeMarkdownTextRun = { - text, - ...(context.bold ? { bold: true } : {}), - ...(context.italic ? { italic: true } : {}), - ...(context.strikethrough ? { strikethrough: true } : {}), - ...(context.code ? { code: true } : {}), - ...(context.href ? { href: context.href } : {}), - ...(context.externalHost ? { externalHost: context.externalHost } : {}), - ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}), - ...(context.sourceText !== undefined ? { sourceText: context.sourceText } : {}), - ...(context.role ? { role: context.role } : {}), - ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}), - ...(context.depth ? { depth: context.depth } : {}), - ...(context.spacing ? { spacing: context.spacing } : {}), - ...(context.firstLineHeadIndent !== undefined - ? { firstLineHeadIndent: context.firstLineHeadIndent } - : {}), - ...(context.headIndent !== undefined ? { headIndent: context.headIndent } : {}), - ...(context.paragraphSpacing !== undefined - ? { paragraphSpacing: context.paragraphSpacing } - : {}), - }; - const previous = runs.at(-1); - if (previous && sameRunStyle(previous, run)) { - runs[runs.length - 1] = { ...previous, text: previous.text + run.text }; - return runs; - } - - runs.push(run); - return runs; -} - -const SKILL_TOKEN_REGEX = - /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; - -function formatSkillLabel(skill: SelectableMarkdownSkill): string { - const displayName = skill.displayName?.trim(); - if (displayName) { - return displayName; - } - return skill.name - .split(/[\s:_-]+/) - .filter(Boolean) - .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) - .join(" "); -} - -function decorateSkillRuns( - runs: ReadonlyArray, - skills: ReadonlyArray, -): ReadonlyArray { - if (skills.length === 0) { - return runs; - } - const skillByName = new Map(skills.map((skill) => [skill.name, skill])); - const decorated: NativeMarkdownTextRun[] = []; - - for (const run of runs) { - if (run.code || run.href || run.fileIcon || run.role === "code-block") { - decorated.push(run); - continue; - } - - let cursor = 0; - let matched = false; - for (const match of run.text.matchAll(SKILL_TOKEN_REGEX)) { - const prefix = match[1] ?? ""; - const name = match[2] ?? ""; - const skill = skillByName.get(name); - if (!skill) { - continue; - } - const start = (match.index ?? 0) + prefix.length; - const end = (match.index ?? 0) + match[0].length; - if (start > cursor) { - decorated.push({ ...run, text: run.text.slice(cursor, start) }); - } - decorated.push({ - ...run, - text: run.text.slice(start, end), - skillName: name, - skillLabel: formatSkillLabel(skill), - }); - cursor = end; - matched = true; - } - if (!matched) { - decorated.push(run); - } else if (cursor < run.text.length) { - decorated.push({ ...run, text: run.text.slice(cursor) }); - } - } - - return decorated; -} - -function decorateMentionRuns(runs: ReadonlyArray) { - return runs.flatMap((run) => { - if (run.code || run.href || run.skillName || run.role === "code-block") return [run]; - const decorated: NativeMarkdownTextRun[] = []; - let cursor = 0; - for (const token of collectComposerInlineTokens(`${run.text} `)) { - if (token.type !== "mention" || !token.source.startsWith("@")) continue; - // Sentence punctuation is not part of an unquoted file reference. - const path = token.source.startsWith('@"') - ? token.value - : token.value.replace(/[.,;!?]+$/, ""); - const presentation = resolveMarkdownLinkPresentation(path); - if (presentation.kind !== "file") continue; - const end = token.end - (token.value.length - path.length); - if (token.start > cursor) - decorated.push({ ...run, text: run.text.slice(cursor, token.start) }); - decorated.push({ - ...run, - text: presentation.label, - href: presentation.href, - fileIcon: presentation.icon, - sourceText: run.text.slice(token.start, end), - }); - cursor = end; - } - if (cursor < run.text.length) decorated.push({ ...run, text: run.text.slice(cursor) }); - return decorated; - }); -} - -function appendChildren( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - context: RunContext, -): NativeMarkdownTextRun[] { - for (const child of node.children ?? []) { - appendNode(runs, child, context); - } - return runs; -} - -function nodeTextContent(node: MarkdownNode): string { - if (node.content !== undefined) { - return node.content; - } - return (node.children ?? []).map(nodeTextContent).join(""); -} - -function fileLinkLabelMarkdown(node: MarkdownNode): string { - const children = (node.children ?? []).map(fileLinkLabelMarkdown).join(""); - switch (node.type) { - case "bold": - return `**${children}**`; - case "italic": - return `*${children}*`; - case "strikethrough": - return `~~${children}~~`; - case "code_inline": { - const content = nodeTextContent(node); - const fence = "`".repeat( - Math.max(0, ...(content.match(/`+/g) ?? []).map((run) => run.length)) + 1, - ); - const padding = /^`|`$|^ .* $/.test(content) && content.trim().length > 0 ? " " : ""; - return `${fence}${padding}${content}${padding}${fence}`; - } - case "soft_break": - return "\n"; - case "line_break": - return " \n"; - case "text": - return textNodeContent(nodeTextContent(node)).replace(/[\\[\]*_`~]/g, "\\$&"); - case "image": - return `![${(node.alt ?? "").replace(/[\\[\]]/g, "\\$&")}](<${node.href ?? ""}>)`; - default: - return node.content ?? children; - } -} - -function appendNode( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - context: RunContext, -): NativeMarkdownTextRun[] { - switch (node.type) { - case "text": - case "math_inline": - return appendRun(runs, textNodeContent(nodeTextContent(node)), context); - case "html_inline": - return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); - case "code_inline": { - const content = nodeTextContent(node); - const presentation = context.href ? null : resolveMarkdownInlineCodePresentation(content); - return presentation - ? appendRun(runs, presentation.label, { - ...context, - href: presentation.href, - fileIcon: presentation.icon, - }) - : appendRun(runs, content, { ...context, code: true }); - } - case "soft_break": - return appendRun(runs, " ", context); - case "line_break": - return appendRun(runs, "\n", context); - case "bold": - return appendChildren(runs, node, { ...context, bold: true }); - case "italic": - return appendChildren(runs, node, { ...context, italic: true }); - case "strikethrough": - return appendChildren(runs, node, { ...context, strikethrough: true }); - case "link": { - const reference = parseComposerContextHref(node.href ?? ""); - if (reference) { - // Build the link in isolation: adjacent links with the same href and - // style would otherwise merge into one run, collapsing two chips and - // their copy ranges into a single reference with a combined label. - const referenceRuns: NativeMarkdownTextRun[] = []; - appendChildren(referenceRuns, node, { - ...context, - href: node.href, - fileIcon: - reference.kind === "image" ? "image" : reference.kind === "terminal" ? "bash" : "text", - }); - runs.push(...referenceRuns); - return runs; - } - const presentation = resolveMarkdownLinkPresentation(node.href ?? ""); - if (presentation.kind === "file") { - const label = textNodeContent(nodeTextContent(node)); - if (!isMarkdownFileLinkLabel(label, presentation.href)) { - const fileRuns: NativeMarkdownTextRun[] = []; - const fileContext = { - ...context, - href: presentation.href, - sourceText: `[${fileLinkLabelMarkdown(node)}](<${presentation.href}>)`, - }; - appendChildren(fileRuns, node, fileContext); - appendRun(fileRuns, " ", fileContext); - appendRun(fileRuns, presentation.label, { - ...fileContext, - fileIcon: presentation.icon, - }); - runs.push(...fileRuns); - return runs; - } - return appendRun(runs, presentation.label, { - ...context, - href: presentation.href, - fileIcon: presentation.icon, - }); - } - if (presentation.kind === "external") { - return appendChildren(runs, node, { - ...context, - href: presentation.href, - externalHost: presentation.host, - }); - } - return appendChildren(runs, node, { - ...context, - ...(presentation.href ? { href: presentation.href } : {}), - }); - } - case "image": - return appendRun(runs, node.alt ?? node.title ?? "", context); - default: - return appendChildren(runs, node, context); - } -} - -export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray { - return appendChildren([], node, EMPTY_CONTEXT); -} - -export function nativeMarkdownWithPreservedSoftBreaks(node: MarkdownNode): MarkdownNode { - const children = node.children?.map(nativeMarkdownWithPreservedSoftBreaks); - return { - ...node, - ...(node.type === "soft_break" ? { type: "line_break" as const } : {}), - ...(children ? { children } : {}), - }; -} - -const WINDOWS_DESTINATION_PATTERN = /\](?:\(|:)\s*\\])*)/g; -const MARKDOWN_ESCAPE_PATTERN = /\\([!-/:-@[-`{-~])/g; - -/** - * md4c reads the `\.` in `C:\me\.t3\shot.png` as an escape, even in a link destination. - * Every backslash in a Windows path is a separator, so link and image paths go back to - * the destination as written in `markdown`. A parsed path that more than one written - * destination could have produced stays as parsed. - */ -export function nativeMarkdownWithAuthoredWindowsPaths( - node: MarkdownNode, - markdown: string, -): MarkdownNode { - const authoredByParsed = new Map(); - for (const [, authored = ""] of markdown.matchAll(WINDOWS_DESTINATION_PATTERN)) { - if (!isWindowsAbsolutePath(authored)) continue; - const parsed = authored.replace(MARKDOWN_ESCAPE_PATTERN, "$1"); - const known = authoredByParsed.get(parsed); - authoredByParsed.set(parsed, known === undefined || known === authored ? authored : null); - } - if (authoredByParsed.size === 0) return node; - const restore = (current: MarkdownNode): MarkdownNode => { - const href = current.href && authoredByParsed.get(current.href); - const children = current.children?.map(restore); - return { ...current, ...(href ? { href } : {}), ...(children ? { children } : {}) }; - }; - return restore(node); -} - -function appendBlockTerminator( - runs: NativeMarkdownTextRun[], - context: RunContext, -): NativeMarkdownTextRun[] { - return appendRun(runs, "\n", context); -} - -function appendSpacer(runs: NativeMarkdownTextRun[], spacing: number): NativeMarkdownTextRun[] { - return appendRun(runs, "\n", { ...EMPTY_CONTEXT, role: "spacer", spacing }); -} - -function appendInlineChildren( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - context: RunContext, -): NativeMarkdownTextRun[] { - for (const child of node.children ?? []) { - appendNode(runs, child, context); - } - return runs; -} - -function isInlineNode(node: MarkdownNode): boolean { - return ( - node.type === "text" || - node.type === "bold" || - node.type === "italic" || - node.type === "strikethrough" || - node.type === "link" || - node.type === "image" || - node.type === "code_inline" || - node.type === "math_inline" || - node.type === "html_inline" || - node.type === "soft_break" || - node.type === "line_break" - ); -} - -export function nativeMarkdownListItemBlocks(node: MarkdownNode): ReadonlyArray { - const blocks: MarkdownNode[] = []; - let inlineNodes: MarkdownNode[] = []; - const flushInlineNodes = () => { - if (inlineNodes.length === 0) { - return; - } - blocks.push({ type: "paragraph", children: inlineNodes }); - inlineNodes = []; - }; - - for (const child of node.children ?? []) { - if (isInlineNode(child)) { - inlineNodes.push(child); - continue; - } - - flushInlineNodes(); - blocks.push(child); - } - flushInlineNodes(); - return blocks; -} - -function appendListItem( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - marker: string, - depth: number, - markerColumnWidth: number, -): NativeMarkdownTextRun[] { - const firstLineHeadIndent = Math.max(0, depth - 1) * 20; - appendRun(runs, `${marker}\t`, { - ...EMPTY_CONTEXT, - role: "list-marker", - depth, - firstLineHeadIndent, - headIndent: firstLineHeadIndent + markerColumnWidth, - paragraphSpacing: 2, - }); - - const children = node.children ?? []; - let wroteInlineContent = false; - for (const child of children) { - if (child.type === "paragraph") { - appendInlineChildren(runs, child, { - ...EMPTY_CONTEXT, - role: "body", - depth, - }); - wroteInlineContent = true; - continue; - } - if (child.type === "list") { - if (wroteInlineContent) { - appendBlockTerminator(runs, { - ...EMPTY_CONTEXT, - role: "list-break", - depth, - spacing: 1, - }); - } - appendList(runs, child, depth + 1); - wroteInlineContent = false; - continue; - } - if (isInlineNode(child)) { - appendNode(runs, child, { - ...EMPTY_CONTEXT, - role: "body", - depth, - }); - wroteInlineContent = true; - continue; - } - appendDocumentBlock(runs, child, depth); - wroteInlineContent = true; - } - - if (wroteInlineContent) { - appendBlockTerminator(runs, { - ...EMPTY_CONTEXT, - role: "list-break", - depth, - spacing: depth === 1 ? 4 : 2, - }); - } - return runs; -} - -function appendList( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - depth: number, -): NativeMarkdownTextRun[] { - const ordered = node.ordered ?? false; - const start = node.start ?? 1; - const children = node.children ?? []; - const markers = children.map((child, index) => - child.type === "task_list_item" - ? child.checked - ? "☑︎" - : "☐︎" - : ordered - ? `${start + index}.` - : depth % 3 === 2 - ? "◦" - : depth % 3 === 0 - ? "▪︎" - : "•", - ); - const markerWidth = ordered - ? Math.max(0, ...markers.map((marker) => Array.from(marker).length)) - : 0; - - for (const [index, child] of children.entries()) { - const marker = markers[index] ?? "•"; - const alignedMarker = - child.type === "task_list_item" - ? marker - : ordered - ? `${"\u2007".repeat(Math.max(0, markerWidth - Array.from(marker).length))}${marker}` - : marker; - const markerColumnWidth = - child.type === "task_list_item" ? 28 : ordered ? 10 + markerWidth * 8 : 24; - appendListItem(runs, child, alignedMarker, depth, markerColumnWidth); - } - return runs; -} - -function appendQuoteBlock( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - depth: number, -): NativeMarkdownTextRun[] { - for (const [index, child] of (node.children ?? []).entries()) { - if (index > 0) { - appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - } - appendRun(runs, "│\u00a0", { - ...EMPTY_CONTEXT, - role: "quote-marker", - depth, - }); - if (child.type === "paragraph") { - appendInlineChildren(runs, child, { - ...EMPTY_CONTEXT, - role: "body", - depth, - }); - } else { - appendDocumentBlock(runs, child, depth); - } - } - appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - return runs; -} - -function appendTableRow( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - depth: number, -): NativeMarkdownTextRun[] { - const cells = node.children ?? []; - for (const [index, cell] of cells.entries()) { - if (index > 0) { - appendRun(runs, "\u00a0│\u00a0", { - ...EMPTY_CONTEXT, - role: "divider", - depth, - }); - } - appendInlineChildren(runs, cell, { - ...EMPTY_CONTEXT, - role: "body", - bold: cell.isHeader ?? false, - depth, - }); - } - appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - return runs; -} - -function appendTable( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - depth: number, -): NativeMarkdownTextRun[] { - const visit = (child: MarkdownNode) => { - if (child.type === "table_row") { - appendTableRow(runs, child, depth); - return; - } - for (const nested of child.children ?? []) { - visit(nested); - } - }; - visit(node); - return runs; -} - -function appendDocumentBlock( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - depth = 0, -): NativeMarkdownTextRun[] { - switch (node.type) { - case "document": { - const children = node.children ?? []; - for (const [index, child] of children.entries()) { - if (index > 0) { - const previous = children[index - 1]; - appendSpacer( - runs, - child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12, - ); - } - appendDocumentBlock(runs, child, depth); - } - return runs; - } - case "heading": { - const context: RunContext = { - ...EMPTY_CONTEXT, - role: "heading", - headingLevel: node.level ?? 1, - depth, - }; - appendInlineChildren(runs, node, context); - return appendBlockTerminator(runs, context); - } - case "paragraph": { - const context: RunContext = { ...EMPTY_CONTEXT, role: "body", depth }; - appendInlineChildren(runs, node, context); - return appendBlockTerminator(runs, context); - } - case "list": - return appendList(runs, node, depth + 1); - case "blockquote": - return appendQuoteBlock(runs, node, depth); - case "code_block": { - if (node.language) { - appendRun(runs, `${node.language.toUpperCase()}\n`, { - ...EMPTY_CONTEXT, - role: "code-language", - code: true, - depth, - }); - } - const content = nodeTextContent(node); - appendRun(runs, content, { - ...EMPTY_CONTEXT, - role: "code-block", - code: true, - depth, - }); - if (!content.endsWith("\n")) { - appendBlockTerminator(runs, { - ...EMPTY_CONTEXT, - role: "code-block", - code: true, - depth, - }); - } - return runs; - } - case "horizontal_rule": - appendRun(runs, "────────────────────────\n", { - ...EMPTY_CONTEXT, - role: "divider", - depth, - }); - return runs; - case "table": - return appendTable(runs, node, depth); - case "html_block": - appendRun(runs, inlineHtmlText(nodeTextContent(node)), { - ...EMPTY_CONTEXT, - role: "body", - depth, - }); - return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - case "math_block": - appendRun(runs, nodeTextContent(node), { ...EMPTY_CONTEXT, role: "body", depth }); - return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - default: - appendInlineChildren(runs, node, { ...EMPTY_CONTEXT, role: "body", depth }); - return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - } -} - -function containsRichBlock(node: MarkdownNode): boolean { - if ( - node.type === "code_block" || - node.type === "blockquote" || - node.type === "table" || - node.type === "image" || - node.type === "horizontal_rule" || - node.type === "html_block" || - node.type === "math_block" - ) { - return true; - } - return (node.children ?? []).some(containsRichBlock); -} - -/** - * Sibling identity for React keys. A source offset survives appends while the - * document streams; the child index is the fallback for offset-free nodes. The - * two never share a namespace, so a positioned node cannot collide with an - * offset-free sibling whose index happens to equal its offset. - */ -export function nativeMarkdownNodePosition(node: MarkdownNode, index: number): string { - return node.beg === undefined ? `index:${index}` : `offset:${node.beg}`; -} - -export function nativeMarkdownDocumentChunks( - document: MarkdownNode, -): ReadonlyArray { - const chunks: NativeMarkdownDocumentChunk[] = []; - let selectableNodes: MarkdownNode[] = []; - let selectableStart = 0; - - const flushSelectable = () => { - const first = selectableNodes[0]; - if (!first) { - return; - } - chunks.push({ - kind: "selectable", - key: `selectable:${nativeMarkdownNodePosition(first, selectableStart)}`, - node: { - type: "document", - children: selectableNodes, - }, - }); - selectableNodes = []; - }; - - for (const [index, child] of (document.children ?? []).entries()) { - if (!containsRichBlock(child)) { - if (selectableNodes.length === 0) { - selectableStart = index; - } - selectableNodes.push(child); - continue; - } - - flushSelectable(); - chunks.push({ - kind: "rich", - key: `rich:${child.type}:${nativeMarkdownNodePosition(child, index)}`, - node: child, - }); - } - flushSelectable(); - return chunks; -} - -function topLevelNodes(node: MarkdownNode): ReadonlyArray { - return node.type === "document" ? (node.children ?? []) : [node]; -} - -export function nativeMarkdownChunkSpacing( - previous: NativeMarkdownDocumentChunk | undefined, - current: NativeMarkdownDocumentChunk, -): number { - if (!previous) { - return 0; - } - - const previousLast = topLevelNodes(previous.node).at(-1); - const currentFirst = topLevelNodes(current.node)[0]; - - if (currentFirst?.type === "heading") { - return 20; - } - if (previousLast?.type === "heading") { - return 10; - } - if (previousLast?.type === "list" && currentFirst?.type === "list") { - return 12; - } - return 14; -} - -export function nativeMarkdownDocumentRuns( - node: MarkdownNode, - skills: ReadonlyArray = [], -): ReadonlyArray { - const runs = appendDocumentBlock([], node); - while (runs.length > 0) { - const lastIndex = runs.length - 1; - const last = runs[lastIndex]; - if (!last?.text.endsWith("\n")) { - break; - } - const text = last.text.slice(0, -1); - if (text.length === 0) { - runs.pop(); - } else { - runs[lastIndex] = { ...last, text }; - } - } - return decorateMentionRuns(decorateSkillRuns(runs, skills)); -} - -const HTML_TAG = /|<\/?[a-z](?:[^>"']|"[^"]*"|'[^']*')*>/gi; - -function attribute(tag: string, name: string): string | null { - const attributes = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g; - for (const match of tag.matchAll(attributes)) { - if (match[1]?.toLowerCase() === name) { - return decodeHtmlEntities(match[2] ?? match[3] ?? match[4] ?? ""); - } - } - return null; -} - -/** Called only for parsed HTML nodes, so Markdown code examples keep their literal tags. */ -export function markdownHtmlContent(content: string): string { - // An HTML block such as
can also contain Markdown fences and code spans. - const codeRanges: Array<{ start: number; end: number }> = []; - const delimiters = [...content.matchAll(/`+|~{3,}/g)]; - for (let index = 0; index < delimiters.length; index += 1) { - const opening = delimiters[index]!; - const lineStart = content.lastIndexOf("\n", opening.index - 1) + 1; - const fenced = - opening[0].length >= 3 && /^ {0,3}$/.test(content.slice(lineStart, opening.index)); - if (opening[0][0] === "~" && !fenced) continue; - let closingIndex = -1; - for (let candidate = index + 1; candidate < delimiters.length; candidate += 1) { - const closing = delimiters[candidate]!; - if (!fenced && closing[0] === opening[0]) { - closingIndex = candidate; - break; - } - if (!fenced || closing[0][0] !== opening[0][0] || closing[0].length < opening[0].length) - continue; - const start = content.lastIndexOf("\n", closing.index - 1) + 1; - const nextLine = content.indexOf("\n", closing.index); - const end = nextLine === -1 ? content.length : nextLine; - if ( - /^ {0,3}$/.test(content.slice(start, closing.index)) && - /^\s*$/.test(content.slice(closing.index + closing[0].length, end)) - ) { - closingIndex = candidate; - break; - } - } - if (closingIndex === -1) { - if (fenced) codeRanges.push({ start: opening.index, end: content.length }); - continue; - } - const closing = delimiters[closingIndex]!; - codeRanges.push({ start: opening.index, end: closing.index + closing[0].length }); - index = closingIndex; - } - return content.replace(HTML_TAG, (tag, offset: number) => { - if (codeRanges.some((range) => offset >= range.start && offset < range.end)) return tag; - if (tag.startsWith("/g, ""), { - gfm: true, - html: true, - math: false, - }), - (markdown) => parseMarkdownWithOptions(markdown, { gfm: true, html: false, math: false }), - ), - ), + () => pullRequestDescriptionContent(parseMarkdownDocument(comment.body)), [comment.body], ); const preview = content.preview; diff --git a/apps/mobile/src/features/pull-requests/pullRequestDescription.test.ts b/apps/mobile/src/features/pull-requests/pullRequestDescription.test.ts index f7e0c1ad4b21ae40e49393070b28c1d67db5c2d7..7f97648dda41bbbff8d9fb6db0afc7fd10f148b4 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDescription.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDescription.test.ts @@ -1,19 +1,21 @@ import { describe, expect, it } from "vite-plus/test"; +import { parseMarkdownDocument } from "../../lib/markdownDocument"; import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; -describe("PR description overview", () => { - it("keeps screenshots in source order, deduplicates embeds, and ignores code and local paths", () => { - const result = pullRequestDescriptionContent({ - type: "document", - children: [ - { type: "paragraph", children: [{ type: "text", content: "Fixes reconnects." }] }, - { type: "code_block", content: "![Example](https://example.com/code.png)" }, - { type: "image", href: "https://example.com/before.png", alt: "Before" }, - { type: "image", href: "https://example.com/after.png", alt: "After" }, - { type: "image", href: "https://example.com/before.png", alt: "Duplicate" }, - { type: "image", href: "screenshots/local.png" }, - ], - }); +describe("pull request description content", () => { + it("keeps screenshot attachments and does not turn code examples or local paths into media", () => { + const result = pullRequestDescriptionContent( + parseMarkdownDocument( + [ + "Fixes reconnects.", + "```markdown\n![Example](https://example.com/code.png)\n```", + "![Before](https://example.com/before.png)", + "![After](https://example.com/after.png)", + "![Duplicate](https://example.com/before.png)", + "![Local](screenshots/local.png)", + ].join("\n\n"), + ), + ); expect(result.attachments).toEqual([ { uri: "https://example.com/before.png", caption: "Before", kind: "image" }, { uri: "https://example.com/after.png", caption: "After", kind: "image" }, @@ -23,28 +25,11 @@ describe("PR description overview", () => { }); it("reads formatted prose and list items without leaking hidden HTML metadata", () => { - const result = pullRequestDescriptionContent({ - type: "document", - children: [ - { type: "html_block", content: "" }, - { - type: "paragraph", - children: [ - { type: "text", content: "Keeps " }, - { type: "bold", children: [{ type: "text", content: "sessions" }] }, - { type: "soft_break" }, - { type: "text", content: "alive." }, - ], - }, - { - type: "list", - children: [ - { type: "list_item", children: [{ type: "text", content: "Reconnect" }] }, - { type: "list_item", children: [{ type: "text", content: "Restore output" }] }, - ], - }, - ], - }); - expect(result.preview).toBe("Keeps sessions alive.\nReconnect\nRestore output"); + const result = pullRequestDescriptionContent( + parseMarkdownDocument( + "\n\nKeeps **sessions**\nalive.\n\n- Reconnect\n- Restore output", + ), + ); + expect(result.preview).toBe("Keeps sessions\nalive.\nReconnect\n\nRestore output"); }); }); diff --git a/apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.test.ts b/apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.test.ts index c69faff2cec295b569cb0a60c52930415624cff9..864649eb53fe2dfa5d33e041d744764831dc7fc0 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import type { MarkdownNode } from "react-native-nitro-markdown/headless"; +import type { MarkdownNode } from "../../lib/markdownDocument"; import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown"; import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; @@ -7,10 +7,10 @@ describe("PR attachments", () => { it("combines embedded images and detected media without duplicate URLs", () => { const uri = "https://drop.example.com/shot.png"; const document = { - type: "document", + type: "root", children: [ - { type: "image", href: uri, alt: "Screenshot" }, - { type: "image", href: uri, alt: "Duplicate" }, + { type: "image", url: uri, alt: "Screenshot" }, + { type: "image", url: uri, alt: "Duplicate" }, ], } as MarkdownNode; const media = splitPullRequestBody( @@ -32,7 +32,7 @@ describe("PR attachments", () => { "https://example.com/docs\n\n```\nhttps://drop.example.com/demo.mp4\n```", ); expect( - pullRequestDescriptionContent({ type: "document" } as MarkdownNode, media).attachments, + pullRequestDescriptionContent({ type: "root" } as MarkdownNode, media).attachments, ).toEqual([]); }); }); diff --git a/apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.ts b/apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.ts index 76e394ad2c4cd4ba88ae3951fcc3250f40f088c0..00b220e376401d3ceb65daf9163352a5c1c8e08b 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.ts @@ -1,5 +1,5 @@ import type { PullRequestBodySegment } from "@t3tools/client-runtime/pull-request-markdown"; -import type { MarkdownNode } from "react-native-nitro-markdown/headless"; +import type { MarkdownNode } from "../../lib/markdownDocument"; /** Read media from the parsed document so code examples never become attachments. */ export function pullRequestDescriptionContent( @@ -13,20 +13,20 @@ export function pullRequestDescriptionContent( const text: string[] = []; const visit = (node: MarkdownNode) => { if (node.type === "image") { - if (node.href && /^https?:\/\//i.test(node.href) && !attachments.has(node.href)) { - attachments.set(node.href, { - uri: node.href, + if (node.url && /^https?:\/\//i.test(node.url) && !attachments.has(node.url)) { + attachments.set(node.url, { + uri: node.url, caption: node.alt || node.title || "Image", kind: "image", }); } return; } - if (node.type === "html_block" || node.type === "html_inline") return; - if (node.content) text.push(node.content); - node.children?.forEach(visit); - if (["paragraph", "heading", "list_item", "code_block"].includes(node.type)) text.push("\n"); - if (node.type === "soft_break" || node.type === "line_break") text.push(" "); + if (node.type === "html") return; + if ("value" in node) text.push(node.value); + if ("children" in node) node.children.forEach(visit); + if (["paragraph", "heading", "listItem", "code"].includes(node.type)) text.push("\n"); + if (node.type === "break") text.push(" "); }; visit(document); for (const segment of media) { diff --git a/apps/mobile/src/features/terminal/TerminalContextSheet.tsx b/apps/mobile/src/features/terminal/TerminalContextSheet.tsx index bbf2226cadd52e173c9ce26c455fc6fca70e661b..18aa6d57ee1242202166960a3f0bb676323609c7 100644 --- a/apps/mobile/src/features/terminal/TerminalContextSheet.tsx +++ b/apps/mobile/src/features/terminal/TerminalContextSheet.tsx @@ -6,10 +6,12 @@ import { } from "@t3tools/contracts"; import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; import { useState } from "react"; -import { Alert, Modal, Platform, Pressable, ScrollView, View } from "react-native"; +import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { REVIEW_MONO_FONT_FAMILY } from "../review/reviewDiffRendering"; import { AppText as Text } from "../../components/AppText"; +import { AppSheet } from "../../components/AppSheet"; +import { MaterialButton } from "../../components/MaterialButton"; import { uuidv4 } from "../../lib/uuid"; import { insertComposerDraftContext } from "../../state/use-composer-drafts"; @@ -54,25 +56,16 @@ export function TerminalContextSheet(props: { props.onAttach(); }; return ( - - + - - Visible terminal output - - Cancel - - - + Tap the first and last line to select a range. - + {lines.map((line, index) => ( = range.start && index <= range.end ? "bg-subtle py-1" : "py-1"} + className={ + index >= range.start && index <= range.end + ? "flex-row gap-3 bg-primary/10 px-3 py-2" + : "flex-row gap-3 px-3 py-2" + } > - {index + 1} {line || " "} + {index + 1} + + + {line || " "} ))} - + + + {tooLarge ? ( - + Select fewer lines to fit the context limit. ) : null} - - Attach selected output - + /> - + ); } diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d0459bb3d2a8adaf15e76b4c85f321008b4b703f..7c463af350dedfa5f2800c9f5413dce8d7b75445 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,4 +1,7 @@ import { useOpenPullRequest } from "../../native/useOpenPullRequest"; +import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo"; +import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; +import { enrichedContextLinkPresentation } from "../../lib/enrichedLinkPresentation"; import { ThreadContextDivider } from "./thread-context-divider"; import { ThreadHandoffRow } from "./thread-handoff-row"; import { SecretRequestCard } from "./SecretRequestCard"; @@ -32,6 +35,7 @@ import { parseComposerContextHref, collectComposerContextReferences, replaceComposerContextReferences, + formatComposerContextReference, } from "@t3tools/shared/composerContextReferences"; import { ComposerContextSheet } from "../../components/ComposerContextSheet"; import { writeComposerContextClipboard } from "../../lib/composerContextClipboard"; @@ -41,7 +45,6 @@ import { } from "@t3tools/shared/codexArtifactTemplates"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; -import { getTextContent, type MarkdownNode } from "react-native-nitro-markdown/headless"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { @@ -75,12 +78,7 @@ import { type RefObject, } from "react"; import { - Markdown, - type CustomRenderers, - type NodeStyleOverrides, - type PartialMarkdownTheme, -} from "react-native-nitro-markdown"; -import { + ActivityIndicator, Alert, Image, Platform, @@ -114,10 +112,11 @@ import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { - hasNativeSelectableMarkdownText, SelectableMarkdownText, type MarkdownFileContextMenu, type MarkdownImageRenderer, + type MarkdownImageSourceResolver, + type MarkdownLinkCustomization, type NativeMarkdownTextStyle, type SelectableMarkdownSkill, } from "../../native/SelectableMarkdownText"; @@ -154,15 +153,15 @@ import { resolveNativeMarkdownTypography, } from "../../lib/appearancePreferences"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; +import { markdownFileIconSource } from "../../lib/markdownFileIcons"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; -import { markdownLinkIconSource } from "@t3tools/mobile-markdown-text/link-icons"; +import { markdownLinkIconSource } from "../../lib/markdownLinkIcons"; import { normalizeNativeMarkdownUrl, resolveMarkdownInlineCodePresentation, resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation, -} from "@t3tools/mobile-markdown-text/links"; +} from "../../lib/markdownLinks"; import { failedFeedRunIds, deriveThreadFeedPresentation, @@ -212,7 +211,6 @@ import { environmentThreadShells, threadEnvironment } from "../../state/threads" import { useAtomCommand } from "../../state/use-atom-command"; import { useV2ItemSupport } from "../../state/v2-item-support"; import { - basename, fileRoutePathSegments, isAbsolutePath, resolveWorkspaceRelativeFilePath, @@ -677,7 +675,6 @@ function MessageAttachmentUnknown(props: { readonly name: string }) { ); } -const ThreadMediaVisibleContext = createContext(false); // LegendList only computes hook visibility when the list has a viewability config. const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; @@ -689,164 +686,24 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) { return {props.children}; } -function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource }) { - const { source } = props; - const visible = useContext(ThreadMediaVisibleContext); - const thumbnailKey = mediaVideoThumbnailKey(source); - const asset = useAssetUrlState( - "environmentId" in source ? source.environmentId : null, - "resource" in source ? source.resource : null, - ); - const refreshAssetUrl = useRefreshAssetUrl( - "environmentId" in source ? source.environmentId : null, - "resource" in source ? source.resource : null, - ); - const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); - return ( - mediaVideoPreviewUri(source, await refreshAssetUrl()) - : undefined - } - name={source.name} - thumbnailKey={thumbnailKey} - thumbnailVisible={visible} - unavailable={"resource" in source && asset._tag === "Failure"} - actionsSource={source.actionsSource} - /> - ); +interface MarkdownLinkHandlers { + readonly onLinkPress: (href: string) => void; + readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; + readonly onFileContextMenuAction: (href: string, actionId: string) => void; + readonly onImagePress?: (href: string) => void; + readonly resolveImageSource?: MarkdownImageSourceResolver; + readonly linkCustomization?: (href: string) => MarkdownLinkCustomization | undefined; } -const MARKDOWN_MONO_FONT = Platform.select({ - ios: "ui-monospace", - android: "monospace", - default: "monospace", -}); - interface MarkdownStyleSets { readonly user: MarkdownStyleSet; readonly assistant: MarkdownStyleSet; } interface MarkdownStyleSet { - readonly theme: PartialMarkdownTheme; - readonly styles: NodeStyleOverrides; - readonly renderers: CustomRenderers; readonly nativeTextStyle: NativeMarkdownTextStyle; } -const failedMarkdownFaviconHosts = new Set(); -const MarkdownLinkLabelContext = createContext<"file" | "other" | null>(null); -const markdownLinkStyles = StyleSheet.create({ - inlineIcon: { - width: 14, - height: 14, - marginHorizontal: 3, - transform: [{ translateY: 2 }], - flexShrink: 0, - }, - favicon: { - borderRadius: 3, - }, -}); - -const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { - readonly children: ReactNode; - readonly color: string; - readonly host: string; - readonly href: string; - readonly onPress: (href: string) => void; -}) { - const [failedHost, setFailedHost] = useState(null); - const linkIcon = resolveMarkdownLinkIcon(props.host); - const faviconUrl = linkIcon ? null : faviconUrlForOrigin(`https://${props.host}`); - - return ( - props.onPress(props.href)} - style={{ - color: props.color, - textDecorationLine: "none", - }} - > - {linkIcon ? ( - - ) : faviconUrl !== null && - failedHost !== props.host && - !failedMarkdownFaviconHosts.has(props.host) ? ( - { - failedMarkdownFaviconHosts.add(props.host); - setFailedHost(props.host); - }} - /> - ) : ( - {" ◉ "} - )} - {props.children} - - ); -}); - -function MarkdownInlineCode(props: { - readonly content: string; - readonly textColor: string; - readonly codeColor: string; - readonly fontSize: number; - readonly lineHeight: number; - readonly onLinkPress: (href: string) => void; -}) { - const insideLink = useContext(MarkdownLinkLabelContext); - const presentation = insideLink ? null : resolveMarkdownInlineCodePresentation(props.content); - return ( - props.onLinkPress(presentation.href) : undefined} - style={{ - color: presentation ? props.textColor : props.codeColor, - fontSize: props.fontSize, - lineHeight: props.lineHeight, - }} - > - {presentation ? ( - - ) : null} - {presentation?.label ?? props.content} - - ); -} - -function MarkdownImage(props: { - readonly node: MarkdownNode; - readonly renderImage: MarkdownImageRenderer; -}) { - const insideLink = useContext(MarkdownLinkLabelContext); - if (insideLink === "file") - return {props.node.alt ?? props.node.title ?? ""}; - return props.node.href - ? props.renderImage({ - href: props.node.href, - alt: props.node.alt ?? null, - title: props.node.title ?? null, - }) - : null; -} - const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record< CodexArtifactTemplate["artifactKind"], AppSymbolName @@ -907,13 +764,6 @@ function ArtifactTemplateCard(props: { ); } -/** Tap opens a link; long-press on a native file chip shows its menu. Built once per feed. */ -interface MarkdownLinkHandlers { - readonly onLinkPress: (href: string) => void; - readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; - readonly onFileContextMenuAction: (href: string, actionId: string) => void; -} - const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly markdown: string; readonly environmentId: EnvironmentId; @@ -939,7 +789,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { if (segment.markdown.trim().length === 0) return null; const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown); - return hasNativeSelectableMarkdownText() ? ( + return ( - ) : ( - - {markdown} - ); }); }); -function MarkdownCodeBlock(props: { - readonly backgroundColor: string; - readonly borderColor: string; - readonly content: string; - readonly copyTintColor: ColorValue; - readonly headerTextColor: string; - readonly fontSize: number; - readonly highlightCode: boolean; - readonly language?: string | null; - readonly lineHeight: number; - readonly textColor: string; - readonly theme: ReviewDiffTheme; -}) { - const content = props.content.replace(/\n$/, ""); - const languageLabel = props.language?.trim() || "text"; - const highlighted = useMarkdownCodeHighlight({ - code: content, - enabled: props.highlightCode && Boolean(props.language?.trim()), - language: props.language, - theme: props.theme, - }); - let tokenOffset = 0; - - return ( - - - - {languageLabel} - - - - - - {highlighted - ? highlighted.map((line, lineIndex) => { - const lineStartOffset = tokenOffset; - const lineText = line.map((token) => token.content).join(""); - const renderedLine = ( - - {line.map((token) => { - const startOffset = tokenOffset; - tokenOffset += token.content.length; - const fontStyle = - token.fontStyle !== null && (token.fontStyle & 1) === 1 - ? ("italic" as const) - : ("normal" as const); - const fontWeight = - token.fontStyle !== null && (token.fontStyle & 2) === 2 - ? ("700" as const) - : ("400" as const); - - return ( - - {token.content} - - ); - })} - {lineIndex + 1 < highlighted.length ? "\n" : ""} - - ); - if (lineIndex + 1 < highlighted.length) { - tokenOffset += 1; - } - return renderedLine; - }) - : content} - - - - ); -} - -function useMarkdownStyles( - onLinkPress: (href: string) => void, - renderImage: MarkdownImageRenderer, -): MarkdownStyleSets { - const { appearance, themeAppearance } = useAppearancePreferences(); - const markdownFontSizes = useMemo( - () => resolveMarkdownFontSizes(appearance.baseFontSize), - [appearance.baseFontSize], - ); +function useMarkdownStyles(): MarkdownStyleSets { + const { appearance } = useAppearancePreferences(); const nativeMarkdownTypography = useMemo( () => resolveNativeMarkdownTypography(appearance.baseFontSize), [appearance.baseFontSize], ); - const themeMode = themeAppearance; const theme = useUniwindTheme(); const markdownBodyColor = theme["--color-md-body"]; const markdownStrongColor = theme["--color-md-strong"]; const markdownLinkColor = theme["--color-md-link"]; - const markdownBlockquoteBg = theme["--color-md-blockquote-bg"]; const markdownBlockquoteBorder = theme["--color-md-blockquote-border"]; const markdownCodeBg = theme["--color-md-code-bg"]; const markdownCodeText = theme["--color-md-code-text"]; @@ -1113,291 +827,14 @@ function useMarkdownStyles( const markdownUserCodeText = theme["--color-md-user-code-text"]; const markdownUserInlineCodeText = theme["--color-user-bubble-foreground-muted"]; const markdownUserFenceBg = theme["--color-md-user-fence-bg"]; - const markdownUserFenceText = theme["--color-md-user-fence-text"]; - const iconSubtleColor = theme["--color-icon-subtle"]; const inlineSkillForeground = theme["--color-inline-skill-foreground"]; const userBubbleSkillForeground = theme["--color-user-bubble-skill-foreground"]; - const userBubbleForegroundMuted = theme["--color-user-bubble-foreground-muted"]; const regularFontFamily = useFontFamily("regular"); const boldFontFamily = useFontFamily("bold"); return useMemo(() => { - const baseTheme: PartialMarkdownTheme = { - colors: { - text: markdownBodyColor, - heading: markdownStrongColor, - link: markdownLinkColor, - blockquote: markdownBlockquoteBorder, - border: markdownHrColor, - surface: "transparent", - surfaceLight: markdownBlockquoteBg, - accent: markdownLinkColor, - tableBorder: markdownHrColor, - tableHeader: markdownBlockquoteBg, - tableHeaderText: markdownStrongColor, - tableRowOdd: "transparent", - tableRowEven: "transparent", - }, - spacing: { - xs: 4, - s: 4, - m: 8, - l: 8, - xl: 16, - }, - fontSizes: { - s: markdownFontSizes.s, - m: markdownFontSizes.m, - h1: markdownFontSizes.h1, - h2: markdownFontSizes.h2, - h3: markdownFontSizes.h3, - h4: markdownFontSizes.h4, - h5: markdownFontSizes.h5, - h6: markdownFontSizes.h6, - }, - fontFamilies: { - regular: regularFontFamily, - heading: boldFontFamily, - mono: MARKDOWN_MONO_FONT, - }, - headingWeight: "700", - borderRadius: { - s: 4, - m: 8, - l: 12, - }, - showCodeLanguage: false, - }; - - const baseStyles: NodeStyleOverrides = { - document: { flexShrink: 1 }, - paragraph: { marginTop: 0, marginBottom: 10 }, - list: { marginTop: 4, marginBottom: 8 }, - list_item: { marginTop: 0, marginBottom: 4 }, - task_list_item: { marginTop: 0, marginBottom: 4 }, - text: { lineHeight: markdownFontSizes.bodyLineHeight }, - bold: { - fontWeight: "700", - color: markdownStrongColor, - fontFamily: boldFontFamily, - }, - italic: { fontStyle: "italic" }, - link: { - color: markdownLinkColor, - textDecorationLine: "underline" as const, - }, - blockquote: { - borderLeftWidth: 2, - borderLeftColor: markdownBlockquoteBorder, - paddingLeft: 11, - paddingVertical: 2, - marginLeft: 0, - marginVertical: 10, - }, - heading: { - fontFamily: boldFontFamily, - color: markdownStrongColor, - marginTop: 18, - marginBottom: 8, - }, - horizontal_rule: { - backgroundColor: markdownHrColor, - height: 1, - marginVertical: 12, - }, - }; - - const createMarkdownRenderers = ( - inlineTextColor: string, - inlineCodeTextColor: string, - blockBackgroundColor: string, - blockTextColor: string, - copyTintColor: ColorValue, - preserveSoftBreaks: boolean, - highlightCode: boolean, - ): CustomRenderers => ({ - link: ({ children, node, href = "" }) => { - const presentation = resolveMarkdownLinkPresentation(href); - if (presentation.kind === "file") { - return ( - - onLinkPress(href)} style={{ color: inlineTextColor }}> - {!isMarkdownFileLinkLabel(getTextContent(node), href) && <>{children} } - onLinkPress(href)} - style={{ color: inlineTextColor }} - > - - {presentation.label} - - - - ); - } - if (presentation.kind === "external") { - return ( - - - {children} - - - ); - } - const linkHref = presentation.href; - // A thread link opens the thread in the app, through the feed's link handler. - const isThreadLink = parseThreadLinkHref(href) !== null; - return ( - - onLinkPress(href) - : linkHref - ? () => { - void tryOpenExternalUrl(linkHref, "markdown-link"); - } - : undefined - } - style={{ color: markdownLinkColor }} - > - {children} - - - ); - }, - list: ({ node, Renderer, ordered = false, start = 1 }) => ( - - {node.children?.map((child, index) => { - const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`; - if (child.type === "task_list_item") { - return ( - - ); - } - return ( - - - {ordered ? `${start + index}.` : "•"} - - - - - - ); - })} - - ), - image: ({ node }) => , - code_inline: ({ content }) => ( - - ), - ...(preserveSoftBreaks - ? { - soft_break: () => {"\n"}, - } - : {}), - code_block: ({ content = "", language }) => ( - - ), - }); - - const userTheme: PartialMarkdownTheme = { - ...baseTheme, - colors: { - ...baseTheme.colors, - text: markdownUserBodyColor, - heading: markdownUserBodyColor, - link: markdownUserBodyColor, - code: markdownUserCodeText, - codeBackground: markdownUserCodeBg, - border: markdownUserFenceBg, - }, - }; - const userStyles: NodeStyleOverrides = { - ...baseStyles, - paragraph: { marginTop: 0, marginBottom: 0 }, - bold: { - fontWeight: "700", - color: markdownUserBodyColor, - fontFamily: boldFontFamily, - }, - heading: { - ...baseStyles.heading, - color: markdownUserBodyColor, - marginTop: 8, - marginBottom: 4, - }, - link: { - color: markdownUserBodyColor, - textDecorationLine: "underline" as const, - }, - }; - - const assistantTheme: PartialMarkdownTheme = { - ...baseTheme, - colors: { - ...baseTheme.colors, - code: markdownCodeText, - codeBackground: markdownCodeBg, - border: markdownCodeBg, - }, - }; - const assistantStyles: NodeStyleOverrides = { - ...baseStyles, - }; - return { user: { - theme: userTheme, - styles: userStyles, - renderers: createMarkdownRenderers( - markdownUserCodeText, - markdownUserInlineCodeText, - markdownUserFenceBg, - markdownUserFenceText, - userBubbleForegroundMuted, - true, - false, - ), nativeTextStyle: { color: markdownUserBodyColor, strongColor: markdownUserBodyColor, @@ -1421,17 +858,6 @@ function useMarkdownStyles( }, }, assistant: { - theme: assistantTheme, - styles: assistantStyles, - renderers: createMarkdownRenderers( - markdownCodeText, - markdownInlineCodeText, - markdownCodeBg, - markdownCodeText, - iconSubtleColor, - false, - true, - ), nativeTextStyle: { color: markdownBodyColor, strongColor: markdownStrongColor, @@ -1458,14 +884,11 @@ function useMarkdownStyles( }, [ boldFontFamily, contextChipBorderColor, - iconSubtleColor, inlineSkillForeground, - markdownBlockquoteBg, markdownBlockquoteBorder, markdownBodyColor, markdownCodeBg, markdownCodeText, - markdownFontSizes, markdownHrColor, markdownInlineCodeText, markdownLinkColor, @@ -1474,14 +897,9 @@ function useMarkdownStyles( markdownUserCodeBg, markdownUserCodeText, markdownUserFenceBg, - markdownUserFenceText, markdownUserInlineCodeText, nativeMarkdownTypography, - onLinkPress, - renderImage, regularFontFamily, - themeMode, - userBubbleForegroundMuted, userBubbleSkillForeground, ]); } @@ -2077,10 +1495,58 @@ function UserMessageContent(props: UserMessageContentProps) { }; return ( <> - { + const reference = parseComposerContextHref(href); + if (!reference) return props.linkHandlers.fileContextMenu(href); + const record = props.context?.records.find( + (item) => item.contextId === reference.contextId, + ); + return { + title: record?.label ?? "Context unavailable", + actions: [ + { id: "open-context", title: "Open context" }, + { id: "copy-context", title: "Copy context", disabled: !record }, + ], + }; + }, + onFileContextMenuAction: (href, actionId) => { + const reference = parseComposerContextHref(href); + if (!reference) return props.linkHandlers.onFileContextMenuAction(href, actionId); + if (actionId === "open-context") return onLinkPress(href); + const record = props.context?.records.find( + (item) => item.contextId === reference.contextId, + ); + if (actionId === "copy-context" && record) { + void writeComposerContextClipboard( + formatComposerContextReference({ + ...reference, + label: record.label, + }), + { + version: 1, + source: { environmentId: props.environmentId }, + records: [record], + }, + ) + .then(() => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)) + .catch(() => { + Alert.alert("Could not copy context", "Try copying this context again."); + }); + } + }, + linkCustomization: (href) => { + const presentation = enrichedContextLinkPresentation(href, props.context?.records); + return presentation + ? { color: presentation.color, icon: markdownFileIconSource(presentation.icon) } + : props.linkHandlers.linkCustomization?.(href); + }, + }} /> {selected ? ( segment.kind === "review-comment"); - // A message can hold both a review comment and context chips. The fragment travels with every - // text run, so copying from the segmented branch carries the same context as the plain one. const contextClipboardFragment = props.context ? (encodeComposerContextFragment({ version: 1, @@ -2109,28 +1573,16 @@ function LegacyUserMessageContent(props: UserMessageContentProps) { }) ?? undefined) : undefined; if (!hasReviewComment) { - if (hasNativeSelectableMarkdownText()) { - return ( - - ); - } return ( - - {text} - + ); } @@ -2152,7 +1604,7 @@ function LegacyUserMessageContent(props: UserMessageContentProps) { return null; } - return hasNativeSelectableMarkdownText() ? ( + return ( - ) : ( - - {text} - ); })} @@ -2333,7 +1775,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const screenColor = theme["--color-screen"]; const userBubbleColor = theme["--color-user-bubble"]; const onMarkdownLinkPress = useCallback( - (href: string) => { + (href: string, imageEmbed = false) => { // A thread link names a thread in this feed's environment. const linkedThreadId = parseThreadLinkHref(href); if (linkedThreadId) { @@ -2343,6 +1785,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); return; } + const media = resolveMarkdownMediaPreview(href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + imageEmbed, + }); + if (media) { + void Haptics.selectionAsync(); + if (media.kind === "video") { + setExpandedVideo((current) => current ?? media.source); + } else { + setExpandedFile((current) => current ?? media.source); + } + return; + } + const presentation = resolveMarkdownLinkPresentation(href); if (presentation.kind === "file") { const relativePath = resolveWorkspaceRelativeFilePath( @@ -2351,22 +1809,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); if (relativePath) { void Haptics.selectionAsync(); - if (isPdfFile({ name: relativePath })) { - setExpandedFile( - (current) => - current ?? { - kind: "pdf", - name: relativePath.split("/").at(-1), - environmentId: props.environmentId, - resource: { - _tag: "workspace-file", - threadId: props.threadId, - path: relativePath, - }, - }, - ); - return; - } navigation.navigate("ThreadFile", { environmentId: String(props.environmentId), threadId: String(props.threadId), @@ -2377,41 +1819,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } } - const media = resolveMarkdownMediaPreview(href, { - environmentId: props.environmentId, - threadId: props.threadId, - workspaceRoot: props.workspaceRoot, - }); - if (media) { - void Haptics.selectionAsync(); - if (media.kind === "video") { - setExpandedVideo((current) => current ?? media.source); - } else { - setExpandedFile((current) => current ?? media.source); - } - return; - } - // A host file outside the workspace, such as a report an agent wrote to // a temp directory, opens read-only in the file screen. if (presentation.kind === "file" && isAbsolutePath(presentation.path)) { void Haptics.selectionAsync(); - if (isPdfFile({ name: presentation.path })) { - setExpandedFile( - (current) => - current ?? { - kind: "pdf", - name: basename(presentation.path), - environmentId: props.environmentId, - resource: { - _tag: "media-file", - threadId: props.threadId, - path: presentation.path, - }, - }, - ); - return; - } navigation.navigate("ThreadFile", { environmentId: String(props.environmentId), threadId: String(props.threadId), @@ -2433,9 +1844,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, [props.environmentId, props.threadId, props.workspaceRoot, navigation, openPullRequest], ); + const resolveImageSource = useMarkdownImageSource({ + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot ?? null, + }); const markdownLinkHandlers = useMemo( () => ({ onLinkPress: onMarkdownLinkPress, + onImagePress: (href) => onMarkdownLinkPress(href, true), + resolveImageSource, fileContextMenu: (href) => { const target = resolveFileChipTarget(href, props.workspaceRoot); return target ? fileChipMenu(target) : undefined; @@ -2459,7 +1877,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } }, }), - [onMarkdownLinkPress, props.workspaceRoot, shareFileChip], + [onMarkdownLinkPress, props.workspaceRoot, shareFileChip, resolveImageSource], ); const renderMarkdownImage = useCallback( (image) => { @@ -2483,6 +1901,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ( void; }) { @@ -126,6 +130,7 @@ export function ThreadMarkdownImageView(props: { setFailedUri(props.uri)} /> @@ -147,23 +152,35 @@ function ThreadMarkdownImageRequest(props: { readonly uri: string; readonly onLoad: (sourceSize: { width: number; height: number }) => void; readonly onError: () => void; + readonly format?: "svg"; }) { const [loaded, setLoaded] = useState(false); return ( <> - { - setLoaded(true); - props.onLoad(event.source); - }} - onError={props.onError} - style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} - /> + {props.format === "svg" ? ( + { + setLoaded(true); + if (size) props.onLoad(size); + }} + onError={props.onError} + /> + ) : ( + { + setLoaded(true); + props.onLoad(event.source); + }} + onError={props.onError} + style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} + /> + )} {loaded ? null : ( void; + readonly onError: () => void; +}) { + const [ast, setAst] = useState(null); + const onLoad = useEffectEvent(props.onLoad); + const onError = useEffectEvent(props.onError); + useEffect(() => { + const controller = new AbortController(); + const load = async () => { + if (props.uri.startsWith("data:image/svg+xml")) { + const comma = props.uri.indexOf(","); + if (comma < 0) throw new Error("SVG data URI has no content"); + const content = decodeURIComponent(props.uri.slice(comma + 1)); + return props.uri.slice(0, comma).includes(";base64") + ? new TextDecoder().decode( + Uint8Array.from(atob(content), (character) => character.charCodeAt(0)), + ) + : content; + } + const response = await fetch(props.uri, { signal: controller.signal }); + if (!response.ok && !(response.status === 0 && props.uri.startsWith("file://"))) + throw new Error(`SVG request failed: ${response.status}`); + return response.text(); + }; + void load() + .then((xml) => { + const parsed = parse(xml); + if (!parsed) throw new Error("SVG has no root element"); + if (controller.signal.aborted) return; + setAst(parsed); + onLoad(resolveSvgImageSize(parsed.props)); + }) + .catch(() => { + if (!controller.signal.aborted) onError(); + }); + return () => controller.abort(); + }, [props.uri]); + return ; +} + /** Environment-hosted image that loads through a signed asset URL. */ export function ThreadMarkdownImage(props: { readonly environmentId: EnvironmentId; readonly resource: Extract< AssetResource, - { readonly _tag: "attachment" | "media-file" | "tool-output-image" } + { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" | "tool-output-image" } >; readonly alt: string | null; readonly srcFragment?: string; @@ -203,6 +262,12 @@ export function ThreadMarkdownImage(props: { } unavailable={assetUrl._tag === "Failure"} knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined} + format={ + (props.resource._tag === "media-file" || props.resource._tag === "draft-workspace-file") && + /\.svg$/i.test(props.resource.path) + ? "svg" + : undefined + } alt={props.alt} actionsSource={props.actionsSource} onPressPreview={(source) => { diff --git a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e01f6ae3b2aa8557505b5ea6115b326810cb8727 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx @@ -0,0 +1,45 @@ +import { createContext, useContext } from "react"; +import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; +import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; +import { + mediaVideoPreviewUri, + mediaVideoThumbnailKey, + type MediaVideoPreviewSource, +} from "../../lib/videoPreviewSource"; + +export const ThreadMediaVisibleContext = createContext(false); + +export function ThreadMarkdownVideo(props: { + readonly source: MediaVideoPreviewSource; + readonly thumbnailVisible?: boolean; +}) { + const { source } = props; + const feedVisible = useContext(ThreadMediaVisibleContext); + const visible = props.thumbnailVisible ?? feedVisible; + const thumbnailKey = mediaVideoThumbnailKey(source); + const asset = useAssetUrlState( + "environmentId" in source ? source.environmentId : null, + "resource" in source ? source.resource : null, + ); + const refreshAssetUrl = useRefreshAssetUrl( + "environmentId" in source ? source.environmentId : null, + "resource" in source ? source.resource : null, + ); + const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); + return ( + mediaVideoPreviewUri(source, await refreshAssetUrl()) + : undefined + } + name={source.name} + thumbnailKey={thumbnailKey} + thumbnailVisible={visible} + unavailable={"resource" in source && asset._tag === "Failure"} + actionsSource={source.actionsSource} + /> + ); +} diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts index 9835c0c37cc17485975c83fb7464b49df010368f..4d64f6505c7e5e80d43f619727d3e5c355e24820 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.ts @@ -1,7 +1,7 @@ import { fileBasename } from "@t3tools/shared/path"; import type { ThreadId } from "@t3tools/contracts"; -import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; -import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types"; +import { resolveMarkdownLinkPresentation } from "../../lib/markdownLinks"; +import type { MarkdownFileContextMenu } from "../../native/SelectableMarkdownText.types"; import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import { diff --git a/apps/mobile/src/features/threads/markdownImageSize.test.ts b/apps/mobile/src/features/threads/markdownImageSize.test.ts index 76170890d5193fab1f447433ff4ef591474e5e94..614640cc3f8a04c7fa7a73ef8f8c776f0244d87a 100644 --- a/apps/mobile/src/features/threads/markdownImageSize.test.ts +++ b/apps/mobile/src/features/threads/markdownImageSize.test.ts @@ -4,6 +4,7 @@ import { MARKDOWN_IMAGE_MAX_HEIGHT, MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize, + resolveSvgImageSize, } from "./markdownImageSize"; describe("resolveMarkdownImageDisplaySize", () => { @@ -60,3 +61,20 @@ describe("resolveMarkdownImageDisplaySize", () => { ).toBeNull(); }); }); + +describe("resolveSvgImageSize", () => { + it("uses pixel dimensions before the viewBox", () => { + expect(resolveSvgImageSize({ width: "32px", height: "48", viewBox: "0 0 100 100" })).toEqual({ + width: 32, + height: 48, + }); + }); + + it("uses the viewBox for responsive SVGs", () => { + expect( + resolveSvgImageSize({ width: "100%", height: "100%", viewBox: "-10, -20, 120, 240" }), + ).toEqual({ width: 120, height: 240 }); + expect(resolveSvgImageSize({ viewBox: "0 0 100 NaN" })).toBeNull(); + expect(resolveSvgImageSize({ viewBox: "0 0 0 100" })).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/markdownImageSize.ts b/apps/mobile/src/features/threads/markdownImageSize.ts index 0fb6f8fbcc6d96f0f5ff7cbe9fa3118ebd7fce17..b5b3c8402b25f397ec7321f1aea53bb9d17dfa3f 100644 --- a/apps/mobile/src/features/threads/markdownImageSize.ts +++ b/apps/mobile/src/features/threads/markdownImageSize.ts @@ -6,6 +6,34 @@ export interface MarkdownImageDisplaySize { readonly height: number; } +/** SVG percentages need a viewport; use the intrinsic viewBox in that case. */ +export function resolveSvgImageSize(props: Readonly>) { + const dimension = (value: unknown) => { + if (typeof value === "number") return value; + if (typeof value !== "string" || !/^\s*\d+(?:\.\d+)?(?:px)?\s*$/.test(value)) return NaN; + return Number.parseFloat(value); + }; + const width = dimension(props.width); + const height = dimension(props.height); + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) + return { width, height }; + const viewBox = + typeof props.viewBox === "string" + ? props.viewBox + .trim() + .split(/[\s,]+/) + .map(Number) + : []; + if ( + viewBox.length !== 4 || + !viewBox.every(Number.isFinite) || + viewBox[2]! <= 0 || + viewBox[3]! <= 0 + ) + return null; + return { width: viewBox[2]!, height: viewBox[3]! }; +} + /** Keeps small images intrinsic while fitting larger images inside the chat viewport. */ export function resolveMarkdownImageDisplaySize(input: { readonly sourceWidth: number; diff --git a/apps/mobile/src/features/threads/worktree-setup-card.tsx b/apps/mobile/src/features/threads/worktree-setup-card.tsx index b2080b002d91be10c6babcb3c9e09fe69d27e22e..1d7d16f1c2a077ec9969fb5970076e81e3858ee6 100644 --- a/apps/mobile/src/features/threads/worktree-setup-card.tsx +++ b/apps/mobile/src/features/threads/worktree-setup-card.tsx @@ -217,6 +217,7 @@ function SetupDetailsSheet({ return ( setBodyHeight(height)} contentContainerStyle={{ diff --git a/apps/mobile/src/features/threads/worktree-setup-sheet.tsx b/apps/mobile/src/features/threads/worktree-setup-sheet.tsx index 99bd9e066e3ef5c5929bd9f800dc213f35a004f3..e4ec7eae99d6f925e34b6068dde71d66047434af 100644 --- a/apps/mobile/src/features/threads/worktree-setup-sheet.tsx +++ b/apps/mobile/src/features/threads/worktree-setup-sheet.tsx @@ -1,15 +1,5 @@ -import { useState, type ReactElement } from "react"; -import { Modal, View } from "react-native"; -import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; -import { withUniwind } from "uniwind"; -import { ContextSheetSize } from "../../components/ContextSheetSize"; - -const NativeScreen = withUniwind(Screen); -const NativeHeader = withUniwind(ScreenStackHeaderConfig, { - backgroundColor: { fromClassName: "backgroundColorClassName", styleProperty: "backgroundColor" }, - color: { fromClassName: "tintColorClassName", styleProperty: "accentColor" }, - titleColor: { fromClassName: "titleColorClassName", styleProperty: "accentColor" }, -}); +import type { ReactElement } from "react"; +import { AppSheet } from "../../components/AppSheet"; export interface WorktreeSetupSheetProps { children: ReactElement; @@ -17,50 +7,10 @@ export interface WorktreeSetupSheetProps { onClose: () => void; } -export function WorktreeSetupSheet({ children, height, onClose }: WorktreeSetupSheetProps) { - const [headerHeight, setHeaderHeight] = useState(44); +export function WorktreeSetupSheet({ children, onClose }: WorktreeSetupSheetProps) { return ( - - - - {/* The nested stack supplies UIKit's navigation bar inside the sheet. */} - - setHeaderHeight(event.nativeEvent.headerHeight)} - className="flex-1 bg-sheet-solid" - > - {children} - - - - - + + {children} + ); } diff --git a/apps/mobile/src/lib/composerChipPresentation.test.ts b/apps/mobile/src/lib/composerChipPresentation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e0af9834522475418983a42d2bbc8eec9b23bab --- /dev/null +++ b/apps/mobile/src/lib/composerChipPresentation.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vite-plus/test"; + +describe("composerChipSizeSuffix", () => { + it("labels attachment records with a human size, matching web's chip", async () => { + const { composerChipSizeSuffix } = await import("./composerChipPresentation"); + expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 1024 })).toBe("1 KB"); + expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 3_700_000 })).toBe("3.5 MB"); + expect(composerChipSizeSuffix({ kind: "image", sizeBytes: 2048 })).toBe("2 KB"); + }); + + it("adds nothing for records that carry no bytes", async () => { + const { composerChipSizeSuffix } = await import("./composerChipPresentation"); + // Terminal/review/PR chips have no size to show. + expect(composerChipSizeSuffix({ kind: "terminal" })).toBe(""); + expect(composerChipSizeSuffix({ kind: "file" })).toBe(""); + expect(composerChipSizeSuffix(undefined)).toBe(""); + }); +}); + +describe("contextChipPresentation image detection", () => { + it("treats a picture attached through the file picker as an image", async () => { + const { contextChipPresentation } = await import("./composerChipPresentation"); + // The document picker types every pick as `file`, so the name has to carry the intent. + expect( + contextChipPresentation("file", { kind: "file", name: "IMG_4997.PNG", mimeType: "" }), + ).toEqual({ accent: "#d55665", symbol: "photo" }); + expect( + contextChipPresentation("file", { + kind: "file", + name: "shot", + mimeType: "image/jpeg", + }), + ).toEqual({ accent: "#d55665", symbol: "photo" }); + }); + + it("leaves genuine documents and videos alone", async () => { + const { contextChipPresentation } = await import("./composerChipPresentation"); + expect( + contextChipPresentation("file", { kind: "file", name: "notes.txt", mimeType: "text/plain" }), + ).toEqual({ accent: "#0090cd", symbol: "doc" }); + expect( + contextChipPresentation("file", { kind: "file", name: "clip.mp4", mimeType: "video/mp4" }), + ).toEqual({ accent: "#d06217", symbol: "play.rectangle" }); + }); +}); + +describe("pull request chip status", () => { + const chip = async (state: string, isDraft = false) => { + const { contextChipPresentation } = await import("./composerChipPresentation"); + return contextChipPresentation("review-comment", { + kind: "review-comment", + sectionId: "pull-request:10978", + pullRequest: { state, isDraft }, + }); + }; + + it("colours a pull request by its state, the way web and the forge do", async () => { + expect((await chip("open")).accent).toBe("#009f6e"); + expect((await chip("open", true)).accent).toBe("#7f8793"); + expect((await chip("merged")).accent).toBe("#8a70dd"); + expect((await chip("closed")).accent).toBe("#d55665"); + }); + + it("keeps one glyph across every state, so only colour carries the status", async () => { + // Web draws a fixed `git-pull-request` and encodes state in colour alone. A per-state glyph + // here would put mobile out of step with it. + const symbols = await Promise.all( + [chip("open"), chip("open", true), chip("merged"), chip("closed")].map( + async (pending) => (await pending).symbol, + ), + ); + expect(new Set(symbols)).toEqual(new Set(["git-pull-request"])); + }); + + it("falls back to the generic pull request chip when the state is unknown", async () => { + const { contextChipPresentation } = await import("./composerChipPresentation"); + // An older server may send no metadata at all; the chip still has to render. + expect( + contextChipPresentation("review-comment", { + kind: "review-comment", + sectionId: "pull-request:1", + }).accent, + ).toBe("#7079e4"); + }); +}); diff --git a/apps/mobile/src/lib/composerChipPresentation.ts b/apps/mobile/src/lib/composerChipPresentation.ts new file mode 100644 index 0000000000000000000000000000000000000000..853ae43f87915bb0a019e59785391d09472d5fee --- /dev/null +++ b/apps/mobile/src/lib/composerChipPresentation.ts @@ -0,0 +1,87 @@ +import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; +import { imageMimeType } from "@t3tools/shared/image"; +import { videoMimeType } from "@t3tools/shared/video"; +/** + * Every accent shares a lightness so no kind reads heavier than another; only hue carries + * identity. These are the sRGB form of the same OKLCH set web uses, so a chip looks the + * same on every surface. See `composerInlineChip.ts`. + */ +const CONTEXT_CHIP_PRESENTATIONS = { + image: { accent: "#d55665", symbol: "photo" }, + video: { accent: "#d06217", symbol: "play.rectangle" }, + file: { accent: "#0090cd", symbol: "doc" }, + mention: { accent: "#0096af", symbol: "doc" }, + terminal: { accent: "#009f6e", symbol: "terminal" }, + element: { accent: "#b87501", symbol: "cursorarrow.click" }, + "preview-annotation": { accent: "#b87501", symbol: "cursorarrow.click" }, + "review-comment": { accent: "#8a70dd", symbol: "text.bubble" }, + "pull-request": { accent: "#7079e4", symbol: "git-pull-request" }, + skill: { accent: "#b261be", symbol: "cube" }, +} as const; + +/** + * The size an attachment chip reports beside its name, matching web. Rendered as its own + * smaller run, so it carries no separator. Only attachment-backed records have bytes. + */ +export function composerChipSizeSuffix(record?: { + readonly kind?: string; + readonly sizeBytes?: number; +}): string { + if (record?.kind !== "file" && record?.kind !== "image") return ""; + return typeof record.sizeBytes === "number" ? formatAttachmentSize(record.sizeBytes) : ""; +} + +/** + * A pull request chip is coloured by what the pull request *is*, the way web colours it and + * the way the forge itself does: green open, grey draft, purple merged, red closed. The glyph + * stays the same across all four, as it does on web — state is carried by colour alone. + */ +const PULL_REQUEST_CHIP_PRESENTATIONS = { + open: { accent: "#009f6e", symbol: "git-pull-request" }, + draft: { accent: "#7f8793", symbol: "git-pull-request" }, + merged: { accent: "#8a70dd", symbol: "git-pull-request" }, + closed: { accent: "#d55665", symbol: "git-pull-request" }, +} as const; + +export function contextChipPresentation( + kind: string, + record?: { + readonly kind?: string; + readonly name?: string; + readonly mimeType?: string; + readonly sectionId?: string; + readonly pullRequest?: { + readonly state?: string; + readonly isDraft?: boolean; + }; + }, +) { + const presentationKind = + kind === "file" && + videoMimeType({ + name: record?.name ?? "", + mimeType: record?.mimeType ?? "", + }) + ? "video" + : // A picture chosen through the file picker is typed `file`, but it is still a + // picture: it reads as one to the user and should not wear the generic file chip. + kind === "file" && + imageMimeType({ name: record?.name ?? "", mimeType: record?.mimeType ?? "" }) !== null + ? "image" + : kind === "review-comment" && record?.sectionId?.startsWith("pull-request:") + ? "pull-request" + : kind; + if (presentationKind === "pull-request") { + const pullRequest = record?.pullRequest; + const state = + pullRequest?.state === "open" && pullRequest.isDraft === true + ? "draft" + : (pullRequest?.state ?? ""); + if (Object.hasOwn(PULL_REQUEST_CHIP_PRESENTATIONS, state)) { + return PULL_REQUEST_CHIP_PRESENTATIONS[state as keyof typeof PULL_REQUEST_CHIP_PRESENTATIONS]; + } + } + return Object.hasOwn(CONTEXT_CHIP_PRESENTATIONS, presentationKind) + ? CONTEXT_CHIP_PRESENTATIONS[presentationKind as keyof typeof CONTEXT_CHIP_PRESENTATIONS] + : CONTEXT_CHIP_PRESENTATIONS.file; +} diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6730ce858c514281d145808b6814ee133af7b51 --- /dev/null +++ b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ComposerContextId, ReviewCommentContextRecord } from "@t3tools/contracts"; +import { + enrichedContextLinkPresentation, + enrichedLinkVariantPattern, + enrichedSkillLinkRegex, + enrichedSkillReference, + ENRICHED_INLINE_FILE_LINK_REGEX, +} from "./enrichedLinkPresentation"; + +describe("Enriched link presentation", () => { + it("recognizes file-shaped code without turning commands or URLs into links", () => { + for (const path of [ + "src/app.tsx", + "./README.md", + "/tmp/report.md", + "C:\\repo\\app.tsx", + "app.tsx:12:3", + ]) { + expect(ENRICHED_INLINE_FILE_LINK_REGEX.test(path), path).toBe(true); + } + for (const text of [ + "npm run dev", + "port:3000", + "TODO:12", + "example.com", + "https://example.com/app.tsx", + "x + y", + "README.md", + "/app/settings", + ]) { + expect(ENRICHED_INLINE_FILE_LINK_REGEX.test(text), text).toBe(false); + } + }); + + it("only recognizes complete known skill names and escapes their punctuation", () => { + const regex = enrichedSkillLinkRegex([{ name: "check" }, { name: "check.mobile" }]); + expect(regex?.exec("Try $check.mobile next")?.[0]).toBe("$check.mobile"); + expect(regex?.exec("Try $check-mobile next")).toBeNull(); + expect(regex?.exec("Try $checkXmobile next")).toBeNull(); + expect(regex?.exec("prefix$check next")).toBeNull(); + expect(enrichedSkillLinkRegex([])).toBeNull(); + }); + it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( + "recognizes and presents known skills with %s while rejecting adjacent tokens", + (sigil) => { + const skills = [{ name: "check", displayName: "Check" }]; + const regex = enrichedSkillLinkRegex(skills)!; + expect(regex.exec(`Try ${sigil}check now`)?.[0]).toBe(`${sigil}check`); + expect(enrichedSkillReference(`${sigil}check`, skills)).toBe(skills[0]); + expect(regex.test(`prefix${sigil}check`)).toBe(false); + expect(regex.test(`${sigil}check-extra`)).toBe(false); + expect(regex.test(`${sigil}unknown`)).toBe(false); + expect(regex.test(`${sigil}${sigil}check`)).toBe(false); + }, + ); + it("matches only the complete URL, including literal regex metacharacters", () => { + const url = "file:///workspace/app[1].tsx?line=3+4"; + const pattern = new RegExp(enrichedLinkVariantPattern(url)); + expect(pattern.test(url)).toBe(true); + expect(pattern.test(`${url}/child`)).toBe(false); + expect(pattern.test("file:///workspace/app1Xtsx?line=34")).toBe(false); + }); + + it("uses context identity for PR status even when labels are identical", () => { + const record: ReviewCommentContextRecord = { + version: 1, + contextId: "review_1" as ComposerContextId, + kind: "review-comment", + label: "Review", + sectionId: "pull-request:7", + sectionTitle: "Review", + filePath: "app.tsx", + startIndex: 0, + endIndex: 1, + rangeLabel: "line 1", + text: "Comment", + diff: "", + pullRequest: { + number: 7, + title: "Review", + url: "https://github.com/example/repo/pull/7", + headBranch: "feature", + baseBranch: "main", + state: "merged", + isDraft: false, + }, + }; + expect( + enrichedContextLinkPresentation("t3-context://v1/review-comment/review_1", [record]), + ).toEqual({ color: "#8a70dd", icon: "git" }); + expect( + enrichedContextLinkPresentation("t3-context://v1/review-comment/review_2", [record]), + ).toEqual({ color: "#8a70dd", icon: "markdown" }); + }); + + it("retains recognizable presentation for unavailable and future context kinds", () => { + expect(enrichedContextLinkPresentation("t3-context://v1/terminal/missing")).toEqual({ + color: "#009f6e", + icon: "bash", + }); + expect(enrichedContextLinkPresentation("t3-context://v1/skill/missing")).toEqual({ + color: "#b261be", + icon: "mcp", + }); + expect(enrichedContextLinkPresentation("t3-context://v1/future/missing")).toEqual({ + color: "#0090cd", + icon: "default", + }); + expect(enrichedContextLinkPresentation("https://example.com/terminal/missing")).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts new file mode 100644 index 0000000000000000000000000000000000000000..3238d28d6df455d2abb06b2c2e50c749c686f423 --- /dev/null +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -0,0 +1,69 @@ +import type { ComposerContextRecord } from "@t3tools/contracts"; +import { contextChipPresentation } from "./composerChipPresentation"; +import type { MarkdownFileIcon } from "./markdownLinks"; +import type { SelectableMarkdownSkill } from "../native/SelectableMarkdownText.types"; +import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; + +const CONTEXT_ICONS: Readonly> = { + photo: "image", + "play.rectangle": "video", + terminal: "bash", + "cursorarrow.click": "react", + "text.bubble": "markdown", + "git-pull-request": "git", + cube: "mcp", + doc: "default", +}; + +/** Context identity comes from the URL, even when different chips share a label. */ +export function enrichedContextLinkPresentation( + href: string, + records?: ReadonlyArray, +) { + const reference = parseComposerContextHref(href); + if (!reference) return undefined; + const record = records?.find((candidate) => candidate.contextId === reference.contextId); + const presentation = contextChipPresentation(reference.kind, record); + return { + color: presentation.accent, + icon: CONTEXT_ICONS[presentation.symbol] ?? "default", + }; +} + +export function enrichedLinkVariantPattern(url: string) { + return `^${url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`; +} + +/** Conservative recognition for Enriched's whole-span inline-code link hook. */ +export const ENRICHED_INLINE_FILE_LINK_REGEX = + /^(?!\S*:\/\/)(?:(?:\.{1,2}[\\/]|~[\\/]|\/(?:Users|home|tmp|var|etc|opt|mnt|Volumes|private|root|usr|bin|sbin|lib|lib64|srv|dev|proc|sys|run|boot|media|workspace|workspaces)\/|[A-Za-z]:[\\/])[^\s`]+|(?:[A-Za-z0-9._-]+[\\/])+[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}|[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+:\d+(?::\d+)?)$/; + +export function enrichedSkillLinkRegex(skills?: ReadonlyArray) { + const names = [...new Set(skills?.map((skill) => skill.name) ?? [])].filter(Boolean); + if (names.length === 0) return null; + names.sort((a, b) => b.length - a.length); + const alternatives = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + return new RegExp( + `(? word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") + ); +} + +export function enrichedSkillReference( + url: string, + skills?: ReadonlyArray, +) { + const match = /^\p{Sc}(.+)$/u.exec(url); + return match ? skills?.find((skill) => skill.name === match[1]) : undefined; +} diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts new file mode 100644 index 0000000000000000000000000000000000000000..f69dd416925010f9476425fec488cef3b01cdb21 --- /dev/null +++ b/apps/mobile/src/lib/markdownDocument.ts @@ -0,0 +1,146 @@ +import type { Root, RootContent } from "mdast"; +import { unified } from "unified"; +import remarkParse from "remark-parse"; +import remarkGfm from "remark-gfm"; + +const parser = unified().use(remarkParse).use(remarkGfm); +export type MarkdownNode = Root | RootContent; + +function decodeCodePoint(codePoint: number, entity: string): string { + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { + return entity; + } + return String.fromCodePoint(codePoint); +} + +function decodeHtmlEntities(value: string): string { + return value.replace( + /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi, + (entity, decimal: string | undefined, hexadecimal: string | undefined) => { + if (decimal) { + return decodeCodePoint(Number.parseInt(decimal, 10), entity); + } + if (hexadecimal) { + return decodeCodePoint(Number.parseInt(hexadecimal, 16), entity); + } + switch (entity.toLowerCase()) { + case "&": + return "&"; + case "'": + return "'"; + case ">": + return ">"; + case "<": + return "<"; + case " ": + return "\u00a0"; + case """: + return '"'; + default: + return entity; + } + }, + ); +} + +const HTML_TAG = /|<\/?[a-z](?:[^>"']|"[^"]*"|'[^']*')*>/gi; + +function attribute(tag: string, name: string): string | null { + const attributes = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g; + for (const match of tag.matchAll(attributes)) { + if (match[1]?.toLowerCase() === name) { + return decodeHtmlEntities(match[2] ?? match[3] ?? match[4] ?? ""); + } + } + return null; +} + +/** Called only for parsed HTML nodes, so Markdown code examples keep their literal tags. */ +export function markdownHtmlContent(content: string): string { + // An HTML block such as
can also contain Markdown fences and code spans. + const codeRanges: Array<{ start: number; end: number }> = []; + const tags = [...content.matchAll(HTML_TAG)]; + let tagIndex = 0; + const delimiters = [...content.matchAll(/`+|~{3,}/g)].filter((delimiter) => { + while (tags[tagIndex] && tags[tagIndex]!.index + tags[tagIndex]![0].length <= delimiter.index) + tagIndex += 1; + const tag = tags[tagIndex]; + return !tag || delimiter.index < tag.index; + }); + for (let index = 0; index < delimiters.length; index += 1) { + const opening = delimiters[index]!; + const lineStart = content.lastIndexOf("\n", opening.index - 1) + 1; + const fenced = + opening[0].length >= 3 && /^ {0,3}$/.test(content.slice(lineStart, opening.index)); + if (opening[0][0] === "~" && !fenced) continue; + let closingIndex = -1; + for (let candidate = index + 1; candidate < delimiters.length; candidate += 1) { + const closing = delimiters[candidate]!; + if (!fenced && closing[0] === opening[0]) { + closingIndex = candidate; + break; + } + if (!fenced || closing[0][0] !== opening[0][0] || closing[0].length < opening[0].length) + continue; + const start = content.lastIndexOf("\n", closing.index - 1) + 1; + const nextLine = content.indexOf("\n", closing.index); + const end = nextLine === -1 ? content.length : nextLine; + if ( + /^ {0,3}$/.test(content.slice(start, closing.index)) && + /^\s*$/.test(content.slice(closing.index + closing[0].length, end)) + ) { + closingIndex = candidate; + break; + } + } + if (closingIndex === -1) { + if (fenced) codeRanges.push({ start: opening.index, end: content.length }); + continue; + } + const closing = delimiters[closingIndex]!; + codeRanges.push({ start: opening.index, end: closing.index + closing[0].length }); + index = closingIndex; + } + return content.replace(HTML_TAG, (tag, offset: number) => { + if (codeRanges.some((range) => offset >= range.start && offset < range.end)) return tag; + if (tag.startsWith("" }, - { type: "html_block", content: "
" }, - { type: "heading", level: 2, children: [{ type: "text", content: "Walkthrough" }] }, - { type: "html_block", content: "
" }, - ], - }; - const result = nativeMarkdownWithHtml(document, () => { - throw new Error("Empty HTML must not be parsed"); - }); - expect(result.children).toEqual([document.children![2]]); - expect(nativeMarkdownDocumentChunks(result)).toHaveLength(1); - expect(markdownHtmlContent("Walkthrough").trim()).toBe("**Walkthrough**"); + it("decodes HTML attributes once and preserves entity-shaped query values through Markdown", () => { + const markdown = + '&lt;'; + expect(markdownWithHtml(markdown)).toBe( + "![\\<]()", + ); + expect(markdownImageSources(markdown)).toEqual([ + "https://example.com/shot.png?label=<&sig=a%26b", + ]); + const paragraph = parseMarkdownDocument(markdown).children[0]; + expect(paragraph?.type).toBe("paragraph"); + if (paragraph?.type === "paragraph") + expect(paragraph.children[0]).toMatchObject({ type: "image", alt: "<" }); }); - it("keeps literal HTML code examples and gives HTML image fragments unique chunk keys", () => { - const code: MarkdownNode = { - type: "code_block", - content: '', - language: "html", - }; - const document: MarkdownNode = { - type: "document", - children: [ - code, - { type: "html_block", content: '' }, - { type: "html_block", content: '' }, - ], - }; - const result = nativeMarkdownWithHtml(document, (content) => ({ - type: "document", - children: [ - { - type: "paragraph", - beg: 0, - end: content.length, - children: [{ type: "image", href: content, beg: 0 }], - }, - ], - })); - expect(result.children![0]).toBe(code); - const keys = nativeMarkdownDocumentChunks(result).map((chunk) => chunk.key); - expect(new Set(keys).size).toBe(keys.length); - expect(result.children![1]?.beg).toBeUndefined(); + it("ignores code delimiters in HTML attributes and comments when collecting screenshots", () => { + const markdown = + '
\nProof\nScreenshot\n\n
'; + expect(markdownHtmlContent(markdown)).toContain( + "![Screenshot]()", + ); + expect(markdownImageSources(markdown)).toEqual(["https://example.com/shot.png"]); + expect(markdownWithHtml(markdown)).not.toContain(" { + const markdown = + "\n
\nWalkthrough\n\n## Changes\n\nText\n\n
"; + const result = markdownWithHtml(markdown); + expect(result).not.toContain("