| author | |
| committer | |
| log | c5d7309152f2c93f7334afe54f99c41372e15d10 |
| tree | 5fb9b7d6ea3d1583ea3fcc1132387dbf37183a61 |
| parent | 6ba6a0daf44333ba6615464118e36d79f6220731 |
| signature | Signed by PGP key B5690EEEBB952194 |
248 files changed, 3950 insertions(+), 9478 deletions(-)
apps/mobile/assets/file-icons/pierre_agents.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_agents.png differ | |||
apps/mobile/assets/file-icons/pierre_astro.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_astro.png differ | |||
apps/mobile/assets/file-icons/pierre_babel.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_babel.png differ | |||
apps/mobile/assets/file-icons/pierre_bash.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_bash.png differ | |||
apps/mobile/assets/file-icons/pierre_biome.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_biome.png differ | |||
apps/mobile/assets/file-icons/pierre_bootstrap.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_bootstrap.png differ | |||
apps/mobile/assets/file-icons/pierre_browserslist.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_browserslist.png differ | |||
apps/mobile/assets/file-icons/pierre_bun.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_bun.png differ | |||
apps/mobile/assets/file-icons/pierre_c.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_c.png differ | |||
apps/mobile/assets/file-icons/pierre_claude.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_claude.png differ | |||
apps/mobile/assets/file-icons/pierre_cpp.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_cpp.png differ | |||
apps/mobile/assets/file-icons/pierre_css.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_css.png differ | |||
apps/mobile/assets/file-icons/pierre_database.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_database.png differ | |||
apps/mobile/assets/file-icons/pierre_default.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_default.png differ | |||
apps/mobile/assets/file-icons/pierre_docker.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_docker.png differ | |||
apps/mobile/assets/file-icons/pierre_eslint.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_eslint.png differ | |||
apps/mobile/assets/file-icons/pierre_font.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_font.png differ | |||
apps/mobile/assets/file-icons/pierre_git.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_git.png differ | |||
apps/mobile/assets/file-icons/pierre_go.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_go.png differ | |||
apps/mobile/assets/file-icons/pierre_graphql.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_graphql.png differ | |||
apps/mobile/assets/file-icons/pierre_html.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_html.png differ | |||
apps/mobile/assets/file-icons/pierre_image.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_image.png differ | |||
apps/mobile/assets/file-icons/pierre_javascript.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_javascript.png differ | |||
apps/mobile/assets/file-icons/pierre_json.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_json.png differ | |||
apps/mobile/assets/file-icons/pierre_markdown.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_markdown.png differ | |||
apps/mobile/assets/file-icons/pierre_mcp.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_mcp.png differ | |||
apps/mobile/assets/file-icons/pierre_nextjs.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_nextjs.png differ | |||
apps/mobile/assets/file-icons/pierre_npm.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_npm.png differ | |||
apps/mobile/assets/file-icons/pierre_oxc.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_oxc.png differ | |||
apps/mobile/assets/file-icons/pierre_pnpm.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_pnpm.png differ | |||
apps/mobile/assets/file-icons/pierre_postcss.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_postcss.png differ | |||
apps/mobile/assets/file-icons/pierre_prettier.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_prettier.png differ | |||
apps/mobile/assets/file-icons/pierre_python.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_python.png differ | |||
apps/mobile/assets/file-icons/pierre_react.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_react.png differ | |||
apps/mobile/assets/file-icons/pierre_ruby.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_ruby.png differ | |||
apps/mobile/assets/file-icons/pierre_rust.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_rust.png differ | |||
apps/mobile/assets/file-icons/pierre_sass.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_sass.png differ | |||
apps/mobile/assets/file-icons/pierre_stylelint.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_stylelint.png differ | |||
apps/mobile/assets/file-icons/pierre_svelte.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_svelte.png differ | |||
apps/mobile/assets/file-icons/pierre_svg.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_svg.png differ | |||
apps/mobile/assets/file-icons/pierre_svgo.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_svgo.png differ | |||
apps/mobile/assets/file-icons/pierre_swift.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_swift.png differ | |||
apps/mobile/assets/file-icons/pierre_table.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_table.png differ | |||
apps/mobile/assets/file-icons/pierre_tailwind.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_tailwind.png differ | |||
apps/mobile/assets/file-icons/pierre_terraform.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_terraform.png differ | |||
apps/mobile/assets/file-icons/pierre_text.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_text.png differ | |||
apps/mobile/assets/file-icons/pierre_typescript.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_typescript.png differ | |||
apps/mobile/assets/file-icons/pierre_video.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_video.png differ | |||
apps/mobile/assets/file-icons/pierre_vite.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_vite.png differ | |||
apps/mobile/assets/file-icons/pierre_vscode.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_vscode.png differ | |||
apps/mobile/assets/file-icons/pierre_vue.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_vue.png differ | |||
apps/mobile/assets/file-icons/pierre_wasm.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_wasm.png differ | |||
apps/mobile/assets/file-icons/pierre_webpack.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_webpack.png differ | |||
apps/mobile/assets/file-icons/pierre_yml.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_yml.png differ | |||
apps/mobile/assets/file-icons/pierre_zig.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_zig.png differ | |||
apps/mobile/assets/file-icons/pierre_zip.png created| Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_zip.png differ | |||
apps/mobile/assets/link-icons/github.png created| Binary files /dev/null and b/apps/mobile/assets/link-icons/github.png differ | |||
apps/mobile/deps/README.md created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | 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. | ||
| 2 | |||
| 3 | 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. | ||
apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz created| Binary files /dev/null and b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz differ | |||
apps/mobile/modules/t3-composer-editor/android/build.gradle-1| ... | @@ -20,7 +20,6 @@ android { | ... | @@ -20,7 +20,6 @@ android { |
| 20 | 20 | ||
| 21 | dependencies { | 21 | dependencies { |
| 22 | implementation project(':expo-modules-core') | 22 | implementation project(':expo-modules-core') |
| 23 | implementation project(':t3tools-mobile-markdown-text') | ||
| 24 | testImplementation 'junit:junit:4.13.2' | 23 | testImplementation 'junit:junit:4.13.2' |
| 25 | testImplementation 'org.robolectric:robolectric:4.16.1' | 24 | testImplementation 'org.robolectric:robolectric:4.16.1' |
| 26 | } | 25 | } |
apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt-1| ... | @@ -29,7 +29,6 @@ import android.widget.EditText | ... | @@ -29,7 +29,6 @@ import android.widget.EditText |
| 29 | import expo.modules.kotlin.AppContext | 29 | import expo.modules.kotlin.AppContext |
| 30 | import expo.modules.kotlin.viewevent.EventDispatcher | 30 | import expo.modules.kotlin.viewevent.EventDispatcher |
| 31 | import expo.modules.kotlin.views.ExpoView | 31 | import expo.modules.kotlin.views.ExpoView |
| 32 | import expo.modules.t3markdowntext.T3ContextChip | ||
| 33 | import org.json.JSONObject | 32 | import org.json.JSONObject |
| 34 | import kotlin.math.max | 33 | import kotlin.math.max |
| 35 | 34 |
apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt created+184| ... | @@ -0,0 +1,184 @@ | ||
| 1 | package expo.modules.t3composereditor | ||
| 2 | |||
| 3 | import android.graphics.Canvas | ||
| 4 | import android.graphics.Color | ||
| 5 | import android.graphics.Paint | ||
| 6 | import android.graphics.Path | ||
| 7 | import android.graphics.RectF | ||
| 8 | import android.graphics.Typeface | ||
| 9 | import android.text.TextPaint | ||
| 10 | import android.text.TextUtils | ||
| 11 | import kotlin.math.ceil | ||
| 12 | import kotlin.math.min | ||
| 13 | |||
| 14 | /** Shared by editable spans and inline chat images so their metrics and colors agree. */ | ||
| 15 | class T3ContextChip( | ||
| 16 | content: Content, | ||
| 17 | fontSize: Float, | ||
| 18 | colors: Colors, | ||
| 19 | maximumWidth: Float, | ||
| 20 | private val density: Float | ||
| 21 | ) { | ||
| 22 | /** What the chip says: its name, the size beside it, and the glyph that leads it. */ | ||
| 23 | data class Content(val label: String, val symbol: String, val detail: String = "") | ||
| 24 | |||
| 25 | data class Colors(val accent: Int, val foreground: Int, val border: Int) | ||
| 26 | |||
| 27 | private val symbol = content.symbol | ||
| 28 | |||
| 29 | private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { | ||
| 30 | textSize = fontSize | ||
| 31 | typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) | ||
| 32 | } | ||
| 33 | private val em = fontSize | ||
| 34 | |||
| 35 | // The size reads as metadata beside the name, so it draws a step down from the label the | ||
| 36 | // way the web chip does. | ||
| 37 | private val detailPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { | ||
| 38 | textSize = fontSize * 0.84f | ||
| 39 | typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) | ||
| 40 | } | ||
| 41 | private val detailText = if (content.detail.isEmpty()) "" else " ${content.detail}" | ||
| 42 | private val detailWidth = if (detailText.isEmpty()) 0f else detailPaint.measureText(detailText) | ||
| 43 | |||
| 44 | // The border is stroked, and a stroke straddles the path it follows, so the box has to | ||
| 45 | // reserve a stroke on every side it insets. Reserving it once left the right edge a | ||
| 46 | // stroke short, and antialiasing shaved that curve flat against the span bounds. | ||
| 47 | private val strokeWidth = density | ||
| 48 | private val inset = strokeWidth * 2f | ||
| 49 | val width = ceil( | ||
| 50 | min( | ||
| 51 | maximumWidth.coerceAtLeast(em * 3), | ||
| 52 | paint.measureText(content.label) + detailWidth + em * 2.5f + inset | ||
| 53 | ) | ||
| 54 | ) | ||
| 55 | val height = ceil(em * 1.41f + inset) | ||
| 56 | private val text = TextUtils.ellipsize( | ||
| 57 | content.label, | ||
| 58 | paint, | ||
| 59 | (width - em * 2.5f - inset - detailWidth).coerceAtLeast(0f), | ||
| 60 | TextUtils.TruncateAt.MIDDLE | ||
| 61 | ).toString() | ||
| 62 | private val fill = Color.argb( | ||
| 63 | 28, | ||
| 64 | Color.red(colors.accent), | ||
| 65 | Color.green(colors.accent), | ||
| 66 | Color.blue(colors.accent) | ||
| 67 | ) | ||
| 68 | private val textColor = blend(colors.accent, colors.foreground, 0.22f) | ||
| 69 | private val borderColor = blend(colors.accent, colors.border, 0.34f) | ||
| 70 | |||
| 71 | // Half a stroke keeps the border inside the box; the rest of the reserved margin is | ||
| 72 | // slack, so an antialiased edge fades out before it reaches the span bounds. | ||
| 73 | private val shape = | ||
| 74 | RectF(inset / 2f, inset / 2f, width - inset / 2f, height - inset / 2f) | ||
| 75 | private val icon = iconPath(symbol) | ||
| 76 | |||
| 77 | fun draw(canvas: Canvas, x: Float, y: Float) { | ||
| 78 | canvas.save() | ||
| 79 | canvas.translate(x, y) | ||
| 80 | paint.style = Paint.Style.FILL | ||
| 81 | paint.color = fill | ||
| 82 | canvas.drawRoundRect(shape, em / 2, em / 2, paint) | ||
| 83 | paint.style = Paint.Style.STROKE | ||
| 84 | paint.strokeWidth = strokeWidth | ||
| 85 | paint.color = borderColor | ||
| 86 | canvas.drawRoundRect(shape, em / 2, em / 2, paint) | ||
| 87 | paint.color = textColor | ||
| 88 | canvas.save() | ||
| 89 | val iconSize = em * 1.17f | ||
| 90 | canvas.translate(em / 2 + inset / 2f, (height - iconSize) / 2) | ||
| 91 | canvas.scale(iconSize / 24, iconSize / 24) | ||
| 92 | paint.strokeWidth = 1.7f | ||
| 93 | paint.strokeJoin = Paint.Join.ROUND | ||
| 94 | paint.strokeCap = Paint.Cap.ROUND | ||
| 95 | canvas.drawPath(icon, paint) | ||
| 96 | canvas.restore() | ||
| 97 | paint.style = Paint.Style.FILL | ||
| 98 | val metrics = paint.fontMetrics | ||
| 99 | val baseline = (height - metrics.descent - metrics.ascent) / 2 | ||
| 100 | canvas.drawText(text, em * 2 + inset / 2f, baseline, paint) | ||
| 101 | if (detailText.isNotEmpty()) { | ||
| 102 | detailPaint.color = paint.color | ||
| 103 | canvas.drawText( | ||
| 104 | detailText, | ||
| 105 | em * 2 + inset / 2f + paint.measureText(text), | ||
| 106 | baseline, | ||
| 107 | detailPaint | ||
| 108 | ) | ||
| 109 | } | ||
| 110 | canvas.restore() | ||
| 111 | } | ||
| 112 | |||
| 113 | companion object { | ||
| 114 | fun color( | ||
| 115 | value: String, | ||
| 116 | fallback: Int | ||
| 117 | ): Int = runCatching { | ||
| 118 | // React Native hex colors put alpha last; Android's parser puts it first. | ||
| 119 | val androidColor = if (value.length == 9 && value.startsWith("#")) { | ||
| 120 | "#${value.takeLast(2)}${value.substring(1, 7)}" | ||
| 121 | } else { | ||
| 122 | value | ||
| 123 | } | ||
| 124 | Color.parseColor(androidColor) | ||
| 125 | }.getOrDefault(fallback) | ||
| 126 | |||
| 127 | private fun blend(accent: Int, base: Int, weight: Float): Int = Color.rgb( | ||
| 128 | (Color.red(accent) * weight + Color.red(base) * (1 - weight)).toInt(), | ||
| 129 | (Color.green(accent) * weight + Color.green(base) * (1 - weight)).toInt(), | ||
| 130 | (Color.blue(accent) * weight + Color.blue(base) * (1 - weight)).toInt(), | ||
| 131 | ) | ||
| 132 | |||
| 133 | private fun iconPath(symbol: String) = Path().apply { | ||
| 134 | fun line(vararg points: Float) { | ||
| 135 | moveTo(points[0], points[1]) | ||
| 136 | for (index in 2 until points.size step 2) lineTo(points[index], points[index + 1]) | ||
| 137 | } | ||
| 138 | when (symbol) { | ||
| 139 | "cube" -> { | ||
| 140 | line(12f, 2f, 21f, 7f, 21f, 17f, 12f, 22f, 3f, 17f, 3f, 7f, 12f, 2f) | ||
| 141 | line(3f, 7f, 12f, 12f, 21f, 7f) | ||
| 142 | line(12f, 12f, 12f, 22f) | ||
| 143 | } | ||
| 144 | // lucide `git-pull-request`, the glyph web draws: two nodes, an elbow, and a stem. | ||
| 145 | "git-pull-request" -> { | ||
| 146 | addCircle(18f, 18f, 3f, Path.Direction.CW) | ||
| 147 | addCircle(6f, 6f, 3f, Path.Direction.CW) | ||
| 148 | moveTo(13f, 6f) | ||
| 149 | lineTo(16f, 6f) | ||
| 150 | cubicTo(17.1f, 6f, 18f, 6.9f, 18f, 8f) | ||
| 151 | lineTo(18f, 15f) | ||
| 152 | line(6f, 9f, 6f, 21f) | ||
| 153 | } | ||
| 154 | "cursorarrow.click" -> { | ||
| 155 | line(4f, 3f, 19f, 12f, 12f, 14f, 9f, 21f, 4f, 3f) | ||
| 156 | line(13f, 15f, 18f, 21f) | ||
| 157 | } | ||
| 158 | "text.bubble" -> { | ||
| 159 | line(3f, 4f, 21f, 4f, 21f, 17f, 10f, 17f, 5f, 21f, 5f, 17f, 3f, 17f, 3f, 4f) | ||
| 160 | line(7f, 8f, 17f, 8f) | ||
| 161 | line(7f, 12f, 14f, 12f) | ||
| 162 | } | ||
| 163 | "terminal", "play.rectangle", "photo" -> { | ||
| 164 | addRoundRect(2f, 4f, 22f, 20f, 2f, 2f, Path.Direction.CW) | ||
| 165 | when (symbol) { | ||
| 166 | "terminal" -> { | ||
| 167 | line(6f, 8f, 10f, 12f, 6f, 16f) | ||
| 168 | line(13f, 16f, 18f, 16f) | ||
| 169 | } | ||
| 170 | "play.rectangle" -> line(9f, 8f, 16f, 12f, 9f, 16f, 9f, 8f) | ||
| 171 | else -> { | ||
| 172 | addCircle(8f, 9f, 1.5f, Path.Direction.CW) | ||
| 173 | line(3f, 18f, 11f, 12f, 15f, 15f, 18f, 12f, 21f, 16f) | ||
| 174 | } | ||
| 175 | } | ||
| 176 | } | ||
| 177 | else -> { | ||
| 178 | line(5f, 2f, 14f, 2f, 20f, 8f, 20f, 22f, 5f, 22f, 5f, 2f) | ||
| 179 | line(14f, 2f, 14f, 8f, 20f, 8f) | ||
| 180 | } | ||
| 181 | } | ||
| 182 | } | ||
| 183 | } | ||
| 184 | } | ||
apps/mobile/modules/t3-markdown-text/LICENSE deleted-20| ... | @@ -1,20 +0,0 @@ | ||
| 1 | MIT License | ||
| 2 | |||
| 3 | Copyright (c) 2024-25 Bluesky PBC | ||
| 4 | Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| 5 | of this software and associated documentation files (the "Software"), to deal | ||
| 6 | in the Software without restriction, including without limitation the rights | ||
| 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| 8 | copies of the Software, and to permit persons to whom the Software is | ||
| 9 | furnished to do so, subject to the following conditions: | ||
| 10 | |||
| 11 | The above copyright notice and this permission notice shall be included in all | ||
| 12 | copies or substantial portions of the Software. | ||
| 13 | |||
| 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| 20 | SOFTWARE. | ||
apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec deleted-25| ... | @@ -1,25 +0,0 @@ | ||
| 1 | require "json" | ||
| 2 | |||
| 3 | package = JSON.parse(File.read(File.join(__dir__, "package.json"))) | ||
| 4 | new_arch_enabled = ENV["RCT_NEW_ARCH_ENABLED"] == "1" | ||
| 5 | |||
| 6 | Pod::Spec.new do |s| | ||
| 7 | s.name = "T3MarkdownText" | ||
| 8 | s.version = package["version"] | ||
| 9 | s.summary = "Native selectable markdown renderer for T3 Code mobile." | ||
| 10 | s.description = "Fabric-backed attributed text and markdown rendering primitives owned by T3 Code." | ||
| 11 | s.homepage = "https://t3tools.com" | ||
| 12 | s.license = { :type => "MIT", :file => "LICENSE" } | ||
| 13 | s.author = { "T3 Tools" => "hello@t3tools.com" } | ||
| 14 | s.platforms = { :ios => min_ios_version_supported } | ||
| 15 | s.source = { :path => "." } | ||
| 16 | s.source_files = "ios/**/*.{h,m,mm,cpp}" | ||
| 17 | |||
| 18 | install_modules_dependencies(s) | ||
| 19 | |||
| 20 | if ENV["USE_FRAMEWORKS"] != nil && new_arch_enabled | ||
| 21 | add_dependency(s, "React-FabricComponents", :additional_framework_paths => [ | ||
| 22 | "react/renderer/textlayoutmanager/platform/ios", | ||
| 23 | ]) | ||
| 24 | end | ||
| 25 | end | ||
apps/mobile/modules/t3-markdown-text/UPSTREAM.md deleted-12| ... | @@ -1,12 +0,0 @@ | ||
| 1 | # Upstream Attribution | ||
| 2 | |||
| 3 | The Fabric attributed-text component in this module originated from | ||
| 4 | [`bluesky-social/react-native-uitextview`](https://github.com/bluesky-social/react-native-uitextview), | ||
| 5 | version `2.2.0`, commit `addc08fea303608f070fe1eeba4bc075f181c4af`. | ||
| 6 | |||
| 7 | The upstream project is Copyright (c) 2024-25 Bluesky PBC and licensed under | ||
| 8 | the MIT License included in this directory. | ||
| 9 | |||
| 10 | T3 Code has substantially modified and renamed the implementation, integrated | ||
| 11 | its markdown renderer, and owns the resulting module going forward. This is not | ||
| 12 | an upstream package dependency or a compatibility fork. | ||
apps/mobile/modules/t3-markdown-text/android/build.gradle deleted-32| ... | @@ -1,32 +0,0 @@ | ||
| 1 | apply plugin: 'com.android.library' | ||
| 2 | apply plugin: 'org.jetbrains.kotlin.android' | ||
| 3 | |||
| 4 | group = 'com.t3tools.markdowntext' | ||
| 5 | version = '0.0.0' | ||
| 6 | |||
| 7 | android { | ||
| 8 | namespace 'expo.modules.t3markdowntext' | ||
| 9 | compileSdk rootProject.ext.compileSdkVersion | ||
| 10 | |||
| 11 | testOptions { | ||
| 12 | unitTests.includeAndroidResources = true | ||
| 13 | } | ||
| 14 | |||
| 15 | defaultConfig { | ||
| 16 | minSdkVersion rootProject.ext.minSdkVersion | ||
| 17 | targetSdkVersion rootProject.ext.targetSdkVersion | ||
| 18 | } | ||
| 19 | } | ||
| 20 | |||
| 21 | dependencies { | ||
| 22 | implementation project(':expo-modules-core') | ||
| 23 | implementation 'com.facebook.react:react-android' | ||
| 24 | testImplementation 'junit:junit:4.13.2' | ||
| 25 | testImplementation 'org.robolectric:robolectric:4.16.1' | ||
| 26 | } | ||
| 27 | |||
| 28 | tasks.withType(Test).configureEach { | ||
| 29 | javaLauncher = javaToolchains.launcherFor { | ||
| 30 | languageVersion = JavaLanguageVersion.of(21) | ||
| 31 | } | ||
| 32 | } | ||
apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3ContextChip.kt deleted-184| ... | @@ -1,184 +0,0 @@ | ||
| 1 | package expo.modules.t3markdowntext | ||
| 2 | |||
| 3 | import android.graphics.Canvas | ||
| 4 | import android.graphics.Color | ||
| 5 | import android.graphics.Paint | ||
| 6 | import android.graphics.Path | ||
| 7 | import android.graphics.RectF | ||
| 8 | import android.graphics.Typeface | ||
| 9 | import android.text.TextPaint | ||
| 10 | import android.text.TextUtils | ||
| 11 | import kotlin.math.ceil | ||
| 12 | import kotlin.math.min | ||
| 13 | |||
| 14 | /** Shared by editable spans and inline chat images so their metrics and colors agree. */ | ||
| 15 | class T3ContextChip( | ||
| 16 | content: Content, | ||
| 17 | fontSize: Float, | ||
| 18 | colors: Colors, | ||
| 19 | maximumWidth: Float, | ||
| 20 | private val density: Float | ||
| 21 | ) { | ||
| 22 | /** What the chip says: its name, the size beside it, and the glyph that leads it. */ | ||
| 23 | data class Content(val label: String, val symbol: String, val detail: String = "") | ||
| 24 | |||
| 25 | data class Colors(val accent: Int, val foreground: Int, val border: Int) | ||
| 26 | |||
| 27 | private val symbol = content.symbol | ||
| 28 | |||
| 29 | private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { | ||
| 30 | textSize = fontSize | ||
| 31 | typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) | ||
| 32 | } | ||
| 33 | private val em = fontSize | ||
| 34 | |||
| 35 | // The size reads as metadata beside the name, so it draws a step down from the label the | ||
| 36 | // way the web chip does. | ||
| 37 | private val detailPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { | ||
| 38 | textSize = fontSize * 0.84f | ||
| 39 | typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) | ||
| 40 | } | ||
| 41 | private val detailText = if (content.detail.isEmpty()) "" else " ${content.detail}" | ||
| 42 | private val detailWidth = if (detailText.isEmpty()) 0f else detailPaint.measureText(detailText) | ||
| 43 | |||
| 44 | // The border is stroked, and a stroke straddles the path it follows, so the box has to | ||
| 45 | // reserve a stroke on every side it insets. Reserving it once left the right edge a | ||
| 46 | // stroke short, and antialiasing shaved that curve flat against the span bounds. | ||
| 47 | private val strokeWidth = density | ||
| 48 | private val inset = strokeWidth * 2f | ||
| 49 | val width = ceil( | ||
| 50 | min( | ||
| 51 | maximumWidth.coerceAtLeast(em * 3), | ||
| 52 | paint.measureText(content.label) + detailWidth + em * 2.5f + inset | ||
| 53 | ) | ||
| 54 | ) | ||
| 55 | val height = ceil(em * 1.41f + inset) | ||
| 56 | private val text = TextUtils.ellipsize( | ||
| 57 | content.label, | ||
| 58 | paint, | ||
| 59 | (width - em * 2.5f - inset - detailWidth).coerceAtLeast(0f), | ||
| 60 | TextUtils.TruncateAt.MIDDLE | ||
| 61 | ).toString() | ||
| 62 | private val fill = Color.argb( | ||
| 63 | 28, | ||
| 64 | Color.red(colors.accent), | ||
| 65 | Color.green(colors.accent), | ||
| 66 | Color.blue(colors.accent) | ||
| 67 | ) | ||
| 68 | private val textColor = blend(colors.accent, colors.foreground, 0.22f) | ||
| 69 | private val borderColor = blend(colors.accent, colors.border, 0.34f) | ||
| 70 | |||
| 71 | // Half a stroke keeps the border inside the box; the rest of the reserved margin is | ||
| 72 | // slack, so an antialiased edge fades out before it reaches the span bounds. | ||
| 73 | private val shape = | ||
| 74 | RectF(inset / 2f, inset / 2f, width - inset / 2f, height - inset / 2f) | ||
| 75 | private val icon = iconPath(symbol) | ||
| 76 | |||
| 77 | fun draw(canvas: Canvas, x: Float, y: Float) { | ||
| 78 | canvas.save() | ||
| 79 | canvas.translate(x, y) | ||
| 80 | paint.style = Paint.Style.FILL | ||
| 81 | paint.color = fill | ||
| 82 | canvas.drawRoundRect(shape, em / 2, em / 2, paint) | ||
| 83 | paint.style = Paint.Style.STROKE | ||
| 84 | paint.strokeWidth = strokeWidth | ||
| 85 | paint.color = borderColor | ||
| 86 | canvas.drawRoundRect(shape, em / 2, em / 2, paint) | ||
| 87 | paint.color = textColor | ||
| 88 | canvas.save() | ||
| 89 | val iconSize = em * 1.17f | ||
| 90 | canvas.translate(em / 2 + inset / 2f, (height - iconSize) / 2) | ||
| 91 | canvas.scale(iconSize / 24, iconSize / 24) | ||
| 92 | paint.strokeWidth = 1.7f | ||
| 93 | paint.strokeJoin = Paint.Join.ROUND | ||
| 94 | paint.strokeCap = Paint.Cap.ROUND | ||
| 95 | canvas.drawPath(icon, paint) | ||
| 96 | canvas.restore() | ||
| 97 | paint.style = Paint.Style.FILL | ||
| 98 | val metrics = paint.fontMetrics | ||
| 99 | val baseline = (height - metrics.descent - metrics.ascent) / 2 | ||
| 100 | canvas.drawText(text, em * 2 + inset / 2f, baseline, paint) | ||
| 101 | if (detailText.isNotEmpty()) { | ||
| 102 | detailPaint.color = paint.color | ||
| 103 | canvas.drawText( | ||
| 104 | detailText, | ||
| 105 | em * 2 + inset / 2f + paint.measureText(text), | ||
| 106 | baseline, | ||
| 107 | detailPaint | ||
| 108 | ) | ||
| 109 | } | ||
| 110 | canvas.restore() | ||
| 111 | } | ||
| 112 | |||
| 113 | companion object { | ||
| 114 | fun color( | ||
| 115 | value: String, | ||
| 116 | fallback: Int | ||
| 117 | ): Int = runCatching { | ||
| 118 | // React Native hex colors put alpha last; Android's parser puts it first. | ||
| 119 | val androidColor = if (value.length == 9 && value.startsWith("#")) { | ||
| 120 | "#${value.takeLast(2)}${value.substring(1, 7)}" | ||
| 121 | } else { | ||
| 122 | value | ||
| 123 | } | ||
| 124 | Color.parseColor(androidColor) | ||
| 125 | }.getOrDefault(fallback) | ||
| 126 | |||
| 127 | private fun blend(accent: Int, base: Int, weight: Float): Int = Color.rgb( | ||
| 128 | (Color.red(accent) * weight + Color.red(base) * (1 - weight)).toInt(), | ||
| 129 | (Color.green(accent) * weight + Color.green(base) * (1 - weight)).toInt(), | ||
| 130 | (Color.blue(accent) * weight + Color.blue(base) * (1 - weight)).toInt(), | ||
| 131 | ) | ||
| 132 | |||
| 133 | private fun iconPath(symbol: String) = Path().apply { | ||
| 134 | fun line(vararg points: Float) { | ||
| 135 | moveTo(points[0], points[1]) | ||
| 136 | for (index in 2 until points.size step 2) lineTo(points[index], points[index + 1]) | ||
| 137 | } | ||
| 138 | when (symbol) { | ||
| 139 | "cube" -> { | ||
| 140 | line(12f, 2f, 21f, 7f, 21f, 17f, 12f, 22f, 3f, 17f, 3f, 7f, 12f, 2f) | ||
| 141 | line(3f, 7f, 12f, 12f, 21f, 7f) | ||
| 142 | line(12f, 12f, 12f, 22f) | ||
| 143 | } | ||
| 144 | // lucide `git-pull-request`, the glyph web draws: two nodes, an elbow, and a stem. | ||
| 145 | "git-pull-request" -> { | ||
| 146 | addCircle(18f, 18f, 3f, Path.Direction.CW) | ||
| 147 | addCircle(6f, 6f, 3f, Path.Direction.CW) | ||
| 148 | moveTo(13f, 6f) | ||
| 149 | lineTo(16f, 6f) | ||
| 150 | cubicTo(17.1f, 6f, 18f, 6.9f, 18f, 8f) | ||
| 151 | lineTo(18f, 15f) | ||
| 152 | line(6f, 9f, 6f, 21f) | ||
| 153 | } | ||
| 154 | "cursorarrow.click" -> { | ||
| 155 | line(4f, 3f, 19f, 12f, 12f, 14f, 9f, 21f, 4f, 3f) | ||
| 156 | line(13f, 15f, 18f, 21f) | ||
| 157 | } | ||
| 158 | "text.bubble" -> { | ||
| 159 | line(3f, 4f, 21f, 4f, 21f, 17f, 10f, 17f, 5f, 21f, 5f, 17f, 3f, 17f, 3f, 4f) | ||
| 160 | line(7f, 8f, 17f, 8f) | ||
| 161 | line(7f, 12f, 14f, 12f) | ||
| 162 | } | ||
| 163 | "terminal", "play.rectangle", "photo" -> { | ||
| 164 | addRoundRect(2f, 4f, 22f, 20f, 2f, 2f, Path.Direction.CW) | ||
| 165 | when (symbol) { | ||
| 166 | "terminal" -> { | ||
| 167 | line(6f, 8f, 10f, 12f, 6f, 16f) | ||
| 168 | line(13f, 16f, 18f, 16f) | ||
| 169 | } | ||
| 170 | "play.rectangle" -> line(9f, 8f, 16f, 12f, 9f, 16f, 9f, 8f) | ||
| 171 | else -> { | ||
| 172 | addCircle(8f, 9f, 1.5f, Path.Direction.CW) | ||
| 173 | line(3f, 18f, 11f, 12f, 15f, 15f, 18f, 12f, 21f, 16f) | ||
| 174 | } | ||
| 175 | } | ||
| 176 | } | ||
| 177 | else -> { | ||
| 178 | line(5f, 2f, 14f, 2f, 20f, 8f, 20f, 22f, 5f, 22f, 5f, 2f) | ||
| 179 | line(14f, 2f, 14f, 8f, 20f, 8f) | ||
| 180 | } | ||
| 181 | } | ||
| 182 | } | ||
| 183 | } | ||
| 184 | } | ||
apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt deleted-302| ... | @@ -1,302 +0,0 @@ | ||
| 1 | package expo.modules.t3markdowntext | ||
| 2 | |||
| 3 | import android.content.ClipData | ||
| 4 | import android.content.ClipboardManager | ||
| 5 | import android.content.Context | ||
| 6 | import android.graphics.Bitmap | ||
| 7 | import android.graphics.Canvas | ||
| 8 | import android.graphics.Color | ||
| 9 | import android.graphics.Paint | ||
| 10 | import android.graphics.Typeface | ||
| 11 | import android.os.Build | ||
| 12 | import android.text.Spannable | ||
| 13 | import android.text.SpannableStringBuilder | ||
| 14 | import android.text.Spanned | ||
| 15 | import android.text.TextPaint | ||
| 16 | import android.text.style.ReplacementSpan | ||
| 17 | import android.view.ActionMode | ||
| 18 | import android.view.Menu | ||
| 19 | import android.view.MenuItem | ||
| 20 | import android.widget.TextView | ||
| 21 | import android.util.Base64 | ||
| 22 | import android.util.LruCache | ||
| 23 | import com.facebook.react.bridge.ReactContext | ||
| 24 | import com.facebook.react.common.assets.ReactFontManager | ||
| 25 | import com.facebook.react.uimanager.UIManagerHelper | ||
| 26 | import expo.modules.kotlin.modules.Module | ||
| 27 | import expo.modules.kotlin.modules.ModuleDefinition | ||
| 28 | import kotlin.math.max | ||
| 29 | import kotlin.math.min | ||
| 30 | import org.json.JSONObject | ||
| 31 | import org.json.JSONArray | ||
| 32 | import java.net.URLEncoder | ||
| 33 | import java.io.ByteArrayOutputStream | ||
| 34 | import kotlin.math.ceil | ||
| 35 | |||
| 36 | private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC" | ||
| 37 | |||
| 38 | // Match React Native's measurement buffer. Android orders tied line-height | ||
| 39 | // spans differently in SpannableString, shifting inline images once RN's | ||
| 40 | // span priorities are exhausted. | ||
| 41 | private object MarkdownSpannableFactory : Spannable.Factory() { | ||
| 42 | override fun newSpannable(source: CharSequence): Spannable = | ||
| 43 | SpannableStringBuilder(source) | ||
| 44 | } | ||
| 45 | |||
| 46 | internal fun copyTextWithoutInlineImages( | ||
| 47 | text: CharSequence, | ||
| 48 | start: Int, | ||
| 49 | end: Int | ||
| 50 | ): String { | ||
| 51 | if (text !is Spanned) return text.subSequence(start, end).toString() | ||
| 52 | |||
| 53 | fun isInlineImage(index: Int): Boolean = | ||
| 54 | index >= 0 && text[index].toString() == OBJECT_REPLACEMENT_CHARACTER && | ||
| 55 | text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty() | ||
| 56 | |||
| 57 | return buildString { | ||
| 58 | for (index in start until end) { | ||
| 59 | // The renderer inserts one NBSP after each image to keep its label on the same line. | ||
| 60 | // Inspect the original text even when selection starts after the image. | ||
| 61 | val isIconSpacer = text[index] == '\u00A0' && isInlineImage(index - 1) | ||
| 62 | if (!isInlineImage(index) && !isIconSpacer) append(text[index]) | ||
| 63 | } | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | private fun canonicalSelection( | ||
| 68 | originalText: String, | ||
| 69 | start: Int, | ||
| 70 | end: Int, | ||
| 71 | ranges: JSONArray? | ||
| 72 | ): String? { | ||
| 73 | if (ranges == null) return null | ||
| 74 | val canonical = StringBuilder(originalText) | ||
| 75 | var hasContext = false | ||
| 76 | for (index in ranges.length() - 1 downTo 0) { | ||
| 77 | val range = ranges.optJSONObject(index) ?: continue | ||
| 78 | val first = max(start, range.optInt("start")) | ||
| 79 | val last = min(end, range.optInt("end")) | ||
| 80 | if (last > first) { | ||
| 81 | canonical.replace(first - start, last - start, range.optString("text")) | ||
| 82 | hasContext = true | ||
| 83 | } | ||
| 84 | } | ||
| 85 | return if (hasContext) canonical.toString().replace(OBJECT_REPLACEMENT_CHARACTER, "") else null | ||
| 86 | } | ||
| 87 | |||
| 88 | private fun selectedContextRecords(records: JSONArray, selectedText: String): JSONArray { | ||
| 89 | val ids = mutableSetOf<String>() | ||
| 90 | for (index in 0 until records.length()) { | ||
| 91 | val record = records.getJSONObject(index) | ||
| 92 | if (selectedText.contains("/${record.optString("contextId")})")) { | ||
| 93 | ids.add(record.optString("contextId")) | ||
| 94 | if (record.has("screenshotContextId")) ids.add(record.getString("screenshotContextId")) | ||
| 95 | } | ||
| 96 | } | ||
| 97 | val copied = JSONArray() | ||
| 98 | for (index in 0 until records.length()) { | ||
| 99 | val record = records.getJSONObject(index) | ||
| 100 | if (ids.contains(record.optString("contextId"))) copied.put(record) | ||
| 101 | } | ||
| 102 | return copied | ||
| 103 | } | ||
| 104 | |||
| 105 | private fun contextClipData(selectedText: String, fragment: String): ClipData { | ||
| 106 | val payload = runCatching { JSONObject(fragment) }.getOrNull() | ||
| 107 | val records = payload?.optJSONArray("records") | ||
| 108 | val copied = records?.let { selectedContextRecords(it, selectedText) } | ||
| 109 | if (payload == null || copied == null || copied.length() == 0) { | ||
| 110 | return ClipData.newPlainText(null, selectedText) | ||
| 111 | } | ||
| 112 | payload.put("records", copied) | ||
| 113 | val attribute = URLEncoder.encode(payload.toString(), "UTF-8").replace("+", "%20") | ||
| 114 | val escaped = selectedText.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") | ||
| 115 | return ClipData.newHtmlText( | ||
| 116 | null, | ||
| 117 | selectedText, | ||
| 118 | "<pre data-t3-context-fragment=\"$attribute\">$escaped</pre>" | ||
| 119 | ) | ||
| 120 | } | ||
| 121 | |||
| 122 | private class SanitizingSelectionActionModeCallback( | ||
| 123 | private val textView: TextView, | ||
| 124 | private val delegate: ActionMode.Callback?, | ||
| 125 | var contextClipboardConfig: String | ||
| 126 | ) : ActionMode.Callback { | ||
| 127 | override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean = | ||
| 128 | delegate?.onCreateActionMode(mode, menu) ?: true | ||
| 129 | |||
| 130 | override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = | ||
| 131 | delegate?.onPrepareActionMode(mode, menu) ?: false | ||
| 132 | |||
| 133 | override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { | ||
| 134 | if (item.itemId == android.R.id.copy && copySelection()) { | ||
| 135 | mode.finish() | ||
| 136 | return true | ||
| 137 | } | ||
| 138 | return delegate?.onActionItemClicked(mode, item) ?: false | ||
| 139 | } | ||
| 140 | |||
| 141 | private fun copySelection(): Boolean { | ||
| 142 | val start = min(textView.selectionStart, textView.selectionEnd) | ||
| 143 | val end = max(textView.selectionStart, textView.selectionEnd) | ||
| 144 | if (start < 0 || end <= start) return false | ||
| 145 | val originalText = textView.text.subSequence(start, end).toString() | ||
| 146 | val config = runCatching { JSONObject(contextClipboardConfig) }.getOrNull() | ||
| 147 | val canonical = canonicalSelection(originalText, start, end, config?.optJSONArray("ranges")) | ||
| 148 | val selectedText = canonical ?: copyTextWithoutInlineImages(textView.text, start, end) | ||
| 149 | val handled = canonical != null || selectedText != originalText | ||
| 150 | if (handled) { | ||
| 151 | val clipboard = | ||
| 152 | textView.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager | ||
| 153 | val clip = if (canonical != null) { | ||
| 154 | contextClipData(selectedText, config?.optString("fragment") ?: "") | ||
| 155 | } else { | ||
| 156 | ClipData.newPlainText(null, selectedText) | ||
| 157 | } | ||
| 158 | clipboard.setPrimaryClip(clip) | ||
| 159 | } | ||
| 160 | return handled | ||
| 161 | } | ||
| 162 | |||
| 163 | override fun onDestroyActionMode(mode: ActionMode) { | ||
| 164 | delegate?.onDestroyActionMode(mode) | ||
| 165 | } | ||
| 166 | } | ||
| 167 | |||
| 168 | internal fun applySelectionHandleColor(textView: TextView, color: Int) { | ||
| 169 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return | ||
| 170 | textView.textSelectHandle?.mutate()?.apply { setTint(color) }?.let(textView::setTextSelectHandle) | ||
| 171 | textView.textSelectHandleLeft?.mutate()?.apply { | ||
| 172 | setTint(color) | ||
| 173 | }?.let(textView::setTextSelectHandleLeft) | ||
| 174 | textView.textSelectHandleRight?.mutate()?.apply { | ||
| 175 | setTint(color) | ||
| 176 | }?.let(textView::setTextSelectHandleRight) | ||
| 177 | } | ||
| 178 | |||
| 179 | class T3MarkdownTextSelectionModule : Module() { | ||
| 180 | private val chipImages = LruCache<String, Map<String, Any>>(128) | ||
| 181 | |||
| 182 | /** | ||
| 183 | * Metrics of the paragraph font a chip sits in, so the inline box it reports can be sized | ||
| 184 | * from the same ascent and descent the surrounding text lays out with. | ||
| 185 | */ | ||
| 186 | private fun paragraphFontMetrics(text: JSONObject?, scale: Float): Paint.FontMetricsInt = | ||
| 187 | TextPaint(Paint.ANTI_ALIAS_FLAG).apply { | ||
| 188 | textSize = (text?.optDouble("fontSize", 15.0)?.toFloat() ?: 15f).coerceIn(6f, 80f) * scale | ||
| 189 | val fontFamily = text?.optString("fontFamily").orEmpty() | ||
| 190 | typeface = if (fontFamily.isEmpty()) { | ||
| 191 | Typeface.DEFAULT | ||
| 192 | } else { | ||
| 193 | ReactFontManager.getInstance() | ||
| 194 | .getTypeface(fontFamily, Typeface.NORMAL, appContext.reactContext?.assets) | ||
| 195 | } | ||
| 196 | }.fontMetricsInt | ||
| 197 | |||
| 198 | private fun setSelectionHandleColor(reactTag: Int, color: Int) { | ||
| 199 | val reactContext = appContext.reactContext as? ReactContext ?: return | ||
| 200 | reactContext.runOnUiQueueThread { | ||
| 201 | val textView = runCatching { | ||
| 202 | UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag) | ||
| 203 | }.getOrNull() as? TextView ?: return@runOnUiQueueThread | ||
| 204 | applySelectionHandleColor(textView, color) | ||
| 205 | } | ||
| 206 | } | ||
| 207 | |||
| 208 | override fun definition() = ModuleDefinition { | ||
| 209 | Name("T3MarkdownTextSelection") | ||
| 210 | |||
| 211 | Function("setSelectionHandleColor") { reactTag: Int, color: Int -> | ||
| 212 | setSelectionHandleColor(reactTag, color) | ||
| 213 | } | ||
| 214 | |||
| 215 | Function("renderContextChip") { payloadJson: String -> | ||
| 216 | val resources = appContext.reactContext?.resources ?: return@Function null | ||
| 217 | val metrics = resources.displayMetrics | ||
| 218 | val fontScale = resources.configuration.fontScale | ||
| 219 | val key = "${metrics.density}:$fontScale:${metrics.widthPixels}:$payloadJson" | ||
| 220 | chipImages.get(key)?.let { return@Function it } | ||
| 221 | val payload = JSONObject(payloadJson) | ||
| 222 | val chip = T3ContextChip( | ||
| 223 | content = T3ContextChip.Content( | ||
| 224 | label = payload.optString("label").take(4096), | ||
| 225 | symbol = payload.optString("symbol", "doc") | ||
| 226 | ), | ||
| 227 | // The line box around the chip is measured with `fontScale` below, so the chip has to | ||
| 228 | // carry it too, or it shrinks against the words beside it at a larger text size. | ||
| 229 | fontSize = | ||
| 230 | payload.optDouble("fontSize", 12.0).toFloat().coerceIn(10f, 40f) * | ||
| 231 | metrics.density * fontScale, | ||
| 232 | colors = T3ContextChip.Colors( | ||
| 233 | accent = T3ContextChip.color(payload.optString("accent"), Color.GRAY), | ||
| 234 | foreground = T3ContextChip.color(payload.optString("foreground"), Color.BLACK), | ||
| 235 | border = T3ContextChip.color(payload.optString("border"), Color.GRAY) | ||
| 236 | ), | ||
| 237 | maximumWidth = (metrics.widthPixels - 80 * metrics.density).coerceAtLeast(100f), | ||
| 238 | density = metrics.density, | ||
| 239 | ) | ||
| 240 | // toInt() truncates, so a fractional pixel of the chip would fall outside the bitmap | ||
| 241 | // and take the right-hand border with it. Round up: a spare column costs nothing. | ||
| 242 | // One spare pixel of transparency on each side. Whatever rounding happens between the | ||
| 243 | // bitmap's pixels and the box's dp then falls on padding instead of on the border. | ||
| 244 | val bleed = 1 | ||
| 245 | val bitmap = Bitmap.createBitmap( | ||
| 246 | ceil(chip.width).toInt() + bleed * 2, | ||
| 247 | ceil(chip.height).toInt() + bleed * 2, | ||
| 248 | Bitmap.Config.ARGB_8888 | ||
| 249 | ) | ||
| 250 | chip.draw(Canvas(bitmap), bleed.toFloat(), bleed.toFloat()) | ||
| 251 | val bytes = ByteArrayOutputStream() | ||
| 252 | bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes) | ||
| 253 | bitmap.recycle() | ||
| 254 | // React Native sits an inline view's box on the text baseline and grows the line's | ||
| 255 | // ascent to fit it, so a box as tall as the chip lifts the chip above the words and | ||
| 256 | // pushes the baseline down. Report a box no taller than the paragraph font's ascent, | ||
| 257 | // which leaves the line exactly as tall as a line of plain text, plus where inside | ||
| 258 | // that box the bitmap must sit so the chip centres on the font's ascent/descent box: | ||
| 259 | // the same rule the composer's ReplacementSpan uses to draw its chips. | ||
| 260 | val lineMetrics = paragraphFontMetrics( | ||
| 261 | payload.optJSONObject("text"), | ||
| 262 | fontScale * metrics.density, | ||
| 263 | ) | ||
| 264 | val ascent = -lineMetrics.ascent | ||
| 265 | val descent = lineMetrics.descent | ||
| 266 | val bitmapWidth = ceil(chip.width) + bleed * 2 | ||
| 267 | val bitmapHeight = ceil(chip.height) + bleed * 2 | ||
| 268 | val result = mapOf<String, Any>( | ||
| 269 | "uri" to | ||
| 270 | "data:image/png;base64,${Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP)}", | ||
| 271 | // Layout rounds dp back to whole pixels. Reporting a hair less than the bitmap lets | ||
| 272 | // that rounding land inside the image and crop its right-hand border, so round the | ||
| 273 | // box up: an extra fraction of a pixel is invisible, a missing border is not. | ||
| 274 | "width" to bitmapWidth / metrics.density, | ||
| 275 | "height" to bitmapHeight / metrics.density, | ||
| 276 | "boxHeight" to ascent / metrics.density, | ||
| 277 | "offsetY" to (ascent + descent - bitmapHeight) / 2f / metrics.density, | ||
| 278 | ) | ||
| 279 | chipImages.put(key, result) | ||
| 280 | result | ||
| 281 | } | ||
| 282 | |||
| 283 | Function("installCopySanitizer") { reactTag: Int, contextClipboardConfig: String -> | ||
| 284 | val reactContext = appContext.reactContext as? ReactContext ?: return@Function | ||
| 285 | reactContext.runOnUiQueueThread { | ||
| 286 | val textView = | ||
| 287 | runCatching { | ||
| 288 | UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag) | ||
| 289 | } | ||
| 290 | .getOrNull() as? TextView ?: return@runOnUiQueueThread | ||
| 291 | val currentCallback = textView.customSelectionActionModeCallback | ||
| 292 | if (currentCallback is SanitizingSelectionActionModeCallback) { | ||
| 293 | currentCallback.contextClipboardConfig = contextClipboardConfig | ||
| 294 | return@runOnUiQueueThread | ||
| 295 | } | ||
| 296 | textView.setSpannableFactory(MarkdownSpannableFactory) | ||
| 297 | textView.customSelectionActionModeCallback = | ||
| 298 | SanitizingSelectionActionModeCallback(textView, currentCallback, contextClipboardConfig) | ||
| 299 | } | ||
| 300 | } | ||
| 301 | } | ||
| 302 | } | ||
apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt deleted-67| ... | @@ -1,67 +0,0 @@ | ||
| 1 | package expo.modules.t3markdowntext | ||
| 2 | |||
| 3 | import android.graphics.Bitmap | ||
| 4 | import android.graphics.Canvas | ||
| 5 | import android.graphics.Color | ||
| 6 | import android.graphics.drawable.ColorDrawable | ||
| 7 | import android.graphics.drawable.Drawable | ||
| 8 | import android.widget.TextView | ||
| 9 | import org.junit.Assert.assertEquals | ||
| 10 | import org.junit.Test | ||
| 11 | import org.junit.runner.RunWith | ||
| 12 | import org.robolectric.RobolectricTestRunner | ||
| 13 | import org.robolectric.RuntimeEnvironment | ||
| 14 | import org.robolectric.annotation.Config | ||
| 15 | import org.robolectric.annotation.GraphicsMode | ||
| 16 | |||
| 17 | @RunWith(RobolectricTestRunner::class) | ||
| 18 | @Config(sdk = [36], manifest = Config.NONE) | ||
| 19 | @GraphicsMode(GraphicsMode.Mode.NATIVE) | ||
| 20 | class MarkdownSelectionColorTest { | ||
| 21 | private fun textView() = TextView(RuntimeEnvironment.getApplication()).apply { | ||
| 22 | setTextSelectHandle(ColorDrawable(Color.WHITE)) | ||
| 23 | setTextSelectHandleLeft(ColorDrawable(Color.WHITE)) | ||
| 24 | setTextSelectHandleRight(ColorDrawable(Color.WHITE)) | ||
| 25 | } | ||
| 26 | |||
| 27 | private fun renderedColor(drawable: Drawable?): Int { | ||
| 28 | requireNotNull(drawable) | ||
| 29 | val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888) | ||
| 30 | drawable.setBounds(0, 0, 4, 4) | ||
| 31 | drawable.draw(Canvas(bitmap)) | ||
| 32 | val color = bitmap.getPixel(2, 2) | ||
| 33 | bitmap.recycle() | ||
| 34 | return color | ||
| 35 | } | ||
| 36 | |||
| 37 | @Test | ||
| 38 | fun retintsAllHandlesWhenTheThemeChangesWithoutChangingTheHighlight() { | ||
| 39 | val text = textView() | ||
| 40 | val highlight = 0x52FF0088 | ||
| 41 | text.highlightColor = highlight | ||
| 42 | |||
| 43 | for (color in listOf(Color.MAGENTA, Color.GREEN, Color.MAGENTA)) { | ||
| 44 | applySelectionHandleColor(text, color) | ||
| 45 | assertEquals(color, renderedColor(text.textSelectHandle)) | ||
| 46 | assertEquals(color, renderedColor(text.textSelectHandleLeft)) | ||
| 47 | assertEquals(color, renderedColor(text.textSelectHandleRight)) | ||
| 48 | assertEquals(highlight, text.highlightColor) | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | @Test | ||
| 53 | fun doesNotTintOtherTextViewsSharingDrawableState() { | ||
| 54 | val original = ColorDrawable(Color.WHITE) | ||
| 55 | val first = textView().apply { | ||
| 56 | setTextSelectHandleLeft(original.constantState!!.newDrawable()) | ||
| 57 | } | ||
| 58 | val second = textView().apply { | ||
| 59 | setTextSelectHandleLeft(original.constantState!!.newDrawable()) | ||
| 60 | } | ||
| 61 | |||
| 62 | applySelectionHandleColor(first, Color.MAGENTA) | ||
| 63 | |||
| 64 | assertEquals(Color.MAGENTA, renderedColor(first.textSelectHandleLeft)) | ||
| 65 | assertEquals(Color.WHITE, renderedColor(second.textSelectHandleLeft)) | ||
| 66 | } | ||
| 67 | } | ||
apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt deleted-48| ... | @@ -1,48 +0,0 @@ | ||
| 1 | package expo.modules.t3markdowntext | ||
| 2 | |||
| 3 | import android.graphics.drawable.ColorDrawable | ||
| 4 | import android.text.SpannableString | ||
| 5 | import android.text.Spanned | ||
| 6 | import android.text.style.ImageSpan | ||
| 7 | import org.junit.Assert.assertEquals | ||
| 8 | import org.junit.Test | ||
| 9 | import org.junit.runner.RunWith | ||
| 10 | import org.robolectric.RobolectricTestRunner | ||
| 11 | import org.robolectric.annotation.Config | ||
| 12 | |||
| 13 | @RunWith(RobolectricTestRunner::class) | ||
| 14 | @Config(sdk = [36], manifest = Config.NONE) | ||
| 15 | class MarkdownSelectionCopyTest { | ||
| 16 | private fun withIcon(value: String): SpannableString = SpannableString(value).apply { | ||
| 17 | val index = value.indexOf('\uFFFC') | ||
| 18 | setSpan(ImageSpan(ColorDrawable()), index, index + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) | ||
| 19 | } | ||
| 20 | |||
| 21 | @Test | ||
| 22 | fun removesIconAndInjectedSpacer() { | ||
| 23 | val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.") | ||
| 24 | assertEquals("main.go:12 starts the server.", copyTextWithoutInlineImages(text, 0, text.length)) | ||
| 25 | } | ||
| 26 | |||
| 27 | @Test | ||
| 28 | fun removesSpacerWhenSelectionStartsAfterIcon() { | ||
| 29 | val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.") | ||
| 30 | assertEquals("main.go:12", copyTextWithoutInlineImages(text, 1, 12)) | ||
| 31 | } | ||
| 32 | |||
| 33 | @Test | ||
| 34 | fun preservesAuthoredWhitespaceAndLiteralObjectCharacters() { | ||
| 35 | val text = withIcon("before\u00A0 \uFFFC\u00A0\u00A0 main.go after\u00A0\uFFFC\u00A0") | ||
| 36 | assertEquals( | ||
| 37 | "before\u00A0 \u00A0 main.go after\u00A0\uFFFC\u00A0", | ||
| 38 | copyTextWithoutInlineImages(text, 0, text.length) | ||
| 39 | ) | ||
| 40 | } | ||
| 41 | |||
| 42 | @Test | ||
| 43 | fun preservesTextWithoutImageSpans() { | ||
| 44 | val text = "\uFFFC\u00A0main.go" | ||
| 45 | assertEquals(text, copyTextWithoutInlineImages(text, 0, text.length)) | ||
| 46 | assertEquals(text, copyTextWithoutInlineImages(SpannableString(text), 0, text.length)) | ||
| 47 | } | ||
| 48 | } | ||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png deleted| Binary files a/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png and /dev/null differ | |||
apps/mobile/modules/t3-markdown-text/expo-module.config.json deleted-6| ... | @@ -1,6 +0,0 @@ | ||
| 1 | { | ||
| 2 | "platforms": ["android"], | ||
| 3 | "android": { | ||
| 4 | "modules": ["expo.modules.t3markdowntext.T3MarkdownTextSelectionModule"] | ||
| 5 | } | ||
| 6 | } | ||
apps/mobile/modules/t3-markdown-text/index.ts deleted-29| ... | @@ -1,29 +0,0 @@ | ||
| 1 | export { markdownFileIconSource } from "./src/markdownFileIcons"; | ||
| 2 | export { | ||
| 3 | resolveMarkdownFileIcon, | ||
| 4 | resolveMarkdownLinkPresentation, | ||
| 5 | type MarkdownFileIcon, | ||
| 6 | type MarkdownLinkPresentation, | ||
| 7 | } from "./src/markdownLinks"; | ||
| 8 | export { | ||
| 9 | nativeMarkdownChunkSpacing, | ||
| 10 | nativeMarkdownDocumentChunks, | ||
| 11 | nativeMarkdownDocumentRuns, | ||
| 12 | nativeMarkdownListItemBlocks, | ||
| 13 | nativeMarkdownTextRuns, | ||
| 14 | type NativeMarkdownDocumentChunk, | ||
| 15 | type NativeMarkdownTextRun, | ||
| 16 | } from "./src/nativeMarkdownText"; | ||
| 17 | export { MarkdownTextPrimitive } from "./src/MarkdownTextPrimitive"; | ||
| 18 | export { | ||
| 19 | SelectableMarkdownText, | ||
| 20 | type MarkdownCodeHighlighter, | ||
| 21 | type MarkdownHighlightedToken, | ||
| 22 | } from "./src/SelectableMarkdownText"; | ||
| 23 | export type { | ||
| 24 | MarkdownFileContextMenu, | ||
| 25 | MarkdownFileContextMenuAction, | ||
| 26 | NativeMarkdownTextStyle, | ||
| 27 | SelectableMarkdownSkill, | ||
| 28 | SelectableMarkdownTextProps, | ||
| 29 | } from "./src/SelectableMarkdownText.types"; | ||
apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h deleted-162| ... | @@ -1,162 +0,0 @@ | ||
| 1 | #pragma once | ||
| 2 | |||
| 3 | #import <UIKit/UIKit.h> | ||
| 4 | |||
| 5 | // Used by both the shadow measurement and UITextView rendering paths. | ||
| 6 | static NSDictionary *T3ContextChipPayload(NSString *uri) | ||
| 7 | { | ||
| 8 | if (![uri hasPrefix:@"chip:"]) return nil; | ||
| 9 | NSData *data = [[uri substringFromIndex:5] dataUsingEncoding:NSUTF8StringEncoding]; | ||
| 10 | id payload = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; | ||
| 11 | if (![payload isKindOfClass:NSDictionary.class]) return nil; | ||
| 12 | // Every consumer draws or substitutes `label`; a missing or non-string one would raise inside | ||
| 13 | // `replaceCharactersInRange:withString:` while a message is rendering. | ||
| 14 | if (![payload[@"label"] isKindOfClass:NSString.class]) return nil; | ||
| 15 | return payload; | ||
| 16 | } | ||
| 17 | |||
| 18 | static UIColor *T3ContextChipColor(NSString *hex) | ||
| 19 | { | ||
| 20 | unsigned int rgb = 0; | ||
| 21 | if (![hex isKindOfClass:NSString.class] || hex.length != 7) return UIColor.labelColor; | ||
| 22 | [[NSScanner scannerWithString:[hex substringFromIndex:1]] scanHexInt:&rgb]; | ||
| 23 | return [UIColor colorWithRed:((rgb >> 16) & 255) / 255.0 | ||
| 24 | green:((rgb >> 8) & 255) / 255.0 | ||
| 25 | blue:(rgb & 255) / 255.0 alpha:1]; | ||
| 26 | } | ||
| 27 | |||
| 28 | static UIColor *T3ContextChipBlend(UIColor *accent, UIColor *base, CGFloat weight) | ||
| 29 | { | ||
| 30 | CGFloat ar = 0, ag = 0, ab = 0, aa = 0, br = 0, bg = 0, bb = 0, ba = 0; | ||
| 31 | [accent getRed:&ar green:&ag blue:&ab alpha:&aa]; | ||
| 32 | [base getRed:&br green:&bg blue:&bb alpha:&ba]; | ||
| 33 | return [UIColor colorWithRed:ar * weight + br * (1 - weight) | ||
| 34 | green:ag * weight + bg * (1 - weight) | ||
| 35 | blue:ab * weight + bb * (1 - weight) | ||
| 36 | alpha:aa * weight + ba * (1 - weight)]; | ||
| 37 | } | ||
| 38 | |||
| 39 | // Some chip glyphs have no SF Symbol that reads correctly: the pull request one would land on | ||
| 40 | // `arrow.triangle.branch`, a road-sign fork that says "branch", not "pull request". Draw those | ||
| 41 | // from the same lucide geometry web and Android use so one chip looks alike on every surface. | ||
| 42 | static UIImage *T3ContextChipVectorIcon(NSString *symbol, CGFloat size, UIColor *color) | ||
| 43 | { | ||
| 44 | if (![symbol isEqualToString:@"git-pull-request"]) return nil; | ||
| 45 | UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] | ||
| 46 | initWithSize:CGSizeMake(size, size)]; | ||
| 47 | return [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) { | ||
| 48 | CGFloat s = size / 24.0; // lucide authors on a 24pt grid. | ||
| 49 | UIBezierPath *path = [UIBezierPath bezierPath]; | ||
| 50 | [path appendPath:[UIBezierPath bezierPathWithArcCenter:CGPointMake(18 * s, 18 * s) | ||
| 51 | radius:3 * s startAngle:0 | ||
| 52 | endAngle:M_PI * 2 clockwise:YES]]; | ||
| 53 | [path appendPath:[UIBezierPath bezierPathWithArcCenter:CGPointMake(6 * s, 6 * s) | ||
| 54 | radius:3 * s startAngle:0 | ||
| 55 | endAngle:M_PI * 2 clockwise:YES]]; | ||
| 56 | [path moveToPoint:CGPointMake(13 * s, 6 * s)]; | ||
| 57 | [path addLineToPoint:CGPointMake(16 * s, 6 * s)]; | ||
| 58 | [path addCurveToPoint:CGPointMake(18 * s, 8 * s) | ||
| 59 | controlPoint1:CGPointMake(17.1 * s, 6 * s) | ||
| 60 | controlPoint2:CGPointMake(18 * s, 6.9 * s)]; | ||
| 61 | [path addLineToPoint:CGPointMake(18 * s, 15 * s)]; | ||
| 62 | [path moveToPoint:CGPointMake(6 * s, 9 * s)]; | ||
| 63 | [path addLineToPoint:CGPointMake(6 * s, 21 * s)]; | ||
| 64 | path.lineWidth = 2 * s; | ||
| 65 | path.lineCapStyle = kCGLineCapRound; | ||
| 66 | path.lineJoinStyle = kCGLineJoinRound; | ||
| 67 | [color setStroke]; | ||
| 68 | [path stroke]; | ||
| 69 | }]; | ||
| 70 | } | ||
| 71 | |||
| 72 | // Centres the chip on the run font's ascent/descent box, the rule the composer span and | ||
| 73 | // Android use, so the chip lands in the same place beside the words on every surface. | ||
| 74 | static inline CGRect T3ContextChipBounds(UIFont *font, CGSize size) | ||
| 75 | { | ||
| 76 | CGFloat y = font != nil ? (font.ascender + font.descender - size.height) / 2 : -3; | ||
| 77 | return CGRectMake(0, y, size.width, size.height); | ||
| 78 | } | ||
| 79 | |||
| 80 | // A bare attachment string carries none of the run's attributes. Losing the paragraph | ||
| 81 | // style at a paragraph's first character drops its line height, and losing the font lets | ||
| 82 | // a chip-only line shrink to the bitmap, so the placeholder keeps both, plus the run | ||
| 83 | // colour so a later re-apply (after an image loads) still tints with it. | ||
| 84 | static inline NSAttributedString *T3MarkdownTextAttachmentString( | ||
| 85 | NSTextAttachment *attachment, NSDictionary<NSAttributedStringKey, id> *runAttributes) | ||
| 86 | { | ||
| 87 | NSMutableAttributedString *string = | ||
| 88 | [[NSAttributedString attributedStringWithAttachment:attachment] mutableCopy]; | ||
| 89 | for (NSAttributedStringKey key in | ||
| 90 | @[ | ||
| 91 | NSFontAttributeName, NSParagraphStyleAttributeName, NSForegroundColorAttributeName, | ||
| 92 | NSBaselineOffsetAttributeName | ||
| 93 | ]) { | ||
| 94 | id value = runAttributes[key]; | ||
| 95 | if (value != nil) { | ||
| 96 | [string addAttribute:key value:value range:NSMakeRange(0, string.length)]; | ||
| 97 | } | ||
| 98 | } | ||
| 99 | return string; | ||
| 100 | } | ||
| 101 | |||
| 102 | static UIFont *T3ContextChipFont(NSDictionary *payload) | ||
| 103 | { | ||
| 104 | CGFloat size = MAX(10, MIN(40, [payload[@"fontSize"] doubleValue])); | ||
| 105 | size *= payload[@"fontSizeMultiplier"] != nil ? [payload[@"fontSizeMultiplier"] doubleValue] : 1; | ||
| 106 | return [UIFont fontWithName:@"DMSans-Medium" size:size] | ||
| 107 | ?: [UIFont systemFontOfSize:size weight:UIFontWeightMedium]; | ||
| 108 | } | ||
| 109 | |||
| 110 | static inline CGSize T3ContextChipSize(NSDictionary *payload, CGFloat maximumWidth) | ||
| 111 | { | ||
| 112 | UIFont *font = T3ContextChipFont(payload); | ||
| 113 | NSString *label = payload[@"label"]; | ||
| 114 | CGFloat textWidth = [label sizeWithAttributes:@{ NSFontAttributeName: font }].width; | ||
| 115 | return CGSizeMake(MIN(maximumWidth, ceil(textWidth + font.pointSize * 2.5)), | ||
| 116 | ceil(font.pointSize * 1.41)); | ||
| 117 | } | ||
| 118 | |||
| 119 | static inline UIImage *T3ContextChipImage(NSDictionary *payload, CGSize size, UIImage *fileIcon) | ||
| 120 | { | ||
| 121 | static NSCache<NSString *, UIImage *> *cache; | ||
| 122 | static dispatch_once_t once; | ||
| 123 | dispatch_once(&once, ^{ cache = [NSCache new]; cache.countLimit = 256; }); | ||
| 124 | NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:NSJSONWritingSortedKeys error:nil]; | ||
| 125 | NSString *key = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; | ||
| 126 | key = [key stringByAppendingString:NSStringFromCGSize(size)]; | ||
| 127 | if (fileIcon != nil) key = [key stringByAppendingString:@":file-icon"]; | ||
| 128 | UIImage *cached = [cache objectForKey:key]; | ||
| 129 | if (cached) return cached; | ||
| 130 | UIFont *font = T3ContextChipFont(payload); | ||
| 131 | CGFloat em = font.pointSize; | ||
| 132 | UIColor *accent = T3ContextChipColor(payload[@"accent"]); | ||
| 133 | UIColor *foreground = T3ContextChipBlend(accent, T3ContextChipColor(payload[@"foreground"]), 0.22); | ||
| 134 | UIColor *border = T3ContextChipBlend(accent, T3ContextChipColor(payload[@"border"]), 0.34); | ||
| 135 | UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:size]; | ||
| 136 | UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) { | ||
| 137 | UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect: | ||
| 138 | CGRectInset(CGRectMake(0, 0, size.width, size.height), 0.5, 0.5) | ||
| 139 | cornerRadius:em * 0.5]; | ||
| 140 | [[accent colorWithAlphaComponent:0.11] setFill]; | ||
| 141 | [path fill]; | ||
| 142 | [border setStroke]; | ||
| 143 | path.lineWidth = 1; | ||
| 144 | [path stroke]; | ||
| 145 | CGFloat iconSize = em * 1.17; | ||
| 146 | UIImage *icon = fileIcon | ||
| 147 | ?: T3ContextChipVectorIcon(payload[@"symbol"], iconSize, foreground) | ||
| 148 | ?: [[UIImage systemImageNamed:payload[@"symbol"] | ||
| 149 | withConfiguration:[UIImageSymbolConfiguration configurationWithPointSize:em weight:UIImageSymbolWeightMedium]] | ||
| 150 | imageWithTintColor:foreground renderingMode:UIImageRenderingModeAlwaysOriginal]; | ||
| 151 | [icon drawInRect:CGRectMake(em * 0.5, (size.height - iconSize) / 2, iconSize, iconSize)]; | ||
| 152 | NSMutableParagraphStyle *paragraph = [NSMutableParagraphStyle new]; | ||
| 153 | paragraph.lineBreakMode = NSLineBreakByTruncatingMiddle; | ||
| 154 | CGFloat x = em * 2; | ||
| 155 | [payload[@"label"] drawInRect:CGRectMake(x, (size.height - font.lineHeight) / 2, | ||
| 156 | MAX(0, size.width - x - em * 0.5), font.lineHeight) | ||
| 157 | withAttributes:@{ NSFontAttributeName: font, NSForegroundColorAttributeName: foreground, | ||
| 158 | NSParagraphStyleAttributeName: paragraph }]; | ||
| 159 | }]; | ||
| 160 | [cache setObject:image forKey:key]; | ||
| 161 | return image; | ||
| 162 | } | ||
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h deleted-13| ... | @@ -1,13 +0,0 @@ | ||
| 1 | #import <React/RCTViewComponentView.h> | ||
| 2 | #import <UIKit/UIKit.h> | ||
| 3 | |||
| 4 | #ifndef T3MarkdownTextNativeComponent_h | ||
| 5 | #define T3MarkdownTextNativeComponent_h | ||
| 6 | |||
| 7 | NS_ASSUME_NONNULL_BEGIN | ||
| 8 | @interface T3MarkdownText : RCTViewComponentView | ||
| 9 | @end | ||
| 10 | |||
| 11 | NS_ASSUME_NONNULL_END | ||
| 12 | |||
| 13 | #endif /* UitextviewViewNativeComponent_h */ | ||
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm deleted-839| ... | @@ -1,839 +0,0 @@ | ||
| 1 | #import "T3MarkdownText.h" | ||
| 2 | #import "T3MarkdownTextShadowNode.h" | ||
| 3 | #import "T3MarkdownTextComponentDescriptor.h" | ||
| 4 | #import "T3MarkdownTextRun.h" | ||
| 5 | #import "T3ContextChip.h" | ||
| 6 | #import <React/RCTConversions.h> | ||
| 7 | #import <objc/runtime.h> | ||
| 8 | |||
| 9 | #import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h> | ||
| 10 | #import <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h> | ||
| 11 | #import <react/renderer/components/T3MarkdownTextSpec/Props.h> | ||
| 12 | #import <react/renderer/components/T3MarkdownTextSpec/RCTComponentViewHelpers.h> | ||
| 13 | #import <React/RCTFabricComponentsPlugins.h> | ||
| 14 | |||
| 15 | using namespace facebook::react; | ||
| 16 | |||
| 17 | @interface T3ContextChipAccessibilityElement : UIAccessibilityElement | ||
| 18 | @property(nonatomic, weak) T3MarkdownTextRun *run; | ||
| 19 | @end | ||
| 20 | |||
| 21 | @implementation T3ContextChipAccessibilityElement | ||
| 22 | - (BOOL)accessibilityActivate | ||
| 23 | { | ||
| 24 | if (self.run == nil) return NO; | ||
| 25 | [self.run onPress]; | ||
| 26 | return YES; | ||
| 27 | } | ||
| 28 | @end | ||
| 29 | |||
| 30 | /** Preserve canonical references and their payload when copying a native text selection. */ | ||
| 31 | @interface T3ContextCopyTextView : UITextView | ||
| 32 | @property(nonatomic, copy) NSDictionary *contextClipboardConfig; | ||
| 33 | @end | ||
| 34 | |||
| 35 | @implementation T3ContextCopyTextView | ||
| 36 | // Read-only text still supports selecting the entire document after selecting a word. | ||
| 37 | - (BOOL)canPerformAction:(SEL)action withSender:(id)sender | ||
| 38 | { | ||
| 39 | if (action == @selector(selectAll:)) { | ||
| 40 | return self.selectable && self.text.length > 0 && self.selectedRange.length < self.text.length; | ||
| 41 | } | ||
| 42 | return [super canPerformAction:action withSender:sender]; | ||
| 43 | } | ||
| 44 | |||
| 45 | - (void)copy:(id)sender | ||
| 46 | { | ||
| 47 | NSRange selected = self.selectedRange; | ||
| 48 | NSArray *ranges = self.contextClipboardConfig[@"ranges"]; | ||
| 49 | if (selected.location == NSNotFound || selected.length == 0 || NSMaxRange(selected) > self.text.length || ranges.count == 0) { | ||
| 50 | [super copy:sender]; | ||
| 51 | return; | ||
| 52 | } | ||
| 53 | NSMutableString *text = [[self.text substringWithRange:selected] mutableCopy]; | ||
| 54 | BOOL hasContext = NO; | ||
| 55 | for (NSDictionary *range in [ranges reverseObjectEnumerator]) { | ||
| 56 | NSUInteger start = [range[@"start"] unsignedIntegerValue]; | ||
| 57 | NSUInteger end = [range[@"end"] unsignedIntegerValue]; | ||
| 58 | if (end <= start || end > self.text.length) continue; | ||
| 59 | NSRange overlap = NSIntersectionRange(selected, NSMakeRange(start, end - start)); | ||
| 60 | if (overlap.length == 0 || ![range[@"text"] isKindOfClass:NSString.class]) continue; | ||
| 61 | [text replaceCharactersInRange:NSMakeRange(overlap.location - selected.location, overlap.length) withString:range[@"text"]]; | ||
| 62 | hasContext = YES; | ||
| 63 | } | ||
| 64 | if (!hasContext) { [super copy:sender]; return; } | ||
| 65 | [text replaceOccurrencesOfString:@"\uFFFC\u00A0" withString:@"" options:0 range:NSMakeRange(0, text.length)]; | ||
| 66 | NSString *fragment = self.contextClipboardConfig[@"fragment"]; | ||
| 67 | NSMutableDictionary *payload = [[NSJSONSerialization JSONObjectWithData:[fragment dataUsingEncoding:NSUTF8StringEncoding] options:NSJSONReadingMutableContainers error:nil] mutableCopy]; | ||
| 68 | NSArray *records = payload[@"records"]; | ||
| 69 | NSMutableArray *copied = [NSMutableArray array]; | ||
| 70 | NSMutableSet *screenshots = [NSMutableSet set]; | ||
| 71 | for (NSDictionary *record in records) { | ||
| 72 | if ([text containsString:[NSString stringWithFormat:@"/%@)", record[@"contextId"]]]) { | ||
| 73 | [copied addObject:record]; | ||
| 74 | if ([record[@"screenshotContextId"] isKindOfClass:NSString.class]) [screenshots addObject:record[@"screenshotContextId"]]; | ||
| 75 | } | ||
| 76 | } | ||
| 77 | for (NSDictionary *record in records) { | ||
| 78 | if ([screenshots containsObject:record[@"contextId"]] && ![copied containsObject:record]) [copied addObject:record]; | ||
| 79 | } | ||
| 80 | payload[@"records"] = copied; | ||
| 81 | NSData *encoded = payload ? [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil] : nil; | ||
| 82 | NSMutableDictionary *item = [@{@"public.utf8-plain-text": text} mutableCopy]; | ||
| 83 | if (encoded && copied.count > 0) { | ||
| 84 | NSString *raw = [[NSString alloc] initWithData:encoded encoding:NSUTF8StringEncoding]; | ||
| 85 | NSString *attribute = [raw stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.alphanumericCharacterSet]; | ||
| 86 | NSString *escaped = [[[text stringByReplacingOccurrencesOfString:@"&" withString:@"&amp;"] stringByReplacingOccurrencesOfString:@"<" withString:@"&lt;"] stringByReplacingOccurrencesOfString:@">" withString:@"&gt;"]; | ||
| 87 | item[@"app.t3.context-fragment"] = encoded; | ||
| 88 | item[@"public.html"] = [[NSString stringWithFormat:@"<pre data-t3-context-fragment=\"%@\">%@</pre>", attribute, escaped] dataUsingEncoding:NSUTF8StringEncoding]; | ||
| 89 | } | ||
| 90 | UIPasteboard.generalPasteboard.items = @[item]; | ||
| 91 | } | ||
| 92 | @end | ||
| 93 | |||
| 94 | static void T3MarkdownTextApplyParagraphStyles( | ||
| 95 | NSMutableAttributedString *attributedString, | ||
| 96 | const std::vector<T3MarkdownTextParagraphStyleRange> &styleRanges) | ||
| 97 | { | ||
| 98 | for (const auto &styleRange : styleRanges) { | ||
| 99 | if (styleRange.length == 0 || styleRange.location >= attributedString.length) { | ||
| 100 | continue; | ||
| 101 | } | ||
| 102 | |||
| 103 | const NSRange markerRange = NSMakeRange( | ||
| 104 | styleRange.location, | ||
| 105 | MIN(styleRange.length, attributedString.length - styleRange.location)); | ||
| 106 | const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; | ||
| 107 | const NSParagraphStyle *existingStyle = | ||
| 108 | [attributedString attribute:NSParagraphStyleAttributeName | ||
| 109 | atIndex:paragraphRange.location | ||
| 110 | effectiveRange:nil]; | ||
| 111 | NSMutableParagraphStyle *paragraphStyle = | ||
| 112 | existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; | ||
| 113 | paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; | ||
| 114 | paragraphStyle.headIndent = styleRange.headIndent; | ||
| 115 | paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; | ||
| 116 | paragraphStyle.tabStops = @[ | ||
| 117 | [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft | ||
| 118 | location:styleRange.headIndent | ||
| 119 | options:@{}] | ||
| 120 | ]; | ||
| 121 | paragraphStyle.defaultTabInterval = styleRange.headIndent; | ||
| 122 | [attributedString addAttribute:NSParagraphStyleAttributeName | ||
| 123 | value:paragraphStyle | ||
| 124 | range:paragraphRange]; | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | static void T3MarkdownTextApplyAttachments( | ||
| 129 | NSMutableAttributedString *attributedString, | ||
| 130 | const std::vector<T3MarkdownTextAttachmentRange> &attachmentRanges, | ||
| 131 | NSDictionary<NSString *, UIImage *> *images) | ||
| 132 | { | ||
| 133 | for (const auto &attachmentRange : attachmentRanges) { | ||
| 134 | if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { | ||
| 135 | continue; | ||
| 136 | } | ||
| 137 | |||
| 138 | NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; | ||
| 139 | NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; | ||
| 140 | UIImage *image = images[imageUri]; | ||
| 141 | const BOOL isSymbol = [imageUri hasPrefix:@"sf:"]; | ||
| 142 | if (isSymbol) { | ||
| 143 | image = [UIImage systemImageNamed:[imageUri substringFromIndex:3]]; | ||
| 144 | } | ||
| 145 | NSDictionary *runAttributes = | ||
| 146 | [attributedString attributesAtIndex:attachmentRange.location effectiveRange:nil]; | ||
| 147 | UIColor *foregroundColor = runAttributes[NSForegroundColorAttributeName]; | ||
| 148 | if (image != nil && (isSymbol || attachmentRange.tintWithForeground)) { | ||
| 149 | image = [image imageWithTintColor:foregroundColor ?: UIColor.labelColor | ||
| 150 | renderingMode:UIImageRenderingModeAlwaysOriginal]; | ||
| 151 | } | ||
| 152 | attachment.image = image ?: [[UIImage alloc] init]; | ||
| 153 | const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange); | ||
| 154 | attachment.bounds = CGRectMake( | ||
| 155 | 0, | ||
| 156 | T3MarkdownTextAttachmentBaselineOffset(attachmentRange), | ||
| 157 | attachmentSize, | ||
| 158 | attachmentSize); | ||
| 159 | NSDictionary *chip = T3ContextChipPayload(imageUri); | ||
| 160 | if (chip != nil) { | ||
| 161 | CGSize size = CGSizeMake(attachmentRange.chipWidth, attachmentRange.chipHeight); | ||
| 162 | attachment.bounds = T3ContextChipBounds(runAttributes[NSFontAttributeName], size); | ||
| 163 | NSString *iconUri = [chip[@"iconUri"] isKindOfClass:NSString.class] ? chip[@"iconUri"] : nil; | ||
| 164 | attachment.image = T3ContextChipImage(chip, size, iconUri ? images[iconUri] : nil); | ||
| 165 | } | ||
| 166 | const NSRange range = NSMakeRange( | ||
| 167 | attachmentRange.location, | ||
| 168 | MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); | ||
| 169 | [attributedString replaceCharactersInRange:range | ||
| 170 | withAttributedString:T3MarkdownTextAttachmentString(attachment, runAttributes)]; | ||
| 171 | } | ||
| 172 | } | ||
| 173 | |||
| 174 | @protocol T3MarkdownOutsideTapTarget <NSObject> | ||
| 175 | - (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView; | ||
| 176 | @end | ||
| 177 | |||
| 178 | @interface T3MarkdownOutsideTapCoordinator : NSObject <UIGestureRecognizerDelegate> | ||
| 179 | |||
| 180 | - (instancetype)initWithWindow:(UIWindow *)window; | ||
| 181 | - (void)addTarget:(id<T3MarkdownOutsideTapTarget>)target; | ||
| 182 | - (void)removeTarget:(id<T3MarkdownOutsideTapTarget>)target; | ||
| 183 | |||
| 184 | @end | ||
| 185 | |||
| 186 | static const void *T3MarkdownOutsideTapCoordinatorKey = | ||
| 187 | &T3MarkdownOutsideTapCoordinatorKey; | ||
| 188 | |||
| 189 | @implementation T3MarkdownOutsideTapCoordinator { | ||
| 190 | __weak UIWindow *_window; | ||
| 191 | UITapGestureRecognizer *_recognizer; | ||
| 192 | NSHashTable<id<T3MarkdownOutsideTapTarget>> *_targets; | ||
| 193 | } | ||
| 194 | |||
| 195 | - (instancetype)initWithWindow:(UIWindow *)window | ||
| 196 | { | ||
| 197 | if (self = [super init]) { | ||
| 198 | _window = window; | ||
| 199 | _targets = [NSHashTable weakObjectsHashTable]; | ||
| 200 | _recognizer = [[UITapGestureRecognizer alloc] initWithTarget:self | ||
| 201 | action:@selector(handleTap:)]; | ||
| 202 | _recognizer.cancelsTouchesInView = NO; | ||
| 203 | _recognizer.delegate = self; | ||
| 204 | [window addGestureRecognizer:_recognizer]; | ||
| 205 | } | ||
| 206 | return self; | ||
| 207 | } | ||
| 208 | |||
| 209 | - (void)addTarget:(id<T3MarkdownOutsideTapTarget>)target | ||
| 210 | { | ||
| 211 | [_targets addObject:target]; | ||
| 212 | } | ||
| 213 | |||
| 214 | - (void)removeTarget:(id<T3MarkdownOutsideTapTarget>)target | ||
| 215 | { | ||
| 216 | [_targets removeObject:target]; | ||
| 217 | if (_targets.count > 0) { | ||
| 218 | return; | ||
| 219 | } | ||
| 220 | |||
| 221 | UIWindow *window = _window; | ||
| 222 | [window removeGestureRecognizer:_recognizer]; | ||
| 223 | if (objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey) == self) { | ||
| 224 | objc_setAssociatedObject( | ||
| 225 | window, | ||
| 226 | T3MarkdownOutsideTapCoordinatorKey, | ||
| 227 | nil, | ||
| 228 | OBJC_ASSOCIATION_RETAIN_NONATOMIC); | ||
| 229 | } | ||
| 230 | } | ||
| 231 | |||
| 232 | - (void)handleTap:(UITapGestureRecognizer *)sender | ||
| 233 | { | ||
| 234 | UIWindow *window = _window; | ||
| 235 | if (window == nil) { | ||
| 236 | return; | ||
| 237 | } | ||
| 238 | |||
| 239 | UIView *hitView = [window hitTest:[sender locationInView:window] withEvent:nil]; | ||
| 240 | if (hitView == nil) { | ||
| 241 | return; | ||
| 242 | } | ||
| 243 | for (id<T3MarkdownOutsideTapTarget> target in _targets.allObjects) { | ||
| 244 | [target clearSelectionForOutsideTapWithHitView:hitView]; | ||
| 245 | } | ||
| 246 | } | ||
| 247 | |||
| 248 | - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer | ||
| 249 | shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer | ||
| 250 | { | ||
| 251 | return YES; | ||
| 252 | } | ||
| 253 | |||
| 254 | @end | ||
| 255 | |||
| 256 | static T3MarkdownOutsideTapCoordinator * | ||
| 257 | T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) | ||
| 258 | { | ||
| 259 | T3MarkdownOutsideTapCoordinator *coordinator = | ||
| 260 | objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey); | ||
| 261 | if (coordinator == nil) { | ||
| 262 | coordinator = [[T3MarkdownOutsideTapCoordinator alloc] initWithWindow:window]; | ||
| 263 | objc_setAssociatedObject( | ||
| 264 | window, | ||
| 265 | T3MarkdownOutsideTapCoordinatorKey, | ||
| 266 | coordinator, | ||
| 267 | OBJC_ASSOCIATION_RETAIN_NONATOMIC); | ||
| 268 | } | ||
| 269 | return coordinator; | ||
| 270 | } | ||
| 271 | |||
| 272 | @interface T3MarkdownText () <RCTT3MarkdownTextViewProtocol, UIGestureRecognizerDelegate, UITextViewDelegate> | ||
| 273 | |||
| 274 | @end | ||
| 275 | |||
| 276 | @interface T3MarkdownText () <T3MarkdownOutsideTapTarget> | ||
| 277 | @end | ||
| 278 | |||
| 279 | @implementation T3MarkdownText { | ||
| 280 | UIView * _view; | ||
| 281 | T3ContextCopyTextView * _textView; | ||
| 282 | T3MarkdownTextShadowNode::ConcreteState::Shared _state; | ||
| 283 | __weak UIWindow * _outsideTapWindow; | ||
| 284 | BOOL _suppressSelectionChange; | ||
| 285 | NSMutableDictionary<NSString *, UIImage *> * _attachmentImages; | ||
| 286 | NSMutableSet<NSString *> * _pendingAttachmentUris; | ||
| 287 | UILongPressGestureRecognizer *_longPressGestureRecognizer; | ||
| 288 | UITapGestureRecognizer *_pressGestureRecognizer; | ||
| 289 | NSArray *_contextAccessibilityElements; | ||
| 290 | BOOL _textLayoutNeedsUpdate; | ||
| 291 | } | ||
| 292 | |||
| 293 | + (ComponentDescriptorProvider)componentDescriptorProvider | ||
| 294 | { | ||
| 295 | return concreteComponentDescriptorProvider<T3MarkdownTextComponentDescriptor>(); | ||
| 296 | } | ||
| 297 | |||
| 298 | - (instancetype)initWithFrame:(CGRect)frame | ||
| 299 | { | ||
| 300 | if (self = [super initWithFrame:frame]) { | ||
| 301 | static const auto defaultProps = std::make_shared<const T3MarkdownTextProps>(); | ||
| 302 | _props = defaultProps; | ||
| 303 | |||
| 304 | _view = [[UIView alloc] init]; | ||
| 305 | self.contentView = _view; | ||
| 306 | self.clipsToBounds = true; | ||
| 307 | |||
| 308 | _textView = [[T3ContextCopyTextView alloc] init]; | ||
| 309 | _attachmentImages = [[NSMutableDictionary alloc] init]; | ||
| 310 | _pendingAttachmentUris = [[NSMutableSet alloc] init]; | ||
| 311 | _textView.scrollEnabled = false; | ||
| 312 | _textView.editable = false; | ||
| 313 | _textView.textContainerInset = UIEdgeInsetsZero; | ||
| 314 | _textView.textContainer.lineFragmentPadding = 0; | ||
| 315 | // The shadow node already sized this view to fit every line. A container | ||
| 316 | // that tracks the frame can come out a fraction of a point shorter after | ||
| 317 | // pixel rounding, and TextKit then drops the last line. Size it by hand | ||
| 318 | // with unbounded height and let the view's bounds clip. | ||
| 319 | _textView.textContainer.widthTracksTextView = NO; | ||
| 320 | _textView.textContainer.heightTracksTextView = NO; | ||
| 321 | _textView.delegate = self; | ||
| 322 | // Chat text supports selection and contextual actions, but not drag-and-drop. | ||
| 323 | _textView.textDragInteraction.enabled = NO; | ||
| 324 | _textView.linkTextAttributes = @{}; | ||
| 325 | // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO. | ||
| 326 | _textView.layoutManager.usesFontLeading = NO; | ||
| 327 | [self addSubview:_textView]; | ||
| 328 | |||
| 329 | _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self | ||
| 330 | action:@selector(handleLongPressIfNecessary:)]; | ||
| 331 | _longPressGestureRecognizer.delegate = self; | ||
| 332 | |||
| 333 | _pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self | ||
| 334 | action:@selector(handlePressIfNecessary:)]; | ||
| 335 | _pressGestureRecognizer.delegate = self; | ||
| 336 | [_pressGestureRecognizer requireGestureRecognizerToFail:_longPressGestureRecognizer]; | ||
| 337 | |||
| 338 | [_textView addGestureRecognizer:_pressGestureRecognizer]; | ||
| 339 | [_textView addGestureRecognizer:_longPressGestureRecognizer]; | ||
| 340 | } | ||
| 341 | |||
| 342 | return self; | ||
| 343 | } | ||
| 344 | |||
| 345 | - (void)didMoveToWindow | ||
| 346 | { | ||
| 347 | [super didMoveToWindow]; | ||
| 348 | if (_outsideTapWindow == self.window) { | ||
| 349 | return; | ||
| 350 | } | ||
| 351 | if (_outsideTapWindow != nil) { | ||
| 352 | T3MarkdownOutsideTapCoordinator *coordinator = | ||
| 353 | objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); | ||
| 354 | [coordinator removeTarget:self]; | ||
| 355 | } | ||
| 356 | _outsideTapWindow = self.window; | ||
| 357 | if (_outsideTapWindow != nil) { | ||
| 358 | [T3MarkdownOutsideTapCoordinatorForWindow(_outsideTapWindow) addTarget:self]; | ||
| 359 | } | ||
| 360 | } | ||
| 361 | |||
| 362 | - (void)dealloc | ||
| 363 | { | ||
| 364 | T3MarkdownOutsideTapCoordinator *coordinator = | ||
| 365 | objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); | ||
| 366 | [coordinator removeTarget:self]; | ||
| 367 | } | ||
| 368 | |||
| 369 | - (NSArray *)accessibilityElements | ||
| 370 | { | ||
| 371 | return _contextAccessibilityElements ?: [super accessibilityElements]; | ||
| 372 | } | ||
| 373 | |||
| 374 | // See RCTParagraphComponentView | ||
| 375 | - (void)prepareForRecycle | ||
| 376 | { | ||
| 377 | [super prepareForRecycle]; | ||
| 378 | T3MarkdownOutsideTapCoordinator *coordinator = | ||
| 379 | objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); | ||
| 380 | [coordinator removeTarget:self]; | ||
| 381 | _outsideTapWindow = nil; | ||
| 382 | _state.reset(); | ||
| 383 | |||
| 384 | // Reset the frame to zero so that when it properly lays out on the next use | ||
| 385 | _textView.frame = CGRectZero; | ||
| 386 | _textView.attributedText = nil; | ||
| 387 | _contextAccessibilityElements = nil; | ||
| 388 | _textLayoutNeedsUpdate = YES; | ||
| 389 | } | ||
| 390 | |||
| 391 | - (void)layoutSubviews | ||
| 392 | { | ||
| 393 | [super layoutSubviews]; | ||
| 394 | // Resize during UIKit's layout pass. Doing this in drawRect can leave the | ||
| 395 | // text view's drawing surface clipped to its previous width after a pane | ||
| 396 | // expands, even though TextKit has already rewrapped the lines. | ||
| 397 | if (!CGRectEqualToRect(_textView.frame, _view.frame)) { | ||
| 398 | _textView.frame = _view.frame; | ||
| 399 | _textLayoutNeedsUpdate = YES; | ||
| 400 | [self setNeedsDisplay]; | ||
| 401 | } | ||
| 402 | } | ||
| 403 | |||
| 404 | - (void)drawRect:(CGRect)rect | ||
| 405 | { | ||
| 406 | if (!_state) { | ||
| 407 | return; | ||
| 408 | } | ||
| 409 | |||
| 410 | const auto &props = *std::static_pointer_cast<T3MarkdownTextProps const>(_props); | ||
| 411 | |||
| 412 | const auto attrString = _state->getData().attributedString; | ||
| 413 | NSMutableAttributedString *convertedAttrString = | ||
| 414 | [RCTNSAttributedStringFromAttributedString(attrString) mutableCopy]; | ||
| 415 | T3MarkdownTextApplyParagraphStyles( | ||
| 416 | convertedAttrString, | ||
| 417 | _state->getData().paragraphStyleRanges); | ||
| 418 | T3MarkdownTextApplyAttachments( | ||
| 419 | convertedAttrString, | ||
| 420 | _state->getData().attachmentRanges, | ||
| 421 | _attachmentImages); | ||
| 422 | // Matches the shadow node so drawn lines sit where measurement put them. | ||
| 423 | RCTApplyBaselineOffset(convertedAttrString); | ||
| 424 | NSUInteger runLocation = 0; | ||
| 425 | for (UIView *child in self.subviews) { | ||
| 426 | if (![child isKindOfClass:[T3MarkdownTextRun class]]) { | ||
| 427 | continue; | ||
| 428 | } | ||
| 429 | |||
| 430 | T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child; | ||
| 431 | const NSRange runRange = NSMakeRange(runLocation, textChild.text.length); | ||
| 432 | runLocation = NSMaxRange(runRange); | ||
| 433 | if (![textChild hasContextMenu] || runRange.length == 0 || | ||
| 434 | NSMaxRange(runRange) > convertedAttrString.length) { | ||
| 435 | continue; | ||
| 436 | } | ||
| 437 | |||
| 438 | NSURL *link = [NSURL URLWithString: | ||
| 439 | [NSString stringWithFormat:@"t3-markdown-run://%ld", (long)textChild.tag]]; | ||
| 440 | if (link != nil) { | ||
| 441 | // A glyph must not be both a link and an attachment. UIKit caches them as | ||
| 442 | // different text-item classes and can send `attachment` to a cached link | ||
| 443 | // on a later tap. Attachment actions already use primaryActionForTextItem. | ||
| 444 | [convertedAttrString enumerateAttribute:NSAttachmentAttributeName | ||
| 445 | inRange:runRange | ||
| 446 | options:0 | ||
| 447 | usingBlock:^(id attachment, NSRange range, BOOL *stop) { | ||
| 448 | if (attachment == nil) { | ||
| 449 | [convertedAttrString addAttribute:NSLinkAttributeName value:link range:range]; | ||
| 450 | } | ||
| 451 | }]; | ||
| 452 | } | ||
| 453 | } | ||
| 454 | [self loadAttachmentImages:_state->getData().attachmentRanges]; | ||
| 455 | |||
| 456 | // Setting attributedText clears any active text selection, and re-assigning | ||
| 457 | // the frame triggers a layout flush that has the same effect. Bail out | ||
| 458 | // entirely when nothing actually changed so a JS-side state update made in | ||
| 459 | // response to onSelectionChange doesn't deselect what the user is selecting. | ||
| 460 | const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; | ||
| 461 | const BOOL frameChanged = _textLayoutNeedsUpdate; | ||
| 462 | if (!textChanged && !frameChanged) { | ||
| 463 | return; | ||
| 464 | } | ||
| 465 | if (textChanged) { | ||
| 466 | // Reassigning attributedText clears any active selection. Save it and | ||
| 467 | // restore after, while suppressing the synthetic textViewDidChangeSelection | ||
| 468 | // events the clear-then-restore would otherwise produce — those would | ||
| 469 | // round-trip to JS and re-trigger this same path, causing a loop. | ||
| 470 | const NSRange savedRange = _textView.selectedRange; | ||
| 471 | _suppressSelectionChange = YES; | ||
| 472 | _textView.attributedText = convertedAttrString; | ||
| 473 | NSMutableString *accessibleText = [convertedAttrString.string mutableCopy]; | ||
| 474 | for (auto it = _state->getData().attachmentRanges.rbegin(); | ||
| 475 | it != _state->getData().attachmentRanges.rend(); ++it) { | ||
| 476 | NSDictionary *chip = T3ContextChipPayload([NSString stringWithUTF8String:it->imageUri.c_str()]); | ||
| 477 | if (chip != nil && it->location < accessibleText.length) { | ||
| 478 | [accessibleText replaceCharactersInRange:NSMakeRange(it->location, 1) withString:chip[@"label"]]; | ||
| 479 | } | ||
| 480 | } | ||
| 481 | _textView.accessibilityLabel = accessibleText; | ||
| 482 | if (savedRange.length > 0 && NSMaxRange(savedRange) <= _textView.attributedText.length) { | ||
| 483 | _textView.selectedRange = savedRange; | ||
| 484 | } | ||
| 485 | _suppressSelectionChange = NO; | ||
| 486 | } | ||
| 487 | if (frameChanged) { | ||
| 488 | _textView.textContainer.size = CGSizeMake(CGRectGetWidth(_view.frame), CGFLOAT_MAX); | ||
| 489 | } | ||
| 490 | _textLayoutNeedsUpdate = NO; | ||
| 491 | |||
| 492 | // Text attachments have no native link element. Expose their existing runs | ||
| 493 | // at the measured glyph bounds, without inserting views into text layout. | ||
| 494 | NSMutableArray *accessibleElements = [NSMutableArray arrayWithObject:_textView]; | ||
| 495 | for (UIView *child in self.subviews) { | ||
| 496 | if (![child isKindOfClass:T3MarkdownTextRun.class]) continue; | ||
| 497 | T3MarkdownTextRun *run = (T3MarkdownTextRun *)child; | ||
| 498 | run.contextChipInteractive = NO; | ||
| 499 | } | ||
| 500 | for (const auto &attachmentRange : _state->getData().attachmentRanges) { | ||
| 501 | NSDictionary *chip = T3ContextChipPayload( | ||
| 502 | [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]); | ||
| 503 | if (![chip[@"interactive"] boolValue]) continue; | ||
| 504 | NSRange range = NSMakeRange(attachmentRange.location, 1); | ||
| 505 | T3MarkdownTextRun *run = [self childForCharacterRange:range]; | ||
| 506 | if (!run || NSMaxRange(range) > convertedAttrString.length) continue; | ||
| 507 | NSRange glyphRange = [_textView.layoutManager glyphRangeForCharacterRange:range actualCharacterRange:nil]; | ||
| 508 | CGRect bounds = [_textView.layoutManager boundingRectForGlyphRange:glyphRange | ||
| 509 | inTextContainer:_textView.textContainer]; | ||
| 510 | bounds = CGRectOffset(bounds, _textView.textContainerInset.left, _textView.textContainerInset.top); | ||
| 511 | run.contextChipInteractive = YES; | ||
| 512 | T3ContextChipAccessibilityElement *element = | ||
| 513 | [[T3ContextChipAccessibilityElement alloc] initWithAccessibilityContainer:self]; | ||
| 514 | element.run = run; | ||
| 515 | element.accessibilityLabel = chip[@"label"]; | ||
| 516 | element.accessibilityTraits = UIAccessibilityTraitButton; | ||
| 517 | element.accessibilityFrameInContainerSpace = [_textView convertRect:bounds toView:self]; | ||
| 518 | [accessibleElements addObject:element]; | ||
| 519 | } | ||
| 520 | _contextAccessibilityElements = accessibleElements; | ||
| 521 | |||
| 522 | __block std::vector<std::string> lines; | ||
| 523 | const int maxLines = props.numberOfLines; | ||
| 524 | [_textView.layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, convertedAttrString.string.length) usingBlock:^(CGRect rect, | ||
| 525 | CGRect usedRect, | ||
| 526 | NSTextContainer * _Nonnull textContainer, | ||
| 527 | NSRange glyphRange, | ||
| 528 | BOOL * _Nonnull stop) { | ||
| 529 | const auto charRange = [self->_textView.layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nil]; | ||
| 530 | const auto line = [self->_textView.text substringWithRange:charRange]; | ||
| 531 | lines.push_back(line.UTF8String); | ||
| 532 | // enumerateLineFragments overshoots maximumNumberOfLines by one on iOS | ||
| 533 | // 18, so cap explicitly. | ||
| 534 | if (maxLines > 0 && lines.size() >= (size_t)maxLines) { | ||
| 535 | *stop = YES; | ||
| 536 | } | ||
| 537 | }]; | ||
| 538 | |||
| 539 | if (_eventEmitter != nullptr) { | ||
| 540 | std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextEventEmitter>(_eventEmitter) | ||
| 541 | ->onTextLayout(facebook::react::T3MarkdownTextEventEmitter::OnTextLayout{static_cast<int>(self.tag), lines}); | ||
| 542 | }; | ||
| 543 | } | ||
| 544 | |||
| 545 | - (void)loadAttachmentImages:(const std::vector<T3MarkdownTextAttachmentRange> &)attachmentRanges | ||
| 546 | { | ||
| 547 | for (const auto &attachmentRange : attachmentRanges) { | ||
| 548 | NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; | ||
| 549 | if ([imageUri hasPrefix:@"sf:"]) { | ||
| 550 | continue; | ||
| 551 | } | ||
| 552 | NSDictionary *chip = T3ContextChipPayload(imageUri); | ||
| 553 | if (chip != nil) { | ||
| 554 | imageUri = [chip[@"iconUri"] isKindOfClass:NSString.class] ? chip[@"iconUri"] : nil; | ||
| 555 | if (imageUri.length == 0) continue; | ||
| 556 | } | ||
| 557 | if (_attachmentImages[imageUri] != nil || [_pendingAttachmentUris containsObject:imageUri]) { | ||
| 558 | continue; | ||
| 559 | } | ||
| 560 | |||
| 561 | NSURL *url = [NSURL URLWithString:imageUri]; | ||
| 562 | if (url == nil) { | ||
| 563 | continue; | ||
| 564 | } | ||
| 565 | if (url.isFileURL) { | ||
| 566 | UIImage *image = [UIImage imageWithContentsOfFile:url.path]; | ||
| 567 | if (image != nil) { | ||
| 568 | _attachmentImages[imageUri] = image; | ||
| 569 | dispatch_async(dispatch_get_main_queue(), ^{ | ||
| 570 | [self refreshDisplayedAttachments]; | ||
| 571 | }); | ||
| 572 | } | ||
| 573 | continue; | ||
| 574 | } | ||
| 575 | |||
| 576 | [_pendingAttachmentUris addObject:imageUri]; | ||
| 577 | [[[NSURLSession sharedSession] dataTaskWithURL:url | ||
| 578 | completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { | ||
| 579 | UIImage *image = data == nil ? nil : [UIImage imageWithData:data]; | ||
| 580 | dispatch_async(dispatch_get_main_queue(), ^{ | ||
| 581 | [self->_pendingAttachmentUris removeObject:imageUri]; | ||
| 582 | if (image != nil) { | ||
| 583 | self->_attachmentImages[imageUri] = image; | ||
| 584 | [self refreshDisplayedAttachments]; | ||
| 585 | } | ||
| 586 | }); | ||
| 587 | }] resume]; | ||
| 588 | } | ||
| 589 | } | ||
| 590 | |||
| 591 | - (void)refreshDisplayedAttachments | ||
| 592 | { | ||
| 593 | if (!_state || _textView.attributedText == nil) { | ||
| 594 | return; | ||
| 595 | } | ||
| 596 | |||
| 597 | NSMutableAttributedString *attributedText = [_textView.attributedText mutableCopy]; | ||
| 598 | T3MarkdownTextApplyAttachments( | ||
| 599 | attributedText, | ||
| 600 | _state->getData().attachmentRanges, | ||
| 601 | _attachmentImages); | ||
| 602 | |||
| 603 | const NSRange savedRange = _textView.selectedRange; | ||
| 604 | _suppressSelectionChange = YES; | ||
| 605 | _textView.attributedText = attributedText; | ||
| 606 | if (savedRange.location != NSNotFound && | ||
| 607 | NSMaxRange(savedRange) <= _textView.attributedText.length) { | ||
| 608 | _textView.selectedRange = savedRange; | ||
| 609 | } | ||
| 610 | _suppressSelectionChange = NO; | ||
| 611 | [_textView setNeedsDisplay]; | ||
| 612 | } | ||
| 613 | |||
| 614 | - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps | ||
| 615 | { | ||
| 616 | const auto &oldViewProps = *std::static_pointer_cast<T3MarkdownTextProps const>(_props); | ||
| 617 | const auto &newViewProps = *std::static_pointer_cast<T3MarkdownTextProps const>(props); | ||
| 618 | if (oldViewProps.contextClipboardConfig != newViewProps.contextClipboardConfig) { | ||
| 619 | NSString *config = RCTNSStringFromString(newViewProps.contextClipboardConfig); | ||
| 620 | _textView.contextClipboardConfig = config.length ? [NSJSONSerialization JSONObjectWithData:[config dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil] : nil; | ||
| 621 | } | ||
| 622 | |||
| 623 | if (oldViewProps.numberOfLines != newViewProps.numberOfLines) { | ||
| 624 | _textView.textContainer.maximumNumberOfLines = newViewProps.numberOfLines; | ||
| 625 | } | ||
| 626 | |||
| 627 | if (oldViewProps.selectable != newViewProps.selectable) { | ||
| 628 | _textView.selectable = newViewProps.selectable; | ||
| 629 | } | ||
| 630 | |||
| 631 | if (oldViewProps.allowFontScaling != newViewProps.allowFontScaling) { | ||
| 632 | if (@available(iOS 11.0, *)) { | ||
| 633 | _textView.adjustsFontForContentSizeCategory = newViewProps.allowFontScaling; | ||
| 634 | } | ||
| 635 | } | ||
| 636 | |||
| 637 | if (oldViewProps.ellipsizeMode != newViewProps.ellipsizeMode) { | ||
| 638 | if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { | ||
| 639 | _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingHead; | ||
| 640 | } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { | ||
| 641 | _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingMiddle; | ||
| 642 | } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { | ||
| 643 | _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingTail; | ||
| 644 | } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Clip) { | ||
| 645 | _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByClipping; | ||
| 646 | } | ||
| 647 | } | ||
| 648 | |||
| 649 | |||
| 650 | // I'm not sure if this is really the right way to handle this style. This means that the entire _view_ the text | ||
| 651 | // is in will have this background color applied. To apply it just to a particular part of a string, you'd need | ||
| 652 | // to do <Text><Text style={{backgroundColor: 'blue'}}>Hello</Text></Text>. | ||
| 653 | // This is how the base <Text> component works though, so we'll go with it for now. Can change later if we want. | ||
| 654 | if (oldViewProps.backgroundColor != newViewProps.backgroundColor) { | ||
| 655 | _textView.backgroundColor = RCTUIColorFromSharedColor(newViewProps.backgroundColor); | ||
| 656 | } | ||
| 657 | |||
| 658 | [super updateProps:props oldProps:oldProps]; | ||
| 659 | } | ||
| 660 | |||
| 661 | // See RCTParagraphComponentView | ||
| 662 | - (void)updateState:(const facebook::react::State::Shared &)state oldState:(const facebook::react::State::Shared &)oldState | ||
| 663 | { | ||
| 664 | _state = std::static_pointer_cast<const T3MarkdownTextShadowNode::ConcreteState>(state); | ||
| 665 | [self setNeedsDisplay]; | ||
| 666 | } | ||
| 667 | |||
| 668 | // MARK: - UIGestureRecognizerDelegate | ||
| 669 | |||
| 670 | - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer | ||
| 671 | { | ||
| 672 | return YES; | ||
| 673 | } | ||
| 674 | |||
| 675 | - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer | ||
| 676 | { | ||
| 677 | if (gestureRecognizer != _longPressGestureRecognizer && | ||
| 678 | gestureRecognizer != _pressGestureRecognizer) { | ||
| 679 | return YES; | ||
| 680 | } | ||
| 681 | |||
| 682 | const auto location = [self getLocationOfPress:gestureRecognizer]; | ||
| 683 | const auto child = [self getTouchChild:location]; | ||
| 684 | return ![child hasContextMenu]; | ||
| 685 | } | ||
| 686 | |||
| 687 | - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch | ||
| 688 | { | ||
| 689 | return YES; | ||
| 690 | } | ||
| 691 | |||
| 692 | - (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView | ||
| 693 | { | ||
| 694 | if ([hitView isDescendantOfView:self]) { | ||
| 695 | return; | ||
| 696 | } | ||
| 697 | // Defer past the current event loop turn so any in-flight edit-menu action | ||
| 698 | // (Copy / Define / Look Up / …) reads the live selection before we clear it. | ||
| 699 | UITextView *textView = _textView; | ||
| 700 | dispatch_async(dispatch_get_main_queue(), ^{ | ||
| 701 | UITextRange *range = textView.selectedTextRange; | ||
| 702 | if (range != nil && !range.isEmpty) { | ||
| 703 | textView.selectedTextRange = nil; | ||
| 704 | } | ||
| 705 | }); | ||
| 706 | } | ||
| 707 | |||
| 708 | // MARK: - Touch handling | ||
| 709 | |||
| 710 | - (nullable T3MarkdownTextRun *)childForCharacterRange:(NSRange)characterRange | ||
| 711 | { | ||
| 712 | NSUInteger location = 0; | ||
| 713 | for (UIView *child in self.subviews) { | ||
| 714 | if (![child isKindOfClass:[T3MarkdownTextRun class]]) { | ||
| 715 | continue; | ||
| 716 | } | ||
| 717 | |||
| 718 | T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child; | ||
| 719 | const NSRange range = NSMakeRange(location, textChild.text.length); | ||
| 720 | if (NSIntersectionRange(range, characterRange).length > 0) { | ||
| 721 | return textChild; | ||
| 722 | } | ||
| 723 | location = NSMaxRange(range); | ||
| 724 | } | ||
| 725 | return nil; | ||
| 726 | } | ||
| 727 | |||
| 728 | - (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender | ||
| 729 | { | ||
| 730 | return [sender locationInView:_textView]; | ||
| 731 | } | ||
| 732 | |||
| 733 | - (T3MarkdownTextRun*)getTouchChild:(CGPoint)location | ||
| 734 | { | ||
| 735 | const auto charIndex = [_textView.layoutManager characterIndexForPoint:location | ||
| 736 | inTextContainer:_textView.textContainer | ||
| 737 | fractionOfDistanceBetweenInsertionPoints:nil | ||
| 738 | ]; | ||
| 739 | |||
| 740 | int currIndex = -1; | ||
| 741 | for (UIView* child in self.subviews) { | ||
| 742 | if (![child isKindOfClass:[T3MarkdownTextRun class]]) { | ||
| 743 | continue; | ||
| 744 | } | ||
| 745 | |||
| 746 | T3MarkdownTextRun* textChild = (T3MarkdownTextRun*)child; | ||
| 747 | |||
| 748 | // This is UTF16 code units!! | ||
| 749 | currIndex += textChild.text.length; | ||
| 750 | |||
| 751 | if (charIndex <= currIndex) { | ||
| 752 | return textChild; | ||
| 753 | } | ||
| 754 | } | ||
| 755 | |||
| 756 | return nil; | ||
| 757 | } | ||
| 758 | |||
| 759 | - (void)handlePressIfNecessary:(UITapGestureRecognizer*)sender | ||
| 760 | { | ||
| 761 | const auto location = [self getLocationOfPress:sender]; | ||
| 762 | const auto child = [self getTouchChild:location]; | ||
| 763 | |||
| 764 | if (child) { | ||
| 765 | [child onPress]; | ||
| 766 | } | ||
| 767 | } | ||
| 768 | |||
| 769 | - (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender | ||
| 770 | { | ||
| 771 | if (sender.state != UIGestureRecognizerStateBegan) { | ||
| 772 | return; | ||
| 773 | } | ||
| 774 | |||
| 775 | const auto location = [self getLocationOfPress:sender]; | ||
| 776 | const auto child = [self getTouchChild:location]; | ||
| 777 | |||
| 778 | if (child) { | ||
| 779 | [child onLongPress]; | ||
| 780 | } | ||
| 781 | } | ||
| 782 | |||
| 783 | // MARK: - UITextViewDelegate | ||
| 784 | |||
| 785 | - (nullable UIAction *)textView:(UITextView *)textView | ||
| 786 | primaryActionForTextItem:(UITextItem *)textItem | ||
| 787 | defaultAction:(UIAction *)defaultAction API_AVAILABLE(ios(17.0)) | ||
| 788 | { | ||
| 789 | T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range]; | ||
| 790 | if (![child hasContextMenu] && !child.contextChipInteractive) { | ||
| 791 | return defaultAction; | ||
| 792 | } | ||
| 793 | |||
| 794 | __weak T3MarkdownTextRun *weakChild = child; | ||
| 795 | return [UIAction actionWithHandler:^(__kindof UIAction *action) { | ||
| 796 | [weakChild onPress]; | ||
| 797 | }]; | ||
| 798 | } | ||
| 799 | |||
| 800 | - (nullable UITextItemMenuConfiguration *)textView:(UITextView *)textView | ||
| 801 | menuConfigurationForTextItem:(UITextItem *)textItem | ||
| 802 | defaultMenu:(UIMenu *)defaultMenu API_AVAILABLE(ios(17.0)) | ||
| 803 | { | ||
| 804 | T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range]; | ||
| 805 | UIMenu *menu = [child contextMenu]; | ||
| 806 | if (child.contextChipInteractive && menu == nil) return nil; | ||
| 807 | return [UITextItemMenuConfiguration configurationWithMenu:menu ?: defaultMenu]; | ||
| 808 | } | ||
| 809 | |||
| 810 | - (void)textViewDidChangeSelection:(UITextView *)textView | ||
| 811 | { | ||
| 812 | if (_suppressSelectionChange) { | ||
| 813 | return; | ||
| 814 | } | ||
| 815 | if (_eventEmitter == nullptr) { | ||
| 816 | return; | ||
| 817 | } | ||
| 818 | |||
| 819 | const NSRange selectedRange = textView.selectedRange; | ||
| 820 | if (selectedRange.location == NSNotFound) { | ||
| 821 | return; | ||
| 822 | } | ||
| 823 | |||
| 824 | // Fires on programmatic selection changes too (e.g. the outside-tap clear | ||
| 825 | // in handleOutsideTap:), so JS will see a synthetic empty-range event then. | ||
| 826 | std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextEventEmitter>(_eventEmitter) | ||
| 827 | ->onSelectionChange(facebook::react::T3MarkdownTextEventEmitter::OnSelectionChange{ | ||
| 828 | static_cast<int>(self.tag), | ||
| 829 | static_cast<int>(selectedRange.location), | ||
| 830 | static_cast<int>(selectedRange.location + selectedRange.length), | ||
| 831 | }); | ||
| 832 | } | ||
| 833 | |||
| 834 | Class<RCTComponentViewProtocol> T3MarkdownTextCls(void) | ||
| 835 | { | ||
| 836 | return T3MarkdownText.class; | ||
| 837 | } | ||
| 838 | |||
| 839 | @end | ||
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h deleted-13| ... | @@ -1,13 +0,0 @@ | ||
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include "T3MarkdownTextShadowNode.h" | ||
| 4 | |||
| 5 | #include <react/renderer/core/ConcreteComponentDescriptor.h> | ||
| 6 | #include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h> | ||
| 7 | |||
| 8 | namespace facebook::react { | ||
| 9 | using T3MarkdownTextComponentDescriptor = ConcreteComponentDescriptor<T3MarkdownTextShadowNode>; | ||
| 10 | |||
| 11 | void T3MarkdownTextSpec_registerComponentDescriptorsFromCodegen( | ||
| 12 | std::shared_ptr<const ComponentDescriptorProviderRegistry> registry); | ||
| 13 | } | ||
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm deleted-35| ... | @@ -1,35 +0,0 @@ | ||
| 1 | #import <React/RCTViewManager.h> | ||
| 2 | #import <React/RCTUIManager.h> | ||
| 3 | #import "Utils.h" | ||
| 4 | |||
| 5 | @interface T3MarkdownTextManager : RCTViewManager | ||
| 6 | @end | ||
| 7 | |||
| 8 | @implementation T3MarkdownTextManager | ||
| 9 | |||
| 10 | RCT_EXPORT_MODULE(T3MarkdownText) | ||
| 11 | |||
| 12 | - (UIView *)view | ||
| 13 | { | ||
| 14 | return [[UIView alloc] init]; | ||
| 15 | } | ||
| 16 | |||
| 17 | RCT_CUSTOM_VIEW_PROPERTY(color, NSString, UIView) | ||
| 18 | { | ||
| 19 | } | ||
| 20 | |||
| 21 | @end | ||
| 22 | |||
| 23 | @interface T3MarkdownTextRunManager : RCTViewManager | ||
| 24 | @end | ||
| 25 | |||
| 26 | @implementation T3MarkdownTextRunManager | ||
| 27 | |||
| 28 | RCT_EXPORT_MODULE(T3MarkdownTextRun) | ||
| 29 | |||
| 30 | - (UIView *)view | ||
| 31 | { | ||
| 32 | return nil; | ||
| 33 | } | ||
| 34 | |||
| 35 | @end | ||
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h deleted-28| ... | @@ -1,28 +0,0 @@ | ||
| 1 | // This guard prevent this file to be compiled in the old architecture. | ||
| 2 | #ifdef RCT_NEW_ARCH_ENABLED | ||
| 3 | #import <React/RCTViewComponentView.h> | ||
| 4 | #import <React/RCTComponent.h> | ||
| 5 | #import <UIKit/UIKit.h> | ||
| 6 | |||
| 7 | #ifndef T3MarkdownTextRunNativeComponent_h | ||
| 8 | #define T3MarkdownTextRunNativeComponent_h | ||
| 9 | |||
| 10 | NS_ASSUME_NONNULL_BEGIN | ||
| 11 | |||
| 12 | @interface T3MarkdownTextRun : RCTViewComponentView | ||
| 13 | |||
| 14 | @property (nonatomic, copy, nullable) NSString *text; | ||
| 15 | @property (nonatomic, assign) BOOL contextChipInteractive; | ||
| 16 | |||
| 17 | - (nullable UIMenu *)contextMenu; | ||
| 18 | - (BOOL)hasContextMenu; | ||
| 19 | - (void)onContextMenuAction:(NSString *)actionIdentifier; | ||
| 20 | - (void)onPress; | ||
| 21 | - (void)onLongPress; | ||
| 22 | |||
| 23 | @end | ||
| 24 | |||
| 25 | NS_ASSUME_NONNULL_END | ||
| 26 | |||
| 27 | #endif /* UitextviewViewNativeComponent_h */ | ||
| 28 | #endif /* RCT_NEW_ARCH_ENABLED */ | ||
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm deleted-140| ... | @@ -1,140 +0,0 @@ | ||
| 1 | #import "T3MarkdownTextRun.h" | ||
| 2 | #import "T3MarkdownText.h" | ||
| 3 | #import "T3MarkdownTextRunComponentDescriptor.h" | ||
| 4 | #import <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h> | ||
| 5 | #import <react/renderer/components/T3MarkdownTextSpec/Props.h> | ||
| 6 | #import <react/renderer/components/T3MarkdownTextSpec/RCTComponentViewHelpers.h> | ||
| 7 | #import <React/RCTFabricComponentsPlugins.h> | ||
| 8 | #import "Utils.h" | ||
| 9 | |||
| 10 | using namespace facebook::react; | ||
| 11 | |||
| 12 | @interface T3MarkdownTextRun () <RCTT3MarkdownTextRunViewProtocol> | ||
| 13 | |||
| 14 | @end | ||
| 15 | |||
| 16 | @implementation T3MarkdownTextRun { | ||
| 17 | NSString * _text; | ||
| 18 | NSString * _contextMenuConfig; | ||
| 19 | } | ||
| 20 | |||
| 21 | + (ComponentDescriptorProvider)componentDescriptorProvider | ||
| 22 | { | ||
| 23 | return concreteComponentDescriptorProvider<T3MarkdownTextRunComponentDescriptor>(); | ||
| 24 | } | ||
| 25 | |||
| 26 | - (instancetype)initWithFrame:(CGRect)frame | ||
| 27 | { | ||
| 28 | if (self = [super initWithFrame:frame]) { | ||
| 29 | static const auto defaultProps = std::make_shared<const T3MarkdownTextRunProps>(); | ||
| 30 | _props = defaultProps; | ||
| 31 | } | ||
| 32 | return self; | ||
| 33 | } | ||
| 34 | |||
| 35 | - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps | ||
| 36 | { | ||
| 37 | const auto &oldViewProps = *std::static_pointer_cast<T3MarkdownTextRunProps const>(_props); | ||
| 38 | const auto &newViewProps = *std::static_pointer_cast<T3MarkdownTextRunProps const>(props); | ||
| 39 | |||
| 40 | if (newViewProps.text != oldViewProps.text) { | ||
| 41 | NSString *text = [NSString stringWithUTF8String:newViewProps.text.c_str()]; | ||
| 42 | _text = text; | ||
| 43 | } | ||
| 44 | |||
| 45 | if (newViewProps.contextMenuConfig != oldViewProps.contextMenuConfig) { | ||
| 46 | _contextMenuConfig = [NSString stringWithUTF8String:newViewProps.contextMenuConfig.c_str()]; | ||
| 47 | } | ||
| 48 | |||
| 49 | [super updateProps:props oldProps:oldProps]; | ||
| 50 | } | ||
| 51 | |||
| 52 | - (BOOL)hasContextMenu | ||
| 53 | { | ||
| 54 | return _contextMenuConfig.length > 0; | ||
| 55 | } | ||
| 56 | |||
| 57 | - (nullable UIMenu *)contextMenu | ||
| 58 | { | ||
| 59 | if (_contextMenuConfig.length == 0) { | ||
| 60 | return nil; | ||
| 61 | } | ||
| 62 | |||
| 63 | NSData *data = [_contextMenuConfig dataUsingEncoding:NSUTF8StringEncoding]; | ||
| 64 | NSDictionary *config = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; | ||
| 65 | if (![config isKindOfClass:[NSDictionary class]]) { | ||
| 66 | return nil; | ||
| 67 | } | ||
| 68 | |||
| 69 | NSArray *actionConfigs = config[@"actions"]; | ||
| 70 | if (![actionConfigs isKindOfClass:[NSArray class]] || actionConfigs.count == 0) { | ||
| 71 | return nil; | ||
| 72 | } | ||
| 73 | |||
| 74 | NSMutableArray<UIMenuElement *> *actions = [NSMutableArray arrayWithCapacity:actionConfigs.count]; | ||
| 75 | __weak T3MarkdownTextRun *weakSelf = self; | ||
| 76 | for (NSDictionary *actionConfig in actionConfigs) { | ||
| 77 | if (![actionConfig isKindOfClass:[NSDictionary class]]) { | ||
| 78 | continue; | ||
| 79 | } | ||
| 80 | NSString *actionIdentifier = actionConfig[@"id"]; | ||
| 81 | NSString *title = actionConfig[@"title"]; | ||
| 82 | if (![actionIdentifier isKindOfClass:[NSString class]] || | ||
| 83 | ![title isKindOfClass:[NSString class]]) { | ||
| 84 | continue; | ||
| 85 | } | ||
| 86 | |||
| 87 | UIAction *action = [UIAction actionWithTitle:title | ||
| 88 | image:nil | ||
| 89 | identifier:actionIdentifier | ||
| 90 | handler:^(__kindof UIAction *selectedAction) { | ||
| 91 | [weakSelf onContextMenuAction:selectedAction.identifier]; | ||
| 92 | }]; | ||
| 93 | if ([actionConfig[@"disabled"] boolValue]) { | ||
| 94 | action.attributes = UIMenuElementAttributesDisabled; | ||
| 95 | } | ||
| 96 | [actions addObject:action]; | ||
| 97 | } | ||
| 98 | |||
| 99 | if (actions.count == 0) { | ||
| 100 | return nil; | ||
| 101 | } | ||
| 102 | NSString *title = [config[@"title"] isKindOfClass:[NSString class]] ? config[@"title"] : @""; | ||
| 103 | return [UIMenu menuWithTitle:title children:actions]; | ||
| 104 | } | ||
| 105 | |||
| 106 | - (void)onContextMenuAction:(NSString *)actionIdentifier | ||
| 107 | { | ||
| 108 | if (_eventEmitter != nullptr) { | ||
| 109 | std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter) | ||
| 110 | ->onContextMenuAction(facebook::react::T3MarkdownTextRunEventEmitter::OnContextMenuAction{ | ||
| 111 | static_cast<int>(self.tag), | ||
| 112 | actionIdentifier.UTF8String, | ||
| 113 | }); | ||
| 114 | } | ||
| 115 | } | ||
| 116 | |||
| 117 | - (void)onPress { | ||
| 118 | if (_eventEmitter != nullptr) { | ||
| 119 | std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter) | ||
| 120 | ->onPress(facebook::react::T3MarkdownTextRunEventEmitter::OnPress{}); | ||
| 121 | } | ||
| 122 | } | ||
| 123 | |||
| 124 | - (void)onLongPress { | ||
| 125 | if (_eventEmitter != nullptr) { | ||
| 126 | std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter) | ||
| 127 | ->onLongPress(facebook::react::T3MarkdownTextRunEventEmitter::OnLongPress{}); | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 131 | + (BOOL)shouldBeRecycled { | ||
| 132 | return NO; | ||
| 133 | } | ||
| 134 | |||
| 135 | Class<RCTComponentViewProtocol> T3MarkdownTextRunCls(void) | ||
| 136 | { | ||
| 137 | return T3MarkdownTextRun.class; | ||
| 138 | } | ||
| 139 | |||
| 140 | @end | ||
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h deleted-13| ... | @@ -1,13 +0,0 @@ | ||
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include "T3MarkdownTextRunShadowNode.h" | ||
| 4 | |||
| 5 | #include <react/renderer/core/ConcreteComponentDescriptor.h> | ||
| 6 | #include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h> | ||
| 7 | |||
| 8 | namespace facebook::react { | ||
| 9 | using T3MarkdownTextRunComponentDescriptor = ConcreteComponentDescriptor<T3MarkdownTextRunShadowNode>; | ||
| 10 | |||
| 11 | void T3MarkdownTextRunSpec_registerComponentDescriptorsFromCodegen( | ||
| 12 | std::shared_ptr<const ComponentDescriptorProviderRegistry> registry); | ||
| 13 | } | ||
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp deleted-6| ... | @@ -1,6 +0,0 @@ | ||
| 1 | #include "T3MarkdownTextRunShadowNode.h" | ||
| 2 | |||
| 3 | namespace facebook::react { | ||
| 4 | |||
| 5 | extern const char T3MarkdownTextRunComponentName[] = "T3MarkdownTextRun"; | ||
| 6 | } // namespace facebook::react | ||
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h deleted-16| ... | @@ -1,16 +0,0 @@ | ||
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h> | ||
| 4 | #include <react/renderer/components/T3MarkdownTextSpec/Props.h> | ||
| 5 | #include <react/renderer/components/T3MarkdownTextSpec/States.h> | ||
| 6 | #include <react/renderer/components/view/ConcreteViewShadowNode.h> | ||
| 7 | |||
| 8 | namespace facebook::react { | ||
| 9 | extern const char T3MarkdownTextRunComponentName[]; | ||
| 10 | |||
| 11 | using T3MarkdownTextRunShadowNode = ConcreteViewShadowNode< | ||
| 12 | T3MarkdownTextRunComponentName, | ||
| 13 | T3MarkdownTextRunProps, | ||
| 14 | T3MarkdownTextRunEventEmitter, | ||
| 15 | T3MarkdownTextRunState>; | ||
| 16 | } | ||
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h deleted-82| ... | @@ -1,82 +0,0 @@ | ||
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h> | ||
| 4 | #include <react/renderer/components/T3MarkdownTextSpec/Props.h> | ||
| 5 | #include <react/renderer/components/view/ConcreteViewShadowNode.h> | ||
| 6 | #include <react/renderer/textlayoutmanager/TextLayoutManager.h> | ||
| 7 | #include <react/renderer/core/LayoutContext.h> | ||
| 8 | #include <react/renderer/core/ShadowNode.h> | ||
| 9 | |||
| 10 | #include <string> | ||
| 11 | #include <vector> | ||
| 12 | |||
| 13 | namespace facebook::react { | ||
| 14 | |||
| 15 | extern const char T3MarkdownTextComponentName[]; | ||
| 16 | |||
| 17 | struct T3MarkdownTextParagraphStyleRange { | ||
| 18 | size_t location; | ||
| 19 | size_t length; | ||
| 20 | Float firstLineHeadIndent; | ||
| 21 | Float headIndent; | ||
| 22 | Float paragraphSpacing; | ||
| 23 | }; | ||
| 24 | |||
| 25 | struct T3MarkdownTextAttachmentRange { | ||
| 26 | size_t location; | ||
| 27 | size_t length; | ||
| 28 | std::string imageUri; | ||
| 29 | /// Recolor the loaded image with the run's foreground color, like `sf:` symbols. | ||
| 30 | bool tintWithForeground; | ||
| 31 | Float chipWidth = 0; | ||
| 32 | Float chipHeight = 0; | ||
| 33 | }; | ||
| 34 | |||
| 35 | inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { | ||
| 36 | return 14; | ||
| 37 | } | ||
| 38 | |||
| 39 | inline Float T3MarkdownTextAttachmentBaselineOffset( | ||
| 40 | const T3MarkdownTextAttachmentRange &) { | ||
| 41 | return -2; | ||
| 42 | } | ||
| 43 | |||
| 44 | class T3MarkdownTextStateReal final { | ||
| 45 | public: | ||
| 46 | AttributedString attributedString; | ||
| 47 | std::vector<T3MarkdownTextParagraphStyleRange> paragraphStyleRanges; | ||
| 48 | std::vector<T3MarkdownTextAttachmentRange> attachmentRanges; | ||
| 49 | }; | ||
| 50 | |||
| 51 | class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode< | ||
| 52 | T3MarkdownTextComponentName, | ||
| 53 | T3MarkdownTextProps, | ||
| 54 | T3MarkdownTextEventEmitter, | ||
| 55 | T3MarkdownTextStateReal> { | ||
| 56 | public: | ||
| 57 | using ConcreteViewShadowNode::ConcreteViewShadowNode; | ||
| 58 | |||
| 59 | T3MarkdownTextShadowNode( | ||
| 60 | const ShadowNode& sourceShadowNode, | ||
| 61 | const ShadowNodeFragment& fragment | ||
| 62 | ); | ||
| 63 | |||
| 64 | static ShadowNodeTraits BaseTraits() { | ||
| 65 | auto traits = ConcreteViewShadowNode::BaseTraits(); | ||
| 66 | traits.set(ShadowNodeTraits::Trait::LeafYogaNode); | ||
| 67 | traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); | ||
| 68 | return traits; | ||
| 69 | } | ||
| 70 | |||
| 71 | void layout(LayoutContext layoutContext) override; | ||
| 72 | |||
| 73 | Size measureContent( | ||
| 74 | const LayoutContext& layoutContext, | ||
| 75 | const LayoutConstraints& layoutConstraints) const override; | ||
| 76 | |||
| 77 | private: | ||
| 78 | mutable AttributedString _attributedString; | ||
| 79 | mutable std::vector<T3MarkdownTextParagraphStyleRange> _paragraphStyleRanges; | ||
| 80 | mutable std::vector<T3MarkdownTextAttachmentRange> _attachmentRanges; | ||
| 81 | }; | ||
| 82 | } // namespace facebook::React | ||
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm deleted-303| ... | @@ -1,303 +0,0 @@ | ||
| 1 | #include "T3MarkdownTextShadowNode.h" | ||
| 2 | #include "T3MarkdownTextRunShadowNode.h" | ||
| 3 | #import "T3ContextChip.h" | ||
| 4 | #include <react/renderer/components/view/ViewShadowNode.h> | ||
| 5 | #import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h> | ||
| 6 | |||
| 7 | #include <algorithm> | ||
| 8 | #include <cmath> | ||
| 9 | |||
| 10 | namespace facebook::react { | ||
| 11 | |||
| 12 | static constexpr Float ParagraphStyleEncodingOffset = 1000; | ||
| 13 | static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:"; | ||
| 14 | static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:"; | ||
| 15 | static constexpr auto LinkAttachmentNativeIdPrefix = "t3-link:"; | ||
| 16 | |||
| 17 | static void applyParagraphStyles( | ||
| 18 | NSMutableAttributedString *attributedString, | ||
| 19 | const std::vector<T3MarkdownTextParagraphStyleRange> &styleRanges) | ||
| 20 | { | ||
| 21 | for (const auto &styleRange : styleRanges) { | ||
| 22 | if (styleRange.length == 0 || styleRange.location >= attributedString.length) { | ||
| 23 | continue; | ||
| 24 | } | ||
| 25 | |||
| 26 | const NSRange markerRange = NSMakeRange( | ||
| 27 | styleRange.location, | ||
| 28 | MIN(styleRange.length, attributedString.length - styleRange.location)); | ||
| 29 | const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; | ||
| 30 | const NSParagraphStyle *existingStyle = | ||
| 31 | [attributedString attribute:NSParagraphStyleAttributeName | ||
| 32 | atIndex:paragraphRange.location | ||
| 33 | effectiveRange:nil]; | ||
| 34 | NSMutableParagraphStyle *paragraphStyle = | ||
| 35 | existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; | ||
| 36 | paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; | ||
| 37 | paragraphStyle.headIndent = styleRange.headIndent; | ||
| 38 | paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; | ||
| 39 | paragraphStyle.tabStops = @[ | ||
| 40 | [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft | ||
| 41 | location:styleRange.headIndent | ||
| 42 | options:@{}] | ||
| 43 | ]; | ||
| 44 | paragraphStyle.defaultTabInterval = styleRange.headIndent; | ||
| 45 | [attributedString addAttribute:NSParagraphStyleAttributeName | ||
| 46 | value:paragraphStyle | ||
| 47 | range:paragraphRange]; | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | static void applyAttachments( | ||
| 52 | NSMutableAttributedString *attributedString, | ||
| 53 | const std::vector<T3MarkdownTextAttachmentRange> &attachmentRanges) | ||
| 54 | { | ||
| 55 | for (const auto &attachmentRange : attachmentRanges) { | ||
| 56 | if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { | ||
| 57 | continue; | ||
| 58 | } | ||
| 59 | |||
| 60 | NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; | ||
| 61 | attachment.image = [[UIImage alloc] init]; | ||
| 62 | const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange); | ||
| 63 | attachment.bounds = CGRectMake( | ||
| 64 | 0, | ||
| 65 | T3MarkdownTextAttachmentBaselineOffset(attachmentRange), | ||
| 66 | attachmentSize, | ||
| 67 | attachmentSize); | ||
| 68 | NSDictionary *runAttributes = | ||
| 69 | [attributedString attributesAtIndex:attachmentRange.location effectiveRange:nil]; | ||
| 70 | if (attachmentRange.chipWidth > 0) { | ||
| 71 | attachment.bounds = T3ContextChipBounds( | ||
| 72 | runAttributes[NSFontAttributeName], | ||
| 73 | CGSizeMake(attachmentRange.chipWidth, attachmentRange.chipHeight)); | ||
| 74 | } | ||
| 75 | const NSRange range = NSMakeRange( | ||
| 76 | attachmentRange.location, | ||
| 77 | MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); | ||
| 78 | [attributedString replaceCharactersInRange:range | ||
| 79 | withAttributedString:T3MarkdownTextAttachmentString(attachment, runAttributes)]; | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | T3MarkdownTextShadowNode::T3MarkdownTextShadowNode( | ||
| 84 | const ShadowNode& sourceShadowNode, | ||
| 85 | const ShadowNodeFragment& fragment | ||
| 86 | ) : ConcreteViewShadowNode(sourceShadowNode, fragment) { | ||
| 87 | }; | ||
| 88 | |||
| 89 | Size T3MarkdownTextShadowNode::measureContent( | ||
| 90 | const LayoutContext& layoutContext, | ||
| 91 | const LayoutConstraints& layoutConstraints) const { | ||
| 92 | const auto &baseProps = getConcreteProps(); | ||
| 93 | |||
| 94 | auto baseTextAttributes = TextAttributes::defaultTextAttributes(); | ||
| 95 | baseTextAttributes.backgroundColor = baseProps.backgroundColor; | ||
| 96 | baseTextAttributes.allowFontScaling = baseProps.allowFontScaling; | ||
| 97 | |||
| 98 | Float fontSizeMultiplier = 1.0; | ||
| 99 | if (baseTextAttributes.allowFontScaling) { | ||
| 100 | fontSizeMultiplier = layoutContext.fontSizeMultiplier; | ||
| 101 | } | ||
| 102 | |||
| 103 | auto baseAttributedString = AttributedString{}; | ||
| 104 | auto paragraphStyleRanges = std::vector<T3MarkdownTextParagraphStyleRange>{}; | ||
| 105 | auto attachmentRanges = std::vector<T3MarkdownTextAttachmentRange>{}; | ||
| 106 | size_t utf16Offset = 0; | ||
| 107 | const auto &children = getChildren(); | ||
| 108 | for (size_t i = 0; i < children.size(); i++) { | ||
| 109 | const auto child = children[i].get(); | ||
| 110 | if (auto textViewChild = dynamic_cast<const T3MarkdownTextRunShadowNode *>(child)) { | ||
| 111 | auto &props = textViewChild->getConcreteProps(); | ||
| 112 | auto fragment = AttributedString::Fragment{}; | ||
| 113 | auto textAttributes = TextAttributes::defaultTextAttributes(); | ||
| 114 | |||
| 115 | textAttributes.allowFontScaling = baseProps.allowFontScaling; | ||
| 116 | textAttributes.backgroundColor = props.backgroundColor; | ||
| 117 | textAttributes.fontSize = props.fontSize * fontSizeMultiplier; | ||
| 118 | textAttributes.lineHeight = props.lineHeight * fontSizeMultiplier; | ||
| 119 | textAttributes.foregroundColor = props.color; | ||
| 120 | const bool hasParagraphStyle = props.shadowRadius >= ParagraphStyleEncodingOffset; | ||
| 121 | if (!hasParagraphStyle) { | ||
| 122 | textAttributes.textShadowColor = props.shadowColor; | ||
| 123 | textAttributes.textShadowOffset = props.shadowOffset; | ||
| 124 | textAttributes.textShadowRadius = props.shadowRadius; | ||
| 125 | } | ||
| 126 | textAttributes.letterSpacing = props.letterSpacing; | ||
| 127 | textAttributes.textDecorationColor = props.textDecorationColor; | ||
| 128 | textAttributes.fontFamily = props.fontFamily; | ||
| 129 | |||
| 130 | if (props.fontStyle == T3MarkdownTextRunFontStyle::Italic) { | ||
| 131 | textAttributes.fontStyle = FontStyle::Italic; | ||
| 132 | } else { | ||
| 133 | textAttributes.fontStyle = FontStyle::Normal; | ||
| 134 | } | ||
| 135 | |||
| 136 | if (props.fontWeight == T3MarkdownTextRunFontWeight::Bold) { | ||
| 137 | textAttributes.fontWeight = FontWeight::Bold; | ||
| 138 | } else if (props.fontWeight == T3MarkdownTextRunFontWeight::UltraLight) { | ||
| 139 | textAttributes.fontWeight = FontWeight::UltraLight; | ||
| 140 | } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Light) { | ||
| 141 | textAttributes.fontWeight = FontWeight::Light; | ||
| 142 | } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Medium) { | ||
| 143 | textAttributes.fontWeight = FontWeight::Medium; | ||
| 144 | } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Semibold) { | ||
| 145 | textAttributes.fontWeight = FontWeight::Semibold; | ||
| 146 | } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Heavy) { | ||
| 147 | textAttributes.fontWeight = FontWeight::Heavy; | ||
| 148 | } else { | ||
| 149 | textAttributes.fontWeight = FontWeight::Regular; | ||
| 150 | } | ||
| 151 | |||
| 152 | if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::LineThrough) { | ||
| 153 | textAttributes.textDecorationLineType = TextDecorationLineType::Strikethrough; | ||
| 154 | } else if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::Underline) { | ||
| 155 | textAttributes.textDecorationLineType = TextDecorationLineType::Underline; | ||
| 156 | } else { | ||
| 157 | textAttributes.textDecorationLineType = TextDecorationLineType::None; | ||
| 158 | } | ||
| 159 | |||
| 160 | if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Solid) { | ||
| 161 | textAttributes.textDecorationStyle = TextDecorationStyle::Solid; | ||
| 162 | } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dotted) { | ||
| 163 | textAttributes.textDecorationStyle = TextDecorationStyle::Dotted; | ||
| 164 | } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dashed) { | ||
| 165 | textAttributes.textDecorationStyle = TextDecorationStyle::Dashed; | ||
| 166 | } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Double) { | ||
| 167 | textAttributes.textDecorationStyle = TextDecorationStyle::Double; | ||
| 168 | } | ||
| 169 | |||
| 170 | if (props.textAlign == T3MarkdownTextRunTextAlign::Left) { | ||
| 171 | textAttributes.alignment = TextAlignment::Left; | ||
| 172 | } else if (props.textAlign == T3MarkdownTextRunTextAlign::Right) { | ||
| 173 | textAttributes.alignment = TextAlignment::Right; | ||
| 174 | } else if (props.textAlign == T3MarkdownTextRunTextAlign::Center) { | ||
| 175 | textAttributes.alignment = TextAlignment::Center; | ||
| 176 | } else if (props.textAlign == T3MarkdownTextRunTextAlign::Justify) { | ||
| 177 | textAttributes.alignment = TextAlignment::Justified; | ||
| 178 | } else if (props.textAlign == T3MarkdownTextRunTextAlign::Auto) { | ||
| 179 | textAttributes.alignment = TextAlignment::Natural; | ||
| 180 | } | ||
| 181 | |||
| 182 | textAttributes.backgroundColor = props.backgroundColor; | ||
| 183 | |||
| 184 | fragment.string = props.text; | ||
| 185 | fragment.textAttributes = textAttributes; | ||
| 186 | |||
| 187 | NSString *fragmentText = [NSString stringWithUTF8String:props.text.c_str()]; | ||
| 188 | const size_t fragmentLength = fragmentText.length; | ||
| 189 | if (hasParagraphStyle) { | ||
| 190 | paragraphStyleRanges.push_back(T3MarkdownTextParagraphStyleRange{ | ||
| 191 | utf16Offset, | ||
| 192 | fragmentLength, | ||
| 193 | props.shadowOffset.width, | ||
| 194 | props.shadowOffset.height, | ||
| 195 | props.shadowRadius - ParagraphStyleEncodingOffset, | ||
| 196 | }); | ||
| 197 | } | ||
| 198 | if (props.nativeId.rfind("t3-chip:", 0) == 0 && fragmentLength > 0) { | ||
| 199 | const std::string uri = props.nativeId.substr(3); | ||
| 200 | NSMutableDictionary *payload = | ||
| 201 | [T3ContextChipPayload([NSString stringWithUTF8String:uri.c_str()]) mutableCopy]; | ||
| 202 | // Chips must scale with the paragraph or smaller Dynamic Type sizes clip them. | ||
| 203 | // Store the scaled payload so measurement and the rendered bitmap use the same font. | ||
| 204 | payload[@"fontSizeMultiplier"] = @(fontSizeMultiplier); | ||
| 205 | NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; | ||
| 206 | NSString *scaledUri = [@"chip:" stringByAppendingString: | ||
| 207 | [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]]; | ||
| 208 | const CGFloat maxWidth = std::isfinite(layoutConstraints.maximumSize.width) | ||
| 209 | ? layoutConstraints.maximumSize.width : 320; | ||
| 210 | const CGSize size = T3ContextChipSize(payload, maxWidth); | ||
| 211 | attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ | ||
| 212 | utf16Offset, 1, std::string(scaledUri.UTF8String), false, | ||
| 213 | static_cast<Float>(size.width), static_cast<Float>(size.height), | ||
| 214 | }); | ||
| 215 | } else if (props.nativeId.rfind(FileAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { | ||
| 216 | attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ | ||
| 217 | utf16Offset, | ||
| 218 | 1, | ||
| 219 | props.nativeId.substr(std::char_traits<char>::length(FileAttachmentNativeIdPrefix)), | ||
| 220 | false, | ||
| 221 | }); | ||
| 222 | } else if ( | ||
| 223 | props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { | ||
| 224 | attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ | ||
| 225 | utf16Offset, | ||
| 226 | 1, | ||
| 227 | props.nativeId.substr( | ||
| 228 | std::char_traits<char>::length(SkillAttachmentNativeIdPrefix)), | ||
| 229 | false, | ||
| 230 | }); | ||
| 231 | } else if ( | ||
| 232 | props.nativeId.rfind(LinkAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { | ||
| 233 | attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ | ||
| 234 | utf16Offset, | ||
| 235 | 1, | ||
| 236 | props.nativeId.substr(std::char_traits<char>::length(LinkAttachmentNativeIdPrefix)), | ||
| 237 | true, | ||
| 238 | }); | ||
| 239 | } | ||
| 240 | utf16Offset += fragmentLength; | ||
| 241 | baseAttributedString.appendFragment(std::move(fragment)); | ||
| 242 | } | ||
| 243 | } | ||
| 244 | |||
| 245 | _attributedString = baseAttributedString; | ||
| 246 | _paragraphStyleRanges = paragraphStyleRanges; | ||
| 247 | _attachmentRanges = attachmentRanges; | ||
| 248 | |||
| 249 | NSMutableAttributedString *convertedAttributedString = | ||
| 250 | [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; | ||
| 251 | applyParagraphStyles(convertedAttributedString, paragraphStyleRanges); | ||
| 252 | applyAttachments(convertedAttributedString, attachmentRanges); | ||
| 253 | // TextKit stacks a paragraph's extra line height above the glyphs. React Native's own | ||
| 254 | // layout manager centres them with a baseline offset; do the same, after attachments | ||
| 255 | // so chips shift with the words. | ||
| 256 | RCTApplyBaselineOffset(convertedAttributedString); | ||
| 257 | |||
| 258 | const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width) | ||
| 259 | ? layoutConstraints.maximumSize.width | ||
| 260 | : CGFLOAT_MAX; | ||
| 261 | NSTextStorage *textStorage = | ||
| 262 | [[NSTextStorage alloc] initWithAttributedString:convertedAttributedString]; | ||
| 263 | NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; | ||
| 264 | layoutManager.usesFontLeading = NO; | ||
| 265 | NSTextContainer *textContainer = | ||
| 266 | [[NSTextContainer alloc] initWithSize:CGSizeMake(maximumWidth, CGFLOAT_MAX)]; | ||
| 267 | textContainer.lineFragmentPadding = 0; | ||
| 268 | textContainer.maximumNumberOfLines = baseProps.numberOfLines; | ||
| 269 | if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { | ||
| 270 | textContainer.lineBreakMode = NSLineBreakByTruncatingHead; | ||
| 271 | } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { | ||
| 272 | textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle; | ||
| 273 | } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { | ||
| 274 | textContainer.lineBreakMode = NSLineBreakByTruncatingTail; | ||
| 275 | } else { | ||
| 276 | textContainer.lineBreakMode = NSLineBreakByClipping; | ||
| 277 | } | ||
| 278 | [layoutManager addTextContainer:textContainer]; | ||
| 279 | [textStorage addLayoutManager:layoutManager]; | ||
| 280 | [layoutManager ensureLayoutForTextContainer:textContainer]; | ||
| 281 | const CGRect usedRect = [layoutManager usedRectForTextContainer:textContainer]; | ||
| 282 | |||
| 283 | return { | ||
| 284 | std::clamp( | ||
| 285 | static_cast<Float>(std::ceil(usedRect.size.width)), | ||
| 286 | layoutConstraints.minimumSize.width, | ||
| 287 | layoutConstraints.maximumSize.width), | ||
| 288 | std::clamp( | ||
| 289 | static_cast<Float>(std::ceil(usedRect.size.height)), | ||
| 290 | layoutConstraints.minimumSize.height, | ||
| 291 | layoutConstraints.maximumSize.height), | ||
| 292 | }; | ||
| 293 | } | ||
| 294 | |||
| 295 | void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) { | ||
| 296 | ensureUnsealed(); | ||
| 297 | setStateData(T3MarkdownTextStateReal{ | ||
| 298 | _attributedString, | ||
| 299 | _paragraphStyleRanges, | ||
| 300 | _attachmentRanges, | ||
| 301 | }); | ||
| 302 | } | ||
| 303 | } | ||
apps/mobile/modules/t3-markdown-text/package.json deleted-57| ... | @@ -1,57 +0,0 @@ | ||
| 1 | { | ||
| 2 | "name": "@t3tools/mobile-markdown-text", | ||
| 3 | "version": "0.0.0", | ||
| 4 | "private": true, | ||
| 5 | "source": "./index.ts", | ||
| 6 | "files": [ | ||
| 7 | "android", | ||
| 8 | "assets", | ||
| 9 | "expo-module.config.json", | ||
| 10 | "ios", | ||
| 11 | "src", | ||
| 12 | "index.ts", | ||
| 13 | "LICENSE", | ||
| 14 | "UPSTREAM.md", | ||
| 15 | "T3MarkdownText.podspec", | ||
| 16 | "react-native.config.js" | ||
| 17 | ], | ||
| 18 | "main": "./index.ts", | ||
| 19 | "types": "./index.ts", | ||
| 20 | "react-native": "./index.ts", | ||
| 21 | "exports": { | ||
| 22 | ".": "./index.ts", | ||
| 23 | "./file-icons": "./src/markdownFileIcons.ts", | ||
| 24 | "./link-icons": "./src/markdownLinkIcons.ts", | ||
| 25 | "./links": "./src/markdownLinks.ts", | ||
| 26 | "./markdown": "./src/nativeMarkdownText.ts", | ||
| 27 | "./primitive": "./src/MarkdownTextPrimitive.tsx", | ||
| 28 | "./renderer": "./src/SelectableMarkdownText.tsx", | ||
| 29 | "./types": "./src/SelectableMarkdownText.types.ts" | ||
| 30 | }, | ||
| 31 | "peerDependencies": { | ||
| 32 | "@t3tools/client-runtime": "*", | ||
| 33 | "@t3tools/shared": "*", | ||
| 34 | "expo": "*", | ||
| 35 | "expo-asset": "*", | ||
| 36 | "expo-clipboard": "*", | ||
| 37 | "expo-haptics": "*", | ||
| 38 | "expo-symbols": "*", | ||
| 39 | "react": "*", | ||
| 40 | "react-native": "*", | ||
| 41 | "react-native-nitro-markdown": "*" | ||
| 42 | }, | ||
| 43 | "codegenConfig": { | ||
| 44 | "name": "T3MarkdownTextSpec", | ||
| 45 | "type": "all", | ||
| 46 | "jsSrcsDir": "src", | ||
| 47 | "ios": { | ||
| 48 | "componentProvider": { | ||
| 49 | "T3MarkdownText": "T3MarkdownText", | ||
| 50 | "T3MarkdownTextRun": "T3MarkdownTextRun" | ||
| 51 | } | ||
| 52 | }, | ||
| 53 | "outputDir": { | ||
| 54 | "ios": "ios/generated" | ||
| 55 | } | ||
| 56 | } | ||
| 57 | } | ||
apps/mobile/modules/t3-markdown-text/react-native.config.js deleted-10| ... | @@ -1,10 +0,0 @@ | ||
| 1 | module.exports = { | ||
| 2 | dependency: { | ||
| 3 | platforms: { | ||
| 4 | ios: { | ||
| 5 | podspecPath: "T3MarkdownText.podspec", | ||
| 6 | }, | ||
| 7 | android: null, | ||
| 8 | }, | ||
| 9 | }, | ||
| 10 | }; | ||
apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs deleted-134| ... | @@ -1,134 +0,0 @@ | ||
| 1 | import * as NodeChildProcess from "node:child_process"; | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodePath from "node:path"; | ||
| 4 | import * as NodeURL from "node:url"; | ||
| 5 | |||
| 6 | import { getBuiltInSpriteSheet } from "@pierre/trees"; | ||
| 7 | |||
| 8 | const scriptDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | ||
| 9 | const moduleDirectory = NodePath.resolve(scriptDirectory, ".."); | ||
| 10 | const repositoryRoot = NodePath.resolve(moduleDirectory, "../../../.."); | ||
| 11 | const outputDirectory = NodePath.join(moduleDirectory, "assets/file-icons"); | ||
| 12 | const generatedModulePath = NodePath.join(moduleDirectory, "src/markdownFileIcons.generated.ts"); | ||
| 13 | const webIconSource = NodeFS.readFileSync( | ||
| 14 | NodePath.join(repositoryRoot, "apps/web/src/pierre-icons.ts"), | ||
| 15 | "utf8", | ||
| 16 | ); | ||
| 17 | const customSprite = webIconSource.match(/const T3_FILE_ICON_SPRITE = `([\s\S]*?)`;/)?.[1]; | ||
| 18 | |||
| 19 | if (!customSprite) { | ||
| 20 | throw new Error("Could not read the T3 Pierre icon sprite from apps/web/src/pierre-icons.ts"); | ||
| 21 | } | ||
| 22 | |||
| 23 | const colors = { | ||
| 24 | astro: "#a631be", | ||
| 25 | babel: "#d5a910", | ||
| 26 | bash: "#199f43", | ||
| 27 | biome: "#1a85d4", | ||
| 28 | bootstrap: "#693acf", | ||
| 29 | browserslist: "#d5a910", | ||
| 30 | bun: "#594c5b", | ||
| 31 | c: "#1a85d4", | ||
| 32 | claude: "#d47628", | ||
| 33 | cpp: "#1a85d4", | ||
| 34 | css: "#693acf", | ||
| 35 | database: "#a631be", | ||
| 36 | default: "#84848a", | ||
| 37 | docker: "#1a85d4", | ||
| 38 | eslint: "#693acf", | ||
| 39 | font: "#84848a", | ||
| 40 | git: "#ff8c5b", | ||
| 41 | go: "#1ca1c7", | ||
| 42 | graphql: "#d32a61", | ||
| 43 | html: "#d47628", | ||
| 44 | image: "#d32a61", | ||
| 45 | javascript: "#d5a910", | ||
| 46 | json: "#d47628", | ||
| 47 | markdown: "#199f43", | ||
| 48 | mcp: "#17a5af", | ||
| 49 | nextjs: "#84848a", | ||
| 50 | npm: "#d52c36", | ||
| 51 | oxc: "#1ca1c7", | ||
| 52 | postcss: "#d52c36", | ||
| 53 | prettier: "#17a5af", | ||
| 54 | python: "#1a85d4", | ||
| 55 | react: "#1ca1c7", | ||
| 56 | ruby: "#d52c36", | ||
| 57 | rust: "#d47628", | ||
| 58 | sass: "#d32a61", | ||
| 59 | stylelint: "#84848a", | ||
| 60 | svelte: "#d52c36", | ||
| 61 | svg: "#d47628", | ||
| 62 | svgo: "#199f43", | ||
| 63 | swift: "#d47628", | ||
| 64 | table: "#17a5af", | ||
| 65 | tailwind: "#1ca1c7", | ||
| 66 | terraform: "#693acf", | ||
| 67 | text: "#84848a", | ||
| 68 | typescript: "#1a85d4", | ||
| 69 | video: "#a631be", | ||
| 70 | vite: "#a631be", | ||
| 71 | vscode: "#1a85d4", | ||
| 72 | vue: "#199f43", | ||
| 73 | wasm: "#693acf", | ||
| 74 | webpack: "#1a85d4", | ||
| 75 | yml: "#d52c36", | ||
| 76 | zig: "#d47628", | ||
| 77 | zip: "#d47628", | ||
| 78 | }; | ||
| 79 | |||
| 80 | const customIcons = { | ||
| 81 | agents: "t3-file-icon-agents", | ||
| 82 | pnpm: "t3-file-icon-pnpm", | ||
| 83 | video: "t3-file-icon-video", | ||
| 84 | }; | ||
| 85 | |||
| 86 | function symbolFromSprite(sprite, id) { | ||
| 87 | const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | ||
| 88 | const match = sprite.match( | ||
| 89 | new RegExp(`<symbol id="${escapedId}"([^>]*)>([\\s\\S]*?)<\\/symbol>`), | ||
| 90 | ); | ||
| 91 | if (!match) throw new Error(`Missing Pierre icon symbol: ${id}`); | ||
| 92 | return { | ||
| 93 | body: match[2], | ||
| 94 | viewBox: match[1].match(/viewBox="([^"]+)"/)?.[1] ?? "0 0 16 16", | ||
| 95 | }; | ||
| 96 | } | ||
| 97 | |||
| 98 | function renderIcon(token, symbol, color) { | ||
| 99 | const svgPath = NodePath.join(outputDirectory, `.pierre-${token}.svg`); | ||
| 100 | const pngPath = NodePath.join(outputDirectory, `pierre_${token}.png`); | ||
| 101 | NodeFS.writeFileSync( | ||
| 102 | svgPath, | ||
| 103 | `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="${symbol.viewBox}" style="color:${color}">${symbol.body}</svg>`, | ||
| 104 | ); | ||
| 105 | NodeChildProcess.execFileSync("sips", ["-s", "format", "png", svgPath, "--out", pngPath], { | ||
| 106 | stdio: "ignore", | ||
| 107 | }); | ||
| 108 | NodeFS.rmSync(svgPath); | ||
| 109 | } | ||
| 110 | |||
| 111 | NodeFS.rmSync(outputDirectory, { recursive: true, force: true }); | ||
| 112 | NodeFS.mkdirSync(outputDirectory, { recursive: true }); | ||
| 113 | |||
| 114 | const builtInSprite = getBuiltInSpriteSheet("complete"); | ||
| 115 | const builtInTokens = [...builtInSprite.matchAll(/<symbol id="file-tree-builtin-([^"]+)"/g)] | ||
| 116 | .map((match) => match[1]) | ||
| 117 | .sort(); | ||
| 118 | |||
| 119 | for (const token of builtInTokens) { | ||
| 120 | renderIcon( | ||
| 121 | token, | ||
| 122 | symbolFromSprite(builtInSprite, `file-tree-builtin-${token}`), | ||
| 123 | colors[token] ?? colors.default, | ||
| 124 | ); | ||
| 125 | } | ||
| 126 | for (const [token, symbolId] of Object.entries(customIcons)) { | ||
| 127 | renderIcon(token, symbolFromSprite(customSprite, symbolId), colors[token] ?? colors.default); | ||
| 128 | } | ||
| 129 | |||
| 130 | const tokens = [...new Set([...builtInTokens, ...Object.keys(customIcons)])].sort(); | ||
| 131 | const generatedSource = `import type { ImageSourcePropType } from "react-native";\n\nexport const MARKDOWN_FILE_ICON_SOURCES = {\n${tokens | ||
| 132 | .map((token) => ` ${token}: require("../assets/file-icons/pierre_${token}.png"),`) | ||
| 133 | .join("\n")}\n} as const satisfies Readonly<Record<string, ImageSourcePropType>>;\n`; | ||
| 134 | NodeFS.writeFileSync(generatedModulePath, generatedSource); | ||
apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx deleted-73| ... | @@ -1,73 +0,0 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import * as Clipboard from "expo-clipboard"; | ||
| 3 | import * as Haptics from "expo-haptics"; | ||
| 4 | import { memo, useEffect, useRef, useState } from "react"; | ||
| 5 | import { Pressable, type ColorValue } from "react-native"; | ||
| 6 | |||
| 7 | const COPY_FEEDBACK_DURATION_MS = 1200; | ||
| 8 | |||
| 9 | function copyTextWithHaptic(value: string): void { | ||
| 10 | void Clipboard.setStringAsync(value); | ||
| 11 | void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); | ||
| 12 | } | ||
| 13 | |||
| 14 | export const CopyTextButton = memo(function CopyTextButton(props: { | ||
| 15 | readonly accessibilityLabel: string; | ||
| 16 | readonly text: string; | ||
| 17 | readonly tintColor: ColorValue; | ||
| 18 | readonly copiedTintColor?: ColorValue; | ||
| 19 | readonly backgroundColor?: ColorValue; | ||
| 20 | readonly borderColor?: ColorValue; | ||
| 21 | readonly iconSize?: number; | ||
| 22 | readonly buttonSize?: number; | ||
| 23 | }) { | ||
| 24 | const [copied, setCopied] = useState(false); | ||
| 25 | const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| 26 | |||
| 27 | useEffect( | ||
| 28 | () => () => { | ||
| 29 | if (resetTimeoutRef.current) { | ||
| 30 | clearTimeout(resetTimeoutRef.current); | ||
| 31 | } | ||
| 32 | }, | ||
| 33 | [], | ||
| 34 | ); | ||
| 35 | |||
| 36 | return ( | ||
| 37 | <Pressable | ||
| 38 | accessibilityRole="button" | ||
| 39 | accessibilityLabel={copied ? "Copied" : props.accessibilityLabel} | ||
| 40 | disabled={props.text.length === 0} | ||
| 41 | hitSlop={8} | ||
| 42 | onPress={() => { | ||
| 43 | copyTextWithHaptic(props.text); | ||
| 44 | setCopied(true); | ||
| 45 | if (resetTimeoutRef.current) { | ||
| 46 | clearTimeout(resetTimeoutRef.current); | ||
| 47 | } | ||
| 48 | resetTimeoutRef.current = setTimeout(() => { | ||
| 49 | setCopied(false); | ||
| 50 | resetTimeoutRef.current = null; | ||
| 51 | }, COPY_FEEDBACK_DURATION_MS); | ||
| 52 | }} | ||
| 53 | style={({ pressed }) => ({ | ||
| 54 | width: props.buttonSize ?? 30, | ||
| 55 | height: props.buttonSize ?? 30, | ||
| 56 | alignItems: "center", | ||
| 57 | justifyContent: "center", | ||
| 58 | borderRadius: 9, | ||
| 59 | borderWidth: props.borderColor ? 1 : 0, | ||
| 60 | borderColor: props.borderColor, | ||
| 61 | backgroundColor: props.backgroundColor, | ||
| 62 | opacity: pressed ? 0.52 : 1, | ||
| 63 | })} | ||
| 64 | > | ||
| 65 | <SymbolView | ||
| 66 | name={copied ? "checkmark" : "doc.on.doc"} | ||
| 67 | size={props.iconSize ?? 13} | ||
| 68 | tintColor={copied ? (props.copiedTintColor ?? props.tintColor) : props.tintColor} | ||
| 69 | type="monochrome" | ||
| 70 | /> | ||
| 71 | </Pressable> | ||
| 72 | ); | ||
| 73 | }); | ||
apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx deleted-179| ... | @@ -1,179 +0,0 @@ | ||
| 1 | import React, { type Ref } from "react"; | ||
| 2 | import { | ||
| 3 | findNodeHandle, | ||
| 4 | Platform, | ||
| 5 | processColor, | ||
| 6 | StyleSheet, | ||
| 7 | Text as RNText, | ||
| 8 | type ColorValue, | ||
| 9 | type TextInstance, | ||
| 10 | type TextProps, | ||
| 11 | type ViewStyle, | ||
| 12 | } from "react-native"; | ||
| 13 | import { setMarkdownSelectionHandleColor } from "./T3MarkdownTextSelectionModule"; | ||
| 14 | import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; | ||
| 15 | import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; | ||
| 16 | import { flattenStyles } from "./util"; | ||
| 17 | |||
| 18 | const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([ | ||
| 19 | false, | ||
| 20 | StyleSheet.create({}), | ||
| 21 | ]); | ||
| 22 | |||
| 23 | const textDefaults = { | ||
| 24 | allowFontScaling: true, | ||
| 25 | selectable: true, | ||
| 26 | } satisfies TextProps; | ||
| 27 | |||
| 28 | const useTextAncestorContext = () => React.useContext(TextAncestorContext); | ||
| 29 | |||
| 30 | /** | ||
| 31 | * Event fired by `onSelectionChange`. `start`/`end` are 0-based UTF-16 indices | ||
| 32 | * into the rendered string. `start === end` means the selection was cleared. | ||
| 33 | */ | ||
| 34 | export type SelectionChangeEvent = { | ||
| 35 | nativeEvent: { target: number; start: number; end: number }; | ||
| 36 | }; | ||
| 37 | |||
| 38 | export type ContextMenuActionEvent = { | ||
| 39 | nativeEvent: { target: number; actionIdentifier: string }; | ||
| 40 | }; | ||
| 41 | |||
| 42 | /** | ||
| 43 | * `onTextLayout` is not offered: the native view reports plain line strings | ||
| 44 | * while the React Native Text fallback reports measured `TextLayoutLine`s. | ||
| 45 | */ | ||
| 46 | export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & { | ||
| 47 | nativeTextRef?: Ref<TextInstance>; | ||
| 48 | selectionHandleColor?: ColorValue; | ||
| 49 | uiTextView?: boolean; | ||
| 50 | contextMenuConfig?: string; | ||
| 51 | contextClipboardConfig?: string; | ||
| 52 | onContextMenuAction?: (event: ContextMenuActionEvent) => void; | ||
| 53 | /** | ||
| 54 | * Fired when the native text selection changes. Only fires on iOS when | ||
| 55 | * `uiTextView` is true. Note: fires on every selection-edge adjustment | ||
| 56 | * (e.g. dragging a selection handle), so consumers driving expensive work | ||
| 57 | * off this event should debounce. | ||
| 58 | */ | ||
| 59 | onSelectionChange?: (event: SelectionChangeEvent) => void; | ||
| 60 | }; | ||
| 61 | |||
| 62 | function MarkdownTextPrimitiveChild({ | ||
| 63 | style, | ||
| 64 | children, | ||
| 65 | nativeTextRef: _nativeTextRef, | ||
| 66 | ...rest | ||
| 67 | }: MarkdownTextPrimitiveProps) { | ||
| 68 | const [isAncestor, rootStyle] = useTextAncestorContext(); | ||
| 69 | |||
| 70 | // Flatten the styles, and apply the root styles when needed | ||
| 71 | const flattenedStyle = React.useMemo(() => flattenStyles(rootStyle, style), [rootStyle, style]); | ||
| 72 | const contextValue = React.useMemo<[boolean, ViewStyle]>( | ||
| 73 | () => [true, flattenedStyle], | ||
| 74 | [flattenedStyle], | ||
| 75 | ); | ||
| 76 | let childPosition = 0; | ||
| 77 | const nativeChildren = React.Children.toArray(children).map((child) => { | ||
| 78 | const position = childPosition; | ||
| 79 | childPosition += 1; | ||
| 80 | |||
| 81 | if (React.isValidElement(child)) { | ||
| 82 | return child; | ||
| 83 | } | ||
| 84 | if (typeof child !== "string" && typeof child !== "number") { | ||
| 85 | return null; | ||
| 86 | } | ||
| 87 | |||
| 88 | const text = child.toString(); | ||
| 89 | return ( | ||
| 90 | // @ts-expect-error The generated run props do not include inherited Text props. | ||
| 91 | <T3MarkdownTextRunNativeComponent | ||
| 92 | key={`text-${position}-${text.length}-${text}`} | ||
| 93 | style={flattenedStyle} | ||
| 94 | text={text} | ||
| 95 | {...rest} | ||
| 96 | /> | ||
| 97 | ); | ||
| 98 | }); | ||
| 99 | |||
| 100 | if (!isAncestor) { | ||
| 101 | // Press handlers are delivered by the text runs; the container never sees them. | ||
| 102 | const { onPress: _onPress, onLongPress: _onLongPress, ...containerProps } = rest; | ||
| 103 | return ( | ||
| 104 | <TextAncestorContext.Provider value={contextValue}> | ||
| 105 | <T3MarkdownTextNativeComponent | ||
| 106 | {...textDefaults} | ||
| 107 | {...containerProps} | ||
| 108 | style={[flattenedStyle]} | ||
| 109 | > | ||
| 110 | {nativeChildren} | ||
| 111 | </T3MarkdownTextNativeComponent> | ||
| 112 | </TextAncestorContext.Provider> | ||
| 113 | ); | ||
| 114 | } | ||
| 115 | |||
| 116 | return <>{nativeChildren}</>; | ||
| 117 | } | ||
| 118 | |||
| 119 | function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPrimitiveProps) { | ||
| 120 | const [isAncestor] = useTextAncestorContext(); | ||
| 121 | |||
| 122 | // Even if the uiTextView prop is set, we can still default to using | ||
| 123 | // normal selection (i.e. base RN text) if the text doesn't need to be | ||
| 124 | // selectable | ||
| 125 | if ((!props.selectable || !props.uiTextView) && !isAncestor) { | ||
| 126 | return <RNText ref={nativeTextRef} {...props} />; | ||
| 127 | } | ||
| 128 | return <MarkdownTextPrimitiveChild {...props} />; | ||
| 129 | } | ||
| 130 | |||
| 131 | function AndroidMarkdownText({ | ||
| 132 | nativeTextRef, | ||
| 133 | selectionHandleColor, | ||
| 134 | onLayout, | ||
| 135 | contextClipboardConfig: _contextClipboardConfig, | ||
| 136 | ...props | ||
| 137 | }: MarkdownTextPrimitiveProps) { | ||
| 138 | const textRef = React.useRef<TextInstance | null>(null); | ||
| 139 | React.useImperativeHandle<TextInstance | null, TextInstance | null>( | ||
| 140 | nativeTextRef, | ||
| 141 | () => textRef.current, | ||
| 142 | [], | ||
| 143 | ); | ||
| 144 | const color = processColor(selectionHandleColor); | ||
| 145 | const applyHandleColor = React.useCallback(() => { | ||
| 146 | if (!textRef.current || typeof color !== "number") return; | ||
| 147 | const reactTag = findNodeHandle(textRef.current); | ||
| 148 | if (reactTag != null) setMarkdownSelectionHandleColor(reactTag, color); | ||
| 149 | }, [color]); | ||
| 150 | |||
| 151 | // RN's selectionColor only sets the highlight. Retint mounted handles when | ||
| 152 | // the theme changes, and after layout when the native view first exists. | ||
| 153 | React.useEffect(applyHandleColor, [applyHandleColor]); | ||
| 154 | |||
| 155 | return ( | ||
| 156 | <RNText | ||
| 157 | ref={textRef} | ||
| 158 | {...props} | ||
| 159 | onLayout={(event) => { | ||
| 160 | applyHandleColor(); | ||
| 161 | onLayout?.(event); | ||
| 162 | }} | ||
| 163 | /> | ||
| 164 | ); | ||
| 165 | } | ||
| 166 | |||
| 167 | export function MarkdownTextPrimitive({ | ||
| 168 | selectionHandleColor, | ||
| 169 | ...props | ||
| 170 | }: MarkdownTextPrimitiveProps) { | ||
| 171 | if (Platform.OS === "android" && selectionHandleColor !== undefined) { | ||
| 172 | return <AndroidMarkdownText {...props} selectionHandleColor={selectionHandleColor} />; | ||
| 173 | } | ||
| 174 | if (Platform.OS !== "ios") { | ||
| 175 | const { nativeTextRef, contextClipboardConfig: _contextClipboardConfig, ...textProps } = props; | ||
| 176 | return <RNText ref={nativeTextRef} {...textProps} />; | ||
| 177 | } | ||
| 178 | return <MarkdownTextPrimitiveInner {...props} />; | ||
| 179 | } | ||
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx deleted-687| ... | @@ -1,687 +0,0 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import { githubAlert } from "./githubAlert"; | ||
| 3 | import { createContext, memo, useContext, useMemo } from "react"; | ||
| 4 | import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native"; | ||
| 5 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | ||
| 6 | |||
| 7 | import { CopyTextButton } from "./CopyTextButton"; | ||
| 8 | import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; | ||
| 9 | import { | ||
| 10 | nativeMarkdownDocumentRuns, | ||
| 11 | nativeMarkdownListItemBlocks, | ||
| 12 | nativeMarkdownNodePosition, | ||
| 13 | } from "./nativeMarkdownText"; | ||
| 14 | import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText"; | ||
| 15 | import type { | ||
| 16 | MarkdownCodeHighlighter, | ||
| 17 | MarkdownHighlightedToken, | ||
| 18 | MarkdownImageRenderer, | ||
| 19 | NativeMarkdownTextStyle, | ||
| 20 | SelectableMarkdownSkill, | ||
| 21 | } from "./SelectableMarkdownText.types"; | ||
| 22 | import { useHighlightedCode, type HighlightedCode } from "./useHighlightedCode"; | ||
| 23 | |||
| 24 | /** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */ | ||
| 25 | export const MarkdownImageRendererContext = createContext<MarkdownImageRenderer | null>(null); | ||
| 26 | |||
| 27 | const MONO_FONT_FAMILY = Platform.select({ | ||
| 28 | ios: "ui-monospace", | ||
| 29 | android: "monospace", | ||
| 30 | default: "monospace", | ||
| 31 | }); | ||
| 32 | |||
| 33 | function nodeKey(node: MarkdownNode, index: number): string { | ||
| 34 | return `${node.type}:${nativeMarkdownNodePosition(node, index)}`; | ||
| 35 | } | ||
| 36 | |||
| 37 | /** Code inside markdown scales with the base text size (12pt at the default 15pt body). */ | ||
| 38 | function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number { | ||
| 39 | return Math.max(10, Math.round(textStyle.fontSize * 0.8)); | ||
| 40 | } | ||
| 41 | |||
| 42 | function codeBlockLineHeight(textStyle: NativeMarkdownTextStyle): number { | ||
| 43 | return codeBlockFontSize(textStyle) + 6; | ||
| 44 | } | ||
| 45 | |||
| 46 | function nodeText(node: MarkdownNode): string { | ||
| 47 | if (node.content !== undefined) { | ||
| 48 | return node.content; | ||
| 49 | } | ||
| 50 | return (node.children ?? []).map(nodeText).join(""); | ||
| 51 | } | ||
| 52 | |||
| 53 | function documentFor(node: MarkdownNode): MarkdownNode { | ||
| 54 | return node.type === "document" ? node : { type: "document", children: [node] }; | ||
| 55 | } | ||
| 56 | |||
| 57 | function SelectableNode(props: { | ||
| 58 | readonly node: MarkdownNode; | ||
| 59 | readonly skills: ReadonlyArray<SelectableMarkdownSkill>; | ||
| 60 | readonly textStyle: NativeMarkdownTextStyle; | ||
| 61 | readonly onLinkPress?: (href: string) => void; | ||
| 62 | }) { | ||
| 63 | return ( | ||
| 64 | <NativeMarkdownSelectableText | ||
| 65 | runs={nativeMarkdownDocumentRuns(documentFor(props.node), props.skills)} | ||
| 66 | textStyle={props.textStyle} | ||
| 67 | onLinkPress={props.onLinkPress} | ||
| 68 | /> | ||
| 69 | ); | ||
| 70 | } | ||
| 71 | |||
| 72 | const HighlightedCodeLine = memo(function HighlightedCodeLine(props: { | ||
| 73 | readonly tokens: ReadonlyArray<MarkdownHighlightedToken>; | ||
| 74 | readonly color: string; | ||
| 75 | readonly newline: boolean; | ||
| 76 | }) { | ||
| 77 | let offset = 0; | ||
| 78 | const children = []; | ||
| 79 | for (const token of props.tokens) { | ||
| 80 | if (!token.content) continue; | ||
| 81 | children.push( | ||
| 82 | <MarkdownTextPrimitive | ||
| 83 | key={offset} | ||
| 84 | style={{ | ||
| 85 | color: token.color ?? props.color, | ||
| 86 | fontFamily: MONO_FONT_FAMILY, | ||
| 87 | fontStyle: token.fontStyle !== null && (token.fontStyle & 1) === 1 ? "italic" : "normal", | ||
| 88 | fontWeight: token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400", | ||
| 89 | }} | ||
| 90 | > | ||
| 91 | {token.content} | ||
| 92 | </MarkdownTextPrimitive>, | ||
| 93 | ); | ||
| 94 | offset += token.content.length; | ||
| 95 | } | ||
| 96 | return ( | ||
| 97 | <MarkdownTextPrimitive> | ||
| 98 | {children} | ||
| 99 | {props.newline ? "\n" : ""} | ||
| 100 | </MarkdownTextPrimitive> | ||
| 101 | ); | ||
| 102 | }); | ||
| 103 | |||
| 104 | function HighlightedCodeText(props: { | ||
| 105 | readonly content: string; | ||
| 106 | readonly highlighted: HighlightedCode | null; | ||
| 107 | readonly textStyle: NativeMarkdownTextStyle; | ||
| 108 | }) { | ||
| 109 | // The text root provides inherited styles through context. A new style object | ||
| 110 | // would rerender every token even when its completed line is unchanged. | ||
| 111 | const fontSize = codeBlockFontSize(props.textStyle); | ||
| 112 | const lineHeight = codeBlockLineHeight(props.textStyle); | ||
| 113 | const style = useMemo( | ||
| 114 | () => ({ | ||
| 115 | color: props.textStyle.codeColor, | ||
| 116 | fontFamily: MONO_FONT_FAMILY, | ||
| 117 | fontSize, | ||
| 118 | lineHeight, | ||
| 119 | }), | ||
| 120 | [props.textStyle.codeColor, fontSize, lineHeight], | ||
| 121 | ); | ||
| 122 | let offset = 0; | ||
| 123 | const lines = []; | ||
| 124 | if (props.highlighted) { | ||
| 125 | for (const tokens of props.highlighted) { | ||
| 126 | lines.push( | ||
| 127 | <HighlightedCodeLine | ||
| 128 | key={offset} | ||
| 129 | tokens={tokens} | ||
| 130 | color={props.textStyle.codeColor} | ||
| 131 | newline={lines.length + 1 < props.highlighted.length} | ||
| 132 | />, | ||
| 133 | ); | ||
| 134 | offset += tokens.reduce((length, token) => length + token.content.length, 0) + 1; | ||
| 135 | } | ||
| 136 | } | ||
| 137 | return ( | ||
| 138 | <MarkdownTextPrimitive | ||
| 139 | uiTextView | ||
| 140 | selectable | ||
| 141 | selectionColor={props.textStyle.selectionColor} | ||
| 142 | selectionHandleColor={props.textStyle.selectionHandleColor} | ||
| 143 | style={style} | ||
| 144 | > | ||
| 145 | {props.highlighted ? lines : props.content} | ||
| 146 | </MarkdownTextPrimitive> | ||
| 147 | ); | ||
| 148 | } | ||
| 149 | |||
| 150 | function NativeCodeBlock(props: { | ||
| 151 | readonly node: MarkdownNode; | ||
| 152 | readonly textStyle: NativeMarkdownTextStyle; | ||
| 153 | readonly highlightCode: MarkdownCodeHighlighter; | ||
| 154 | readonly compact?: boolean; | ||
| 155 | }) { | ||
| 156 | const content = nodeText(props.node).replace(/\n$/, ""); | ||
| 157 | const colorScheme = useColorScheme(); | ||
| 158 | const theme = colorScheme === "dark" ? "dark" : "light"; | ||
| 159 | const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode); | ||
| 160 | const languageLabel = props.node.language?.toUpperCase() ?? "CODE"; | ||
| 161 | return ( | ||
| 162 | <View | ||
| 163 | style={{ | ||
| 164 | backgroundColor: props.textStyle.codeBlockBackgroundColor, | ||
| 165 | borderColor: props.textStyle.dividerColor, | ||
| 166 | borderCurve: "continuous", | ||
| 167 | borderRadius: 10, | ||
| 168 | borderWidth: 1, | ||
| 169 | marginVertical: props.compact ? 7 : 0, | ||
| 170 | overflow: "hidden", | ||
| 171 | }} | ||
| 172 | > | ||
| 173 | <View | ||
| 174 | style={{ | ||
| 175 | minHeight: 42, | ||
| 176 | borderBottomColor: props.textStyle.dividerColor, | ||
| 177 | borderBottomWidth: 1, | ||
| 178 | paddingLeft: 14, | ||
| 179 | paddingRight: 6, | ||
| 180 | flexDirection: "row", | ||
| 181 | alignItems: "center", | ||
| 182 | justifyContent: "space-between", | ||
| 183 | }} | ||
| 184 | > | ||
| 185 | <MarkdownTextPrimitive | ||
| 186 | selectable | ||
| 187 | selectionColor={props.textStyle.selectionColor} | ||
| 188 | selectionHandleColor={props.textStyle.selectionHandleColor} | ||
| 189 | style={{ | ||
| 190 | flex: 1, | ||
| 191 | color: props.textStyle.mutedColor, | ||
| 192 | fontFamily: MONO_FONT_FAMILY, | ||
| 193 | fontSize: codeBlockFontSize(props.textStyle), | ||
| 194 | }} | ||
| 195 | > | ||
| 196 | {languageLabel} | ||
| 197 | </MarkdownTextPrimitive> | ||
| 198 | <CopyTextButton | ||
| 199 | accessibilityLabel={`Copy ${languageLabel.toLowerCase()} code`} | ||
| 200 | text={content} | ||
| 201 | tintColor={props.textStyle.mutedColor} | ||
| 202 | copiedTintColor={props.textStyle.linkColor} | ||
| 203 | backgroundColor={props.textStyle.codeBackgroundColor} | ||
| 204 | borderColor={props.textStyle.dividerColor} | ||
| 205 | buttonSize={34} | ||
| 206 | iconSize={14} | ||
| 207 | /> | ||
| 208 | </View> | ||
| 209 | <ScrollView | ||
| 210 | horizontal | ||
| 211 | bounces={false} | ||
| 212 | nestedScrollEnabled={Platform.OS === "android"} | ||
| 213 | showsHorizontalScrollIndicator={false} | ||
| 214 | contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 12 }} | ||
| 215 | > | ||
| 216 | <HighlightedCodeText | ||
| 217 | content={content} | ||
| 218 | highlighted={highlighted} | ||
| 219 | textStyle={props.textStyle} | ||
| 220 | /> | ||
| 221 | </ScrollView> | ||
| 222 | </View> | ||
| 223 | ); | ||
| 224 | } | ||
| 225 | |||
| 226 | function collectTableRows(node: MarkdownNode): MarkdownNode[] { | ||
| 227 | const rows: MarkdownNode[] = []; | ||
| 228 | const visit = (child: MarkdownNode) => { | ||
| 229 | if (child.type === "table_row") { | ||
| 230 | rows.push(child); | ||
| 231 | return; | ||
| 232 | } | ||
| 233 | for (const nested of child.children ?? []) { | ||
| 234 | visit(nested); | ||
| 235 | } | ||
| 236 | }; | ||
| 237 | visit(node); | ||
| 238 | return rows; | ||
| 239 | } | ||
| 240 | |||
| 241 | function NativeTable(props: { | ||
| 242 | readonly node: MarkdownNode; | ||
| 243 | readonly skills: ReadonlyArray<SelectableMarkdownSkill>; | ||
| 244 | readonly textStyle: NativeMarkdownTextStyle; | ||
| 245 | readonly onLinkPress?: (href: string) => void; | ||
| 246 | }) { | ||
| 247 | const rows = collectTableRows(props.node); | ||
| 248 | return ( | ||
| 249 | <ScrollView | ||
| 250 | horizontal | ||
| 251 | bounces={false} | ||
| 252 | nestedScrollEnabled={Platform.OS === "android"} | ||
| 253 | showsHorizontalScrollIndicator={false} | ||
| 254 | > | ||
| 255 | <View | ||
| 256 | style={{ | ||
| 257 | borderColor: props.textStyle.dividerColor, | ||
| 258 | borderCurve: "continuous", | ||
| 259 | borderRadius: 8, | ||
| 260 | borderWidth: 1, | ||
| 261 | overflow: "hidden", | ||
| 262 | }} | ||
| 263 | > | ||
| 264 | {rows.map((row, rowIndex) => ( | ||
| 265 | <View | ||
| 266 | key={nodeKey(row, rowIndex)} | ||
| 267 | style={{ | ||
| 268 | flexDirection: "row", | ||
| 269 | backgroundColor: rowIndex === 0 ? props.textStyle.codeBackgroundColor : "transparent", | ||
| 270 | borderTopColor: props.textStyle.dividerColor, | ||
| 271 | borderTopWidth: rowIndex === 0 ? 0 : 1, | ||
| 272 | }} | ||
| 273 | > | ||
| 274 | {(row.children ?? []).map((cell, cellIndex) => ( | ||
| 275 | <View | ||
| 276 | key={nodeKey(cell, cellIndex)} | ||
| 277 | style={{ | ||
| 278 | width: 160, | ||
| 279 | borderLeftColor: props.textStyle.dividerColor, | ||
| 280 | borderLeftWidth: cellIndex === 0 ? 0 : 1, | ||
| 281 | paddingHorizontal: 10, | ||
| 282 | paddingVertical: 8, | ||
| 283 | }} | ||
| 284 | > | ||
| 285 | <NativeMarkdownSelectableText | ||
| 286 | runs={nativeMarkdownDocumentRuns(documentFor(cell), props.skills).map((run) => | ||
| 287 | rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, | ||
| 288 | )} | ||
| 289 | textStyle={props.textStyle} | ||
| 290 | onLinkPress={props.onLinkPress} | ||
| 291 | /> | ||
| 292 | </View> | ||
| 293 | ))} | ||
| 294 | </View> | ||
| 295 | ))} | ||
| 296 | </View> | ||
| 297 | </ScrollView> | ||
| 298 | ); | ||
| 299 | } | ||
| 300 | |||
| 301 | function NativeMarkdownImage(props: { | ||
| 302 | readonly node: MarkdownNode; | ||
| 303 | readonly skills: ReadonlyArray<SelectableMarkdownSkill>; | ||
| 304 | readonly textStyle: NativeMarkdownTextStyle; | ||
| 305 | readonly onLinkPress?: (href: string) => void; | ||
| 306 | }) { | ||
| 307 | const renderImage = useContext(MarkdownImageRendererContext); | ||
| 308 | const href = props.node.href; | ||
| 309 | if (!href) { | ||
| 310 | return ( | ||
| 311 | <SelectableNode | ||
| 312 | node={props.node} | ||
| 313 | skills={props.skills} | ||
| 314 | textStyle={props.textStyle} | ||
| 315 | onLinkPress={props.onLinkPress} | ||
| 316 | /> | ||
| 317 | ); | ||
| 318 | } | ||
| 319 | |||
| 320 | if (renderImage) { | ||
| 321 | const rendered = renderImage({ | ||
| 322 | href, | ||
| 323 | alt: props.node.alt ?? null, | ||
| 324 | title: props.node.title ?? null, | ||
| 325 | }); | ||
| 326 | if (rendered != null) { | ||
| 327 | return <>{rendered}</>; | ||
| 328 | } | ||
| 329 | } | ||
| 330 | |||
| 331 | return ( | ||
| 332 | <View style={{ gap: 6 }}> | ||
| 333 | <Image | ||
| 334 | source={{ uri: href }} | ||
| 335 | resizeMode="contain" | ||
| 336 | accessibilityLabel={props.node.alt ?? props.node.title} | ||
| 337 | style={{ | ||
| 338 | width: "100%", | ||
| 339 | aspectRatio: 16 / 9, | ||
| 340 | backgroundColor: props.textStyle.codeBackgroundColor, | ||
| 341 | borderRadius: 10, | ||
| 342 | }} | ||
| 343 | /> | ||
| 344 | {props.node.alt ? ( | ||
| 345 | <MarkdownTextPrimitive | ||
| 346 | selectable | ||
| 347 | selectionColor={props.textStyle.selectionColor} | ||
| 348 | selectionHandleColor={props.textStyle.selectionHandleColor} | ||
| 349 | style={{ | ||
| 350 | color: props.textStyle.mutedColor, | ||
| 351 | fontFamily: props.textStyle.fontFamily, | ||
| 352 | fontSize: 12, | ||
| 353 | lineHeight: 16, | ||
| 354 | }} | ||
| 355 | > | ||
| 356 | {props.node.alt} | ||
| 357 | </MarkdownTextPrimitive> | ||
| 358 | ) : null} | ||
| 359 | </View> | ||
| 360 | ); | ||
| 361 | } | ||
| 362 | |||
| 363 | function inlineGroups(nodes: ReadonlyArray<MarkdownNode>): MarkdownNode[] { | ||
| 364 | const groups: MarkdownNode[] = []; | ||
| 365 | let inline: MarkdownNode[] = []; | ||
| 366 | const flush = () => { | ||
| 367 | if (inline.length === 0) { | ||
| 368 | return; | ||
| 369 | } | ||
| 370 | groups.push({ type: "paragraph", children: inline }); | ||
| 371 | inline = []; | ||
| 372 | }; | ||
| 373 | |||
| 374 | for (const node of nodes) { | ||
| 375 | if (node.type === "image") { | ||
| 376 | flush(); | ||
| 377 | groups.push(node); | ||
| 378 | } else { | ||
| 379 | inline.push(node); | ||
| 380 | } | ||
| 381 | } | ||
| 382 | flush(); | ||
| 383 | return groups; | ||
| 384 | } | ||
| 385 | |||
| 386 | function NativeMixedParagraph(props: { | ||
| 387 | readonly node: MarkdownNode; | ||
| 388 | readonly skills: ReadonlyArray<SelectableMarkdownSkill>; | ||
| 389 | readonly textStyle: NativeMarkdownTextStyle; | ||
| 390 | readonly onLinkPress?: (href: string) => void; | ||
| 391 | }) { | ||
| 392 | return ( | ||
| 393 | <View style={{ gap: 8 }}> | ||
| 394 | {inlineGroups(props.node.children ?? []).map((child, index) => | ||
| 395 | child.type === "image" ? ( | ||
| 396 | <NativeMarkdownImage | ||
| 397 | key={nodeKey(child, index)} | ||
| 398 | node={child} | ||
| 399 | skills={props.skills} | ||
| 400 | textStyle={props.textStyle} | ||
| 401 | onLinkPress={props.onLinkPress} | ||
| 402 | /> | ||
| 403 | ) : ( | ||
| 404 | <SelectableNode | ||
| 405 | key={nodeKey(child, index)} | ||
| 406 | node={child} | ||
| 407 | skills={props.skills} | ||
| 408 | textStyle={props.textStyle} | ||
| 409 | onLinkPress={props.onLinkPress} | ||
| 410 | /> | ||
| 411 | ), | ||
| 412 | )} | ||
| 413 | </View> | ||
| 414 | ); | ||
| 415 | } | ||
| 416 | |||
| 417 | function NativeList(props: { | ||
| 418 | readonly node: MarkdownNode; | ||
| 419 | readonly skills: ReadonlyArray<SelectableMarkdownSkill>; | ||
| 420 | readonly textStyle: NativeMarkdownTextStyle; | ||
| 421 | readonly highlightCode: MarkdownCodeHighlighter; | ||
| 422 | readonly onLinkPress?: (href: string) => void; | ||
| 423 | readonly depth: number; | ||
| 424 | }) { | ||
| 425 | const ordered = props.node.ordered ?? false; | ||
| 426 | const start = props.node.start ?? 1; | ||
| 427 | const nested = props.depth > 0; | ||
| 428 | return ( | ||
| 429 | <View | ||
| 430 | style={{ | ||
| 431 | gap: nested ? 3 : 5, | ||
| 432 | }} | ||
| 433 | > | ||
| 434 | {(props.node.children ?? []).map((item, index) => { | ||
| 435 | const taskMarker = item.type === "task_list_item"; | ||
| 436 | const marker = taskMarker | ||
| 437 | ? item.checked | ||
| 438 | ? "☑︎" | ||
| 439 | : "☐︎" | ||
| 440 | : ordered | ||
| 441 | ? `${start + index}.` | ||
| 442 | : props.depth % 3 === 1 | ||
| 443 | ? "◦" | ||
| 444 | : props.depth % 3 === 2 | ||
| 445 | ? "▪︎" | ||
| 446 | : "•"; | ||
| 447 | const markerWidth = ordered ? 28 : taskMarker ? 20 : 18; | ||
| 448 | const markerOffset = taskMarker ? 3 : ordered ? 0 : 2; | ||
| 449 | return ( | ||
| 450 | <View | ||
| 451 | key={nodeKey(item, index)} | ||
| 452 | style={{ alignItems: "flex-start", flexDirection: "row" }} | ||
| 453 | > | ||
| 454 | <View | ||
| 455 | style={{ | ||
| 456 | width: markerWidth, | ||
| 457 | height: props.textStyle.lineHeight, | ||
| 458 | marginRight: 6, | ||
| 459 | alignItems: ordered ? "flex-end" : "center", | ||
| 460 | justifyContent: "flex-start", | ||
| 461 | }} | ||
| 462 | > | ||
| 463 | <Text | ||
| 464 | style={{ | ||
| 465 | color: props.textStyle.color, | ||
| 466 | fontFamily: props.textStyle.fontFamily, | ||
| 467 | fontSize: taskMarker ? 14 : props.textStyle.fontSize, | ||
| 468 | lineHeight: props.textStyle.lineHeight, | ||
| 469 | fontVariant: ordered ? ["tabular-nums"] : undefined, | ||
| 470 | transform: [{ translateY: markerOffset }], | ||
| 471 | }} | ||
| 472 | > | ||
| 473 | {marker} | ||
| 474 | </Text> | ||
| 475 | </View> | ||
| 476 | <View style={{ flex: 1, minWidth: 0, gap: 2 }}> | ||
| 477 | {nativeMarkdownListItemBlocks(item).map((child, childIndex) => ( | ||
| 478 | <NativeMarkdownBlock | ||
| 479 | key={nodeKey(child, childIndex)} | ||
| 480 | node={child} | ||
| 481 | skills={props.skills} | ||
| 482 | textStyle={props.textStyle} | ||
| 483 | highlightCode={props.highlightCode} | ||
| 484 | onLinkPress={props.onLinkPress} | ||
| 485 | depth={props.depth + 1} | ||
| 486 | compact | ||
| 487 | /> | ||
| 488 | ))} | ||
| 489 | </View> | ||
| 490 | </View> | ||
| 491 | ); | ||
| 492 | })} | ||
| 493 | </View> | ||
| 494 | ); | ||
| 495 | } | ||
| 496 | |||
| 497 | export function NativeMarkdownBlock(props: { | ||
| 498 | readonly node: MarkdownNode; | ||
| 499 | readonly skills: ReadonlyArray<SelectableMarkdownSkill>; | ||
| 500 | readonly textStyle: NativeMarkdownTextStyle; | ||
| 501 | readonly highlightCode: MarkdownCodeHighlighter; | ||
| 502 | readonly onLinkPress?: (href: string) => void; | ||
| 503 | readonly depth?: number; | ||
| 504 | readonly compact?: boolean; | ||
| 505 | }) { | ||
| 506 | const depth = props.depth ?? 0; | ||
| 507 | const dark = useColorScheme() === "dark"; | ||
| 508 | switch (props.node.type) { | ||
| 509 | case "document": | ||
| 510 | return ( | ||
| 511 | <View style={{ gap: 8 }}> | ||
| 512 | {(props.node.children ?? []).map((child, index) => ( | ||
| 513 | <NativeMarkdownBlock | ||
| 514 | key={nodeKey(child, index)} | ||
| 515 | node={child} | ||
| 516 | skills={props.skills} | ||
| 517 | textStyle={props.textStyle} | ||
| 518 | highlightCode={props.highlightCode} | ||
| 519 | onLinkPress={props.onLinkPress} | ||
| 520 | depth={depth} | ||
| 521 | /> | ||
| 522 | ))} | ||
| 523 | </View> | ||
| 524 | ); | ||
| 525 | case "code_block": | ||
| 526 | return ( | ||
| 527 | <NativeCodeBlock | ||
| 528 | node={props.node} | ||
| 529 | textStyle={props.textStyle} | ||
| 530 | highlightCode={props.highlightCode} | ||
| 531 | compact={props.compact} | ||
| 532 | /> | ||
| 533 | ); | ||
| 534 | case "table": | ||
| 535 | return ( | ||
| 536 | <NativeTable | ||
| 537 | node={props.node} | ||
| 538 | skills={props.skills} | ||
| 539 | textStyle={props.textStyle} | ||
| 540 | onLinkPress={props.onLinkPress} | ||
| 541 | /> | ||
| 542 | ); | ||
| 543 | case "image": | ||
| 544 | return ( | ||
| 545 | <NativeMarkdownImage | ||
| 546 | node={props.node} | ||
| 547 | skills={props.skills} | ||
| 548 | textStyle={props.textStyle} | ||
| 549 | onLinkPress={props.onLinkPress} | ||
| 550 | /> | ||
| 551 | ); | ||
| 552 | case "horizontal_rule": | ||
| 553 | return ( | ||
| 554 | <View | ||
| 555 | style={{ | ||
| 556 | height: 1, | ||
| 557 | backgroundColor: props.textStyle.dividerColor, | ||
| 558 | }} | ||
| 559 | /> | ||
| 560 | ); | ||
| 561 | case "blockquote": { | ||
| 562 | const alert = githubAlert(props.node); | ||
| 563 | const alertColor = dark ? alert?.color : alert?.lightColor; | ||
| 564 | return ( | ||
| 565 | <View | ||
| 566 | style={{ | ||
| 567 | borderLeftColor: alertColor ?? props.textStyle.quoteMarkerColor, | ||
| 568 | borderLeftWidth: 2, | ||
| 569 | marginVertical: props.compact ? 4 : 0, | ||
| 570 | paddingLeft: 11, | ||
| 571 | paddingVertical: 2, | ||
| 572 | gap: 6, | ||
| 573 | }} | ||
| 574 | > | ||
| 575 | {alert ? ( | ||
| 576 | <View style={{ flexDirection: "row", alignItems: "center", gap: 7 }}> | ||
| 577 | {Platform.OS === "ios" ? ( | ||
| 578 | <SymbolView name={alert.symbol} size={17} tintColor={alertColor} /> | ||
| 579 | ) : null} | ||
| 580 | <Text | ||
| 581 | style={{ | ||
| 582 | color: alertColor, | ||
| 583 | fontFamily: props.textStyle.boldFontFamily, | ||
| 584 | fontSize: props.textStyle.fontSize, | ||
| 585 | }} | ||
| 586 | > | ||
| 587 | {alert.title} | ||
| 588 | </Text> | ||
| 589 | </View> | ||
| 590 | ) : null} | ||
| 591 | {(alert?.children ?? props.node.children ?? []).map((child, index) => ( | ||
| 592 | <NativeMarkdownBlock | ||
| 593 | key={nodeKey(child, index)} | ||
| 594 | node={child} | ||
| 595 | skills={props.skills} | ||
| 596 | textStyle={props.textStyle} | ||
| 597 | highlightCode={props.highlightCode} | ||
| 598 | onLinkPress={props.onLinkPress} | ||
| 599 | depth={depth} | ||
| 600 | compact | ||
| 601 | /> | ||
| 602 | ))} | ||
| 603 | </View> | ||
| 604 | ); | ||
| 605 | } | ||
| 606 | case "list": | ||
| 607 | return ( | ||
| 608 | <NativeList | ||
| 609 | node={props.node} | ||
| 610 | skills={props.skills} | ||
| 611 | textStyle={props.textStyle} | ||
| 612 | highlightCode={props.highlightCode} | ||
| 613 | onLinkPress={props.onLinkPress} | ||
| 614 | depth={depth} | ||
| 615 | /> | ||
| 616 | ); | ||
| 617 | case "paragraph": | ||
| 618 | return (props.node.children ?? []).some((child) => child.type === "image") ? ( | ||
| 619 | <NativeMixedParagraph | ||
| 620 | node={props.node} | ||
| 621 | skills={props.skills} | ||
| 622 | textStyle={props.textStyle} | ||
| 623 | onLinkPress={props.onLinkPress} | ||
| 624 | /> | ||
| 625 | ) : ( | ||
| 626 | <SelectableNode | ||
| 627 | node={props.node} | ||
| 628 | skills={props.skills} | ||
| 629 | textStyle={props.textStyle} | ||
| 630 | onLinkPress={props.onLinkPress} | ||
| 631 | /> | ||
| 632 | ); | ||
| 633 | case "html_block": | ||
| 634 | case "math_block": | ||
| 635 | return ( | ||
| 636 | <View | ||
| 637 | style={{ | ||
| 638 | marginVertical: props.compact ? 2 : 0, | ||
| 639 | paddingHorizontal: props.node.type === "math_block" ? 10 : 0, | ||
| 640 | paddingVertical: props.node.type === "math_block" ? 8 : 0, | ||
| 641 | backgroundColor: | ||
| 642 | props.node.type === "math_block" | ||
| 643 | ? props.textStyle.codeBackgroundColor | ||
| 644 | : "transparent", | ||
| 645 | }} | ||
| 646 | > | ||
| 647 | <SelectableNode | ||
| 648 | node={props.node} | ||
| 649 | skills={props.skills} | ||
| 650 | textStyle={props.textStyle} | ||
| 651 | onLinkPress={props.onLinkPress} | ||
| 652 | /> | ||
| 653 | </View> | ||
| 654 | ); | ||
| 655 | case "table_head": | ||
| 656 | case "table_body": | ||
| 657 | case "table_row": | ||
| 658 | case "table_cell": | ||
| 659 | case "list_item": | ||
| 660 | case "task_list_item": | ||
| 661 | return ( | ||
| 662 | <View style={{ gap: 4 }}> | ||
| 663 | {(props.node.children ?? []).map((child, index) => ( | ||
| 664 | <NativeMarkdownBlock | ||
| 665 | key={nodeKey(child, index)} | ||
| 666 | node={child} | ||
| 667 | skills={props.skills} | ||
| 668 | textStyle={props.textStyle} | ||
| 669 | highlightCode={props.highlightCode} | ||
| 670 | onLinkPress={props.onLinkPress} | ||
| 671 | depth={depth} | ||
| 672 | compact | ||
| 673 | /> | ||
| 674 | ))} | ||
| 675 | </View> | ||
| 676 | ); | ||
| 677 | default: | ||
| 678 | return ( | ||
| 679 | <SelectableNode | ||
| 680 | node={props.node} | ||
| 681 | skills={props.skills} | ||
| 682 | textStyle={props.textStyle} | ||
| 683 | onLinkPress={props.onLinkPress} | ||
| 684 | /> | ||
| 685 | ); | ||
| 686 | } | ||
| 687 | } | ||
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx deleted-446| ... | @@ -1,446 +0,0 @@ | ||
| 1 | import { createContext, useCallback, useContext, useMemo } from "react"; | ||
| 2 | import { decodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; | ||
| 3 | import { | ||
| 4 | findNodeHandle, | ||
| 5 | Image, | ||
| 6 | Linking, | ||
| 7 | Platform, | ||
| 8 | StyleSheet, | ||
| 9 | type TextInstance, | ||
| 10 | type TextStyle, | ||
| 11 | useColorScheme, | ||
| 12 | View, | ||
| 13 | } from "react-native"; | ||
| 14 | |||
| 15 | import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; | ||
| 16 | import { markdownFileIconSource, markdownIconAssetUri } from "./markdownFileIcons"; | ||
| 17 | import { markdownLinkIconSource } from "./markdownLinkIcons"; | ||
| 18 | import { resolveMarkdownFileIcon, resolveMarkdownLinkIcon } from "./markdownLinks"; | ||
| 19 | import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; | ||
| 20 | import { nativeMarkdownContextCopyRanges } from "./nativeMarkdownText"; | ||
| 21 | import type { | ||
| 22 | MarkdownFileContextMenu, | ||
| 23 | NativeMarkdownTextStyle, | ||
| 24 | } from "./SelectableMarkdownText.types"; | ||
| 25 | import { | ||
| 26 | installMarkdownCopySanitizer, | ||
| 27 | renderAndroidContextChip, | ||
| 28 | } from "./T3MarkdownTextSelectionModule"; | ||
| 29 | import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; | ||
| 30 | import { contextChipPresentation } from "./nativeMarkdownText"; | ||
| 31 | |||
| 32 | export const MarkdownContextClipboardContext = createContext(""); | ||
| 33 | |||
| 34 | export interface MarkdownFileContextMenuHandlers { | ||
| 35 | readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; | ||
| 36 | readonly onFileContextMenuAction: (href: string, actionId: string) => void; | ||
| 37 | } | ||
| 38 | |||
| 39 | /** Set by SelectableMarkdownText so file chips anywhere in the block tree get the same menu. */ | ||
| 40 | export const MarkdownFileContextMenuContext = createContext<MarkdownFileContextMenuHandlers | null>( | ||
| 41 | null, | ||
| 42 | ); | ||
| 43 | |||
| 44 | const EXTERNAL_LINK_PREFIX = "◉ "; | ||
| 45 | const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; | ||
| 46 | const SKILL_ICON_PLACEHOLDER = "\uFFFC"; | ||
| 47 | // React Native turns a run whose whole text is U+FFFC into a bare inline-view attachment | ||
| 48 | // with no font or paragraph style, so a chip opening a paragraph would drop its line | ||
| 49 | // height. Any other single character keeps the run's attributes; the native side swaps it | ||
| 50 | // for the chip attachment either way. | ||
| 51 | const IOS_CHIP_PLACEHOLDER = "\u200B"; | ||
| 52 | const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; | ||
| 53 | const MONO_FONT_FAMILY = Platform.select({ | ||
| 54 | ios: "ui-monospace", | ||
| 55 | android: "monospace", | ||
| 56 | default: "monospace", | ||
| 57 | }); | ||
| 58 | const styles = StyleSheet.create({ | ||
| 59 | inlineIcon: { | ||
| 60 | width: 14, | ||
| 61 | height: 14, | ||
| 62 | marginHorizontal: 3, | ||
| 63 | transform: [{ translateY: 2 }], | ||
| 64 | }, | ||
| 65 | }); | ||
| 66 | |||
| 67 | function runKeySignature(run: NativeMarkdownTextRun): string { | ||
| 68 | return [ | ||
| 69 | run.text, | ||
| 70 | run.bold, | ||
| 71 | run.italic, | ||
| 72 | run.strikethrough, | ||
| 73 | run.code, | ||
| 74 | run.href, | ||
| 75 | run.externalHost, | ||
| 76 | run.fileIcon, | ||
| 77 | run.skillName, | ||
| 78 | run.skillLabel, | ||
| 79 | run.sourceText, | ||
| 80 | run.role, | ||
| 81 | run.headingLevel, | ||
| 82 | run.depth, | ||
| 83 | run.spacing, | ||
| 84 | run.firstLineHeadIndent, | ||
| 85 | run.headIndent, | ||
| 86 | run.paragraphSpacing, | ||
| 87 | ].join(":"); | ||
| 88 | } | ||
| 89 | |||
| 90 | const DEFAULT_BODY_FONT_SIZE = 15; | ||
| 91 | const DEFAULT_HEADING_FONT_SIZES = [22, 19, 17, 16, 15, 15] as const; | ||
| 92 | |||
| 93 | function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel: number): number { | ||
| 94 | const index = Math.max(0, Math.min(5, headingLevel - 1)); | ||
| 95 | const configured = textStyle.headingFontSizes?.[index]; | ||
| 96 | if (typeof configured === "number" && Number.isFinite(configured)) { | ||
| 97 | return configured; | ||
| 98 | } | ||
| 99 | |||
| 100 | const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE; | ||
| 101 | return Math.max(12, Math.round((DEFAULT_HEADING_FONT_SIZES[index] ?? 15) * scale)); | ||
| 102 | } | ||
| 103 | |||
| 104 | function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { | ||
| 105 | const isFile = run.fileIcon != null; | ||
| 106 | const isSkill = run.skillName != null; | ||
| 107 | const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1)); | ||
| 108 | const headingFontSize = resolveHeadingFontSize(textStyle, headingLevel); | ||
| 109 | const isHeading = run.role === "heading"; | ||
| 110 | const isCodeBlock = run.role === "code-block" || run.role === "code-language"; | ||
| 111 | const hasParagraphStyle = run.headIndent !== undefined; | ||
| 112 | const textDecorationLine = run.strikethrough | ||
| 113 | ? "line-through" | ||
| 114 | : run.href && !isFile | ||
| 115 | ? "underline" | ||
| 116 | : "none"; | ||
| 117 | |||
| 118 | return { | ||
| 119 | color: isFile | ||
| 120 | ? textStyle.fileTextColor | ||
| 121 | : isSkill | ||
| 122 | ? textStyle.skillTextColor | ||
| 123 | : run.href | ||
| 124 | ? textStyle.linkColor | ||
| 125 | : isHeading | ||
| 126 | ? textStyle.strongColor | ||
| 127 | : run.role === "quote-marker" | ||
| 128 | ? textStyle.quoteMarkerColor | ||
| 129 | : run.role === "divider" | ||
| 130 | ? textStyle.dividerColor | ||
| 131 | : run.role === "code-language" | ||
| 132 | ? textStyle.mutedColor | ||
| 133 | : run.role === "list-marker" | ||
| 134 | ? textStyle.mutedColor | ||
| 135 | : isCodeBlock | ||
| 136 | ? textStyle.codeColor | ||
| 137 | : run.code | ||
| 138 | ? textStyle.inlineCodeColor | ||
| 139 | : run.bold | ||
| 140 | ? textStyle.strongColor | ||
| 141 | : textStyle.color, | ||
| 142 | fontFamily: | ||
| 143 | isFile || isSkill | ||
| 144 | ? textStyle.boldFontFamily | ||
| 145 | : run.code || isCodeBlock | ||
| 146 | ? MONO_FONT_FAMILY | ||
| 147 | : isHeading | ||
| 148 | ? textStyle.headingFontFamily | ||
| 149 | : run.bold | ||
| 150 | ? textStyle.boldFontFamily | ||
| 151 | : textStyle.fontFamily, | ||
| 152 | fontSize: | ||
| 153 | run.role === "spacer" | ||
| 154 | ? (run.spacing ?? 10) | ||
| 155 | : run.role === "list-break" | ||
| 156 | ? textStyle.fontSize | ||
| 157 | : isHeading | ||
| 158 | ? headingFontSize | ||
| 159 | : run.role === "code-language" | ||
| 160 | ? Math.max(10, Math.round(textStyle.fontSize * 0.73)) | ||
| 161 | : run.code || isCodeBlock | ||
| 162 | ? Math.max(12, textStyle.fontSize - 2) | ||
| 163 | : textStyle.fontSize, | ||
| 164 | lineHeight: | ||
| 165 | run.role === "spacer" | ||
| 166 | ? (run.spacing ?? 10) | ||
| 167 | : run.role === "list-break" | ||
| 168 | ? textStyle.lineHeight + (run.spacing ?? 0) | ||
| 169 | : isHeading | ||
| 170 | ? Math.max(headingFontSize + 6, textStyle.lineHeight + 2) | ||
| 171 | : isCodeBlock | ||
| 172 | ? Math.max(16, textStyle.lineHeight - 2) | ||
| 173 | : textStyle.lineHeight, | ||
| 174 | fontStyle: run.italic ? "italic" : "normal", | ||
| 175 | fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400", | ||
| 176 | textDecorationLine, | ||
| 177 | backgroundColor: isCodeBlock | ||
| 178 | ? textStyle.codeBlockBackgroundColor | ||
| 179 | : parseComposerContextHref(run.href ?? "") | ||
| 180 | ? textStyle.codeBackgroundColor | ||
| 181 | : undefined, | ||
| 182 | ...(hasParagraphStyle | ||
| 183 | ? { | ||
| 184 | shadowColor: "transparent", | ||
| 185 | shadowOffset: { | ||
| 186 | width: run.firstLineHeadIndent ?? 0, | ||
| 187 | height: run.headIndent, | ||
| 188 | }, | ||
| 189 | shadowRadius: PARAGRAPH_STYLE_ENCODING_OFFSET + (run.paragraphSpacing ?? 0), | ||
| 190 | } | ||
| 191 | : {}), | ||
| 192 | }; | ||
| 193 | } | ||
| 194 | |||
| 195 | export function NativeMarkdownSelectableText(props: { | ||
| 196 | readonly runs: ReadonlyArray<NativeMarkdownTextRun>; | ||
| 197 | readonly textStyle: NativeMarkdownTextStyle; | ||
| 198 | readonly onLinkPress?: (href: string) => void; | ||
| 199 | }) { | ||
| 200 | const colorScheme = useColorScheme(); | ||
| 201 | const menu = useContext(MarkdownFileContextMenuContext); | ||
| 202 | const contextClipboardFragment = useContext(MarkdownContextClipboardContext); | ||
| 203 | const contextRecords = useMemo( | ||
| 204 | () => decodeComposerContextFragment(contextClipboardFragment)?.records ?? [], | ||
| 205 | [contextClipboardFragment], | ||
| 206 | ); | ||
| 207 | const containsInlineIcon = props.runs.some( | ||
| 208 | (run) => | ||
| 209 | run.fileIcon != null || | ||
| 210 | run.skillName != null || | ||
| 211 | parseComposerContextHref(run.href ?? "") !== null || | ||
| 212 | (run.externalHost != null && resolveMarkdownLinkIcon(run.externalHost) !== null), | ||
| 213 | ); | ||
| 214 | const keyedRuns = useMemo(() => { | ||
| 215 | const occurrences = new Map<string, number>(); | ||
| 216 | const prefixedExternalLinks = new Set<string>(); | ||
| 217 | return props.runs.map((run) => { | ||
| 218 | const signature = runKeySignature(run); | ||
| 219 | const occurrence = occurrences.get(signature) ?? 0; | ||
| 220 | occurrences.set(signature, occurrence + 1); | ||
| 221 | |||
| 222 | let text = run.text; | ||
| 223 | let linkIcon = null; | ||
| 224 | const contextReference = parseComposerContextHref(run.href ?? ""); | ||
| 225 | const contextRecord = contextReference | ||
| 226 | ? contextRecords.find((record) => record.contextId === contextReference.contextId) | ||
| 227 | : undefined; | ||
| 228 | const chip = | ||
| 229 | contextReference || run.skillName || run.fileIcon | ||
| 230 | ? { | ||
| 231 | ...contextChipPresentation( | ||
| 232 | contextReference?.kind ?? (run.skillName ? "skill" : "mention"), | ||
| 233 | contextRecord, | ||
| 234 | ), | ||
| 235 | label: run.skillLabel ?? run.text, | ||
| 236 | interactive: Boolean(run.href), | ||
| 237 | iconUri: | ||
| 238 | !contextReference && run.fileIcon | ||
| 239 | ? markdownIconAssetUri(markdownFileIconSource(run.fileIcon)) | ||
| 240 | : contextRecord?.kind === "mention" && "path" in contextRecord | ||
| 241 | ? markdownIconAssetUri( | ||
| 242 | markdownFileIconSource(resolveMarkdownFileIcon(contextRecord.path)), | ||
| 243 | ) | ||
| 244 | : undefined, | ||
| 245 | fontSize: props.textStyle.fontSize * 0.8, | ||
| 246 | foreground: props.textStyle.color, | ||
| 247 | border: props.textStyle.contextChipBorderColor ?? props.textStyle.dividerColor, | ||
| 248 | } | ||
| 249 | : null; | ||
| 250 | // Android sizes the chip's inline box from the paragraph font so the line box stays | ||
| 251 | // the height of a plain text line; see renderContextChip. | ||
| 252 | const androidChip = | ||
| 253 | Platform.OS === "android" && chip | ||
| 254 | ? renderAndroidContextChip( | ||
| 255 | JSON.stringify({ | ||
| 256 | ...chip, | ||
| 257 | text: { | ||
| 258 | fontFamily: props.textStyle.fontFamily, | ||
| 259 | fontSize: props.textStyle.fontSize, | ||
| 260 | }, | ||
| 261 | }), | ||
| 262 | ) | ||
| 263 | : null; | ||
| 264 | if (androidChip) { | ||
| 265 | text = ""; | ||
| 266 | } else if (chip && Platform.OS === "ios") { | ||
| 267 | text = IOS_CHIP_PLACEHOLDER; | ||
| 268 | } else if (run.fileIcon && Platform.OS === "ios") { | ||
| 269 | text = `${INLINE_ATTACHMENT_PREFIX}${text}`; | ||
| 270 | } else if (run.skillName && run.skillLabel) { | ||
| 271 | text = | ||
| 272 | Platform.OS === "ios" | ||
| 273 | ? `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}` | ||
| 274 | : `$${run.skillName}`; | ||
| 275 | } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { | ||
| 276 | prefixedExternalLinks.add(run.href); | ||
| 277 | linkIcon = resolveMarkdownLinkIcon(run.externalHost); | ||
| 278 | if (linkIcon === null) { | ||
| 279 | text = `${EXTERNAL_LINK_PREFIX}${text}`; | ||
| 280 | } else if (Platform.OS === "ios") { | ||
| 281 | text = `${INLINE_ATTACHMENT_PREFIX}${text}`; | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 285 | return { key: `${signature}:${occurrence}`, run, text, linkIcon, chip, androidChip }; | ||
| 286 | }); | ||
| 287 | }, [props.runs, props.textStyle, contextRecords]); | ||
| 288 | const ranges = nativeMarkdownContextCopyRanges( | ||
| 289 | keyedRuns.map(({ run, text, linkIcon, androidChip }) => ({ | ||
| 290 | run, | ||
| 291 | text, | ||
| 292 | inlineImageLength: | ||
| 293 | Platform.OS === "android" && (androidChip || run.fileIcon || linkIcon) ? 1 : 0, | ||
| 294 | })), | ||
| 295 | ); | ||
| 296 | const contextClipboardConfig = ranges.length | ||
| 297 | ? JSON.stringify({ fragment: contextClipboardFragment, ranges }) | ||
| 298 | : ""; | ||
| 299 | const attachAndroidText = useCallback( | ||
| 300 | (textView: TextInstance | null) => { | ||
| 301 | if (Platform.OS !== "android" || !containsInlineIcon || !textView) return; | ||
| 302 | const reactTag = findNodeHandle(textView); | ||
| 303 | if (typeof reactTag === "number") { | ||
| 304 | installMarkdownCopySanitizer(reactTag, contextClipboardConfig); | ||
| 305 | } | ||
| 306 | }, | ||
| 307 | [containsInlineIcon, contextClipboardConfig], | ||
| 308 | ); | ||
| 309 | // T3MarkdownText only rebuilds its attributed string during native layout. A | ||
| 310 | // color-only child update can otherwise leave the previous appearance cached. | ||
| 311 | const appearanceKey = [ | ||
| 312 | colorScheme ?? "unspecified", | ||
| 313 | props.textStyle.fontSize, | ||
| 314 | props.textStyle.lineHeight, | ||
| 315 | props.textStyle.headingFontSizes?.join(","), | ||
| 316 | props.textStyle.color, | ||
| 317 | props.textStyle.strongColor, | ||
| 318 | props.textStyle.mutedColor, | ||
| 319 | props.textStyle.linkColor, | ||
| 320 | props.textStyle.inlineCodeColor, | ||
| 321 | props.textStyle.codeColor, | ||
| 322 | props.textStyle.codeBackgroundColor, | ||
| 323 | props.textStyle.codeBlockBackgroundColor, | ||
| 324 | props.textStyle.fileTextColor, | ||
| 325 | props.textStyle.skillTextColor, | ||
| 326 | props.textStyle.quoteMarkerColor, | ||
| 327 | props.textStyle.dividerColor, | ||
| 328 | props.textStyle.contextChipBorderColor, | ||
| 329 | ].join(":"); | ||
| 330 | |||
| 331 | return ( | ||
| 332 | <MarkdownTextPrimitive | ||
| 333 | key={appearanceKey} | ||
| 334 | nativeTextRef={attachAndroidText} | ||
| 335 | contextClipboardConfig={contextClipboardConfig} | ||
| 336 | accessibilityLabel={ | ||
| 337 | Platform.OS === "android" && containsInlineIcon | ||
| 338 | ? props.runs.map((run) => run.skillLabel ?? run.text).join("") | ||
| 339 | : undefined | ||
| 340 | } | ||
| 341 | uiTextView | ||
| 342 | selectable | ||
| 343 | selectionColor={props.textStyle.selectionColor} | ||
| 344 | selectionHandleColor={props.textStyle.selectionHandleColor} | ||
| 345 | style={{ | ||
| 346 | flexShrink: 1, | ||
| 347 | minWidth: 0, | ||
| 348 | color: props.textStyle.color, | ||
| 349 | fontFamily: props.textStyle.fontFamily, | ||
| 350 | fontSize: props.textStyle.fontSize, | ||
| 351 | lineHeight: props.textStyle.lineHeight, | ||
| 352 | }} | ||
| 353 | > | ||
| 354 | {keyedRuns.map(({ key, run, text, linkIcon, chip, androidChip }) => { | ||
| 355 | const href = run.href; | ||
| 356 | const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; | ||
| 357 | const onPress = href | ||
| 358 | ? () => { | ||
| 359 | if (props.onLinkPress) props.onLinkPress(href); | ||
| 360 | else void Linking.openURL(href); | ||
| 361 | } | ||
| 362 | : undefined; | ||
| 363 | const fileIconUri = run.fileIcon | ||
| 364 | ? markdownIconAssetUri(markdownFileIconSource(run.fileIcon)) | ||
| 365 | : undefined; | ||
| 366 | const linkIconUri = linkIcon | ||
| 367 | ? markdownIconAssetUri(markdownLinkIconSource(linkIcon)) | ||
| 368 | : undefined; | ||
| 369 | return ( | ||
| 370 | <MarkdownTextPrimitive | ||
| 371 | key={key} | ||
| 372 | accessibilityLabel={androidChip ? chip?.label : undefined} | ||
| 373 | nativeID={ | ||
| 374 | Platform.OS === "ios" | ||
| 375 | ? chip | ||
| 376 | ? `t3-chip:${JSON.stringify(chip)}` | ||
| 377 | : fileIconUri | ||
| 378 | ? `t3-file:${fileIconUri}` | ||
| 379 | : run.skillName | ||
| 380 | ? "t3-skill:sf:cube" | ||
| 381 | : linkIconUri | ||
| 382 | ? `t3-link:${linkIconUri}` | ||
| 383 | : undefined | ||
| 384 | : undefined | ||
| 385 | } | ||
| 386 | contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined} | ||
| 387 | style={[ | ||
| 388 | runStyle(run, props.textStyle), | ||
| 389 | chip ? { backgroundColor: "transparent" } : undefined, | ||
| 390 | ]} | ||
| 391 | onPress={onPress} | ||
| 392 | onContextMenuAction={ | ||
| 393 | contextMenu && href && menu | ||
| 394 | ? (event) => menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier) | ||
| 395 | : undefined | ||
| 396 | } | ||
| 397 | > | ||
| 398 | {androidChip ? ( | ||
| 399 | // The inline box sits on the baseline and is only as tall as the font's | ||
| 400 | // ascent, so it never changes the line's height. The bitmap hangs off that | ||
| 401 | // box (views in text are not clipped) to centre the chip on the text. | ||
| 402 | <View | ||
| 403 | accessible | ||
| 404 | accessibilityLabel={chip?.label} | ||
| 405 | accessibilityRole={onPress ? "button" : "image"} | ||
| 406 | accessibilityActions={onPress ? [{ name: "activate" }] : undefined} | ||
| 407 | onAccessibilityAction={ | ||
| 408 | onPress | ||
| 409 | ? (event) => { | ||
| 410 | if (event.nativeEvent.actionName === "activate") onPress(); | ||
| 411 | } | ||
| 412 | : undefined | ||
| 413 | } | ||
| 414 | style={{ width: androidChip.width, height: androidChip.boxHeight }} | ||
| 415 | > | ||
| 416 | <Image | ||
| 417 | // The bitmap is measured in whole pixels but laid out in dp, so the box can | ||
| 418 | // round a hair narrower than the image. `cover` would crop that difference | ||
| 419 | // off the right-hand border; `contain` fits the whole chip instead. | ||
| 420 | resizeMode="contain" | ||
| 421 | source={{ uri: androidChip.uri }} | ||
| 422 | style={{ | ||
| 423 | position: "absolute", | ||
| 424 | left: 0, | ||
| 425 | top: androidChip.offsetY, | ||
| 426 | width: androidChip.width, | ||
| 427 | height: androidChip.height, | ||
| 428 | }} | ||
| 429 | /> | ||
| 430 | </View> | ||
| 431 | ) : Platform.OS === "android" && run.fileIcon ? ( | ||
| 432 | <Image source={markdownFileIconSource(run.fileIcon)} style={styles.inlineIcon} /> | ||
| 433 | ) : Platform.OS === "android" && linkIcon ? ( | ||
| 434 | <Image | ||
| 435 | source={markdownLinkIconSource(linkIcon)} | ||
| 436 | style={styles.inlineIcon} | ||
| 437 | tintColor={props.textStyle.linkColor} | ||
| 438 | /> | ||
| 439 | ) : null} | ||
| 440 | {text} | ||
| 441 | </MarkdownTextPrimitive> | ||
| 442 | ); | ||
| 443 | })} | ||
| 444 | </MarkdownTextPrimitive> | ||
| 445 | ); | ||
| 446 | } | ||
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx deleted-125| ... | @@ -1,125 +0,0 @@ | ||
| 1 | import { useMemo } from "react"; | ||
| 2 | import { View } from "react-native"; | ||
| 3 | import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | nativeMarkdownWithHtml, | ||
| 7 | nativeMarkdownChunkSpacing, | ||
| 8 | nativeMarkdownDocumentChunks, | ||
| 9 | nativeMarkdownDocumentRuns, | ||
| 10 | nativeMarkdownWithAuthoredWindowsPaths, | ||
| 11 | nativeMarkdownWithPreservedSoftBreaks, | ||
| 12 | } from "./nativeMarkdownText"; | ||
| 13 | import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock"; | ||
| 14 | import { | ||
| 15 | MarkdownContextClipboardContext, | ||
| 16 | MarkdownFileContextMenuContext, | ||
| 17 | NativeMarkdownSelectableText, | ||
| 18 | type MarkdownFileContextMenuHandlers, | ||
| 19 | } from "./NativeMarkdownSelectableText"; | ||
| 20 | import type { | ||
| 21 | SelectableMarkdownSkill, | ||
| 22 | SelectableMarkdownTextProps, | ||
| 23 | } from "./SelectableMarkdownText.types"; | ||
| 24 | |||
| 25 | const EMPTY_SKILLS: ReadonlyArray<SelectableMarkdownSkill> = []; | ||
| 26 | |||
| 27 | export type { | ||
| 28 | MarkdownCodeHighlighter, | ||
| 29 | MarkdownHighlightedToken, | ||
| 30 | MarkdownImageRenderer, | ||
| 31 | MarkdownImageRequest, | ||
| 32 | NativeMarkdownTextStyle, | ||
| 33 | SelectableMarkdownSkill, | ||
| 34 | SelectableMarkdownTextProps, | ||
| 35 | } from "./SelectableMarkdownText.types"; | ||
| 36 | |||
| 37 | export function hasNativeSelectableMarkdownText(): boolean { | ||
| 38 | return true; | ||
| 39 | } | ||
| 40 | |||
| 41 | export function SelectableMarkdownText({ | ||
| 42 | markdown, | ||
| 43 | contextClipboardFragment, | ||
| 44 | skills = EMPTY_SKILLS, | ||
| 45 | textStyle, | ||
| 46 | highlightCode, | ||
| 47 | preserveSoftBreaks = false, | ||
| 48 | onLinkPress, | ||
| 49 | fileContextMenu, | ||
| 50 | onFileContextMenuAction, | ||
| 51 | renderImage, | ||
| 52 | marginTop = 0, | ||
| 53 | marginBottom = 0, | ||
| 54 | }: SelectableMarkdownTextProps) { | ||
| 55 | const chunks = useMemo(() => { | ||
| 56 | const parsedDocument = nativeMarkdownWithAuthoredWindowsPaths( | ||
| 57 | nativeMarkdownWithHtml( | ||
| 58 | parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false }), | ||
| 59 | (content) => parseMarkdownWithOptions(content, { gfm: true, html: false, math: false }), | ||
| 60 | ), | ||
| 61 | markdown, | ||
| 62 | ); | ||
| 63 | const document = preserveSoftBreaks | ||
| 64 | ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument) | ||
| 65 | : parsedDocument; | ||
| 66 | return nativeMarkdownDocumentChunks(document).map((chunk) => | ||
| 67 | chunk.kind === "selectable" | ||
| 68 | ? { | ||
| 69 | ...chunk, | ||
| 70 | runs: nativeMarkdownDocumentRuns(chunk.node, skills), | ||
| 71 | } | ||
| 72 | : chunk, | ||
| 73 | ); | ||
| 74 | }, [markdown, preserveSoftBreaks, skills]); | ||
| 75 | |||
| 76 | const fileContextMenuHandlers = useMemo<MarkdownFileContextMenuHandlers | null>( | ||
| 77 | () => | ||
| 78 | fileContextMenu && onFileContextMenuAction | ||
| 79 | ? { fileContextMenu, onFileContextMenuAction } | ||
| 80 | : null, | ||
| 81 | [fileContextMenu, onFileContextMenuAction], | ||
| 82 | ); | ||
| 83 | |||
| 84 | return ( | ||
| 85 | <MarkdownContextClipboardContext.Provider value={contextClipboardFragment ?? ""}> | ||
| 86 | <MarkdownImageRendererContext.Provider value={renderImage ?? null}> | ||
| 87 | <MarkdownFileContextMenuContext.Provider value={fileContextMenuHandlers}> | ||
| 88 | {/* A percentage width here creates a cyclic intrinsic measurement inside | ||
| 89 | shrink-to-fit containers such as user-message bubbles. Yoga then gives | ||
| 90 | the native text node an unbounded second pass and the parent only clips | ||
| 91 | the resulting single-line width instead of reflowing it. */} | ||
| 92 | <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}> | ||
| 93 | {chunks.map((chunk, index) => { | ||
| 94 | const content = | ||
| 95 | chunk.kind === "rich" ? ( | ||
| 96 | <NativeMarkdownBlock | ||
| 97 | node={chunk.node} | ||
| 98 | skills={skills} | ||
| 99 | textStyle={textStyle} | ||
| 100 | highlightCode={highlightCode} | ||
| 101 | onLinkPress={onLinkPress} | ||
| 102 | /> | ||
| 103 | ) : ( | ||
| 104 | <NativeMarkdownSelectableText | ||
| 105 | runs={chunk.runs} | ||
| 106 | textStyle={textStyle} | ||
| 107 | onLinkPress={onLinkPress} | ||
| 108 | /> | ||
| 109 | ); | ||
| 110 | |||
| 111 | return ( | ||
| 112 | <View | ||
| 113 | key={chunk.key} | ||
| 114 | style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }} | ||
| 115 | > | ||
| 116 | {content} | ||
| 117 | </View> | ||
| 118 | ); | ||
| 119 | })} | ||
| 120 | </View> | ||
| 121 | </MarkdownFileContextMenuContext.Provider> | ||
| 122 | </MarkdownImageRendererContext.Provider> | ||
| 123 | </MarkdownContextClipboardContext.Provider> | ||
| 124 | ); | ||
| 125 | } | ||
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts deleted-92| ... | @@ -1,92 +0,0 @@ | ||
| 1 | export interface NativeMarkdownTextStyle { | ||
| 2 | readonly selectionColor?: string; | ||
| 3 | readonly selectionHandleColor?: string; | ||
| 4 | readonly color: string; | ||
| 5 | readonly strongColor: string; | ||
| 6 | readonly mutedColor: string; | ||
| 7 | readonly linkColor: string; | ||
| 8 | readonly inlineCodeColor: string; | ||
| 9 | readonly codeColor: string; | ||
| 10 | readonly codeBackgroundColor: string; | ||
| 11 | readonly codeBlockBackgroundColor: string; | ||
| 12 | readonly fileTextColor: string; | ||
| 13 | readonly skillTextColor: string; | ||
| 14 | readonly quoteMarkerColor: string; | ||
| 15 | readonly dividerColor: string; | ||
| 16 | readonly contextChipBorderColor?: string; | ||
| 17 | readonly fontSize: number; | ||
| 18 | readonly lineHeight: number; | ||
| 19 | readonly fontFamily: string; | ||
| 20 | readonly headingFontFamily: string; | ||
| 21 | readonly boldFontFamily: string; | ||
| 22 | readonly headingFontSizes?: ReadonlyArray<number>; | ||
| 23 | } | ||
| 24 | |||
| 25 | export interface MarkdownHighlightedToken { | ||
| 26 | readonly content: string; | ||
| 27 | readonly color: string | null; | ||
| 28 | readonly fontStyle: number | null; | ||
| 29 | } | ||
| 30 | |||
| 31 | export interface MarkdownCodeHighlightInput { | ||
| 32 | /** Identity of the mounted code block, for incremental highlighting. */ | ||
| 33 | readonly session?: object; | ||
| 34 | readonly code: string; | ||
| 35 | readonly language?: string | null; | ||
| 36 | readonly theme: "light" | "dark"; | ||
| 37 | } | ||
| 38 | export interface MarkdownCodeHighlighter { | ||
| 39 | ( | ||
| 40 | input: MarkdownCodeHighlightInput, | ||
| 41 | ): Promise<ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>>; | ||
| 42 | /** Optional synchronous result for a small append to an already warm block. */ | ||
| 43 | read?: ( | ||
| 44 | input: MarkdownCodeHighlightInput, | ||
| 45 | ) => ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>> | undefined; | ||
| 46 | } | ||
| 47 | |||
| 48 | export interface SelectableMarkdownSkill { | ||
| 49 | readonly name: string; | ||
| 50 | readonly displayName?: string | null; | ||
| 51 | } | ||
| 52 | |||
| 53 | export interface MarkdownImageRequest { | ||
| 54 | readonly href: string; | ||
| 55 | readonly alt: string | null; | ||
| 56 | readonly title: string | null; | ||
| 57 | } | ||
| 58 | |||
| 59 | /** | ||
| 60 | * App-supplied renderer for markdown images. The module cannot load | ||
| 61 | * workspace-relative image paths itself — the host app resolves them (for | ||
| 62 | * example through a signed asset URL) and returns the element to show. | ||
| 63 | * Returning null falls back to the module's plain remote-URI rendering. | ||
| 64 | */ | ||
| 65 | export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode; | ||
| 66 | |||
| 67 | export interface MarkdownFileContextMenuAction { | ||
| 68 | readonly id: string; | ||
| 69 | readonly title: string; | ||
| 70 | readonly disabled?: boolean; | ||
| 71 | } | ||
| 72 | |||
| 73 | export interface MarkdownFileContextMenu { | ||
| 74 | readonly title?: string; | ||
| 75 | readonly actions: ReadonlyArray<MarkdownFileContextMenuAction>; | ||
| 76 | } | ||
| 77 | |||
| 78 | export interface SelectableMarkdownTextProps { | ||
| 79 | readonly markdown: string; | ||
| 80 | /** Opaque context payload supplied by the host for native selection copy. */ | ||
| 81 | readonly contextClipboardFragment?: string; | ||
| 82 | readonly textStyle: NativeMarkdownTextStyle; | ||
| 83 | readonly highlightCode: MarkdownCodeHighlighter; | ||
| 84 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; | ||
| 85 | readonly preserveSoftBreaks?: boolean; | ||
| 86 | readonly onLinkPress?: (href: string) => void; | ||
| 87 | readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined; | ||
| 88 | readonly onFileContextMenuAction?: (href: string, actionId: string) => void; | ||
| 89 | readonly renderImage?: MarkdownImageRenderer; | ||
| 90 | readonly marginTop?: number; | ||
| 91 | readonly marginBottom?: number; | ||
| 92 | } | ||
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts deleted-50| ... | @@ -1,50 +0,0 @@ | ||
| 1 | import { codegenNativeComponent, type CodegenTypes, type ViewProps } from "react-native"; | ||
| 2 | |||
| 3 | interface TargetedEvent { | ||
| 4 | target: CodegenTypes.Int32; | ||
| 5 | } | ||
| 6 | |||
| 7 | interface TextLayoutEvent extends TargetedEvent { | ||
| 8 | lines: string[]; | ||
| 9 | } | ||
| 10 | |||
| 11 | /** | ||
| 12 | * Event fired when text selection changes in the MarkdownTextPrimitive. | ||
| 13 | * @property target - The view tag identifier | ||
| 14 | * @property start - The start index of the selected range (0-based) | ||
| 15 | * @property end - The end index of the selected range (0-based, exclusive) | ||
| 16 | */ | ||
| 17 | interface SelectionChangeEvent extends TargetedEvent { | ||
| 18 | start: CodegenTypes.Int32; | ||
| 19 | end: CodegenTypes.Int32; | ||
| 20 | } | ||
| 21 | |||
| 22 | type EllipsizeMode = "head" | "middle" | "tail" | "clip"; | ||
| 23 | |||
| 24 | interface NativeProps extends ViewProps { | ||
| 25 | contextClipboardConfig?: string; | ||
| 26 | numberOfLines?: CodegenTypes.Int32; | ||
| 27 | allowFontScaling?: CodegenTypes.WithDefault<boolean, true>; | ||
| 28 | ellipsizeMode?: CodegenTypes.WithDefault<EllipsizeMode, "tail">; | ||
| 29 | selectable?: boolean; | ||
| 30 | onTextLayout?: CodegenTypes.BubblingEventHandler<TextLayoutEvent>; | ||
| 31 | /** | ||
| 32 | * Callback fired when the text selection changes. | ||
| 33 | * | ||
| 34 | * @example | ||
| 35 | * ```tsx | ||
| 36 | * <MarkdownTextPrimitive | ||
| 37 | * onSelectionChange={(event) => { | ||
| 38 | * console.log('Selection:', event.nativeEvent.start, event.nativeEvent.end); | ||
| 39 | * }} | ||
| 40 | * > | ||
| 41 | * Selectable text | ||
| 42 | * </MarkdownTextPrimitive> | ||
| 43 | * ``` | ||
| 44 | */ | ||
| 45 | onSelectionChange?: CodegenTypes.BubblingEventHandler<SelectionChangeEvent>; | ||
| 46 | } | ||
| 47 | |||
| 48 | export default codegenNativeComponent<NativeProps>("T3MarkdownText", { | ||
| 49 | excludedPlatforms: ["android"], | ||
| 50 | }); | ||
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts deleted-55| ... | @@ -1,55 +0,0 @@ | ||
| 1 | import { | ||
| 2 | codegenNativeComponent, | ||
| 3 | type CodegenTypes, | ||
| 4 | type ColorValue, | ||
| 5 | type ViewProps, | ||
| 6 | } from "react-native"; | ||
| 7 | |||
| 8 | interface TargetedEvent { | ||
| 9 | target: CodegenTypes.Int32; | ||
| 10 | } | ||
| 11 | |||
| 12 | interface ContextMenuActionEvent extends TargetedEvent { | ||
| 13 | actionIdentifier: string; | ||
| 14 | } | ||
| 15 | |||
| 16 | type TextDecorationLine = "none" | "underline" | "line-through"; | ||
| 17 | |||
| 18 | type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed"; | ||
| 19 | |||
| 20 | export type NativeFontWeight = | ||
| 21 | | "normal" | ||
| 22 | | "bold" | ||
| 23 | | "ultraLight" | ||
| 24 | | "light" | ||
| 25 | | "medium" | ||
| 26 | | "semibold" | ||
| 27 | | "heavy"; | ||
| 28 | |||
| 29 | type FontStyle = "normal" | "italic"; | ||
| 30 | |||
| 31 | type TextAlign = "auto" | "left" | "right" | "center" | "justify"; | ||
| 32 | |||
| 33 | interface NativeProps extends ViewProps { | ||
| 34 | text: string; | ||
| 35 | color?: ColorValue; | ||
| 36 | fontSize?: CodegenTypes.Float; | ||
| 37 | fontStyle?: CodegenTypes.WithDefault<FontStyle, "normal">; | ||
| 38 | fontWeight?: CodegenTypes.WithDefault<NativeFontWeight, "normal">; | ||
| 39 | fontFamily?: string; | ||
| 40 | letterSpacing?: CodegenTypes.Float; | ||
| 41 | lineHeight?: CodegenTypes.Float; | ||
| 42 | textDecorationLine?: CodegenTypes.WithDefault<TextDecorationLine, "none">; | ||
| 43 | textDecorationStyle?: CodegenTypes.WithDefault<TextDecorationStyle, "solid">; | ||
| 44 | textDecorationColor?: ColorValue; | ||
| 45 | textAlign?: CodegenTypes.WithDefault<TextAlign, "auto">; | ||
| 46 | shadowRadius?: CodegenTypes.WithDefault<CodegenTypes.Float, 0>; | ||
| 47 | contextMenuConfig?: string; | ||
| 48 | onPress?: CodegenTypes.BubblingEventHandler<TargetedEvent>; | ||
| 49 | onLongPress?: CodegenTypes.BubblingEventHandler<TargetedEvent>; | ||
| 50 | onContextMenuAction?: CodegenTypes.BubblingEventHandler<ContextMenuActionEvent>; | ||
| 51 | } | ||
| 52 | |||
| 53 | export default codegenNativeComponent<NativeProps>("T3MarkdownTextRun", { | ||
| 54 | excludedPlatforms: ["android"], | ||
| 55 | }); | ||
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts deleted-30| ... | @@ -1,30 +0,0 @@ | ||
| 1 | import { requireOptionalNativeModule } from "expo"; | ||
| 2 | |||
| 3 | interface T3MarkdownTextSelectionNativeModule { | ||
| 4 | readonly setSelectionHandleColor?: (reactTag: number, color: number) => void; | ||
| 5 | readonly installCopySanitizer: (reactTag: number, contextClipboardConfig: string) => void; | ||
| 6 | readonly renderContextChip?: (payloadJson: string) => { | ||
| 7 | readonly uri: string; | ||
| 8 | readonly width: number; | ||
| 9 | readonly height: number; | ||
| 10 | /** Inline box height: the paragraph font's ascent, so the line box never grows. */ | ||
| 11 | readonly boxHeight: number; | ||
| 12 | /** Bitmap top relative to the box top; negative when the chip overhangs the box. */ | ||
| 13 | readonly offsetY: number; | ||
| 14 | } | null; | ||
| 15 | } | ||
| 16 | |||
| 17 | const nativeModule = | ||
| 18 | requireOptionalNativeModule<T3MarkdownTextSelectionNativeModule>("T3MarkdownTextSelection"); | ||
| 19 | |||
| 20 | export function installMarkdownCopySanitizer(reactTag: number, contextClipboardConfig = ""): void { | ||
| 21 | nativeModule?.installCopySanitizer(reactTag, contextClipboardConfig); | ||
| 22 | } | ||
| 23 | |||
| 24 | export function setMarkdownSelectionHandleColor(reactTag: number, color: number): void { | ||
| 25 | nativeModule?.setSelectionHandleColor?.(reactTag, color); | ||
| 26 | } | ||
| 27 | |||
| 28 | export function renderAndroidContextChip(payloadJson: string) { | ||
| 29 | return nativeModule?.renderContextChip?.(payloadJson) ?? null; | ||
| 30 | } | ||
apps/mobile/modules/t3-markdown-text/src/githubAlert.test.ts deleted-42| ... | @@ -1,42 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | ||
| 3 | import { githubAlert } from "./githubAlert"; | ||
| 4 | |||
| 5 | const quote = (...children: MarkdownNode[]): MarkdownNode => ({ | ||
| 6 | type: "blockquote", | ||
| 7 | children: [{ type: "paragraph", children }], | ||
| 8 | }); | ||
| 9 | |||
| 10 | describe("GitHub alerts in native Markdown", () => { | ||
| 11 | it.each(["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"])( | ||
| 12 | "recognizes %s and retains formatted content", | ||
| 13 | (marker) => { | ||
| 14 | const bold: MarkdownNode = { type: "bold", children: [{ type: "text", content: "Details" }] }; | ||
| 15 | const node = quote({ type: "text", content: `[!${marker}]` }, { type: "soft_break" }, bold); | ||
| 16 | const original = structuredClone(node); | ||
| 17 | const alert = githubAlert(node); | ||
| 18 | expect(alert?.title.toUpperCase()).toBe(marker); | ||
| 19 | expect(alert?.children[0]?.children).toEqual([bold]); | ||
| 20 | expect(node).toEqual(original); | ||
| 21 | }, | ||
| 22 | ); | ||
| 23 | it("handles text nodes containing the line break and following text", () => { | ||
| 24 | expect( | ||
| 25 | githubAlert(quote({ type: "text", content: "[!WARNING]\nKeep this text." }))?.children[0] | ||
| 26 | ?.children?.[0]?.content, | ||
| 27 | ).toBe("Keep this text."); | ||
| 28 | }); | ||
| 29 | it("leaves inline markers, code, and ordinary quotes alone", () => { | ||
| 30 | expect(githubAlert(quote({ type: "text", content: "[!WARNING] inline" }))).toBeNull(); | ||
| 31 | expect( | ||
| 32 | githubAlert( | ||
| 33 | quote( | ||
| 34 | { type: "text", content: "[!WARNING]" }, | ||
| 35 | { type: "bold", children: [{ type: "text", content: "inline" }] }, | ||
| 36 | ), | ||
| 37 | ), | ||
| 38 | ).toBeNull(); | ||
| 39 | expect(githubAlert({ type: "code_block", content: "> [!WARNING]" })).toBeNull(); | ||
| 40 | expect(githubAlert(quote({ type: "text", content: "Just a quote" }))).toBeNull(); | ||
| 41 | }); | ||
| 42 | }); | ||
apps/mobile/modules/t3-markdown-text/src/githubAlert.ts deleted-52| ... | @@ -1,52 +0,0 @@ | ||
| 1 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | ||
| 2 | |||
| 3 | const ALERTS = { | ||
| 4 | NOTE: { title: "Note", color: "#4493f8", lightColor: "#0969da", symbol: "info.circle" }, | ||
| 5 | TIP: { title: "Tip", color: "#3fb950", lightColor: "#1a7f37", symbol: "lightbulb" }, | ||
| 6 | IMPORTANT: { | ||
| 7 | title: "Important", | ||
| 8 | color: "#ab7df8", | ||
| 9 | lightColor: "#8250df", | ||
| 10 | symbol: "exclamationmark.bubble", | ||
| 11 | }, | ||
| 12 | WARNING: { | ||
| 13 | title: "Warning", | ||
| 14 | color: "#d29922", | ||
| 15 | lightColor: "#9a6700", | ||
| 16 | symbol: "exclamationmark.triangle", | ||
| 17 | }, | ||
| 18 | CAUTION: { | ||
| 19 | title: "Caution", | ||
| 20 | color: "#f85149", | ||
| 21 | lightColor: "#cf222e", | ||
| 22 | symbol: "exclamationmark.octagon", | ||
| 23 | }, | ||
| 24 | } as const; | ||
| 25 | |||
| 26 | /** GitHub alerts are blockquotes whose first line contains only the alert marker. */ | ||
| 27 | export function githubAlert(node: MarkdownNode) { | ||
| 28 | if (node.type !== "blockquote") return null; | ||
| 29 | const paragraph = node.children?.[0]; | ||
| 30 | const text = paragraph?.children?.[0]; | ||
| 31 | if (paragraph?.type !== "paragraph" || text?.type !== "text") return null; | ||
| 32 | const match = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](\r?\n|$)/i.exec(text.content ?? ""); | ||
| 33 | if (!match) return null; | ||
| 34 | const rest = (text.content ?? "").slice(match[0].length); | ||
| 35 | const siblings = paragraph.children?.slice(1) ?? []; | ||
| 36 | if ( | ||
| 37 | !rest && | ||
| 38 | !match[2] && | ||
| 39 | siblings.length && | ||
| 40 | !["soft_break", "line_break"].includes(siblings[0]!.type) | ||
| 41 | ) | ||
| 42 | return null; | ||
| 43 | if (!rest && ["soft_break", "line_break"].includes(siblings[0]?.type ?? "")) siblings.shift(); | ||
| 44 | const children = [...(rest ? [{ ...text, content: rest }] : []), ...siblings]; | ||
| 45 | return { | ||
| 46 | ...ALERTS[match[1]!.toUpperCase() as keyof typeof ALERTS], | ||
| 47 | children: [ | ||
| 48 | ...(children.length ? [{ ...paragraph, children }] : []), | ||
| 49 | ...(node.children?.slice(1) ?? []), | ||
| 50 | ], | ||
| 51 | }; | ||
| 52 | } | ||
apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts deleted-60| ... | @@ -1,60 +0,0 @@ | ||
| 1 | import type { ImageSourcePropType } from "react-native"; | ||
| 2 | |||
| 3 | export const MARKDOWN_FILE_ICON_SOURCES = { | ||
| 4 | agents: require("../assets/file-icons/pierre_agents.png"), | ||
| 5 | astro: require("../assets/file-icons/pierre_astro.png"), | ||
| 6 | babel: require("../assets/file-icons/pierre_babel.png"), | ||
| 7 | bash: require("../assets/file-icons/pierre_bash.png"), | ||
| 8 | biome: require("../assets/file-icons/pierre_biome.png"), | ||
| 9 | bootstrap: require("../assets/file-icons/pierre_bootstrap.png"), | ||
| 10 | browserslist: require("../assets/file-icons/pierre_browserslist.png"), | ||
| 11 | bun: require("../assets/file-icons/pierre_bun.png"), | ||
| 12 | c: require("../assets/file-icons/pierre_c.png"), | ||
| 13 | claude: require("../assets/file-icons/pierre_claude.png"), | ||
| 14 | cpp: require("../assets/file-icons/pierre_cpp.png"), | ||
| 15 | css: require("../assets/file-icons/pierre_css.png"), | ||
| 16 | database: require("../assets/file-icons/pierre_database.png"), | ||
| 17 | default: require("../assets/file-icons/pierre_default.png"), | ||
| 18 | docker: require("../assets/file-icons/pierre_docker.png"), | ||
| 19 | eslint: require("../assets/file-icons/pierre_eslint.png"), | ||
| 20 | font: require("../assets/file-icons/pierre_font.png"), | ||
| 21 | git: require("../assets/file-icons/pierre_git.png"), | ||
| 22 | go: require("../assets/file-icons/pierre_go.png"), | ||
| 23 | graphql: require("../assets/file-icons/pierre_graphql.png"), | ||
| 24 | html: require("../assets/file-icons/pierre_html.png"), | ||
| 25 | image: require("../assets/file-icons/pierre_image.png"), | ||
| 26 | javascript: require("../assets/file-icons/pierre_javascript.png"), | ||
| 27 | json: require("../assets/file-icons/pierre_json.png"), | ||
| 28 | markdown: require("../assets/file-icons/pierre_markdown.png"), | ||
| 29 | mcp: require("../assets/file-icons/pierre_mcp.png"), | ||
| 30 | nextjs: require("../assets/file-icons/pierre_nextjs.png"), | ||
| 31 | npm: require("../assets/file-icons/pierre_npm.png"), | ||
| 32 | oxc: require("../assets/file-icons/pierre_oxc.png"), | ||
| 33 | pnpm: require("../assets/file-icons/pierre_pnpm.png"), | ||
| 34 | postcss: require("../assets/file-icons/pierre_postcss.png"), | ||
| 35 | prettier: require("../assets/file-icons/pierre_prettier.png"), | ||
| 36 | python: require("../assets/file-icons/pierre_python.png"), | ||
| 37 | react: require("../assets/file-icons/pierre_react.png"), | ||
| 38 | ruby: require("../assets/file-icons/pierre_ruby.png"), | ||
| 39 | rust: require("../assets/file-icons/pierre_rust.png"), | ||
| 40 | sass: require("../assets/file-icons/pierre_sass.png"), | ||
| 41 | stylelint: require("../assets/file-icons/pierre_stylelint.png"), | ||
| 42 | svelte: require("../assets/file-icons/pierre_svelte.png"), | ||
| 43 | svg: require("../assets/file-icons/pierre_svg.png"), | ||
| 44 | svgo: require("../assets/file-icons/pierre_svgo.png"), | ||
| 45 | swift: require("../assets/file-icons/pierre_swift.png"), | ||
| 46 | table: require("../assets/file-icons/pierre_table.png"), | ||
| 47 | tailwind: require("../assets/file-icons/pierre_tailwind.png"), | ||
| 48 | terraform: require("../assets/file-icons/pierre_terraform.png"), | ||
| 49 | text: require("../assets/file-icons/pierre_text.png"), | ||
| 50 | typescript: require("../assets/file-icons/pierre_typescript.png"), | ||
| 51 | video: require("../assets/file-icons/pierre_video.png"), | ||
| 52 | vite: require("../assets/file-icons/pierre_vite.png"), | ||
| 53 | vscode: require("../assets/file-icons/pierre_vscode.png"), | ||
| 54 | vue: require("../assets/file-icons/pierre_vue.png"), | ||
| 55 | wasm: require("../assets/file-icons/pierre_wasm.png"), | ||
| 56 | webpack: require("../assets/file-icons/pierre_webpack.png"), | ||
| 57 | yml: require("../assets/file-icons/pierre_yml.png"), | ||
| 58 | zig: require("../assets/file-icons/pierre_zig.png"), | ||
| 59 | zip: require("../assets/file-icons/pierre_zip.png"), | ||
| 60 | } as const satisfies Readonly<Record<string, ImageSourcePropType>>; | ||
apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts deleted-17| ... | @@ -1,17 +0,0 @@ | ||
| 1 | import { Image, type ImageSourcePropType } from "react-native"; | ||
| 2 | |||
| 3 | import type { MarkdownFileIcon } from "./markdownLinks"; | ||
| 4 | import { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; | ||
| 5 | |||
| 6 | export function markdownFileIconSource(icon: MarkdownFileIcon): ImageSourcePropType { | ||
| 7 | return MARKDOWN_FILE_ICON_SOURCES[icon]; | ||
| 8 | } | ||
| 9 | |||
| 10 | /** | ||
| 11 | * Bundled icon URI handed to native views that draw the icon themselves. The | ||
| 12 | * sources are static requires, so this only comes back undefined when the | ||
| 13 | * bundler dropped the asset; callers treat a missing icon as "no icon". | ||
| 14 | */ | ||
| 15 | export function markdownIconAssetUri(source: ImageSourcePropType): string | undefined { | ||
| 16 | return Image.resolveAssetSource(source)?.uri; | ||
| 17 | } | ||
apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts deleted-12| ... | @@ -1,12 +0,0 @@ | ||
| 1 | import type { ImageSourcePropType } from "react-native"; | ||
| 2 | |||
| 3 | import type { MarkdownLinkIcon } from "./markdownLinks"; | ||
| 4 | |||
| 5 | // Black-on-transparent marks; callers tint them with the link color. | ||
| 6 | const MARKDOWN_LINK_ICON_SOURCES = { | ||
| 7 | github: require("../assets/link-icons/github.png"), | ||
| 8 | } as const satisfies Readonly<Record<MarkdownLinkIcon, ImageSourcePropType>>; | ||
| 9 | |||
| 10 | export function markdownLinkIconSource(icon: MarkdownLinkIcon): ImageSourcePropType { | ||
| 11 | return MARKDOWN_LINK_ICON_SOURCES[icon]; | ||
| 12 | } | ||
apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts deleted-313| ... | @@ -1,313 +0,0 @@ | ||
| 1 | import { fileBasename } from "@t3tools/shared/path"; | ||
| 2 | import { formatFilePathPosition } from "@t3tools/shared/fileLinks"; | ||
| 3 | import { | ||
| 4 | inlineCodeFilePathCandidate, | ||
| 5 | normalizeMarkdownLinkDestination, | ||
| 6 | parseMarkdownFileLink, | ||
| 7 | } from "@t3tools/shared/markdownLinks"; | ||
| 8 | import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; | ||
| 9 | import { videoMimeType } from "@t3tools/shared/video"; | ||
| 10 | |||
| 11 | import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; | ||
| 12 | |||
| 13 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | ||
| 14 | |||
| 15 | export type MarkdownLinkPresentation = | ||
| 16 | | { | ||
| 17 | readonly kind: "external"; | ||
| 18 | readonly href: string; | ||
| 19 | readonly host: string; | ||
| 20 | } | ||
| 21 | | { | ||
| 22 | readonly kind: "file"; | ||
| 23 | readonly href: string; | ||
| 24 | readonly icon: MarkdownFileIcon; | ||
| 25 | readonly label: string; | ||
| 26 | readonly path: string; | ||
| 27 | readonly line?: number; | ||
| 28 | readonly column?: number; | ||
| 29 | } | ||
| 30 | | { | ||
| 31 | readonly kind: "link"; | ||
| 32 | readonly href: string | null; | ||
| 33 | }; | ||
| 34 | |||
| 35 | export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; | ||
| 36 | |||
| 37 | export type MarkdownLinkIcon = "github"; | ||
| 38 | |||
| 39 | /** | ||
| 40 | * Sites whose brand mark replaces the generic external-link glyph. The marks | ||
| 41 | * are monochrome and tinted with the link color, so they follow the theme. | ||
| 42 | */ | ||
| 43 | export function resolveMarkdownLinkIcon(host: string): MarkdownLinkIcon | null { | ||
| 44 | const hostname = host.toLowerCase(); | ||
| 45 | if (hostname === "github.com" || hostname.endsWith(".github.com")) return "github"; | ||
| 46 | return null; | ||
| 47 | } | ||
| 48 | |||
| 49 | const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = { | ||
| 50 | ".babelrc": "babel", | ||
| 51 | ".babelrc.json": "babel", | ||
| 52 | ".bash_profile": "bash", | ||
| 53 | ".bashrc": "bash", | ||
| 54 | ".browserslistrc": "browserslist", | ||
| 55 | ".dockerignore": "docker", | ||
| 56 | ".eslintignore": "eslint", | ||
| 57 | ".eslintrc": "eslint", | ||
| 58 | ".eslintrc.cjs": "eslint", | ||
| 59 | ".eslintrc.js": "eslint", | ||
| 60 | ".eslintrc.json": "eslint", | ||
| 61 | ".eslintrc.yaml": "eslint", | ||
| 62 | ".eslintrc.yml": "eslint", | ||
| 63 | ".gitattributes": "git", | ||
| 64 | ".gitignore": "git", | ||
| 65 | ".gitkeep": "git", | ||
| 66 | ".gitmodules": "git", | ||
| 67 | ".oxlintrc.json": "oxc", | ||
| 68 | ".postcssrc": "postcss", | ||
| 69 | ".postcssrc.json": "postcss", | ||
| 70 | ".postcssrc.yaml": "postcss", | ||
| 71 | ".postcssrc.yml": "postcss", | ||
| 72 | ".prettierignore": "prettier", | ||
| 73 | ".prettierrc": "prettier", | ||
| 74 | ".prettierrc.json": "prettier", | ||
| 75 | ".prettierrc.cjs": "prettier", | ||
| 76 | ".prettierrc.js": "prettier", | ||
| 77 | ".prettierrc.mjs": "prettier", | ||
| 78 | ".prettierrc.toml": "prettier", | ||
| 79 | ".prettierrc.yaml": "prettier", | ||
| 80 | ".prettierrc.yml": "prettier", | ||
| 81 | ".stylelintignore": "stylelint", | ||
| 82 | ".stylelintrc": "stylelint", | ||
| 83 | ".stylelintrc.cjs": "stylelint", | ||
| 84 | ".stylelintrc.js": "stylelint", | ||
| 85 | ".stylelintrc.json": "stylelint", | ||
| 86 | ".stylelintrc.mjs": "stylelint", | ||
| 87 | ".stylelintrc.yaml": "stylelint", | ||
| 88 | ".stylelintrc.yml": "stylelint", | ||
| 89 | ".terraform.lock.hcl": "terraform", | ||
| 90 | ".zprofile": "bash", | ||
| 91 | ".zshenv": "bash", | ||
| 92 | ".zshrc": "bash", | ||
| 93 | "agents.md": "agents", | ||
| 94 | "babel.config.js": "babel", | ||
| 95 | "babel.config.cjs": "babel", | ||
| 96 | "babel.config.json": "babel", | ||
| 97 | "babel.config.mjs": "babel", | ||
| 98 | "biome.json": "biome", | ||
| 99 | "biome.jsonc": "biome", | ||
| 100 | "bun.lock": "bun", | ||
| 101 | "bun.lockb": "bun", | ||
| 102 | "bunfig.toml": "bun", | ||
| 103 | "claude.md": "claude", | ||
| 104 | "compose.yaml": "docker", | ||
| 105 | "compose.yml": "docker", | ||
| 106 | "docker-compose.yaml": "docker", | ||
| 107 | "docker-compose.yml": "docker", | ||
| 108 | "docker-compose.override.yml": "docker", | ||
| 109 | dockerfile: "docker", | ||
| 110 | "eslint.config.js": "eslint", | ||
| 111 | "eslint.config.cjs": "eslint", | ||
| 112 | "eslint.config.mjs": "eslint", | ||
| 113 | "eslint.config.mts": "eslint", | ||
| 114 | "eslint.config.ts": "eslint", | ||
| 115 | gemfile: "ruby", | ||
| 116 | "next.config.js": "nextjs", | ||
| 117 | "next.config.mjs": "nextjs", | ||
| 118 | "next.config.mts": "nextjs", | ||
| 119 | "next.config.ts": "nextjs", | ||
| 120 | "package.json": "npm", | ||
| 121 | "pnpm-lock.yaml": "pnpm", | ||
| 122 | "pnpm-workspace.yaml": "pnpm", | ||
| 123 | "postcss.config.js": "postcss", | ||
| 124 | "postcss.config.cjs": "postcss", | ||
| 125 | "postcss.config.mjs": "postcss", | ||
| 126 | "postcss.config.ts": "postcss", | ||
| 127 | "prettier.config.js": "prettier", | ||
| 128 | "prettier.config.cjs": "prettier", | ||
| 129 | "prettier.config.mjs": "prettier", | ||
| 130 | rakefile: "ruby", | ||
| 131 | "readme.md": "markdown", | ||
| 132 | "stylelint.config.js": "stylelint", | ||
| 133 | "stylelint.config.cjs": "stylelint", | ||
| 134 | "stylelint.config.mjs": "stylelint", | ||
| 135 | "svgo.config.js": "svgo", | ||
| 136 | "svgo.config.cjs": "svgo", | ||
| 137 | "svgo.config.mjs": "svgo", | ||
| 138 | "svgo.config.ts": "svgo", | ||
| 139 | "tailwind.config.js": "tailwind", | ||
| 140 | "tailwind.config.cjs": "tailwind", | ||
| 141 | "tailwind.config.mjs": "tailwind", | ||
| 142 | "tailwind.config.ts": "tailwind", | ||
| 143 | "tsconfig.json": "typescript", | ||
| 144 | "vite.config.js": "vite", | ||
| 145 | "vite.config.mjs": "vite", | ||
| 146 | "vite.config.mts": "vite", | ||
| 147 | "vite.config.ts": "vite", | ||
| 148 | "webpack.config.js": "webpack", | ||
| 149 | "webpack.config.babel.js": "webpack", | ||
| 150 | "webpack.config.cjs": "webpack", | ||
| 151 | "webpack.config.mjs": "webpack", | ||
| 152 | "webpack.config.ts": "webpack", | ||
| 153 | }; | ||
| 154 | |||
| 155 | const FILE_ICON_BY_EXTENSION: Readonly<Record<string, MarkdownFileIcon>> = { | ||
| 156 | "7z": "zip", | ||
| 157 | astro: "astro", | ||
| 158 | avif: "image", | ||
| 159 | "code-workspace": "vscode", | ||
| 160 | bash: "bash", | ||
| 161 | bmp: "image", | ||
| 162 | bz2: "zip", | ||
| 163 | c: "c", | ||
| 164 | cc: "cpp", | ||
| 165 | cpp: "cpp", | ||
| 166 | cxx: "cpp", | ||
| 167 | css: "css", | ||
| 168 | csv: "table", | ||
| 169 | cts: "typescript", | ||
| 170 | db: "database", | ||
| 171 | env: "text", | ||
| 172 | "env.development": "text", | ||
| 173 | "env.local": "text", | ||
| 174 | "env.production": "text", | ||
| 175 | eot: "font", | ||
| 176 | erb: "ruby", | ||
| 177 | fish: "bash", | ||
| 178 | gif: "image", | ||
| 179 | go: "go", | ||
| 180 | gql: "graphql", | ||
| 181 | graphql: "graphql", | ||
| 182 | gz: "zip", | ||
| 183 | h: "c", | ||
| 184 | hh: "cpp", | ||
| 185 | hpp: "cpp", | ||
| 186 | hxx: "cpp", | ||
| 187 | htm: "html", | ||
| 188 | html: "html", | ||
| 189 | ico: "image", | ||
| 190 | icns: "image", | ||
| 191 | ini: "text", | ||
| 192 | inl: "cpp", | ||
| 193 | jar: "zip", | ||
| 194 | jpeg: "image", | ||
| 195 | jpg: "image", | ||
| 196 | js: "javascript", | ||
| 197 | jsx: "react", | ||
| 198 | json: "json", | ||
| 199 | jsonc: "json", | ||
| 200 | less: "css", | ||
| 201 | md: "markdown", | ||
| 202 | mdx: "markdown", | ||
| 203 | "mdx.tsx": "markdown", | ||
| 204 | mjs: "javascript", | ||
| 205 | mts: "typescript", | ||
| 206 | png: "image", | ||
| 207 | postcss: "css", | ||
| 208 | py: "python", | ||
| 209 | pyi: "python", | ||
| 210 | pyw: "python", | ||
| 211 | pyx: "python", | ||
| 212 | rake: "ruby", | ||
| 213 | rar: "zip", | ||
| 214 | rb: "ruby", | ||
| 215 | rs: "rust", | ||
| 216 | sass: "sass", | ||
| 217 | scss: "sass", | ||
| 218 | sh: "bash", | ||
| 219 | sql: "database", | ||
| 220 | sqlite: "database", | ||
| 221 | sqlite3: "database", | ||
| 222 | svelte: "svelte", | ||
| 223 | svg: "svg", | ||
| 224 | swift: "swift", | ||
| 225 | tar: "zip", | ||
| 226 | tf: "terraform", | ||
| 227 | tfstate: "terraform", | ||
| 228 | tfvars: "terraform", | ||
| 229 | tgz: "zip", | ||
| 230 | ts: "typescript", | ||
| 231 | tsv: "table", | ||
| 232 | tsx: "react", | ||
| 233 | txt: "text", | ||
| 234 | woff: "font", | ||
| 235 | woff2: "font", | ||
| 236 | vue: "vue", | ||
| 237 | wasm: "wasm", | ||
| 238 | webp: "image", | ||
| 239 | yml: "yml", | ||
| 240 | yaml: "yml", | ||
| 241 | zig: "zig", | ||
| 242 | zip: "zip", | ||
| 243 | zsh: "bash", | ||
| 244 | }; | ||
| 245 | |||
| 246 | /** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */ | ||
| 247 | export function normalizeNativeMarkdownUrl(value: string): string { | ||
| 248 | return value.startsWith("//") ? `https:${value}` : value; | ||
| 249 | } | ||
| 250 | |||
| 251 | export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { | ||
| 252 | const basename = fileBasename(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); | ||
| 253 | if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video"; | ||
| 254 | const exactIcon = FILE_ICON_BY_NAME[basename]; | ||
| 255 | if (exactIcon) return exactIcon; | ||
| 256 | if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { | ||
| 257 | return "typescript"; | ||
| 258 | } | ||
| 259 | const segments = basename.split("."); | ||
| 260 | for (let index = 1; index < segments.length; index += 1) { | ||
| 261 | const icon = FILE_ICON_BY_EXTENSION[segments.slice(index).join(".")]; | ||
| 262 | if (icon) return icon; | ||
| 263 | } | ||
| 264 | return "default"; | ||
| 265 | } | ||
| 266 | |||
| 267 | export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { | ||
| 268 | const normalized = normalizeMarkdownLinkDestination(href); | ||
| 269 | try { | ||
| 270 | const parsed = new URL(normalizeNativeMarkdownUrl(normalized)); | ||
| 271 | if (parsed.protocol === "http:" || parsed.protocol === "https:") { | ||
| 272 | return { | ||
| 273 | kind: "external", | ||
| 274 | href: parsed.toString(), | ||
| 275 | host: parsed.hostname, | ||
| 276 | }; | ||
| 277 | } | ||
| 278 | } catch { | ||
| 279 | // Relative paths and non-URL link destinations are handled below. | ||
| 280 | } | ||
| 281 | |||
| 282 | const target = parseMarkdownFileLink(normalized); | ||
| 283 | if (target) { | ||
| 284 | return { | ||
| 285 | kind: "file", | ||
| 286 | href: normalized, | ||
| 287 | icon: resolveMarkdownFileIcon(target.path), | ||
| 288 | label: fileBasename(formatFilePathPosition(target)), | ||
| 289 | path: target.path, | ||
| 290 | ...(target.line ? { line: target.line } : {}), | ||
| 291 | ...(target.column ? { column: target.column } : {}), | ||
| 292 | }; | ||
| 293 | } | ||
| 294 | |||
| 295 | return { | ||
| 296 | kind: "link", | ||
| 297 | // A thread link keeps its href so a press reaches the feed, which opens the thread. | ||
| 298 | href: | ||
| 299 | /^(?:mailto|tel):/i.test(normalized) || parseThreadLinkHref(normalized) !== null | ||
| 300 | ? normalized | ||
| 301 | : null, | ||
| 302 | }; | ||
| 303 | } | ||
| 304 | |||
| 305 | /** Backticks become file references only when the shared path heuristic recognizes the whole span. */ | ||
| 306 | export function resolveMarkdownInlineCodePresentation( | ||
| 307 | content: string, | ||
| 308 | ): Extract<MarkdownLinkPresentation, { readonly kind: "file" }> | null { | ||
| 309 | const candidate = inlineCodeFilePathCandidate(content); | ||
| 310 | if (candidate === null) return null; | ||
| 311 | const presentation = resolveMarkdownLinkPresentation(candidate); | ||
| 312 | return presentation.kind === "file" ? presentation : null; | ||
| 313 | } | ||
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts deleted-1159| ... | @@ -1,1159 +0,0 @@ | ||
| 1 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | ||
| 2 | import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; | ||
| 3 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; | ||
| 4 | import { imageMimeType } from "@t3tools/shared/image"; | ||
| 5 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | ||
| 6 | import { videoMimeType } from "@t3tools/shared/video"; | ||
| 7 | /** | ||
| 8 | * Every accent shares a lightness so no kind reads heavier than another; only hue carries | ||
| 9 | * identity. These are the sRGB form of the same OKLCH set web uses, so a chip looks the | ||
| 10 | * same on every surface. See `composerInlineChip.ts`. | ||
| 11 | */ | ||
| 12 | const CONTEXT_CHIP_PRESENTATIONS = { | ||
| 13 | image: { accent: "#d55665", symbol: "photo" }, | ||
| 14 | video: { accent: "#d06217", symbol: "play.rectangle" }, | ||
| 15 | file: { accent: "#0090cd", symbol: "doc" }, | ||
| 16 | mention: { accent: "#0096af", symbol: "doc" }, | ||
| 17 | terminal: { accent: "#009f6e", symbol: "terminal" }, | ||
| 18 | element: { accent: "#b87501", symbol: "cursorarrow.click" }, | ||
| 19 | "preview-annotation": { accent: "#b87501", symbol: "cursorarrow.click" }, | ||
| 20 | "review-comment": { accent: "#8a70dd", symbol: "text.bubble" }, | ||
| 21 | "pull-request": { accent: "#7079e4", symbol: "git-pull-request" }, | ||
| 22 | skill: { accent: "#b261be", symbol: "cube" }, | ||
| 23 | thread: { accent: "#009c96", symbol: "text.bubble" }, | ||
| 24 | } as const; | ||
| 25 | |||
| 26 | /** | ||
| 27 | * The size an attachment chip reports beside its name, matching web. Rendered as its own | ||
| 28 | * smaller run, so it carries no separator. Only attachment-backed records have bytes. | ||
| 29 | */ | ||
| 30 | export function composerChipSizeSuffix(record?: { | ||
| 31 | readonly kind?: string; | ||
| 32 | readonly sizeBytes?: number; | ||
| 33 | }): string { | ||
| 34 | if (record?.kind !== "file" && record?.kind !== "image") return ""; | ||
| 35 | return typeof record.sizeBytes === "number" ? formatAttachmentSize(record.sizeBytes) : ""; | ||
| 36 | } | ||
| 37 | |||
| 38 | /** | ||
| 39 | * A pull request chip is coloured by what the pull request *is*, the way web colours it and | ||
| 40 | * the way the forge itself does: green open, grey draft, purple merged, red closed. The glyph | ||
| 41 | * stays the same across all four, as it does on web — state is carried by colour alone. | ||
| 42 | */ | ||
| 43 | const PULL_REQUEST_CHIP_PRESENTATIONS = { | ||
| 44 | open: { accent: "#009f6e", symbol: "git-pull-request" }, | ||
| 45 | draft: { accent: "#7f8793", symbol: "git-pull-request" }, | ||
| 46 | merged: { accent: "#8a70dd", symbol: "git-pull-request" }, | ||
| 47 | closed: { accent: "#d55665", symbol: "git-pull-request" }, | ||
| 48 | } as const; | ||
| 49 | |||
| 50 | export function contextChipPresentation( | ||
| 51 | kind: string, | ||
| 52 | record?: { | ||
| 53 | readonly kind?: string; | ||
| 54 | readonly name?: string; | ||
| 55 | readonly mimeType?: string; | ||
| 56 | readonly sectionId?: string; | ||
| 57 | readonly pullRequest?: { | ||
| 58 | readonly state?: string; | ||
| 59 | readonly isDraft?: boolean; | ||
| 60 | }; | ||
| 61 | }, | ||
| 62 | ) { | ||
| 63 | const presentationKind = | ||
| 64 | kind === "file" && | ||
| 65 | videoMimeType({ | ||
| 66 | name: record?.name ?? "", | ||
| 67 | mimeType: record?.mimeType ?? "", | ||
| 68 | }) | ||
| 69 | ? "video" | ||
| 70 | : // A picture chosen through the file picker is typed `file`, but it is still a | ||
| 71 | // picture: it reads as one to the user and should not wear the generic file chip. | ||
| 72 | kind === "file" && | ||
| 73 | imageMimeType({ name: record?.name ?? "", mimeType: record?.mimeType ?? "" }) !== null | ||
| 74 | ? "image" | ||
| 75 | : kind === "review-comment" && record?.sectionId?.startsWith("pull-request:") | ||
| 76 | ? "pull-request" | ||
| 77 | : kind; | ||
| 78 | if (presentationKind === "pull-request") { | ||
| 79 | const pullRequest = record?.pullRequest; | ||
| 80 | const state = | ||
| 81 | pullRequest?.state === "open" && pullRequest.isDraft === true | ||
| 82 | ? "draft" | ||
| 83 | : (pullRequest?.state ?? ""); | ||
| 84 | if (Object.hasOwn(PULL_REQUEST_CHIP_PRESENTATIONS, state)) { | ||
| 85 | return PULL_REQUEST_CHIP_PRESENTATIONS[state as keyof typeof PULL_REQUEST_CHIP_PRESENTATIONS]; | ||
| 86 | } | ||
| 87 | } | ||
| 88 | return Object.hasOwn(CONTEXT_CHIP_PRESENTATIONS, presentationKind) | ||
| 89 | ? CONTEXT_CHIP_PRESENTATIONS[presentationKind as keyof typeof CONTEXT_CHIP_PRESENTATIONS] | ||
| 90 | : CONTEXT_CHIP_PRESENTATIONS.file; | ||
| 91 | } | ||
| 92 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; | ||
| 93 | import { | ||
| 94 | formatComposerContextReference, | ||
| 95 | parseComposerContextHref, | ||
| 96 | } from "@t3tools/shared/composerContextReferences"; | ||
| 97 | |||
| 98 | /** Native selections count UTF-16 display units, including each inline image placeholder. */ | ||
| 99 | export function nativeMarkdownContextCopyRanges( | ||
| 100 | runs: ReadonlyArray<{ | ||
| 101 | readonly run: { | ||
| 102 | readonly href?: string; | ||
| 103 | readonly text: string; | ||
| 104 | readonly skillName?: string; | ||
| 105 | readonly fileIcon?: string; | ||
| 106 | readonly sourceText?: string; | ||
| 107 | }; | ||
| 108 | readonly text: string; | ||
| 109 | readonly inlineImageLength: number; | ||
| 110 | }>, | ||
| 111 | ) { | ||
| 112 | let offset = 0; | ||
| 113 | const ranges: Array<{ start: number; end: number; text: string }> = []; | ||
| 114 | for (const [index, { run, text, inlineImageLength }] of runs.entries()) { | ||
| 115 | const start = offset; | ||
| 116 | offset += text.length + inlineImageLength; | ||
| 117 | const reference = parseComposerContextHref(run.href ?? ""); | ||
| 118 | const source = reference | ||
| 119 | ? formatComposerContextReference({ ...reference, label: run.text }) | ||
| 120 | : run.skillName | ||
| 121 | ? `$${run.skillName}` | ||
| 122 | : run.sourceText !== undefined | ||
| 123 | ? run.sourceText | ||
| 124 | : run.fileIcon && run.href | ||
| 125 | ? `[${run.text}](<${run.href}>)` | ||
| 126 | : null; | ||
| 127 | if (source === null) continue; | ||
| 128 | const previous = ranges.at(-1); | ||
| 129 | if ( | ||
| 130 | run.sourceText !== undefined && | ||
| 131 | !runs[index - 1]?.run.fileIcon && | ||
| 132 | previous?.end === start && | ||
| 133 | previous.text === source | ||
| 134 | ) { | ||
| 135 | previous.end = offset; | ||
| 136 | } else { | ||
| 137 | ranges.push({ start, end: offset, text: source }); | ||
| 138 | } | ||
| 139 | } | ||
| 140 | return ranges; | ||
| 141 | } | ||
| 142 | |||
| 143 | import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; | ||
| 144 | import { | ||
| 145 | resolveMarkdownInlineCodePresentation, | ||
| 146 | resolveMarkdownLinkPresentation, | ||
| 147 | type MarkdownFileIcon, | ||
| 148 | } from "./markdownLinks"; | ||
| 149 | |||
| 150 | export interface NativeMarkdownTextRun { | ||
| 151 | readonly text: string; | ||
| 152 | readonly bold?: boolean; | ||
| 153 | readonly italic?: boolean; | ||
| 154 | readonly strikethrough?: boolean; | ||
| 155 | readonly code?: boolean; | ||
| 156 | readonly href?: string; | ||
| 157 | readonly externalHost?: string; | ||
| 158 | readonly fileIcon?: MarkdownFileIcon; | ||
| 159 | readonly skillName?: string; | ||
| 160 | readonly skillLabel?: string; | ||
| 161 | readonly sourceText?: string; | ||
| 162 | readonly role?: | ||
| 163 | | "body" | ||
| 164 | | "heading" | ||
| 165 | | "list-marker" | ||
| 166 | | "list-break" | ||
| 167 | | "quote-marker" | ||
| 168 | | "code-block" | ||
| 169 | | "code-language" | ||
| 170 | | "divider" | ||
| 171 | | "spacer"; | ||
| 172 | readonly headingLevel?: number; | ||
| 173 | readonly depth?: number; | ||
| 174 | readonly spacing?: number; | ||
| 175 | readonly firstLineHeadIndent?: number; | ||
| 176 | readonly headIndent?: number; | ||
| 177 | readonly paragraphSpacing?: number; | ||
| 178 | } | ||
| 179 | |||
| 180 | export type NativeMarkdownDocumentChunk = | ||
| 181 | | { | ||
| 182 | readonly kind: "selectable"; | ||
| 183 | readonly key: string; | ||
| 184 | readonly node: MarkdownNode; | ||
| 185 | } | ||
| 186 | | { | ||
| 187 | readonly kind: "rich"; | ||
| 188 | readonly key: string; | ||
| 189 | readonly node: MarkdownNode; | ||
| 190 | }; | ||
| 191 | |||
| 192 | interface RunContext { | ||
| 193 | readonly bold: boolean; | ||
| 194 | readonly italic: boolean; | ||
| 195 | readonly strikethrough: boolean; | ||
| 196 | readonly code: boolean; | ||
| 197 | readonly href?: string; | ||
| 198 | readonly externalHost?: string; | ||
| 199 | readonly fileIcon?: MarkdownFileIcon; | ||
| 200 | readonly sourceText?: string; | ||
| 201 | readonly role?: NativeMarkdownTextRun["role"]; | ||
| 202 | readonly headingLevel?: number; | ||
| 203 | readonly depth?: number; | ||
| 204 | readonly spacing?: number; | ||
| 205 | readonly firstLineHeadIndent?: number; | ||
| 206 | readonly headIndent?: number; | ||
| 207 | readonly paragraphSpacing?: number; | ||
| 208 | } | ||
| 209 | |||
| 210 | const EMPTY_CONTEXT: RunContext = { | ||
| 211 | bold: false, | ||
| 212 | italic: false, | ||
| 213 | strikethrough: false, | ||
| 214 | code: false, | ||
| 215 | }; | ||
| 216 | |||
| 217 | const INLINE_HTML_TAG_PATTERN = /<\/?(?:kbd|mark|sub|sup|u)(?:\s[^>]*)?>/gi; | ||
| 218 | |||
| 219 | function decodeCodePoint(codePoint: number, entity: string): string { | ||
| 220 | if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { | ||
| 221 | return entity; | ||
| 222 | } | ||
| 223 | return String.fromCodePoint(codePoint); | ||
| 224 | } | ||
| 225 | |||
| 226 | function decodeHtmlEntitiesOnce(value: string): string { | ||
| 227 | return value.replace( | ||
| 228 | /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi, | ||
| 229 | (entity, decimal: string | undefined, hexadecimal: string | undefined) => { | ||
| 230 | if (decimal) { | ||
| 231 | return decodeCodePoint(Number.parseInt(decimal, 10), entity); | ||
| 232 | } | ||
| 233 | if (hexadecimal) { | ||
| 234 | return decodeCodePoint(Number.parseInt(hexadecimal, 16), entity); | ||
| 235 | } | ||
| 236 | switch (entity.toLowerCase()) { | ||
| 237 | case "&amp;": | ||
| 238 | return "&"; | ||
| 239 | case "&apos;": | ||
| 240 | return "'"; | ||
| 241 | case "&gt;": | ||
| 242 | return ">"; | ||
| 243 | case "&lt;": | ||
| 244 | return "<"; | ||
| 245 | case "&nbsp;": | ||
| 246 | return "\u00a0"; | ||
| 247 | case "&quot;": | ||
| 248 | return '"'; | ||
| 249 | default: | ||
| 250 | return entity; | ||
| 251 | } | ||
| 252 | }, | ||
| 253 | ); | ||
| 254 | } | ||
| 255 | |||
| 256 | function decodeHtmlEntities(value: string): string { | ||
| 257 | let decoded = value; | ||
| 258 | for (let pass = 0; pass < 2; pass += 1) { | ||
| 259 | const next = decodeHtmlEntitiesOnce(decoded); | ||
| 260 | if (next === decoded) { | ||
| 261 | break; | ||
| 262 | } | ||
| 263 | decoded = next; | ||
| 264 | } | ||
| 265 | return decoded; | ||
| 266 | } | ||
| 267 | |||
| 268 | function textNodeContent(value: string): string { | ||
| 269 | return decodeHtmlEntities(value).replace(INLINE_HTML_TAG_PATTERN, ""); | ||
| 270 | } | ||
| 271 | |||
| 272 | function inlineHtmlText(value: string): string { | ||
| 273 | if (/^<br\s*\/?>$/i.test(value.trim())) { | ||
| 274 | return "\n"; | ||
| 275 | } | ||
| 276 | return decodeHtmlEntities(value.replace(/<[^>]+>/g, "")); | ||
| 277 | } | ||
| 278 | |||
| 279 | function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun): boolean { | ||
| 280 | return ( | ||
| 281 | left.bold === right.bold && | ||
| 282 | left.italic === right.italic && | ||
| 283 | left.strikethrough === right.strikethrough && | ||
| 284 | left.code === right.code && | ||
| 285 | left.href === right.href && | ||
| 286 | left.externalHost === right.externalHost && | ||
| 287 | left.fileIcon === right.fileIcon && | ||
| 288 | left.sourceText === right.sourceText && | ||
| 289 | left.skillName === right.skillName && | ||
| 290 | left.skillLabel === right.skillLabel && | ||
| 291 | left.role === right.role && | ||
| 292 | left.headingLevel === right.headingLevel && | ||
| 293 | left.depth === right.depth && | ||
| 294 | left.spacing === right.spacing && | ||
| 295 | left.firstLineHeadIndent === right.firstLineHeadIndent && | ||
| 296 | left.headIndent === right.headIndent && | ||
| 297 | left.paragraphSpacing === right.paragraphSpacing | ||
| 298 | ); | ||
| 299 | } | ||
| 300 | |||
| 301 | function appendRun( | ||
| 302 | runs: NativeMarkdownTextRun[], | ||
| 303 | text: string, | ||
| 304 | context: RunContext, | ||
| 305 | ): NativeMarkdownTextRun[] { | ||
| 306 | if (text.length === 0) { | ||
| 307 | return runs; | ||
| 308 | } | ||
| 309 | |||
| 310 | const run: NativeMarkdownTextRun = { | ||
| 311 | text, | ||
| 312 | ...(context.bold ? { bold: true } : {}), | ||
| 313 | ...(context.italic ? { italic: true } : {}), | ||
| 314 | ...(context.strikethrough ? { strikethrough: true } : {}), | ||
| 315 | ...(context.code ? { code: true } : {}), | ||
| 316 | ...(context.href ? { href: context.href } : {}), | ||
| 317 | ...(context.externalHost ? { externalHost: context.externalHost } : {}), | ||
| 318 | ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}), | ||
| 319 | ...(context.sourceText !== undefined ? { sourceText: context.sourceText } : {}), | ||
| 320 | ...(context.role ? { role: context.role } : {}), | ||
| 321 | ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}), | ||
| 322 | ...(context.depth ? { depth: context.depth } : {}), | ||
| 323 | ...(context.spacing ? { spacing: context.spacing } : {}), | ||
| 324 | ...(context.firstLineHeadIndent !== undefined | ||
| 325 | ? { firstLineHeadIndent: context.firstLineHeadIndent } | ||
| 326 | : {}), | ||
| 327 | ...(context.headIndent !== undefined ? { headIndent: context.headIndent } : {}), | ||
| 328 | ...(context.paragraphSpacing !== undefined | ||
| 329 | ? { paragraphSpacing: context.paragraphSpacing } | ||
| 330 | : {}), | ||
| 331 | }; | ||
| 332 | const previous = runs.at(-1); | ||
| 333 | if (previous && sameRunStyle(previous, run)) { | ||
| 334 | runs[runs.length - 1] = { ...previous, text: previous.text + run.text }; | ||
| 335 | return runs; | ||
| 336 | } | ||
| 337 | |||
| 338 | runs.push(run); | ||
| 339 | return runs; | ||
| 340 | } | ||
| 341 | |||
| 342 | const SKILL_TOKEN_REGEX = | ||
| 343 | /(^|\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; | ||
| 344 | |||
| 345 | function formatSkillLabel(skill: SelectableMarkdownSkill): string { | ||
| 346 | const displayName = skill.displayName?.trim(); | ||
| 347 | if (displayName) { | ||
| 348 | return displayName; | ||
| 349 | } | ||
| 350 | return skill.name | ||
| 351 | .split(/[\s:_-]+/) | ||
| 352 | .filter(Boolean) | ||
| 353 | .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) | ||
| 354 | .join(" "); | ||
| 355 | } | ||
| 356 | |||
| 357 | function decorateSkillRuns( | ||
| 358 | runs: ReadonlyArray<NativeMarkdownTextRun>, | ||
| 359 | skills: ReadonlyArray<SelectableMarkdownSkill>, | ||
| 360 | ): ReadonlyArray<NativeMarkdownTextRun> { | ||
| 361 | if (skills.length === 0) { | ||
| 362 | return runs; | ||
| 363 | } | ||
| 364 | const skillByName = new Map(skills.map((skill) => [skill.name, skill])); | ||
| 365 | const decorated: NativeMarkdownTextRun[] = []; | ||
| 366 | |||
| 367 | for (const run of runs) { | ||
| 368 | if (run.code || run.href || run.fileIcon || run.role === "code-block") { | ||
| 369 | decorated.push(run); | ||
| 370 | continue; | ||
| 371 | } | ||
| 372 | |||
| 373 | let cursor = 0; | ||
| 374 | let matched = false; | ||
| 375 | for (const match of run.text.matchAll(SKILL_TOKEN_REGEX)) { | ||
| 376 | const prefix = match[1] ?? ""; | ||
| 377 | const name = match[2] ?? ""; | ||
| 378 | const skill = skillByName.get(name); | ||
| 379 | if (!skill) { | ||
| 380 | continue; | ||
| 381 | } | ||
| 382 | const start = (match.index ?? 0) + prefix.length; | ||
| 383 | const end = (match.index ?? 0) + match[0].length; | ||
| 384 | if (start > cursor) { | ||
| 385 | decorated.push({ ...run, text: run.text.slice(cursor, start) }); | ||
| 386 | } | ||
| 387 | decorated.push({ | ||
| 388 | ...run, | ||
| 389 | text: run.text.slice(start, end), | ||
| 390 | skillName: name, | ||
| 391 | skillLabel: formatSkillLabel(skill), | ||
| 392 | }); | ||
| 393 | cursor = end; | ||
| 394 | matched = true; | ||
| 395 | } | ||
| 396 | if (!matched) { | ||
| 397 | decorated.push(run); | ||
| 398 | } else if (cursor < run.text.length) { | ||
| 399 | decorated.push({ ...run, text: run.text.slice(cursor) }); | ||
| 400 | } | ||
| 401 | } | ||
| 402 | |||
| 403 | return decorated; | ||
| 404 | } | ||
| 405 | |||
| 406 | function decorateMentionRuns(runs: ReadonlyArray<NativeMarkdownTextRun>) { | ||
| 407 | return runs.flatMap((run) => { | ||
| 408 | if (run.code || run.href || run.skillName || run.role === "code-block") return [run]; | ||
| 409 | const decorated: NativeMarkdownTextRun[] = []; | ||
| 410 | let cursor = 0; | ||
| 411 | for (const token of collectComposerInlineTokens(`${run.text} `)) { | ||
| 412 | if (token.type !== "mention" || !token.source.startsWith("@")) continue; | ||
| 413 | // Sentence punctuation is not part of an unquoted file reference. | ||
| 414 | const path = token.source.startsWith('@"') | ||
| 415 | ? token.value | ||
| 416 | : token.value.replace(/[.,;!?]+$/, ""); | ||
| 417 | const presentation = resolveMarkdownLinkPresentation(path); | ||
| 418 | if (presentation.kind !== "file") continue; | ||
| 419 | const end = token.end - (token.value.length - path.length); | ||
| 420 | if (token.start > cursor) | ||
| 421 | decorated.push({ ...run, text: run.text.slice(cursor, token.start) }); | ||
| 422 | decorated.push({ | ||
| 423 | ...run, | ||
| 424 | text: presentation.label, | ||
| 425 | href: presentation.href, | ||
| 426 | fileIcon: presentation.icon, | ||
| 427 | sourceText: run.text.slice(token.start, end), | ||
| 428 | }); | ||
| 429 | cursor = end; | ||
| 430 | } | ||
| 431 | if (cursor < run.text.length) decorated.push({ ...run, text: run.text.slice(cursor) }); | ||
| 432 | return decorated; | ||
| 433 | }); | ||
| 434 | } | ||
| 435 | |||
| 436 | function appendChildren( | ||
| 437 | runs: NativeMarkdownTextRun[], | ||
| 438 | node: MarkdownNode, | ||
| 439 | context: RunContext, | ||
| 440 | ): NativeMarkdownTextRun[] { | ||
| 441 | for (const child of node.children ?? []) { | ||
| 442 | appendNode(runs, child, context); | ||
| 443 | } | ||
| 444 | return runs; | ||
| 445 | } | ||
| 446 | |||
| 447 | function nodeTextContent(node: MarkdownNode): string { | ||
| 448 | if (node.content !== undefined) { | ||
| 449 | return node.content; | ||
| 450 | } | ||
| 451 | return (node.children ?? []).map(nodeTextContent).join(""); | ||
| 452 | } | ||
| 453 | |||
| 454 | function fileLinkLabelMarkdown(node: MarkdownNode): string { | ||
| 455 | const children = (node.children ?? []).map(fileLinkLabelMarkdown).join(""); | ||
| 456 | switch (node.type) { | ||
| 457 | case "bold": | ||
| 458 | return `**${children}**`; | ||
| 459 | case "italic": | ||
| 460 | return `*${children}*`; | ||
| 461 | case "strikethrough": | ||
| 462 | return `~~${children}~~`; | ||
| 463 | case "code_inline": { | ||
| 464 | const content = nodeTextContent(node); | ||
| 465 | const fence = "`".repeat( | ||
| 466 | Math.max(0, ...(content.match(/`+/g) ?? []).map((run) => run.length)) + 1, | ||
| 467 | ); | ||
| 468 | const padding = /^`|`$|^ .* $/.test(content) && content.trim().length > 0 ? " " : ""; | ||
| 469 | return `${fence}${padding}${content}${padding}${fence}`; | ||
| 470 | } | ||
| 471 | case "soft_break": | ||
| 472 | return "\n"; | ||
| 473 | case "line_break": | ||
| 474 | return " \n"; | ||
| 475 | case "text": | ||
| 476 | return textNodeContent(nodeTextContent(node)).replace(/[\\[\]*_`~]/g, "\\$&"); | ||
| 477 | case "image": | ||
| 478 | return `![${(node.alt ?? "").replace(/[\\[\]]/g, "\\$&")}](<${node.href ?? ""}>)`; | ||
| 479 | default: | ||
| 480 | return node.content ?? children; | ||
| 481 | } | ||
| 482 | } | ||
| 483 | |||
| 484 | function appendNode( | ||
| 485 | runs: NativeMarkdownTextRun[], | ||
| 486 | node: MarkdownNode, | ||
| 487 | context: RunContext, | ||
| 488 | ): NativeMarkdownTextRun[] { | ||
| 489 | switch (node.type) { | ||
| 490 | case "text": | ||
| 491 | case "math_inline": | ||
| 492 | return appendRun(runs, textNodeContent(nodeTextContent(node)), context); | ||
| 493 | case "html_inline": | ||
| 494 | return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); | ||
| 495 | case "code_inline": { | ||
| 496 | const content = nodeTextContent(node); | ||
| 497 | const presentation = context.href ? null : resolveMarkdownInlineCodePresentation(content); | ||
| 498 | return presentation | ||
| 499 | ? appendRun(runs, presentation.label, { | ||
| 500 | ...context, | ||
| 501 | href: presentation.href, | ||
| 502 | fileIcon: presentation.icon, | ||
| 503 | }) | ||
| 504 | : appendRun(runs, content, { ...context, code: true }); | ||
| 505 | } | ||
| 506 | case "soft_break": | ||
| 507 | return appendRun(runs, " ", context); | ||
| 508 | case "line_break": | ||
| 509 | return appendRun(runs, "\n", context); | ||
| 510 | case "bold": | ||
| 511 | return appendChildren(runs, node, { ...context, bold: true }); | ||
| 512 | case "italic": | ||
| 513 | return appendChildren(runs, node, { ...context, italic: true }); | ||
| 514 | case "strikethrough": | ||
| 515 | return appendChildren(runs, node, { ...context, strikethrough: true }); | ||
| 516 | case "link": { | ||
| 517 | const reference = parseComposerContextHref(node.href ?? ""); | ||
| 518 | if (reference) { | ||
| 519 | // Build the link in isolation: adjacent links with the same href and | ||
| 520 | // style would otherwise merge into one run, collapsing two chips and | ||
| 521 | // their copy ranges into a single reference with a combined label. | ||
| 522 | const referenceRuns: NativeMarkdownTextRun[] = []; | ||
| 523 | appendChildren(referenceRuns, node, { | ||
| 524 | ...context, | ||
| 525 | href: node.href, | ||
| 526 | fileIcon: | ||
| 527 | reference.kind === "image" ? "image" : reference.kind === "terminal" ? "bash" : "text", | ||
| 528 | }); | ||
| 529 | runs.push(...referenceRuns); | ||
| 530 | return runs; | ||
| 531 | } | ||
| 532 | const presentation = resolveMarkdownLinkPresentation(node.href ?? ""); | ||
| 533 | if (presentation.kind === "file") { | ||
| 534 | const label = textNodeContent(nodeTextContent(node)); | ||
| 535 | if (!isMarkdownFileLinkLabel(label, presentation.href)) { | ||
| 536 | const fileRuns: NativeMarkdownTextRun[] = []; | ||
| 537 | const fileContext = { | ||
| 538 | ...context, | ||
| 539 | href: presentation.href, | ||
| 540 | sourceText: `[${fileLinkLabelMarkdown(node)}](<${presentation.href}>)`, | ||
| 541 | }; | ||
| 542 | appendChildren(fileRuns, node, fileContext); | ||
| 543 | appendRun(fileRuns, " ", fileContext); | ||
| 544 | appendRun(fileRuns, presentation.label, { | ||
| 545 | ...fileContext, | ||
| 546 | fileIcon: presentation.icon, | ||
| 547 | }); | ||
| 548 | runs.push(...fileRuns); | ||
| 549 | return runs; | ||
| 550 | } | ||
| 551 | return appendRun(runs, presentation.label, { | ||
| 552 | ...context, | ||
| 553 | href: presentation.href, | ||
| 554 | fileIcon: presentation.icon, | ||
| 555 | }); | ||
| 556 | } | ||
| 557 | if (presentation.kind === "external") { | ||
| 558 | return appendChildren(runs, node, { | ||
| 559 | ...context, | ||
| 560 | href: presentation.href, | ||
| 561 | externalHost: presentation.host, | ||
| 562 | }); | ||
| 563 | } | ||
| 564 | return appendChildren(runs, node, { | ||
| 565 | ...context, | ||
| 566 | ...(presentation.href ? { href: presentation.href } : {}), | ||
| 567 | }); | ||
| 568 | } | ||
| 569 | case "image": | ||
| 570 | return appendRun(runs, node.alt ?? node.title ?? "", context); | ||
| 571 | default: | ||
| 572 | return appendChildren(runs, node, context); | ||
| 573 | } | ||
| 574 | } | ||
| 575 | |||
| 576 | export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray<NativeMarkdownTextRun> { | ||
| 577 | return appendChildren([], node, EMPTY_CONTEXT); | ||
| 578 | } | ||
| 579 | |||
| 580 | export function nativeMarkdownWithPreservedSoftBreaks(node: MarkdownNode): MarkdownNode { | ||
| 581 | const children = node.children?.map(nativeMarkdownWithPreservedSoftBreaks); | ||
| 582 | return { | ||
| 583 | ...node, | ||
| 584 | ...(node.type === "soft_break" ? { type: "line_break" as const } : {}), | ||
| 585 | ...(children ? { children } : {}), | ||
| 586 | }; | ||
| 587 | } | ||
| 588 | |||
| 589 | const WINDOWS_DESTINATION_PATTERN = /\](?:\(|:)\s*<?((?:[A-Za-z]:|\\\\)(?:\\.|[^\s()<>\\])*)/g; | ||
| 590 | const MARKDOWN_ESCAPE_PATTERN = /\\([!-/:-@[-`{-~])/g; | ||
| 591 | |||
| 592 | /** | ||
| 593 | * md4c reads the `\.` in `C:\me\.t3\shot.png` as an escape, even in a link destination. | ||
| 594 | * Every backslash in a Windows path is a separator, so link and image paths go back to | ||
| 595 | * the destination as written in `markdown`. A parsed path that more than one written | ||
| 596 | * destination could have produced stays as parsed. | ||
| 597 | */ | ||
| 598 | export function nativeMarkdownWithAuthoredWindowsPaths( | ||
| 599 | node: MarkdownNode, | ||
| 600 | markdown: string, | ||
| 601 | ): MarkdownNode { | ||
| 602 | const authoredByParsed = new Map<string, string | null>(); | ||
| 603 | for (const [, authored = ""] of markdown.matchAll(WINDOWS_DESTINATION_PATTERN)) { | ||
| 604 | if (!isWindowsAbsolutePath(authored)) continue; | ||
| 605 | const parsed = authored.replace(MARKDOWN_ESCAPE_PATTERN, "$1"); | ||
| 606 | const known = authoredByParsed.get(parsed); | ||
| 607 | authoredByParsed.set(parsed, known === undefined || known === authored ? authored : null); | ||
| 608 | } | ||
| 609 | if (authoredByParsed.size === 0) return node; | ||
| 610 | const restore = (current: MarkdownNode): MarkdownNode => { | ||
| 611 | const href = current.href && authoredByParsed.get(current.href); | ||
| 612 | const children = current.children?.map(restore); | ||
| 613 | return { ...current, ...(href ? { href } : {}), ...(children ? { children } : {}) }; | ||
| 614 | }; | ||
| 615 | return restore(node); | ||
| 616 | } | ||
| 617 | |||
| 618 | function appendBlockTerminator( | ||
| 619 | runs: NativeMarkdownTextRun[], | ||
| 620 | context: RunContext, | ||
| 621 | ): NativeMarkdownTextRun[] { | ||
| 622 | return appendRun(runs, "\n", context); | ||
| 623 | } | ||
| 624 | |||
| 625 | function appendSpacer(runs: NativeMarkdownTextRun[], spacing: number): NativeMarkdownTextRun[] { | ||
| 626 | return appendRun(runs, "\n", { ...EMPTY_CONTEXT, role: "spacer", spacing }); | ||
| 627 | } | ||
| 628 | |||
| 629 | function appendInlineChildren( | ||
| 630 | runs: NativeMarkdownTextRun[], | ||
| 631 | node: MarkdownNode, | ||
| 632 | context: RunContext, | ||
| 633 | ): NativeMarkdownTextRun[] { | ||
| 634 | for (const child of node.children ?? []) { | ||
| 635 | appendNode(runs, child, context); | ||
| 636 | } | ||
| 637 | return runs; | ||
| 638 | } | ||
| 639 | |||
| 640 | function isInlineNode(node: MarkdownNode): boolean { | ||
| 641 | return ( | ||
| 642 | node.type === "text" || | ||
| 643 | node.type === "bold" || | ||
| 644 | node.type === "italic" || | ||
| 645 | node.type === "strikethrough" || | ||
| 646 | node.type === "link" || | ||
| 647 | node.type === "image" || | ||
| 648 | node.type === "code_inline" || | ||
| 649 | node.type === "math_inline" || | ||
| 650 | node.type === "html_inline" || | ||
| 651 | node.type === "soft_break" || | ||
| 652 | node.type === "line_break" | ||
| 653 | ); | ||
| 654 | } | ||
| 655 | |||
| 656 | export function nativeMarkdownListItemBlocks(node: MarkdownNode): ReadonlyArray<MarkdownNode> { | ||
| 657 | const blocks: MarkdownNode[] = []; | ||
| 658 | let inlineNodes: MarkdownNode[] = []; | ||
| 659 | const flushInlineNodes = () => { | ||
| 660 | if (inlineNodes.length === 0) { | ||
| 661 | return; | ||
| 662 | } | ||
| 663 | blocks.push({ type: "paragraph", children: inlineNodes }); | ||
| 664 | inlineNodes = []; | ||
| 665 | }; | ||
| 666 | |||
| 667 | for (const child of node.children ?? []) { | ||
| 668 | if (isInlineNode(child)) { | ||
| 669 | inlineNodes.push(child); | ||
| 670 | continue; | ||
| 671 | } | ||
| 672 | |||
| 673 | flushInlineNodes(); | ||
| 674 | blocks.push(child); | ||
| 675 | } | ||
| 676 | flushInlineNodes(); | ||
| 677 | return blocks; | ||
| 678 | } | ||
| 679 | |||
| 680 | function appendListItem( | ||
| 681 | runs: NativeMarkdownTextRun[], | ||
| 682 | node: MarkdownNode, | ||
| 683 | marker: string, | ||
| 684 | depth: number, | ||
| 685 | markerColumnWidth: number, | ||
| 686 | ): NativeMarkdownTextRun[] { | ||
| 687 | const firstLineHeadIndent = Math.max(0, depth - 1) * 20; | ||
| 688 | appendRun(runs, `${marker}\t`, { | ||
| 689 | ...EMPTY_CONTEXT, | ||
| 690 | role: "list-marker", | ||
| 691 | depth, | ||
| 692 | firstLineHeadIndent, | ||
| 693 | headIndent: firstLineHeadIndent + markerColumnWidth, | ||
| 694 | paragraphSpacing: 2, | ||
| 695 | }); | ||
| 696 | |||
| 697 | const children = node.children ?? []; | ||
| 698 | let wroteInlineContent = false; | ||
| 699 | for (const child of children) { | ||
| 700 | if (child.type === "paragraph") { | ||
| 701 | appendInlineChildren(runs, child, { | ||
| 702 | ...EMPTY_CONTEXT, | ||
| 703 | role: "body", | ||
| 704 | depth, | ||
| 705 | }); | ||
| 706 | wroteInlineContent = true; | ||
| 707 | continue; | ||
| 708 | } | ||
| 709 | if (child.type === "list") { | ||
| 710 | if (wroteInlineContent) { | ||
| 711 | appendBlockTerminator(runs, { | ||
| 712 | ...EMPTY_CONTEXT, | ||
| 713 | role: "list-break", | ||
| 714 | depth, | ||
| 715 | spacing: 1, | ||
| 716 | }); | ||
| 717 | } | ||
| 718 | appendList(runs, child, depth + 1); | ||
| 719 | wroteInlineContent = false; | ||
| 720 | continue; | ||
| 721 | } | ||
| 722 | if (isInlineNode(child)) { | ||
| 723 | appendNode(runs, child, { | ||
| 724 | ...EMPTY_CONTEXT, | ||
| 725 | role: "body", | ||
| 726 | depth, | ||
| 727 | }); | ||
| 728 | wroteInlineContent = true; | ||
| 729 | continue; | ||
| 730 | } | ||
| 731 | appendDocumentBlock(runs, child, depth); | ||
| 732 | wroteInlineContent = true; | ||
| 733 | } | ||
| 734 | |||
| 735 | if (wroteInlineContent) { | ||
| 736 | appendBlockTerminator(runs, { | ||
| 737 | ...EMPTY_CONTEXT, | ||
| 738 | role: "list-break", | ||
| 739 | depth, | ||
| 740 | spacing: depth === 1 ? 4 : 2, | ||
| 741 | }); | ||
| 742 | } | ||
| 743 | return runs; | ||
| 744 | } | ||
| 745 | |||
| 746 | function appendList( | ||
| 747 | runs: NativeMarkdownTextRun[], | ||
| 748 | node: MarkdownNode, | ||
| 749 | depth: number, | ||
| 750 | ): NativeMarkdownTextRun[] { | ||
| 751 | const ordered = node.ordered ?? false; | ||
| 752 | const start = node.start ?? 1; | ||
| 753 | const children = node.children ?? []; | ||
| 754 | const markers = children.map((child, index) => | ||
| 755 | child.type === "task_list_item" | ||
| 756 | ? child.checked | ||
| 757 | ? "☑︎" | ||
| 758 | : "☐︎" | ||
| 759 | : ordered | ||
| 760 | ? `${start + index}.` | ||
| 761 | : depth % 3 === 2 | ||
| 762 | ? "◦" | ||
| 763 | : depth % 3 === 0 | ||
| 764 | ? "▪︎" | ||
| 765 | : "•", | ||
| 766 | ); | ||
| 767 | const markerWidth = ordered | ||
| 768 | ? Math.max(0, ...markers.map((marker) => Array.from(marker).length)) | ||
| 769 | : 0; | ||
| 770 | |||
| 771 | for (const [index, child] of children.entries()) { | ||
| 772 | const marker = markers[index] ?? "•"; | ||
| 773 | const alignedMarker = | ||
| 774 | child.type === "task_list_item" | ||
| 775 | ? marker | ||
| 776 | : ordered | ||
| 777 | ? `${"\u2007".repeat(Math.max(0, markerWidth - Array.from(marker).length))}${marker}` | ||
| 778 | : marker; | ||
| 779 | const markerColumnWidth = | ||
| 780 | child.type === "task_list_item" ? 28 : ordered ? 10 + markerWidth * 8 : 24; | ||
| 781 | appendListItem(runs, child, alignedMarker, depth, markerColumnWidth); | ||
| 782 | } | ||
| 783 | return runs; | ||
| 784 | } | ||
| 785 | |||
| 786 | function appendQuoteBlock( | ||
| 787 | runs: NativeMarkdownTextRun[], | ||
| 788 | node: MarkdownNode, | ||
| 789 | depth: number, | ||
| 790 | ): NativeMarkdownTextRun[] { | ||
| 791 | for (const [index, child] of (node.children ?? []).entries()) { | ||
| 792 | if (index > 0) { | ||
| 793 | appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); | ||
| 794 | } | ||
| 795 | appendRun(runs, "│\u00a0", { | ||
| 796 | ...EMPTY_CONTEXT, | ||
| 797 | role: "quote-marker", | ||
| 798 | depth, | ||
| 799 | }); | ||
| 800 | if (child.type === "paragraph") { | ||
| 801 | appendInlineChildren(runs, child, { | ||
| 802 | ...EMPTY_CONTEXT, | ||
| 803 | role: "body", | ||
| 804 | depth, | ||
| 805 | }); | ||
| 806 | } else { | ||
| 807 | appendDocumentBlock(runs, child, depth); | ||
| 808 | } | ||
| 809 | } | ||
| 810 | appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); | ||
| 811 | return runs; | ||
| 812 | } | ||
| 813 | |||
| 814 | function appendTableRow( | ||
| 815 | runs: NativeMarkdownTextRun[], | ||
| 816 | node: MarkdownNode, | ||
| 817 | depth: number, | ||
| 818 | ): NativeMarkdownTextRun[] { | ||
| 819 | const cells = node.children ?? []; | ||
| 820 | for (const [index, cell] of cells.entries()) { | ||
| 821 | if (index > 0) { | ||
| 822 | appendRun(runs, "\u00a0│\u00a0", { | ||
| 823 | ...EMPTY_CONTEXT, | ||
| 824 | role: "divider", | ||
| 825 | depth, | ||
| 826 | }); | ||
| 827 | } | ||
| 828 | appendInlineChildren(runs, cell, { | ||
| 829 | ...EMPTY_CONTEXT, | ||
| 830 | role: "body", | ||
| 831 | bold: cell.isHeader ?? false, | ||
| 832 | depth, | ||
| 833 | }); | ||
| 834 | } | ||
| 835 | appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); | ||
| 836 | return runs; | ||
| 837 | } | ||
| 838 | |||
| 839 | function appendTable( | ||
| 840 | runs: NativeMarkdownTextRun[], | ||
| 841 | node: MarkdownNode, | ||
| 842 | depth: number, | ||
| 843 | ): NativeMarkdownTextRun[] { | ||
| 844 | const visit = (child: MarkdownNode) => { | ||
| 845 | if (child.type === "table_row") { | ||
| 846 | appendTableRow(runs, child, depth); | ||
| 847 | return; | ||
| 848 | } | ||
| 849 | for (const nested of child.children ?? []) { | ||
| 850 | visit(nested); | ||
| 851 | } | ||
| 852 | }; | ||
| 853 | visit(node); | ||
| 854 | return runs; | ||
| 855 | } | ||
| 856 | |||
| 857 | function appendDocumentBlock( | ||
| 858 | runs: NativeMarkdownTextRun[], | ||
| 859 | node: MarkdownNode, | ||
| 860 | depth = 0, | ||
| 861 | ): NativeMarkdownTextRun[] { | ||
| 862 | switch (node.type) { | ||
| 863 | case "document": { | ||
| 864 | const children = node.children ?? []; | ||
| 865 | for (const [index, child] of children.entries()) { | ||
| 866 | if (index > 0) { | ||
| 867 | const previous = children[index - 1]; | ||
| 868 | appendSpacer( | ||
| 869 | runs, | ||
| 870 | child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12, | ||
| 871 | ); | ||
| 872 | } | ||
| 873 | appendDocumentBlock(runs, child, depth); | ||
| 874 | } | ||
| 875 | return runs; | ||
| 876 | } | ||
| 877 | case "heading": { | ||
| 878 | const context: RunContext = { | ||
| 879 | ...EMPTY_CONTEXT, | ||
| 880 | role: "heading", | ||
| 881 | headingLevel: node.level ?? 1, | ||
| 882 | depth, | ||
| 883 | }; | ||
| 884 | appendInlineChildren(runs, node, context); | ||
| 885 | return appendBlockTerminator(runs, context); | ||
| 886 | } | ||
| 887 | case "paragraph": { | ||
| 888 | const context: RunContext = { ...EMPTY_CONTEXT, role: "body", depth }; | ||
| 889 | appendInlineChildren(runs, node, context); | ||
| 890 | return appendBlockTerminator(runs, context); | ||
| 891 | } | ||
| 892 | case "list": | ||
| 893 | return appendList(runs, node, depth + 1); | ||
| 894 | case "blockquote": | ||
| 895 | return appendQuoteBlock(runs, node, depth); | ||
| 896 | case "code_block": { | ||
| 897 | if (node.language) { | ||
| 898 | appendRun(runs, `${node.language.toUpperCase()}\n`, { | ||
| 899 | ...EMPTY_CONTEXT, | ||
| 900 | role: "code-language", | ||
| 901 | code: true, | ||
| 902 | depth, | ||
| 903 | }); | ||
| 904 | } | ||
| 905 | const content = nodeTextContent(node); | ||
| 906 | appendRun(runs, content, { | ||
| 907 | ...EMPTY_CONTEXT, | ||
| 908 | role: "code-block", | ||
| 909 | code: true, | ||
| 910 | depth, | ||
| 911 | }); | ||
| 912 | if (!content.endsWith("\n")) { | ||
| 913 | appendBlockTerminator(runs, { | ||
| 914 | ...EMPTY_CONTEXT, | ||
| 915 | role: "code-block", | ||
| 916 | code: true, | ||
| 917 | depth, | ||
| 918 | }); | ||
| 919 | } | ||
| 920 | return runs; | ||
| 921 | } | ||
| 922 | case "horizontal_rule": | ||
| 923 | appendRun(runs, "────────────────────────\n", { | ||
| 924 | ...EMPTY_CONTEXT, | ||
| 925 | role: "divider", | ||
| 926 | depth, | ||
| 927 | }); | ||
| 928 | return runs; | ||
| 929 | case "table": | ||
| 930 | return appendTable(runs, node, depth); | ||
| 931 | case "html_block": | ||
| 932 | appendRun(runs, inlineHtmlText(nodeTextContent(node)), { | ||
| 933 | ...EMPTY_CONTEXT, | ||
| 934 | role: "body", | ||
| 935 | depth, | ||
| 936 | }); | ||
| 937 | return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); | ||
| 938 | case "math_block": | ||
| 939 | appendRun(runs, nodeTextContent(node), { ...EMPTY_CONTEXT, role: "body", depth }); | ||
| 940 | return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); | ||
| 941 | default: | ||
| 942 | appendInlineChildren(runs, node, { ...EMPTY_CONTEXT, role: "body", depth }); | ||
| 943 | return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); | ||
| 944 | } | ||
| 945 | } | ||
| 946 | |||
| 947 | function containsRichBlock(node: MarkdownNode): boolean { | ||
| 948 | if ( | ||
| 949 | node.type === "code_block" || | ||
| 950 | node.type === "blockquote" || | ||
| 951 | node.type === "table" || | ||
| 952 | node.type === "image" || | ||
| 953 | node.type === "horizontal_rule" || | ||
| 954 | node.type === "html_block" || | ||
| 955 | node.type === "math_block" | ||
| 956 | ) { | ||
| 957 | return true; | ||
| 958 | } | ||
| 959 | return (node.children ?? []).some(containsRichBlock); | ||
| 960 | } | ||
| 961 | |||
| 962 | /** | ||
| 963 | * Sibling identity for React keys. A source offset survives appends while the | ||
| 964 | * document streams; the child index is the fallback for offset-free nodes. The | ||
| 965 | * two never share a namespace, so a positioned node cannot collide with an | ||
| 966 | * offset-free sibling whose index happens to equal its offset. | ||
| 967 | */ | ||
| 968 | export function nativeMarkdownNodePosition(node: MarkdownNode, index: number): string { | ||
| 969 | return node.beg === undefined ? `index:${index}` : `offset:${node.beg}`; | ||
| 970 | } | ||
| 971 | |||
| 972 | export function nativeMarkdownDocumentChunks( | ||
| 973 | document: MarkdownNode, | ||
| 974 | ): ReadonlyArray<NativeMarkdownDocumentChunk> { | ||
| 975 | const chunks: NativeMarkdownDocumentChunk[] = []; | ||
| 976 | let selectableNodes: MarkdownNode[] = []; | ||
| 977 | let selectableStart = 0; | ||
| 978 | |||
| 979 | const flushSelectable = () => { | ||
| 980 | const first = selectableNodes[0]; | ||
| 981 | if (!first) { | ||
| 982 | return; | ||
| 983 | } | ||
| 984 | chunks.push({ | ||
| 985 | kind: "selectable", | ||
| 986 | key: `selectable:${nativeMarkdownNodePosition(first, selectableStart)}`, | ||
| 987 | node: { | ||
| 988 | type: "document", | ||
| 989 | children: selectableNodes, | ||
| 990 | }, | ||
| 991 | }); | ||
| 992 | selectableNodes = []; | ||
| 993 | }; | ||
| 994 | |||
| 995 | for (const [index, child] of (document.children ?? []).entries()) { | ||
| 996 | if (!containsRichBlock(child)) { | ||
| 997 | if (selectableNodes.length === 0) { | ||
| 998 | selectableStart = index; | ||
| 999 | } | ||
| 1000 | selectableNodes.push(child); | ||
| 1001 | continue; | ||
| 1002 | } | ||
| 1003 | |||
| 1004 | flushSelectable(); | ||
| 1005 | chunks.push({ | ||
| 1006 | kind: "rich", | ||
| 1007 | key: `rich:${child.type}:${nativeMarkdownNodePosition(child, index)}`, | ||
| 1008 | node: child, | ||
| 1009 | }); | ||
| 1010 | } | ||
| 1011 | flushSelectable(); | ||
| 1012 | return chunks; | ||
| 1013 | } | ||
| 1014 | |||
| 1015 | function topLevelNodes(node: MarkdownNode): ReadonlyArray<MarkdownNode> { | ||
| 1016 | return node.type === "document" ? (node.children ?? []) : [node]; | ||
| 1017 | } | ||
| 1018 | |||
| 1019 | export function nativeMarkdownChunkSpacing( | ||
| 1020 | previous: NativeMarkdownDocumentChunk | undefined, | ||
| 1021 | current: NativeMarkdownDocumentChunk, | ||
| 1022 | ): number { | ||
| 1023 | if (!previous) { | ||
| 1024 | return 0; | ||
| 1025 | } | ||
| 1026 | |||
| 1027 | const previousLast = topLevelNodes(previous.node).at(-1); | ||
| 1028 | const currentFirst = topLevelNodes(current.node)[0]; | ||
| 1029 | |||
| 1030 | if (currentFirst?.type === "heading") { | ||
| 1031 | return 20; | ||
| 1032 | } | ||
| 1033 | if (previousLast?.type === "heading") { | ||
| 1034 | return 10; | ||
| 1035 | } | ||
| 1036 | if (previousLast?.type === "list" && currentFirst?.type === "list") { | ||
| 1037 | return 12; | ||
| 1038 | } | ||
| 1039 | return 14; | ||
| 1040 | } | ||
| 1041 | |||
| 1042 | export function nativeMarkdownDocumentRuns( | ||
| 1043 | node: MarkdownNode, | ||
| 1044 | skills: ReadonlyArray<SelectableMarkdownSkill> = [], | ||
| 1045 | ): ReadonlyArray<NativeMarkdownTextRun> { | ||
| 1046 | const runs = appendDocumentBlock([], node); | ||
| 1047 | while (runs.length > 0) { | ||
| 1048 | const lastIndex = runs.length - 1; | ||
| 1049 | const last = runs[lastIndex]; | ||
| 1050 | if (!last?.text.endsWith("\n")) { | ||
| 1051 | break; | ||
| 1052 | } | ||
| 1053 | const text = last.text.slice(0, -1); | ||
| 1054 | if (text.length === 0) { | ||
| 1055 | runs.pop(); | ||
| 1056 | } else { | ||
| 1057 | runs[lastIndex] = { ...last, text }; | ||
| 1058 | } | ||
| 1059 | } | ||
| 1060 | return decorateMentionRuns(decorateSkillRuns(runs, skills)); | ||
| 1061 | } | ||
| 1062 | |||
| 1063 | const HTML_TAG = /<!--[\s\S]*?-->|<\/?[a-z](?:[^>"']|"[^"]*"|'[^']*')*>/gi; | ||
| 1064 | |||
| 1065 | function attribute(tag: string, name: string): string | null { | ||
| 1066 | const attributes = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g; | ||
| 1067 | for (const match of tag.matchAll(attributes)) { | ||
| 1068 | if (match[1]?.toLowerCase() === name) { | ||
| 1069 | return decodeHtmlEntities(match[2] ?? match[3] ?? match[4] ?? ""); | ||
| 1070 | } | ||
| 1071 | } | ||
| 1072 | return null; | ||
| 1073 | } | ||
| 1074 | |||
| 1075 | /** Called only for parsed HTML nodes, so Markdown code examples keep their literal tags. */ | ||
| 1076 | export function markdownHtmlContent(content: string): string { | ||
| 1077 | // An HTML block such as <details> can also contain Markdown fences and code spans. | ||
| 1078 | const codeRanges: Array<{ start: number; end: number }> = []; | ||
| 1079 | const delimiters = [...content.matchAll(/`+|~{3,}/g)]; | ||
| 1080 | for (let index = 0; index < delimiters.length; index += 1) { | ||
| 1081 | const opening = delimiters[index]!; | ||
| 1082 | const lineStart = content.lastIndexOf("\n", opening.index - 1) + 1; | ||
| 1083 | const fenced = | ||
| 1084 | opening[0].length >= 3 && /^ {0,3}$/.test(content.slice(lineStart, opening.index)); | ||
| 1085 | if (opening[0][0] === "~" && !fenced) continue; | ||
| 1086 | let closingIndex = -1; | ||
| 1087 | for (let candidate = index + 1; candidate < delimiters.length; candidate += 1) { | ||
| 1088 | const closing = delimiters[candidate]!; | ||
| 1089 | if (!fenced && closing[0] === opening[0]) { | ||
| 1090 | closingIndex = candidate; | ||
| 1091 | break; | ||
| 1092 | } | ||
| 1093 | if (!fenced || closing[0][0] !== opening[0][0] || closing[0].length < opening[0].length) | ||
| 1094 | continue; | ||
| 1095 | const start = content.lastIndexOf("\n", closing.index - 1) + 1; | ||
| 1096 | const nextLine = content.indexOf("\n", closing.index); | ||
| 1097 | const end = nextLine === -1 ? content.length : nextLine; | ||
| 1098 | if ( | ||
| 1099 | /^ {0,3}$/.test(content.slice(start, closing.index)) && | ||
| 1100 | /^\s*$/.test(content.slice(closing.index + closing[0].length, end)) | ||
| 1101 | ) { | ||
| 1102 | closingIndex = candidate; | ||
| 1103 | break; | ||
| 1104 | } | ||
| 1105 | } | ||
| 1106 | if (closingIndex === -1) { | ||
| 1107 | if (fenced) codeRanges.push({ start: opening.index, end: content.length }); | ||
| 1108 | continue; | ||
| 1109 | } | ||
| 1110 | const closing = delimiters[closingIndex]!; | ||
| 1111 | codeRanges.push({ start: opening.index, end: closing.index + closing[0].length }); | ||
| 1112 | index = closingIndex; | ||
| 1113 | } | ||
| 1114 | return content.replace(HTML_TAG, (tag, offset: number) => { | ||
| 1115 | if (codeRanges.some((range) => offset >= range.start && offset < range.end)) return tag; | ||
| 1116 | if (tag.startsWith("<!--")) return ""; | ||
| 1117 | if (/^<img\b/i.test(tag)) { | ||
| 1118 | const src = attribute(tag, "src"); | ||
| 1119 | if (!src) return ""; | ||
| 1120 | const alt = (attribute(tag, "alt") ?? "").replace(/[\\[\]]/g, "\\$&"); | ||
| 1121 | const href = src.replace(/</g, "%3C").replace(/>/g, "%3E").replace(/\n/g, ""); | ||
| 1122 | return ``; | ||
| 1123 | } | ||
| 1124 | if (/^<summary\b/i.test(tag)) return "\n\n**"; | ||
| 1125 | if (/^<\/summary\s*>/i.test(tag)) return "**\n\n"; | ||
| 1126 | if (/^<br\b/i.test(tag)) return " \n"; | ||
| 1127 | if (/^<\/?(?:p|div|details)\b/i.test(tag)) return "\n\n"; | ||
| 1128 | return ""; | ||
| 1129 | }); | ||
| 1130 | } | ||
| 1131 | |||
| 1132 | export function nativeMarkdownWithHtml( | ||
| 1133 | document: MarkdownNode, | ||
| 1134 | parse: (content: string) => MarkdownNode, | ||
| 1135 | ): MarkdownNode { | ||
| 1136 | const withoutOffsets = (node: MarkdownNode): MarkdownNode => { | ||
| 1137 | const { beg: _beg, end: _end, children, ...rest } = node; | ||
| 1138 | return { ...rest, ...(children ? { children: children.map(withoutOffsets) } : {}) }; | ||
| 1139 | }; | ||
| 1140 | const transform = (node: MarkdownNode): MarkdownNode[] => { | ||
| 1141 | if (node.type === "html_block" || node.type === "html_inline") { | ||
| 1142 | const content = markdownHtmlContent(node.content ?? ""); | ||
| 1143 | if (!content.trim()) | ||
| 1144 | return /^<br\b/i.test(node.content ?? "") ? [{ type: "line_break" }] : []; | ||
| 1145 | // Fragment offsets restart at zero, so they cannot identify chunks in the parent document. | ||
| 1146 | const children = (parse(content).children ?? []).map(withoutOffsets); | ||
| 1147 | return node.type === "html_inline" | ||
| 1148 | ? children.flatMap((child) => | ||
| 1149 | child.type === "paragraph" ? (child.children ?? []) : [child], | ||
| 1150 | ) | ||
| 1151 | : children; | ||
| 1152 | } | ||
| 1153 | if (!node.children) return [node]; | ||
| 1154 | const children = node.children.flatMap(transform); | ||
| 1155 | if (node.type === "paragraph" && children.length === 0) return []; | ||
| 1156 | return [{ ...node, children }]; | ||
| 1157 | }; | ||
| 1158 | return { ...document, children: (document.children ?? []).flatMap(transform) }; | ||
| 1159 | } | ||
apps/mobile/modules/t3-markdown-text/src/pendingCodeHighlight.test.ts deleted-28| ... | @@ -1,28 +0,0 @@ | ||
| 1 | import { expect, it } from "vite-plus/test"; | ||
| 2 | import { pendingCodeHighlight } from "./pendingCodeHighlight"; | ||
| 3 | |||
| 4 | it("keeps completed colors and exact current text without retaining an edited tail", () => { | ||
| 5 | const colored = [ | ||
| 6 | [{ content: "const n = 1;", color: "red", fontStyle: 0 }], | ||
| 7 | [{ content: "partial", color: "blue", fontStyle: 0 }], | ||
| 8 | ]; | ||
| 9 | const result = pendingCodeHighlight( | ||
| 10 | "const n = 1;\npartial", | ||
| 11 | "const n = 1;\nchanged\nnext", | ||
| 12 | colored, | ||
| 13 | )!; | ||
| 14 | expect(result[0]).toBe(colored[0]); | ||
| 15 | expect(result.map((line) => line.map((token) => token.content).join("")).join("\n")).toBe( | ||
| 16 | "const n = 1;\nchanged\nnext", | ||
| 17 | ); | ||
| 18 | expect( | ||
| 19 | result | ||
| 20 | .slice(1) | ||
| 21 | .flat() | ||
| 22 | .every((token) => token.color === null), | ||
| 23 | ).toBe(true); | ||
| 24 | expect( | ||
| 25 | pendingCodeHighlight("const n = 1;\npartial", "const n = 2;\npartial", colored), | ||
| 26 | ).toBeNull(); | ||
| 27 | expect(pendingCodeHighlight("partial", "other", colored)).toBeNull(); | ||
| 28 | }); | ||
apps/mobile/modules/t3-markdown-text/src/pendingCodeHighlight.ts deleted-19| ... | @@ -1,19 +0,0 @@ | ||
| 1 | import type { MarkdownHighlightedToken } from "./SelectableMarkdownText.types"; | ||
| 2 | |||
| 3 | /** Keep finished lines colored while the current line awaits highlighting. */ | ||
| 4 | export function pendingCodeHighlight( | ||
| 5 | previousCode: string, | ||
| 6 | code: string, | ||
| 7 | tokens: ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>, | ||
| 8 | ): ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>> | null { | ||
| 9 | const end = previousCode.lastIndexOf("\n") + 1; | ||
| 10 | if (!end || !code.startsWith(previousCode.slice(0, end))) return null; | ||
| 11 | const completedLines = previousCode.slice(0, end).split("\n").length - 1; | ||
| 12 | return [ | ||
| 13 | ...tokens.slice(0, completedLines), | ||
| 14 | ...code | ||
| 15 | .slice(end) | ||
| 16 | .split("\n") | ||
| 17 | .map((content) => [{ content, color: null, fontStyle: null }]), | ||
| 18 | ]; | ||
| 19 | } | ||
apps/mobile/modules/t3-markdown-text/src/useHighlightedCode.test.tsx deleted-189| ... | @@ -1,189 +0,0 @@ | ||
| 1 | import { act, useLayoutEffect } from "react"; | ||
| 2 | import { createRoot, type Root } from "react-dom/client"; | ||
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import type { MarkdownCodeHighlighter } from "./SelectableMarkdownText.types"; | ||
| 6 | import { useHighlightedCode, type HighlightedCode } from "./useHighlightedCode"; | ||
| 7 | |||
| 8 | type Deferred = { | ||
| 9 | readonly code: string; | ||
| 10 | readonly resolve: (tokens: HighlightedCode) => void; | ||
| 11 | }; | ||
| 12 | |||
| 13 | /** One colored line per newline-separated line, so a line's color proves where it came from. */ | ||
| 14 | function tokensFor(code: string, color: string): HighlightedCode { | ||
| 15 | return code.split("\n").map((content) => [{ content, color, fontStyle: 0 }]); | ||
| 16 | } | ||
| 17 | |||
| 18 | function lineText(tokens: HighlightedCode | null): string | null { | ||
| 19 | return tokens?.map((line) => line.map((token) => token.content).join("")).join("\n") ?? null; | ||
| 20 | } | ||
| 21 | |||
| 22 | function lineColors(tokens: HighlightedCode | null): ReadonlyArray<string | null> { | ||
| 23 | return tokens?.map((line) => line[0]?.color ?? null) ?? []; | ||
| 24 | } | ||
| 25 | |||
| 26 | const deferred: Deferred[] = []; | ||
| 27 | const readResults = new Map<string, HighlightedCode>(); | ||
| 28 | let renders = 0; | ||
| 29 | let result: HighlightedCode | null = null; | ||
| 30 | let root: Root; | ||
| 31 | |||
| 32 | const highlightCode: MarkdownCodeHighlighter = Object.assign( | ||
| 33 | vi.fn( | ||
| 34 | (input: { code: string }) => | ||
| 35 | new Promise<HighlightedCode>((resolve) => { | ||
| 36 | deferred.push({ code: input.code, resolve }); | ||
| 37 | }), | ||
| 38 | ), | ||
| 39 | { | ||
| 40 | read: vi.fn((input: { code: string }) => readResults.get(input.code)), | ||
| 41 | }, | ||
| 42 | ); | ||
| 43 | |||
| 44 | function Probe(props: { | ||
| 45 | readonly code: string; | ||
| 46 | readonly language?: string; | ||
| 47 | readonly theme?: "light" | "dark"; | ||
| 48 | }) { | ||
| 49 | const tokens = useHighlightedCode( | ||
| 50 | props.code, | ||
| 51 | props.language ?? "ts", | ||
| 52 | props.theme ?? "dark", | ||
| 53 | highlightCode, | ||
| 54 | ); | ||
| 55 | useLayoutEffect(() => { | ||
| 56 | renders += 1; | ||
| 57 | result = tokens; | ||
| 58 | }); | ||
| 59 | return null; | ||
| 60 | } | ||
| 61 | |||
| 62 | async function render(props: Parameters<typeof Probe>[0]) { | ||
| 63 | await act(() => root.render(<Probe {...props} />)); | ||
| 64 | } | ||
| 65 | |||
| 66 | async function resolveAsync(index: number, color: string) { | ||
| 67 | const pending = deferred[index]; | ||
| 68 | if (!pending) throw new Error(`no pending highlight at ${index}`); | ||
| 69 | await act(async () => { | ||
| 70 | pending.resolve(tokensFor(pending.code, color)); | ||
| 71 | }); | ||
| 72 | } | ||
| 73 | |||
| 74 | beforeEach(async () => { | ||
| 75 | // The probe has no DOM output, but ReactDOM needs an event target. | ||
| 76 | const document = { | ||
| 77 | nodeType: 9, | ||
| 78 | addEventListener() {}, | ||
| 79 | removeEventListener() {}, | ||
| 80 | }; | ||
| 81 | const container = { | ||
| 82 | nodeType: 1, | ||
| 83 | tagName: "DIV", | ||
| 84 | namespaceURI: "http://www.w3.org/1999/xhtml", | ||
| 85 | ownerDocument: document, | ||
| 86 | addEventListener() {}, | ||
| 87 | removeEventListener() {}, | ||
| 88 | }; | ||
| 89 | vi.stubGlobal("document", document); | ||
| 90 | vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); | ||
| 91 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 92 | deferred.length = 0; | ||
| 93 | readResults.clear(); | ||
| 94 | renders = 0; | ||
| 95 | result = null; | ||
| 96 | vi.mocked(highlightCode).mockClear(); | ||
| 97 | vi.mocked(highlightCode.read!).mockClear(); | ||
| 98 | root = createRoot(container as unknown as HTMLElement); | ||
| 99 | }); | ||
| 100 | |||
| 101 | afterEach(async () => { | ||
| 102 | await act(() => root.unmount()); | ||
| 103 | vi.unstubAllGlobals(); | ||
| 104 | }); | ||
| 105 | |||
| 106 | // Every test uses a distinct language so the module-level token cache from an | ||
| 107 | // earlier test can never satisfy a later one. | ||
| 108 | describe("useHighlightedCode", () => { | ||
| 109 | it("keeps colors from the latest synchronous read when a later read misses", async () => { | ||
| 110 | const language = "sync-baseline"; | ||
| 111 | await render({ code: "a", language }); | ||
| 112 | expect(result).toBeNull(); | ||
| 113 | await resolveAsync(0, "async"); | ||
| 114 | expect(lineColors(result)).toEqual(["async"]); | ||
| 115 | |||
| 116 | readResults.set("a\nb", tokensFor("a\nb", "sync-1")); | ||
| 117 | readResults.set("a\nb\nc", tokensFor("a\nb\nc", "sync-2")); | ||
| 118 | const rendersBeforeAppends = renders; | ||
| 119 | await render({ code: "a\nb", language }); | ||
| 120 | await render({ code: "a\nb\nc", language }); | ||
| 121 | expect(lineColors(result)).toEqual(["sync-2", "sync-2", "sync-2"]); | ||
| 122 | // A synchronous hit is one render per append: no state update, no highlighter call. | ||
| 123 | expect(renders - rendersBeforeAppends).toBe(2); | ||
| 124 | expect(highlightCode).toHaveBeenCalledTimes(1); | ||
| 125 | |||
| 126 | await render({ code: "a\nb\nc\nd", language }); | ||
| 127 | expect(highlightCode).toHaveBeenCalledTimes(2); | ||
| 128 | expect(lineText(result)).toBe("a\nb\nc\nd"); | ||
| 129 | // Lines before the previous code's final newline are the completed ones. | ||
| 130 | expect(lineColors(result)).toEqual(["sync-2", "sync-2", null, null]); | ||
| 131 | |||
| 132 | await resolveAsync(1, "async-2"); | ||
| 133 | expect(lineColors(result)).toEqual(["async-2", "async-2", "async-2", "async-2"]); | ||
| 134 | }); | ||
| 135 | |||
| 136 | it("does not show obsolete text after a non-append edit", async () => { | ||
| 137 | const language = "non-append"; | ||
| 138 | await render({ code: "a\nb", language }); | ||
| 139 | await resolveAsync(0, "async"); | ||
| 140 | readResults.set("a\nb\nc", tokensFor("a\nb\nc", "sync")); | ||
| 141 | await render({ code: "a\nb\nc", language }); | ||
| 142 | expect(lineColors(result)).toEqual(["sync", "sync", "sync"]); | ||
| 143 | |||
| 144 | await render({ code: "x\nb\nc", language }); | ||
| 145 | expect(result).toBeNull(); | ||
| 146 | await resolveAsync(1, "async-2"); | ||
| 147 | expect(lineText(result)).toBe("x\nb\nc"); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("never reuses colors across a language or theme change", async () => { | ||
| 151 | const language = "theme-change"; | ||
| 152 | await render({ code: "a\nb", language }); | ||
| 153 | await resolveAsync(0, "async"); | ||
| 154 | readResults.set("a\nb\nc", tokensFor("a\nb\nc", "sync")); | ||
| 155 | await render({ code: "a\nb\nc", language }); | ||
| 156 | expect(lineColors(result)).toEqual(["sync", "sync", "sync"]); | ||
| 157 | |||
| 158 | await render({ code: "a\nb\nc\nd", language, theme: "light" }); | ||
| 159 | expect(result).toBeNull(); | ||
| 160 | await render({ code: "a\nb\nc\nd", language: `${language}-other` }); | ||
| 161 | expect(result).toBeNull(); | ||
| 162 | }); | ||
| 163 | |||
| 164 | it("ignores an asynchronous completion that is older than the current code", async () => { | ||
| 165 | const language = "stale-async"; | ||
| 166 | await render({ code: "a\nb", language }); | ||
| 167 | await resolveAsync(0, "async"); | ||
| 168 | |||
| 169 | await render({ code: "a\nb\nc", language }); | ||
| 170 | expect(deferred).toHaveLength(2); | ||
| 171 | expect(lineColors(result)).toEqual(["async", null, null]); | ||
| 172 | |||
| 173 | readResults.set("a\nb\nc\nd", tokensFor("a\nb\nc\nd", "sync")); | ||
| 174 | await render({ code: "a\nb\nc\nd", language }); | ||
| 175 | expect(lineColors(result)).toEqual(["sync", "sync", "sync", "sync"]); | ||
| 176 | |||
| 177 | await render({ code: "a\nb\nc\nd\ne", language }); | ||
| 178 | expect(deferred).toHaveLength(3); | ||
| 179 | expect(lineColors(result)).toEqual(["sync", "sync", "sync", null, null]); | ||
| 180 | |||
| 181 | // The older request finishes after newer synchronous results exist. | ||
| 182 | await resolveAsync(1, "stale"); | ||
| 183 | expect(lineText(result)).toBe("a\nb\nc\nd\ne"); | ||
| 184 | expect(lineColors(result)).toEqual(["sync", "sync", "sync", null, null]); | ||
| 185 | |||
| 186 | await resolveAsync(2, "async-3"); | ||
| 187 | expect(lineColors(result)).toEqual(["async-3", "async-3", "async-3", "async-3", "async-3"]); | ||
| 188 | }); | ||
| 189 | }); | ||
apps/mobile/modules/t3-markdown-text/src/useHighlightedCode.ts deleted-149| ... | @@ -1,149 +0,0 @@ | ||
| 1 | import { useEffect, useMemo, useRef, useState } from "react"; | ||
| 2 | |||
| 3 | import { pendingCodeHighlight } from "./pendingCodeHighlight"; | ||
| 4 | import type { | ||
| 5 | MarkdownCodeHighlighter, | ||
| 6 | MarkdownHighlightedToken, | ||
| 7 | } from "./SelectableMarkdownText.types"; | ||
| 8 | |||
| 9 | export type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>; | ||
| 10 | |||
| 11 | interface HighlightedCodeResult { | ||
| 12 | readonly key: string; | ||
| 13 | readonly code: string; | ||
| 14 | readonly language: string | undefined; | ||
| 15 | readonly theme: "light" | "dark"; | ||
| 16 | readonly tokens: HighlightedCode | null; | ||
| 17 | } | ||
| 18 | |||
| 19 | const highlightedCodeCache = new Map<string, HighlightedCode>(); | ||
| 20 | const highlightedCodePromiseCache = new Map<string, Promise<HighlightedCode>>(); | ||
| 21 | const HIGHLIGHTED_CODE_CACHE_LIMIT = 64; | ||
| 22 | |||
| 23 | function codeHighlightCacheKey( | ||
| 24 | code: string, | ||
| 25 | language: string | undefined, | ||
| 26 | theme: "light" | "dark", | ||
| 27 | ): string { | ||
| 28 | return `${theme}:${language ?? "text"}:${code}`; | ||
| 29 | } | ||
| 30 | |||
| 31 | function cacheHighlightedCode(key: string, tokens: HighlightedCode): void { | ||
| 32 | highlightedCodeCache.delete(key); | ||
| 33 | highlightedCodeCache.set(key, tokens); | ||
| 34 | |||
| 35 | while (highlightedCodeCache.size > HIGHLIGHTED_CODE_CACHE_LIMIT) { | ||
| 36 | const oldestKey = highlightedCodeCache.keys().next().value; | ||
| 37 | if (oldestKey === undefined) { | ||
| 38 | break; | ||
| 39 | } | ||
| 40 | highlightedCodeCache.delete(oldestKey); | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | function loadHighlightedCode( | ||
| 45 | code: string, | ||
| 46 | language: string | undefined, | ||
| 47 | theme: "light" | "dark", | ||
| 48 | highlightCode: MarkdownCodeHighlighter, | ||
| 49 | session: object, | ||
| 50 | ): Promise<HighlightedCode> { | ||
| 51 | const key = codeHighlightCacheKey(code, language, theme); | ||
| 52 | const cached = highlightedCodeCache.get(key); | ||
| 53 | if (cached) { | ||
| 54 | return Promise.resolve(cached); | ||
| 55 | } | ||
| 56 | |||
| 57 | const pending = highlightedCodePromiseCache.get(key); | ||
| 58 | if (pending) { | ||
| 59 | return pending; | ||
| 60 | } | ||
| 61 | |||
| 62 | const promise = highlightCode({ code, language, theme, session }) | ||
| 63 | .then((tokens) => { | ||
| 64 | cacheHighlightedCode(key, tokens); | ||
| 65 | highlightedCodePromiseCache.delete(key); | ||
| 66 | return tokens; | ||
| 67 | }) | ||
| 68 | .catch((error) => { | ||
| 69 | highlightedCodePromiseCache.delete(key); | ||
| 70 | throw error; | ||
| 71 | }); | ||
| 72 | highlightedCodePromiseCache.set(key, promise); | ||
| 73 | return promise; | ||
| 74 | } | ||
| 75 | |||
| 76 | /** | ||
| 77 | * Tokens for a code block, or null while nothing usable exists yet. A cached or | ||
| 78 | * synchronous result renders in the same pass. Otherwise the most recent | ||
| 79 | * completed result seeds `pendingCodeHighlight` so finished lines keep their | ||
| 80 | * colors while the asynchronous highlighter catches up. | ||
| 81 | */ | ||
| 82 | export function useHighlightedCode( | ||
| 83 | code: string, | ||
| 84 | language: string | undefined, | ||
| 85 | theme: "light" | "dark", | ||
| 86 | highlightCode: MarkdownCodeHighlighter, | ||
| 87 | ): HighlightedCode | null { | ||
| 88 | const [session] = useState(() => ({})); | ||
| 89 | const key = codeHighlightCacheKey(code, language, theme); | ||
| 90 | const ready = useMemo( | ||
| 91 | () => highlightedCodeCache.get(key) ?? highlightCode.read?.({ code, language, theme, session }), | ||
| 92 | [code, language, theme, key, highlightCode, session], | ||
| 93 | ); | ||
| 94 | const [highlighted, setHighlighted] = useState<HighlightedCodeResult>(() => ({ | ||
| 95 | code, | ||
| 96 | language, | ||
| 97 | theme, | ||
| 98 | key, | ||
| 99 | tokens: highlightedCodeCache.get(key) ?? null, | ||
| 100 | })); | ||
| 101 | // Synchronous reads never touch state, so each append costs one render. The | ||
| 102 | // ref remembers the newest of them, and is cleared whenever an asynchronous | ||
| 103 | // result commits so `highlighted` is the baseline again from then on. | ||
| 104 | const latestRead = useRef<HighlightedCodeResult | null>(null); | ||
| 105 | |||
| 106 | useEffect(() => { | ||
| 107 | if (ready) { | ||
| 108 | latestRead.current = { code, language, theme, key, tokens: ready }; | ||
| 109 | return; | ||
| 110 | } | ||
| 111 | let active = true; | ||
| 112 | const commit = (tokens: HighlightedCode | null) => { | ||
| 113 | latestRead.current = null; | ||
| 114 | setHighlighted({ code, language, theme, key, tokens }); | ||
| 115 | }; | ||
| 116 | const cached = highlightedCodeCache.get(key); | ||
| 117 | if (cached) { | ||
| 118 | cacheHighlightedCode(key, cached); | ||
| 119 | commit(cached); | ||
| 120 | return () => { | ||
| 121 | active = false; | ||
| 122 | }; | ||
| 123 | } | ||
| 124 | |||
| 125 | void loadHighlightedCode(code, language, theme, highlightCode, session) | ||
| 126 | .then((tokens) => { | ||
| 127 | if (active) { | ||
| 128 | commit(tokens); | ||
| 129 | } | ||
| 130 | }) | ||
| 131 | .catch(() => { | ||
| 132 | if (active) { | ||
| 133 | commit(null); | ||
| 134 | } | ||
| 135 | }); | ||
| 136 | return () => { | ||
| 137 | active = false; | ||
| 138 | }; | ||
| 139 | }, [code, highlightCode, key, language, theme, ready, session]); | ||
| 140 | |||
| 141 | if (ready) return ready; | ||
| 142 | // oxlint-disable-next-line react/refs -- Written only after commit; a discarded render never advances it. | ||
| 143 | const baseline = latestRead.current ?? highlighted; | ||
| 144 | if (baseline.key === key) return baseline.tokens; | ||
| 145 | if (baseline.tokens && baseline.language === language && baseline.theme === theme) { | ||
| 146 | return pendingCodeHighlight(baseline.code, code, baseline.tokens); | ||
| 147 | } | ||
| 148 | return null; | ||
| 149 | } | ||
apps/mobile/modules/t3-markdown-text/src/util.ts deleted-62| ... | @@ -1,62 +0,0 @@ | ||
| 1 | import { type StyleProp, StyleSheet, type TextStyle } from "react-native"; | ||
| 2 | import type { NativeFontWeight } from "./T3MarkdownTextRunNativeComponent"; | ||
| 3 | |||
| 4 | export function flattenStyles(rootStyle: TextStyle, style: StyleProp<TextStyle>) { | ||
| 5 | const flattenedStyle = StyleSheet.flatten([rootStyle, style]) as TextStyle; | ||
| 6 | return { | ||
| 7 | ...flattenedStyle, | ||
| 8 | fontWeight: fontWeightToNativeProp(flattenedStyle.fontWeight ?? "normal"), | ||
| 9 | backgroundColor: flattenedStyle.backgroundColor | ||
| 10 | ? flattenedStyle.backgroundColor | ||
| 11 | : "transparent", | ||
| 12 | shadowOffset: flattenedStyle.shadowOffset | ||
| 13 | ? flattenedStyle.shadowOffset | ||
| 14 | : { width: 0, height: 0 }, | ||
| 15 | }; | ||
| 16 | } | ||
| 17 | |||
| 18 | // Codegen doesn't like using integer values for enums (c++ L) so we'll conver them to the proper native prop | ||
| 19 | // value before returning flattened styles. | ||
| 20 | function fontWeightToNativeProp(fontWeight: TextStyle["fontWeight"]): NativeFontWeight { | ||
| 21 | switch (fontWeight) { | ||
| 22 | case "normal": | ||
| 23 | return "normal"; | ||
| 24 | case "bold": | ||
| 25 | return "bold"; | ||
| 26 | case 100: | ||
| 27 | case "100": | ||
| 28 | case "ultralight": | ||
| 29 | return "ultraLight"; | ||
| 30 | case 200: | ||
| 31 | case "200": | ||
| 32 | return "ultraLight"; | ||
| 33 | case 300: | ||
| 34 | case "300": | ||
| 35 | case "light": | ||
| 36 | return "light"; | ||
| 37 | case 400: | ||
| 38 | case "400": | ||
| 39 | case "regular": | ||
| 40 | return "normal"; | ||
| 41 | case 500: | ||
| 42 | case "500": | ||
| 43 | case "medium": | ||
| 44 | return "medium"; | ||
| 45 | case 600: | ||
| 46 | case "600": | ||
| 47 | case "semibold": | ||
| 48 | return "semibold"; | ||
| 49 | case 700: | ||
| 50 | case "700": | ||
| 51 | return "semibold"; | ||
| 52 | case 800: | ||
| 53 | case "800": | ||
| 54 | return "bold"; | ||
| 55 | case 900: | ||
| 56 | case "900": | ||
| 57 | case "heavy": | ||
| 58 | return "heavy"; | ||
| 59 | default: | ||
| 60 | return "normal"; | ||
| 61 | } | ||
| 62 | } | ||
apps/mobile/modules/t3-source-text/LICENSE created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | MIT License | ||
| 2 | |||
| 3 | Copyright (c) 2024-25 Bluesky PBC | ||
| 4 | Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| 5 | of this software and associated documentation files (the "Software"), to deal | ||
| 6 | in the Software without restriction, including without limitation the rights | ||
| 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| 8 | copies of the Software, and to permit persons to whom the Software is | ||
| 9 | furnished to do so, subject to the following conditions: | ||
| 10 | |||
| 11 | The above copyright notice and this permission notice shall be included in all | ||
| 12 | copies or substantial portions of the Software. | ||
| 13 | |||
| 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| 20 | SOFTWARE. | ||
apps/mobile/modules/t3-source-text/T3SourceText.podspec created+25| ... | @@ -0,0 +1,25 @@ | ||
| 1 | require "json" | ||
| 2 | |||
| 3 | package = JSON.parse(File.read(File.join(__dir__, "package.json"))) | ||
| 4 | new_arch_enabled = ENV["RCT_NEW_ARCH_ENABLED"] == "1" | ||
| 5 | |||
| 6 | Pod::Spec.new do |s| | ||
| 7 | s.name = "T3SourceText" | ||
| 8 | s.version = package["version"] | ||
| 9 | s.summary = "Native selectable source text for T3 Code mobile." | ||
| 10 | s.description = "Fabric-backed styled source text with whole-document selection." | ||
| 11 | s.homepage = "https://t3tools.com" | ||
| 12 | s.license = { :type => "MIT", :file => "LICENSE" } | ||
| 13 | s.author = { "T3 Tools" => "hello@t3tools.com" } | ||
| 14 | s.platforms = { :ios => min_ios_version_supported } | ||
| 15 | s.source = { :path => "." } | ||
| 16 | s.source_files = "ios/**/*.{h,m,mm,cpp}" | ||
| 17 | |||
| 18 | install_modules_dependencies(s) | ||
| 19 | |||
| 20 | if ENV["USE_FRAMEWORKS"] != nil && new_arch_enabled | ||
| 21 | add_dependency(s, "React-FabricComponents", :additional_framework_paths => [ | ||
| 22 | "react/renderer/textlayoutmanager/platform/ios", | ||
| 23 | ]) | ||
| 24 | end | ||
| 25 | end | ||
apps/mobile/modules/t3-source-text/UPSTREAM.md created+11| ... | @@ -0,0 +1,11 @@ | ||
| 1 | # Upstream Attribution | ||
| 2 | |||
| 3 | The Fabric attributed-text component in this module originated from | ||
| 4 | [`bluesky-social/react-native-uitextview`](https://github.com/bluesky-social/react-native-uitextview), | ||
| 5 | version `2.2.0`, commit `addc08fea303608f070fe1eeba4bc075f181c4af`. | ||
| 6 | |||
| 7 | The upstream project is Copyright (c) 2024-25 Bluesky PBC and licensed under | ||
| 8 | the MIT License included in this directory. | ||
| 9 | |||
| 10 | T3 Code has substantially modified and renamed the implementation, limited it to selectable source-file text, and owns the resulting module going forward. This is not | ||
| 11 | an upstream package dependency or a compatibility fork. | ||
apps/mobile/modules/t3-source-text/index.ts created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | export { SourceText } from "./src/SourceText"; | ||
apps/mobile/modules/t3-source-text/ios/T3SourceText.h created+13| ... | @@ -0,0 +1,13 @@ | ||
| 1 | #import <React/RCTViewComponentView.h> | ||
| 2 | #import <UIKit/UIKit.h> | ||
| 3 | |||
| 4 | #ifndef T3SourceTextNativeComponent_h | ||
| 5 | #define T3SourceTextNativeComponent_h | ||
| 6 | |||
| 7 | NS_ASSUME_NONNULL_BEGIN | ||
| 8 | @interface T3SourceText : RCTViewComponentView | ||
| 9 | @end | ||
| 10 | |||
| 11 | NS_ASSUME_NONNULL_END | ||
| 12 | |||
| 13 | #endif /* UitextviewViewNativeComponent_h */ | ||
apps/mobile/modules/t3-source-text/ios/T3SourceText.mm created+302| ... | @@ -0,0 +1,302 @@ | ||
| 1 | #import "T3SourceText.h" | ||
| 2 | #import <React/RCTFabricComponentsPlugins.h> | ||
| 3 | #import "T3SourceTextComponentDescriptor.h" | ||
| 4 | #import "T3SourceTextShadowNode.h" | ||
| 5 | #import <React/RCTConversions.h> | ||
| 6 | #import <objc/runtime.h> | ||
| 7 | #import <react/renderer/components/T3SourceTextSpec/EventEmitters.h> | ||
| 8 | #import <react/renderer/components/T3SourceTextSpec/Props.h> | ||
| 9 | #import <react/renderer/components/T3SourceTextSpec/RCTComponentViewHelpers.h> | ||
| 10 | #import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h> | ||
| 11 | |||
| 12 | using namespace facebook::react; | ||
| 13 | |||
| 14 | @interface T3SourceTextView : UITextView | ||
| 15 | @end | ||
| 16 | |||
| 17 | @implementation T3SourceTextView | ||
| 18 | - (BOOL)canPerformAction:(SEL)action withSender:(id)sender { | ||
| 19 | // Read-only UITextViews must still allow selecting the whole file. | ||
| 20 | if (action == @selector(selectAll:)) { | ||
| 21 | return self.selectable && self.text.length > 0 && | ||
| 22 | self.selectedRange.length < self.text.length; | ||
| 23 | } | ||
| 24 | return [super canPerformAction:action withSender:sender]; | ||
| 25 | } | ||
| 26 | @end | ||
| 27 | |||
| 28 | @protocol T3SourceTextOutsideTapTarget <NSObject> | ||
| 29 | - (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView; | ||
| 30 | @end | ||
| 31 | |||
| 32 | @interface T3SourceTextOutsideTapCoordinator | ||
| 33 | : NSObject <UIGestureRecognizerDelegate> | ||
| 34 | |||
| 35 | - (instancetype)initWithWindow:(UIWindow *)window; | ||
| 36 | - (void)addTarget:(id<T3SourceTextOutsideTapTarget>)target; | ||
| 37 | - (void)removeTarget:(id<T3SourceTextOutsideTapTarget>)target; | ||
| 38 | |||
| 39 | @end | ||
| 40 | |||
| 41 | static const void *T3SourceTextOutsideTapCoordinatorKey = | ||
| 42 | &T3SourceTextOutsideTapCoordinatorKey; | ||
| 43 | |||
| 44 | @implementation T3SourceTextOutsideTapCoordinator { | ||
| 45 | __weak UIWindow *_window; | ||
| 46 | UITapGestureRecognizer *_recognizer; | ||
| 47 | NSHashTable<id<T3SourceTextOutsideTapTarget>> *_targets; | ||
| 48 | } | ||
| 49 | |||
| 50 | - (instancetype)initWithWindow:(UIWindow *)window { | ||
| 51 | if (self = [super init]) { | ||
| 52 | _window = window; | ||
| 53 | _targets = [NSHashTable weakObjectsHashTable]; | ||
| 54 | _recognizer = | ||
| 55 | [[UITapGestureRecognizer alloc] initWithTarget:self | ||
| 56 | action:@selector(handleTap:)]; | ||
| 57 | _recognizer.cancelsTouchesInView = NO; | ||
| 58 | _recognizer.delegate = self; | ||
| 59 | [window addGestureRecognizer:_recognizer]; | ||
| 60 | } | ||
| 61 | return self; | ||
| 62 | } | ||
| 63 | |||
| 64 | - (void)addTarget:(id<T3SourceTextOutsideTapTarget>)target { | ||
| 65 | [_targets addObject:target]; | ||
| 66 | } | ||
| 67 | |||
| 68 | - (void)removeTarget:(id<T3SourceTextOutsideTapTarget>)target { | ||
| 69 | [_targets removeObject:target]; | ||
| 70 | if (_targets.count > 0) { | ||
| 71 | return; | ||
| 72 | } | ||
| 73 | |||
| 74 | UIWindow *window = _window; | ||
| 75 | [window removeGestureRecognizer:_recognizer]; | ||
| 76 | if (objc_getAssociatedObject(window, T3SourceTextOutsideTapCoordinatorKey) == | ||
| 77 | self) { | ||
| 78 | objc_setAssociatedObject(window, T3SourceTextOutsideTapCoordinatorKey, nil, | ||
| 79 | OBJC_ASSOCIATION_RETAIN_NONATOMIC); | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | - (void)handleTap:(UITapGestureRecognizer *)sender { | ||
| 84 | UIWindow *window = _window; | ||
| 85 | if (window == nil) { | ||
| 86 | return; | ||
| 87 | } | ||
| 88 | |||
| 89 | UIView *hitView = [window hitTest:[sender locationInView:window] | ||
| 90 | withEvent:nil]; | ||
| 91 | if (hitView == nil) { | ||
| 92 | return; | ||
| 93 | } | ||
| 94 | for (id<T3SourceTextOutsideTapTarget> target in _targets.allObjects) { | ||
| 95 | [target clearSelectionForOutsideTapWithHitView:hitView]; | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer | ||
| 100 | shouldRecognizeSimultaneouslyWithGestureRecognizer: | ||
| 101 | (UIGestureRecognizer *)otherGestureRecognizer { | ||
| 102 | return YES; | ||
| 103 | } | ||
| 104 | |||
| 105 | @end | ||
| 106 | |||
| 107 | static T3SourceTextOutsideTapCoordinator * | ||
| 108 | T3SourceTextOutsideTapCoordinatorForWindow(UIWindow *window) { | ||
| 109 | T3SourceTextOutsideTapCoordinator *coordinator = | ||
| 110 | objc_getAssociatedObject(window, T3SourceTextOutsideTapCoordinatorKey); | ||
| 111 | if (coordinator == nil) { | ||
| 112 | coordinator = | ||
| 113 | [[T3SourceTextOutsideTapCoordinator alloc] initWithWindow:window]; | ||
| 114 | objc_setAssociatedObject(window, T3SourceTextOutsideTapCoordinatorKey, | ||
| 115 | coordinator, OBJC_ASSOCIATION_RETAIN_NONATOMIC); | ||
| 116 | } | ||
| 117 | return coordinator; | ||
| 118 | } | ||
| 119 | |||
| 120 | @interface T3SourceText () <RCTT3SourceTextViewProtocol, UITextViewDelegate, | ||
| 121 | T3SourceTextOutsideTapTarget> | ||
| 122 | @end | ||
| 123 | |||
| 124 | @implementation T3SourceText { | ||
| 125 | UIView *_view; | ||
| 126 | T3SourceTextView *_textView; | ||
| 127 | BOOL _textLayoutNeedsUpdate; | ||
| 128 | T3SourceTextShadowNode::ConcreteState::Shared _state; | ||
| 129 | __weak UIWindow *_outsideTapWindow; | ||
| 130 | BOOL _suppressSelectionChange; | ||
| 131 | } | ||
| 132 | |||
| 133 | + (ComponentDescriptorProvider)componentDescriptorProvider { | ||
| 134 | return concreteComponentDescriptorProvider<T3SourceTextComponentDescriptor>(); | ||
| 135 | } | ||
| 136 | |||
| 137 | - (instancetype)initWithFrame:(CGRect)frame { | ||
| 138 | if (self = [super initWithFrame:frame]) { | ||
| 139 | static const auto defaultProps = | ||
| 140 | std::make_shared<const T3SourceTextProps>(); | ||
| 141 | _props = defaultProps; | ||
| 142 | _view = [[UIView alloc] init]; | ||
| 143 | self.contentView = _view; | ||
| 144 | self.clipsToBounds = YES; | ||
| 145 | _textView = [[T3SourceTextView alloc] init]; | ||
| 146 | _textView.scrollEnabled = NO; | ||
| 147 | _textView.editable = NO; | ||
| 148 | _textView.textContainerInset = UIEdgeInsetsZero; | ||
| 149 | _textView.textContainer.lineFragmentPadding = 0; | ||
| 150 | _textView.textContainer.lineBreakMode = NSLineBreakByTruncatingTail; | ||
| 151 | _textView.textContainer.widthTracksTextView = NO; | ||
| 152 | _textView.textContainer.heightTracksTextView = NO; | ||
| 153 | _textView.delegate = self; | ||
| 154 | _textView.textDragInteraction.enabled = NO; | ||
| 155 | _textView.layoutManager.usesFontLeading = NO; | ||
| 156 | [self addSubview:_textView]; | ||
| 157 | } | ||
| 158 | return self; | ||
| 159 | } | ||
| 160 | |||
| 161 | - (void)didMoveToWindow { | ||
| 162 | [super didMoveToWindow]; | ||
| 163 | if (_outsideTapWindow == self.window) | ||
| 164 | return; | ||
| 165 | T3SourceTextOutsideTapCoordinator *coordinator = objc_getAssociatedObject( | ||
| 166 | _outsideTapWindow, T3SourceTextOutsideTapCoordinatorKey); | ||
| 167 | [coordinator removeTarget:self]; | ||
| 168 | _outsideTapWindow = self.window; | ||
| 169 | if (_outsideTapWindow != nil) { | ||
| 170 | [T3SourceTextOutsideTapCoordinatorForWindow(_outsideTapWindow) | ||
| 171 | addTarget:self]; | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 175 | - (void)dealloc { | ||
| 176 | T3SourceTextOutsideTapCoordinator *coordinator = objc_getAssociatedObject( | ||
| 177 | _outsideTapWindow, T3SourceTextOutsideTapCoordinatorKey); | ||
| 178 | [coordinator removeTarget:self]; | ||
| 179 | } | ||
| 180 | |||
| 181 | - (void)prepareForRecycle { | ||
| 182 | [super prepareForRecycle]; | ||
| 183 | T3SourceTextOutsideTapCoordinator *coordinator = objc_getAssociatedObject( | ||
| 184 | _outsideTapWindow, T3SourceTextOutsideTapCoordinatorKey); | ||
| 185 | [coordinator removeTarget:self]; | ||
| 186 | _outsideTapWindow = nil; | ||
| 187 | _state.reset(); | ||
| 188 | _textView.frame = CGRectZero; | ||
| 189 | _textView.attributedText = nil; | ||
| 190 | _textLayoutNeedsUpdate = YES; | ||
| 191 | } | ||
| 192 | |||
| 193 | - (void)layoutSubviews { | ||
| 194 | [super layoutSubviews]; | ||
| 195 | if (!CGRectEqualToRect(_textView.frame, _view.frame)) { | ||
| 196 | _textView.frame = _view.frame; | ||
| 197 | _textLayoutNeedsUpdate = YES; | ||
| 198 | [self setNeedsDisplay]; | ||
| 199 | } | ||
| 200 | } | ||
| 201 | |||
| 202 | - (void)drawRect:(CGRect)rect { | ||
| 203 | if (!_state) | ||
| 204 | return; | ||
| 205 | NSMutableAttributedString *text = [RCTNSAttributedStringFromAttributedString( | ||
| 206 | _state->getData().attributedString) mutableCopy]; | ||
| 207 | RCTApplyBaselineOffset(text); | ||
| 208 | const BOOL textChanged = | ||
| 209 | ![_textView.attributedText isEqualToAttributedString:text]; | ||
| 210 | const BOOL frameChanged = _textLayoutNeedsUpdate; | ||
| 211 | if (!textChanged && !frameChanged) | ||
| 212 | return; | ||
| 213 | if (textChanged) { | ||
| 214 | const NSRange savedRange = _textView.selectedRange; | ||
| 215 | _suppressSelectionChange = YES; | ||
| 216 | _textView.attributedText = text; | ||
| 217 | if (savedRange.location != NSNotFound && savedRange.length > 0 && | ||
| 218 | NSMaxRange(savedRange) <= text.length) { | ||
| 219 | _textView.selectedRange = savedRange; | ||
| 220 | } | ||
| 221 | _suppressSelectionChange = NO; | ||
| 222 | } | ||
| 223 | if (frameChanged) | ||
| 224 | _textView.textContainer.size = CGSizeMake(CGRectGetWidth(_view.frame), CGFLOAT_MAX); | ||
| 225 | _textLayoutNeedsUpdate = NO; | ||
| 226 | } | ||
| 227 | |||
| 228 | - (void)updateProps:(Props::Shared const &)props | ||
| 229 | oldProps:(Props::Shared const &)oldProps { | ||
| 230 | const auto &oldViewProps = | ||
| 231 | *std::static_pointer_cast<T3SourceTextProps const>(_props); | ||
| 232 | const auto &newViewProps = | ||
| 233 | *std::static_pointer_cast<T3SourceTextProps const>(props); | ||
| 234 | if (oldViewProps.numberOfLines != newViewProps.numberOfLines) { | ||
| 235 | _textView.textContainer.maximumNumberOfLines = newViewProps.numberOfLines; | ||
| 236 | } | ||
| 237 | if (oldViewProps.selectable != newViewProps.selectable) | ||
| 238 | _textView.selectable = newViewProps.selectable; | ||
| 239 | if (oldViewProps.allowFontScaling != newViewProps.allowFontScaling) { | ||
| 240 | _textView.adjustsFontForContentSizeCategory = newViewProps.allowFontScaling; | ||
| 241 | } | ||
| 242 | if (oldViewProps.ellipsizeMode != newViewProps.ellipsizeMode) { | ||
| 243 | switch (newViewProps.ellipsizeMode) { | ||
| 244 | case T3SourceTextEllipsizeMode::Head: | ||
| 245 | _textView.textContainer.lineBreakMode = NSLineBreakByTruncatingHead; | ||
| 246 | break; | ||
| 247 | case T3SourceTextEllipsizeMode::Middle: | ||
| 248 | _textView.textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle; | ||
| 249 | break; | ||
| 250 | case T3SourceTextEllipsizeMode::Tail: | ||
| 251 | _textView.textContainer.lineBreakMode = NSLineBreakByTruncatingTail; | ||
| 252 | break; | ||
| 253 | case T3SourceTextEllipsizeMode::Clip: | ||
| 254 | _textView.textContainer.lineBreakMode = NSLineBreakByClipping; | ||
| 255 | break; | ||
| 256 | } | ||
| 257 | } | ||
| 258 | if (oldViewProps.backgroundColor != newViewProps.backgroundColor) { | ||
| 259 | _textView.backgroundColor = | ||
| 260 | RCTUIColorFromSharedColor(newViewProps.backgroundColor); | ||
| 261 | } | ||
| 262 | [super updateProps:props oldProps:oldProps]; | ||
| 263 | } | ||
| 264 | |||
| 265 | - (void)updateState:(const facebook::react::State::Shared &)state | ||
| 266 | oldState:(const facebook::react::State::Shared &)oldState { | ||
| 267 | _state = | ||
| 268 | std::static_pointer_cast<const T3SourceTextShadowNode::ConcreteState>( | ||
| 269 | state); | ||
| 270 | [self setNeedsDisplay]; | ||
| 271 | } | ||
| 272 | |||
| 273 | - (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView { | ||
| 274 | if ([hitView isDescendantOfView:self]) | ||
| 275 | return; | ||
| 276 | UITextView *textView = _textView; | ||
| 277 | // Allow a pending native Copy action to consume its selection first. | ||
| 278 | dispatch_async(dispatch_get_main_queue(), ^{ | ||
| 279 | UITextRange *range = textView.selectedTextRange; | ||
| 280 | if (range != nil && !range.isEmpty) | ||
| 281 | textView.selectedTextRange = nil; | ||
| 282 | }); | ||
| 283 | } | ||
| 284 | |||
| 285 | - (void)textViewDidChangeSelection:(UITextView *)textView { | ||
| 286 | if (_suppressSelectionChange || _eventEmitter == nullptr) | ||
| 287 | return; | ||
| 288 | const NSRange range = textView.selectedRange; | ||
| 289 | if (range.location == NSNotFound) | ||
| 290 | return; | ||
| 291 | std::dynamic_pointer_cast<const facebook::react::T3SourceTextEventEmitter>( | ||
| 292 | _eventEmitter) | ||
| 293 | ->onSelectionChange( | ||
| 294 | facebook::react::T3SourceTextEventEmitter::OnSelectionChange{ | ||
| 295 | static_cast<int>(self.tag), static_cast<int>(range.location), | ||
| 296 | static_cast<int>(NSMaxRange(range))}); | ||
| 297 | } | ||
| 298 | |||
| 299 | Class<RCTComponentViewProtocol> T3SourceTextCls(void) { | ||
| 300 | return T3SourceText.class; | ||
| 301 | } | ||
| 302 | @end | ||
apps/mobile/modules/t3-source-text/ios/T3SourceTextComponentDescriptor.h created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include "T3SourceTextShadowNode.h" | ||
| 4 | |||
| 5 | #include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h> | ||
| 6 | #include <react/renderer/core/ConcreteComponentDescriptor.h> | ||
| 7 | |||
| 8 | namespace facebook::react { | ||
| 9 | using T3SourceTextComponentDescriptor = | ||
| 10 | ConcreteComponentDescriptor<T3SourceTextShadowNode>; | ||
| 11 | |||
| 12 | void T3SourceTextSpec_registerComponentDescriptorsFromCodegen( | ||
| 13 | std::shared_ptr<const ComponentDescriptorProviderRegistry> registry); | ||
| 14 | } // namespace facebook::react | ||
apps/mobile/modules/t3-source-text/ios/T3SourceTextManager.mm created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | #import <React/RCTUIManager.h> | ||
| 2 | #import <React/RCTViewManager.h> | ||
| 3 | |||
| 4 | @interface T3SourceTextManager : RCTViewManager | ||
| 5 | @end | ||
| 6 | |||
| 7 | @implementation T3SourceTextManager | ||
| 8 | |||
| 9 | RCT_EXPORT_MODULE(T3SourceText) | ||
| 10 | |||
| 11 | - (UIView *)view { | ||
| 12 | return [[UIView alloc] init]; | ||
| 13 | } | ||
| 14 | |||
| 15 | @end | ||
| 16 | |||
| 17 | @interface T3SourceTextRunManager : RCTViewManager | ||
| 18 | @end | ||
| 19 | |||
| 20 | @implementation T3SourceTextRunManager | ||
| 21 | |||
| 22 | RCT_EXPORT_MODULE(T3SourceTextRun) | ||
| 23 | |||
| 24 | - (UIView *)view { | ||
| 25 | return nil; | ||
| 26 | } | ||
| 27 | |||
| 28 | @end | ||
apps/mobile/modules/t3-source-text/ios/T3SourceTextRun.h created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | // This guard prevent this file to be compiled in the old architecture. | ||
| 2 | #ifdef RCT_NEW_ARCH_ENABLED | ||
| 3 | #import <React/RCTComponent.h> | ||
| 4 | #import <React/RCTViewComponentView.h> | ||
| 5 | #import <UIKit/UIKit.h> | ||
| 6 | |||
| 7 | #ifndef T3SourceTextRunNativeComponent_h | ||
| 8 | #define T3SourceTextRunNativeComponent_h | ||
| 9 | |||
| 10 | NS_ASSUME_NONNULL_BEGIN | ||
| 11 | |||
| 12 | @interface T3SourceTextRun : RCTViewComponentView | ||
| 13 | |||
| 14 | @property(nonatomic, copy, nullable) NSString *text; | ||
| 15 | @end | ||
| 16 | |||
| 17 | NS_ASSUME_NONNULL_END | ||
| 18 | |||
| 19 | #endif /* UitextviewViewNativeComponent_h */ | ||
| 20 | #endif /* RCT_NEW_ARCH_ENABLED */ | ||
apps/mobile/modules/t3-source-text/ios/T3SourceTextRun.mm created+54| ... | @@ -0,0 +1,54 @@ | ||
| 1 | #import "T3SourceTextRun.h" | ||
| 2 | #import <React/RCTFabricComponentsPlugins.h> | ||
| 3 | #import "T3SourceTextRunComponentDescriptor.h" | ||
| 4 | #import <react/renderer/components/T3SourceTextSpec/Props.h> | ||
| 5 | #import <react/renderer/components/T3SourceTextSpec/RCTComponentViewHelpers.h> | ||
| 6 | |||
| 7 | using namespace facebook::react; | ||
| 8 | |||
| 9 | @interface T3SourceTextRun () <RCTT3SourceTextRunViewProtocol> | ||
| 10 | |||
| 11 | @end | ||
| 12 | |||
| 13 | @implementation T3SourceTextRun { | ||
| 14 | NSString *_text; | ||
| 15 | } | ||
| 16 | |||
| 17 | + (ComponentDescriptorProvider)componentDescriptorProvider { | ||
| 18 | return concreteComponentDescriptorProvider< | ||
| 19 | T3SourceTextRunComponentDescriptor>(); | ||
| 20 | } | ||
| 21 | |||
| 22 | - (instancetype)initWithFrame:(CGRect)frame { | ||
| 23 | if (self = [super initWithFrame:frame]) { | ||
| 24 | static const auto defaultProps = | ||
| 25 | std::make_shared<const T3SourceTextRunProps>(); | ||
| 26 | _props = defaultProps; | ||
| 27 | } | ||
| 28 | return self; | ||
| 29 | } | ||
| 30 | |||
| 31 | - (void)updateProps:(Props::Shared const &)props | ||
| 32 | oldProps:(Props::Shared const &)oldProps { | ||
| 33 | const auto &oldViewProps = | ||
| 34 | *std::static_pointer_cast<T3SourceTextRunProps const>(_props); | ||
| 35 | const auto &newViewProps = | ||
| 36 | *std::static_pointer_cast<T3SourceTextRunProps const>(props); | ||
| 37 | |||
| 38 | if (newViewProps.text != oldViewProps.text) { | ||
| 39 | NSString *text = [NSString stringWithUTF8String:newViewProps.text.c_str()]; | ||
| 40 | _text = text; | ||
| 41 | } | ||
| 42 | |||
| 43 | [super updateProps:props oldProps:oldProps]; | ||
| 44 | } | ||
| 45 | |||
| 46 | + (BOOL)shouldBeRecycled { | ||
| 47 | return NO; | ||
| 48 | } | ||
| 49 | |||
| 50 | Class<RCTComponentViewProtocol> T3SourceTextRunCls(void) { | ||
| 51 | return T3SourceTextRun.class; | ||
| 52 | } | ||
| 53 | |||
| 54 | @end | ||
apps/mobile/modules/t3-source-text/ios/T3SourceTextRunComponentDescriptor.h created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include "T3SourceTextRunShadowNode.h" | ||
| 4 | |||
| 5 | #include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h> | ||
| 6 | #include <react/renderer/core/ConcreteComponentDescriptor.h> | ||
| 7 | |||
| 8 | namespace facebook::react { | ||
| 9 | using T3SourceTextRunComponentDescriptor = | ||
| 10 | ConcreteComponentDescriptor<T3SourceTextRunShadowNode>; | ||
| 11 | |||
| 12 | void T3SourceTextRunSpec_registerComponentDescriptorsFromCodegen( | ||
| 13 | std::shared_ptr<const ComponentDescriptorProviderRegistry> registry); | ||
| 14 | } // namespace facebook::react | ||
apps/mobile/modules/t3-source-text/ios/T3SourceTextRunShadowNode.cpp created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | #include "T3SourceTextRunShadowNode.h" | ||
| 2 | |||
| 3 | namespace facebook::react { | ||
| 4 | |||
| 5 | extern const char T3SourceTextRunComponentName[] = "T3SourceTextRun"; | ||
| 6 | } // namespace facebook::react | ||
apps/mobile/modules/t3-source-text/ios/T3SourceTextRunShadowNode.h created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include <react/renderer/components/T3SourceTextSpec/EventEmitters.h> | ||
| 4 | #include <react/renderer/components/T3SourceTextSpec/Props.h> | ||
| 5 | #include <react/renderer/components/T3SourceTextSpec/States.h> | ||
| 6 | #include <react/renderer/components/view/ConcreteViewShadowNode.h> | ||
| 7 | |||
| 8 | namespace facebook::react { | ||
| 9 | extern const char T3SourceTextRunComponentName[]; | ||
| 10 | |||
| 11 | using T3SourceTextRunShadowNode = | ||
| 12 | ConcreteViewShadowNode<T3SourceTextRunComponentName, T3SourceTextRunProps, | ||
| 13 | T3SourceTextRunEventEmitter, T3SourceTextRunState>; | ||
| 14 | } // namespace facebook::react | ||
apps/mobile/modules/t3-source-text/ios/T3SourceTextShadowNode.h created+45| ... | @@ -0,0 +1,45 @@ | ||
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include <react/renderer/components/T3SourceTextSpec/EventEmitters.h> | ||
| 4 | #include <react/renderer/components/T3SourceTextSpec/Props.h> | ||
| 5 | #include <react/renderer/components/view/ConcreteViewShadowNode.h> | ||
| 6 | #include <react/renderer/core/LayoutContext.h> | ||
| 7 | #include <react/renderer/core/ShadowNode.h> | ||
| 8 | #include <react/renderer/textlayoutmanager/TextLayoutManager.h> | ||
| 9 | |||
| 10 | namespace facebook::react { | ||
| 11 | |||
| 12 | extern const char T3SourceTextComponentName[]; | ||
| 13 | |||
| 14 | class T3SourceTextStateReal final { | ||
| 15 | public: | ||
| 16 | AttributedString attributedString; | ||
| 17 | }; | ||
| 18 | |||
| 19 | class T3SourceTextShadowNode final | ||
| 20 | : public ConcreteViewShadowNode<T3SourceTextComponentName, | ||
| 21 | T3SourceTextProps, T3SourceTextEventEmitter, | ||
| 22 | T3SourceTextStateReal> { | ||
| 23 | public: | ||
| 24 | using ConcreteViewShadowNode::ConcreteViewShadowNode; | ||
| 25 | |||
| 26 | T3SourceTextShadowNode(const ShadowNode &sourceShadowNode, | ||
| 27 | const ShadowNodeFragment &fragment); | ||
| 28 | |||
| 29 | static ShadowNodeTraits BaseTraits() { | ||
| 30 | auto traits = ConcreteViewShadowNode::BaseTraits(); | ||
| 31 | traits.set(ShadowNodeTraits::Trait::LeafYogaNode); | ||
| 32 | traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); | ||
| 33 | return traits; | ||
| 34 | } | ||
| 35 | |||
| 36 | void layout(LayoutContext layoutContext) override; | ||
| 37 | |||
| 38 | Size | ||
| 39 | measureContent(const LayoutContext &layoutContext, | ||
| 40 | const LayoutConstraints &layoutConstraints) const override; | ||
| 41 | |||
| 42 | private: | ||
| 43 | mutable AttributedString _attributedString; | ||
| 44 | }; | ||
| 45 | } // namespace facebook::react | ||
apps/mobile/modules/t3-source-text/ios/T3SourceTextShadowNode.mm created+171| ... | @@ -0,0 +1,171 @@ | ||
| 1 | #include "T3SourceTextShadowNode.h" | ||
| 2 | #include "T3SourceTextRunShadowNode.h" | ||
| 3 | #include <react/renderer/components/view/ViewShadowNode.h> | ||
| 4 | #import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h> | ||
| 5 | |||
| 6 | #include <algorithm> | ||
| 7 | #include <cmath> | ||
| 8 | |||
| 9 | namespace facebook::react { | ||
| 10 | |||
| 11 | T3SourceTextShadowNode::T3SourceTextShadowNode( | ||
| 12 | const ShadowNode &sourceShadowNode, const ShadowNodeFragment &fragment) | ||
| 13 | : ConcreteViewShadowNode(sourceShadowNode, fragment){}; | ||
| 14 | |||
| 15 | Size T3SourceTextShadowNode::measureContent( | ||
| 16 | const LayoutContext &layoutContext, | ||
| 17 | const LayoutConstraints &layoutConstraints) const { | ||
| 18 | const auto &baseProps = getConcreteProps(); | ||
| 19 | |||
| 20 | auto baseTextAttributes = TextAttributes::defaultTextAttributes(); | ||
| 21 | baseTextAttributes.backgroundColor = baseProps.backgroundColor; | ||
| 22 | baseTextAttributes.allowFontScaling = baseProps.allowFontScaling; | ||
| 23 | |||
| 24 | Float fontSizeMultiplier = 1.0; | ||
| 25 | if (baseTextAttributes.allowFontScaling) { | ||
| 26 | fontSizeMultiplier = layoutContext.fontSizeMultiplier; | ||
| 27 | } | ||
| 28 | |||
| 29 | auto baseAttributedString = AttributedString{}; | ||
| 30 | const auto &children = getChildren(); | ||
| 31 | for (size_t i = 0; i < children.size(); i++) { | ||
| 32 | const auto child = children[i].get(); | ||
| 33 | if (auto textViewChild = | ||
| 34 | dynamic_cast<const T3SourceTextRunShadowNode *>(child)) { | ||
| 35 | auto &props = textViewChild->getConcreteProps(); | ||
| 36 | auto fragment = AttributedString::Fragment{}; | ||
| 37 | auto textAttributes = TextAttributes::defaultTextAttributes(); | ||
| 38 | |||
| 39 | textAttributes.allowFontScaling = baseProps.allowFontScaling; | ||
| 40 | textAttributes.backgroundColor = props.backgroundColor; | ||
| 41 | textAttributes.fontSize = props.fontSize * fontSizeMultiplier; | ||
| 42 | textAttributes.lineHeight = props.lineHeight * fontSizeMultiplier; | ||
| 43 | textAttributes.foregroundColor = props.color; | ||
| 44 | textAttributes.textShadowColor = props.shadowColor; | ||
| 45 | textAttributes.textShadowOffset = props.shadowOffset; | ||
| 46 | textAttributes.textShadowRadius = props.shadowRadius; | ||
| 47 | textAttributes.letterSpacing = props.letterSpacing; | ||
| 48 | textAttributes.textDecorationColor = props.textDecorationColor; | ||
| 49 | textAttributes.fontFamily = props.fontFamily; | ||
| 50 | |||
| 51 | if (props.fontStyle == T3SourceTextRunFontStyle::Italic) { | ||
| 52 | textAttributes.fontStyle = FontStyle::Italic; | ||
| 53 | } else { | ||
| 54 | textAttributes.fontStyle = FontStyle::Normal; | ||
| 55 | } | ||
| 56 | |||
| 57 | if (props.fontWeight == T3SourceTextRunFontWeight::Bold) { | ||
| 58 | textAttributes.fontWeight = FontWeight::Bold; | ||
| 59 | } else if (props.fontWeight == T3SourceTextRunFontWeight::UltraLight) { | ||
| 60 | textAttributes.fontWeight = FontWeight::UltraLight; | ||
| 61 | } else if (props.fontWeight == T3SourceTextRunFontWeight::Light) { | ||
| 62 | textAttributes.fontWeight = FontWeight::Light; | ||
| 63 | } else if (props.fontWeight == T3SourceTextRunFontWeight::Medium) { | ||
| 64 | textAttributes.fontWeight = FontWeight::Medium; | ||
| 65 | } else if (props.fontWeight == T3SourceTextRunFontWeight::Semibold) { | ||
| 66 | textAttributes.fontWeight = FontWeight::Semibold; | ||
| 67 | } else if (props.fontWeight == T3SourceTextRunFontWeight::Heavy) { | ||
| 68 | textAttributes.fontWeight = FontWeight::Heavy; | ||
| 69 | } else { | ||
| 70 | textAttributes.fontWeight = FontWeight::Regular; | ||
| 71 | } | ||
| 72 | |||
| 73 | if (props.textDecorationLine == | ||
| 74 | T3SourceTextRunTextDecorationLine::LineThrough) { | ||
| 75 | textAttributes.textDecorationLineType = | ||
| 76 | TextDecorationLineType::Strikethrough; | ||
| 77 | } else if (props.textDecorationLine == | ||
| 78 | T3SourceTextRunTextDecorationLine::Underline) { | ||
| 79 | textAttributes.textDecorationLineType = | ||
| 80 | TextDecorationLineType::Underline; | ||
| 81 | } else { | ||
| 82 | textAttributes.textDecorationLineType = TextDecorationLineType::None; | ||
| 83 | } | ||
| 84 | |||
| 85 | if (props.textDecorationStyle == | ||
| 86 | T3SourceTextRunTextDecorationStyle::Solid) { | ||
| 87 | textAttributes.textDecorationStyle = TextDecorationStyle::Solid; | ||
| 88 | } else if (props.textDecorationStyle == | ||
| 89 | T3SourceTextRunTextDecorationStyle::Dotted) { | ||
| 90 | textAttributes.textDecorationStyle = TextDecorationStyle::Dotted; | ||
| 91 | } else if (props.textDecorationStyle == | ||
| 92 | T3SourceTextRunTextDecorationStyle::Dashed) { | ||
| 93 | textAttributes.textDecorationStyle = TextDecorationStyle::Dashed; | ||
| 94 | } else if (props.textDecorationStyle == | ||
| 95 | T3SourceTextRunTextDecorationStyle::Double) { | ||
| 96 | textAttributes.textDecorationStyle = TextDecorationStyle::Double; | ||
| 97 | } | ||
| 98 | |||
| 99 | if (props.textAlign == T3SourceTextRunTextAlign::Left) { | ||
| 100 | textAttributes.alignment = TextAlignment::Left; | ||
| 101 | } else if (props.textAlign == T3SourceTextRunTextAlign::Right) { | ||
| 102 | textAttributes.alignment = TextAlignment::Right; | ||
| 103 | } else if (props.textAlign == T3SourceTextRunTextAlign::Center) { | ||
| 104 | textAttributes.alignment = TextAlignment::Center; | ||
| 105 | } else if (props.textAlign == T3SourceTextRunTextAlign::Justify) { | ||
| 106 | textAttributes.alignment = TextAlignment::Justified; | ||
| 107 | } else if (props.textAlign == T3SourceTextRunTextAlign::Auto) { | ||
| 108 | textAttributes.alignment = TextAlignment::Natural; | ||
| 109 | } | ||
| 110 | |||
| 111 | textAttributes.backgroundColor = props.backgroundColor; | ||
| 112 | |||
| 113 | fragment.string = props.text; | ||
| 114 | fragment.textAttributes = textAttributes; | ||
| 115 | |||
| 116 | baseAttributedString.appendFragment(std::move(fragment)); | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | _attributedString = baseAttributedString; | ||
| 121 | |||
| 122 | NSMutableAttributedString *convertedAttributedString = | ||
| 123 | [RCTNSAttributedStringFromAttributedString(baseAttributedString) | ||
| 124 | mutableCopy]; | ||
| 125 | // Match React Native's baseline offset in both measurement and rendering. | ||
| 126 | RCTApplyBaselineOffset(convertedAttributedString); | ||
| 127 | |||
| 128 | const CGFloat maximumWidth = | ||
| 129 | std::isfinite(layoutConstraints.maximumSize.width) | ||
| 130 | ? layoutConstraints.maximumSize.width | ||
| 131 | : CGFLOAT_MAX; | ||
| 132 | NSTextStorage *textStorage = [[NSTextStorage alloc] | ||
| 133 | initWithAttributedString:convertedAttributedString]; | ||
| 134 | NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; | ||
| 135 | layoutManager.usesFontLeading = NO; | ||
| 136 | NSTextContainer *textContainer = [[NSTextContainer alloc] | ||
| 137 | initWithSize:CGSizeMake(maximumWidth, CGFLOAT_MAX)]; | ||
| 138 | textContainer.lineFragmentPadding = 0; | ||
| 139 | textContainer.maximumNumberOfLines = baseProps.numberOfLines; | ||
| 140 | if (baseProps.ellipsizeMode == T3SourceTextEllipsizeMode::Head) { | ||
| 141 | textContainer.lineBreakMode = NSLineBreakByTruncatingHead; | ||
| 142 | } else if (baseProps.ellipsizeMode == T3SourceTextEllipsizeMode::Middle) { | ||
| 143 | textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle; | ||
| 144 | } else if (baseProps.ellipsizeMode == T3SourceTextEllipsizeMode::Tail) { | ||
| 145 | textContainer.lineBreakMode = NSLineBreakByTruncatingTail; | ||
| 146 | } else { | ||
| 147 | textContainer.lineBreakMode = NSLineBreakByClipping; | ||
| 148 | } | ||
| 149 | [layoutManager addTextContainer:textContainer]; | ||
| 150 | [textStorage addLayoutManager:layoutManager]; | ||
| 151 | [layoutManager ensureLayoutForTextContainer:textContainer]; | ||
| 152 | const CGRect usedRect = | ||
| 153 | [layoutManager usedRectForTextContainer:textContainer]; | ||
| 154 | |||
| 155 | return { | ||
| 156 | std::clamp(static_cast<Float>(std::ceil(usedRect.size.width)), | ||
| 157 | layoutConstraints.minimumSize.width, | ||
| 158 | layoutConstraints.maximumSize.width), | ||
| 159 | std::clamp(static_cast<Float>(std::ceil(usedRect.size.height)), | ||
| 160 | layoutConstraints.minimumSize.height, | ||
| 161 | layoutConstraints.maximumSize.height), | ||
| 162 | }; | ||
| 163 | } | ||
| 164 | |||
| 165 | void T3SourceTextShadowNode::layout(LayoutContext layoutContext) { | ||
| 166 | ensureUnsealed(); | ||
| 167 | setStateData(T3SourceTextStateReal{ | ||
| 168 | _attributedString, | ||
| 169 | }); | ||
| 170 | } | ||
| 171 | } // namespace facebook::react | ||
apps/mobile/modules/t3-source-text/package.json created+35| ... | @@ -0,0 +1,35 @@ | ||
| 1 | { | ||
| 2 | "name": "@t3tools/mobile-source-text", | ||
| 3 | "version": "0.0.0", | ||
| 4 | "private": true, | ||
| 5 | "files": [ | ||
| 6 | "ios", | ||
| 7 | "src", | ||
| 8 | "index.ts", | ||
| 9 | "LICENSE", | ||
| 10 | "UPSTREAM.md", | ||
| 11 | "T3SourceText.podspec", | ||
| 12 | "react-native.config.js" | ||
| 13 | ], | ||
| 14 | "main": "./index.ts", | ||
| 15 | "types": "./index.ts", | ||
| 16 | "react-native": "./index.ts", | ||
| 17 | "peerDependencies": { | ||
| 18 | "react": "*", | ||
| 19 | "react-native": "*" | ||
| 20 | }, | ||
| 21 | "codegenConfig": { | ||
| 22 | "name": "T3SourceTextSpec", | ||
| 23 | "type": "components", | ||
| 24 | "jsSrcsDir": "src", | ||
| 25 | "ios": { | ||
| 26 | "componentProvider": { | ||
| 27 | "T3SourceText": "T3SourceText", | ||
| 28 | "T3SourceTextRun": "T3SourceTextRun" | ||
| 29 | } | ||
| 30 | }, | ||
| 31 | "outputDir": { | ||
| 32 | "ios": "ios/generated" | ||
| 33 | } | ||
| 34 | } | ||
| 35 | } | ||
apps/mobile/modules/t3-source-text/react-native.config.js created+10| ... | @@ -0,0 +1,10 @@ | ||
| 1 | module.exports = { | ||
| 2 | dependency: { | ||
| 3 | platforms: { | ||
| 4 | ios: { | ||
| 5 | podspecPath: "T3SourceText.podspec", | ||
| 6 | }, | ||
| 7 | android: null, | ||
| 8 | }, | ||
| 9 | }, | ||
| 10 | }; | ||
apps/mobile/modules/t3-source-text/src/SourceText.tsx created+54| ... | @@ -0,0 +1,54 @@ | ||
| 1 | import React, { type ComponentRef, type Ref } from "react"; | ||
| 2 | import { Platform, StyleSheet, Text, type TextProps, type TextStyle } from "react-native"; | ||
| 3 | import T3SourceTextRunNativeComponent from "./T3SourceTextRunNativeComponent"; | ||
| 4 | import T3SourceTextNativeComponent from "./T3SourceTextNativeComponent"; | ||
| 5 | import { flattenStyles } from "./util"; | ||
| 6 | |||
| 7 | const TextAncestorContext = React.createContext<[boolean, TextStyle]>([false, {}]); | ||
| 8 | |||
| 9 | export type SourceTextProps = Omit<TextProps, "onTextLayout" | "onPress" | "onLongPress"> & { | ||
| 10 | nativeTextRef?: Ref<ComponentRef<typeof Text>>; | ||
| 11 | onSelectionChange?: (event: { | ||
| 12 | nativeEvent: { target: number; start: number; end: number }; | ||
| 13 | }) => void; | ||
| 14 | }; | ||
| 15 | |||
| 16 | /** One selectable document on iOS, with nested runs carrying syntax colors. */ | ||
| 17 | export function SourceText({ style, children, nativeTextRef, ...props }: SourceTextProps) { | ||
| 18 | const [isAncestor, rootStyle] = React.useContext(TextAncestorContext); | ||
| 19 | const flattenedStyle = React.useMemo(() => flattenStyles(rootStyle, style), [rootStyle, style]); | ||
| 20 | const contextValue = React.useMemo<[boolean, TextStyle]>( | ||
| 21 | () => [true, StyleSheet.flatten([rootStyle, style]) ?? {}], | ||
| 22 | [rootStyle, style], | ||
| 23 | ); | ||
| 24 | if (Platform.OS !== "ios") { | ||
| 25 | const { onSelectionChange: _onSelectionChange, ...textProps } = props; | ||
| 26 | return ( | ||
| 27 | <Text ref={nativeTextRef} style={style} {...textProps}> | ||
| 28 | {children} | ||
| 29 | </Text> | ||
| 30 | ); | ||
| 31 | } | ||
| 32 | const runs = React.Children.toArray(children).map((child, index) => { | ||
| 33 | if (React.isValidElement(child)) return child; | ||
| 34 | if (typeof child !== "string" && typeof child !== "number") return null; | ||
| 35 | return ( | ||
| 36 | <T3SourceTextRunNativeComponent | ||
| 37 | key={index} | ||
| 38 | style={flattenedStyle} | ||
| 39 | {...flattenedStyle} | ||
| 40 | text={String(child)} | ||
| 41 | /> | ||
| 42 | ); | ||
| 43 | }); | ||
| 44 | if (isAncestor) { | ||
| 45 | return <TextAncestorContext.Provider value={contextValue}>{runs}</TextAncestorContext.Provider>; | ||
| 46 | } | ||
| 47 | return ( | ||
| 48 | <TextAncestorContext.Provider value={contextValue}> | ||
| 49 | <T3SourceTextNativeComponent allowFontScaling selectable {...props} style={flattenedStyle}> | ||
| 50 | {runs} | ||
| 51 | </T3SourceTextNativeComponent> | ||
| 52 | </TextAncestorContext.Provider> | ||
| 53 | ); | ||
| 54 | } | ||
apps/mobile/modules/t3-source-text/src/T3SourceTextNativeComponent.ts created+45| ... | @@ -0,0 +1,45 @@ | ||
| 1 | import { codegenNativeComponent, type CodegenTypes } from "react-native"; | ||
| 2 | import type { ViewProps } from "react-native"; | ||
| 3 | |||
| 4 | interface TargetedEvent { | ||
| 5 | target: CodegenTypes.Int32; | ||
| 6 | } | ||
| 7 | |||
| 8 | /** | ||
| 9 | * Event fired when text selection changes in the SourceText. | ||
| 10 | * @property target - The view tag identifier | ||
| 11 | * @property start - The start index of the selected range (0-based) | ||
| 12 | * @property end - The end index of the selected range (0-based, exclusive) | ||
| 13 | */ | ||
| 14 | interface SelectionChangeEvent extends TargetedEvent { | ||
| 15 | start: CodegenTypes.Int32; | ||
| 16 | end: CodegenTypes.Int32; | ||
| 17 | } | ||
| 18 | |||
| 19 | type EllipsizeMode = "head" | "middle" | "tail" | "clip"; | ||
| 20 | |||
| 21 | interface NativeProps extends ViewProps { | ||
| 22 | numberOfLines?: CodegenTypes.Int32; | ||
| 23 | allowFontScaling?: CodegenTypes.WithDefault<boolean, true>; | ||
| 24 | ellipsizeMode?: CodegenTypes.WithDefault<EllipsizeMode, "tail">; | ||
| 25 | selectable?: boolean; | ||
| 26 | /** | ||
| 27 | * Callback fired when the text selection changes. | ||
| 28 | * | ||
| 29 | * @example | ||
| 30 | * ```tsx | ||
| 31 | * <SourceText | ||
| 32 | * onSelectionChange={(event) => { | ||
| 33 | * console.log('Selection:', event.nativeEvent.start, event.nativeEvent.end); | ||
| 34 | * }} | ||
| 35 | * > | ||
| 36 | * Selectable text | ||
| 37 | * </SourceText> | ||
| 38 | * ``` | ||
| 39 | */ | ||
| 40 | onSelectionChange?: CodegenTypes.BubblingEventHandler<SelectionChangeEvent>; | ||
| 41 | } | ||
| 42 | |||
| 43 | export default codegenNativeComponent<NativeProps>("T3SourceText", { | ||
| 44 | excludedPlatforms: ["android"], | ||
| 45 | }); | ||
apps/mobile/modules/t3-source-text/src/T3SourceTextRunNativeComponent.ts created+39| ... | @@ -0,0 +1,39 @@ | ||
| 1 | import { codegenNativeComponent, type CodegenTypes } from "react-native"; | ||
| 2 | import type { ColorValue, ViewProps } from "react-native"; | ||
| 3 | |||
| 4 | type TextDecorationLine = "none" | "underline" | "line-through"; | ||
| 5 | |||
| 6 | type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed"; | ||
| 7 | |||
| 8 | export type NativeFontWeight = | ||
| 9 | | "normal" | ||
| 10 | | "bold" | ||
| 11 | | "ultraLight" | ||
| 12 | | "light" | ||
| 13 | | "medium" | ||
| 14 | | "semibold" | ||
| 15 | | "heavy"; | ||
| 16 | |||
| 17 | type FontStyle = "normal" | "italic"; | ||
| 18 | |||
| 19 | type TextAlign = "auto" | "left" | "right" | "center" | "justify"; | ||
| 20 | |||
| 21 | interface NativeProps extends ViewProps { | ||
| 22 | text: string; | ||
| 23 | color?: ColorValue; | ||
| 24 | fontSize?: CodegenTypes.Float; | ||
| 25 | fontStyle?: CodegenTypes.WithDefault<FontStyle, "normal">; | ||
| 26 | fontWeight?: CodegenTypes.WithDefault<NativeFontWeight, "normal">; | ||
| 27 | fontFamily?: string; | ||
| 28 | letterSpacing?: CodegenTypes.Float; | ||
| 29 | lineHeight?: CodegenTypes.Float; | ||
| 30 | textDecorationLine?: CodegenTypes.WithDefault<TextDecorationLine, "none">; | ||
| 31 | textDecorationStyle?: CodegenTypes.WithDefault<TextDecorationStyle, "solid">; | ||
| 32 | textDecorationColor?: ColorValue; | ||
| 33 | textAlign?: CodegenTypes.WithDefault<TextAlign, "auto">; | ||
| 34 | shadowRadius?: CodegenTypes.WithDefault<CodegenTypes.Float, 0>; | ||
| 35 | } | ||
| 36 | |||
| 37 | export default codegenNativeComponent<NativeProps>("T3SourceTextRun", { | ||
| 38 | excludedPlatforms: ["android"], | ||
| 39 | }); | ||
apps/mobile/modules/t3-source-text/src/util.ts created+79| ... | @@ -0,0 +1,79 @@ | ||
| 1 | import { I18nManager, type StyleProp, StyleSheet, type TextStyle } from "react-native"; | ||
| 2 | import type { NativeFontWeight } from "./T3SourceTextRunNativeComponent"; | ||
| 3 | |||
| 4 | export function flattenStyles(rootStyle: TextStyle, style: StyleProp<TextStyle>) { | ||
| 5 | const flattenedStyle = StyleSheet.flatten([rootStyle, style]) as TextStyle; | ||
| 6 | const textDecorationLine = flattenedStyle.textDecorationLine; | ||
| 7 | return { | ||
| 8 | ...flattenedStyle, | ||
| 9 | textAlign: | ||
| 10 | flattenedStyle.textAlign === "start" | ||
| 11 | ? I18nManager.isRTL | ||
| 12 | ? ("right" as const) | ||
| 13 | : ("left" as const) | ||
| 14 | : flattenedStyle.textAlign === "end" | ||
| 15 | ? I18nManager.isRTL | ||
| 16 | ? ("left" as const) | ||
| 17 | : ("right" as const) | ||
| 18 | : flattenedStyle.textAlign, | ||
| 19 | textDecorationStyle: | ||
| 20 | flattenedStyle.textDecorationStyle === "wavy" ? "solid" : flattenedStyle.textDecorationStyle, | ||
| 21 | textDecorationLine: | ||
| 22 | textDecorationLine === "underline" || textDecorationLine === "line-through" | ||
| 23 | ? textDecorationLine | ||
| 24 | : ("none" as const), | ||
| 25 | fontWeight: fontWeightToNativeProp(flattenedStyle.fontWeight ?? "normal"), | ||
| 26 | backgroundColor: flattenedStyle.backgroundColor | ||
| 27 | ? flattenedStyle.backgroundColor | ||
| 28 | : "transparent", | ||
| 29 | shadowOffset: flattenedStyle.shadowOffset | ||
| 30 | ? flattenedStyle.shadowOffset | ||
| 31 | : { width: 0, height: 0 }, | ||
| 32 | }; | ||
| 33 | } | ||
| 34 | |||
| 35 | // Codegen doesn't like using integer values for enums (c++ L) so we'll conver them to the proper native prop | ||
| 36 | // value before returning flattened styles. | ||
| 37 | function fontWeightToNativeProp(fontWeight: TextStyle["fontWeight"]): NativeFontWeight { | ||
| 38 | switch (fontWeight) { | ||
| 39 | case "normal": | ||
| 40 | return "normal"; | ||
| 41 | case "bold": | ||
| 42 | return "bold"; | ||
| 43 | case 100: | ||
| 44 | case "100": | ||
| 45 | case "ultralight": | ||
| 46 | return "ultraLight"; | ||
| 47 | case 200: | ||
| 48 | case "200": | ||
| 49 | return "ultraLight"; | ||
| 50 | case 300: | ||
| 51 | case "300": | ||
| 52 | case "light": | ||
| 53 | return "light"; | ||
| 54 | case 400: | ||
| 55 | case "400": | ||
| 56 | case "regular": | ||
| 57 | return "normal"; | ||
| 58 | case 500: | ||
| 59 | case "500": | ||
| 60 | case "medium": | ||
| 61 | return "medium"; | ||
| 62 | case 600: | ||
| 63 | case "600": | ||
| 64 | case "semibold": | ||
| 65 | return "semibold"; | ||
| 66 | case 700: | ||
| 67 | case "700": | ||
| 68 | return "semibold"; | ||
| 69 | case 800: | ||
| 70 | case "800": | ||
| 71 | return "bold"; | ||
| 72 | case 900: | ||
| 73 | case "900": | ||
| 74 | case "heavy": | ||
| 75 | return "heavy"; | ||
| 76 | default: | ||
| 77 | return "normal"; | ||
| 78 | } | ||
| 79 | } | ||
apps/mobile/package.json+13-5| ... | @@ -40,7 +40,7 @@ | ... | @@ -40,7 +40,7 @@ |
| 40 | "config:preview": "APP_VARIANT=preview expo config", | 40 | "config:preview": "APP_VARIANT=preview expo config", |
| 41 | "config:prod": "APP_VARIANT=production expo config", | 41 | "config:prod": "APP_VARIANT=production expo config", |
| 42 | "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", | 42 | "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", |
| 43 | "sync:pierre-icons": "node modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs", | 43 | "sync:pierre-icons": "node scripts/sync-pierre-file-icons.mjs", |
| 44 | "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts", | 44 | "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts", |
| 45 | "test": "vp test run --config ../../vite.config.ts --dir .", | 45 | "test": "vp test run --config ../../vite.config.ts --dir .", |
| 46 | "typecheck": "tsc --noEmit" | 46 | "typecheck": "tsc --noEmit" |
| ... | @@ -66,8 +66,8 @@ | ... | @@ -66,8 +66,8 @@ |
| 66 | "@shikijs/themes": "4.2.0", | 66 | "@shikijs/themes": "4.2.0", |
| 67 | "@t3tools/client-runtime": "workspace:*", | 67 | "@t3tools/client-runtime": "workspace:*", |
| 68 | "@t3tools/contracts": "workspace:*", | 68 | "@t3tools/contracts": "workspace:*", |
| 69 | "@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text", | ||
| 70 | "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", | 69 | "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", |
| 70 | "@t3tools/mobile-source-text": "file:./modules/t3-source-text", | ||
| 71 | "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", | 71 | "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", |
| 72 | "@t3tools/provider-acp-registry": "workspace:*", | 72 | "@t3tools/provider-acp-registry": "workspace:*", |
| 73 | "@t3tools/provider-core": "workspace:*", | 73 | "@t3tools/provider-core": "workspace:*", |
| ... | @@ -120,11 +120,10 @@ | ... | @@ -120,11 +120,10 @@ |
| 120 | "react": "19.3.0", | 120 | "react": "19.3.0", |
| 121 | "react-dom": "19.3.0", | 121 | "react-dom": "19.3.0", |
| 122 | "react-native": "0.88.0-rc.3", | 122 | "react-native": "0.88.0-rc.3", |
| 123 | "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.10.tgz", | ||
| 123 | "react-native-gesture-handler": "~3.2.1", | 124 | "react-native-gesture-handler": "~3.2.1", |
| 124 | "react-native-image-viewing": "^0.2.2", | 125 | "react-native-image-viewing": "^0.2.2", |
| 125 | "react-native-keyboard-controller": "1.22.6", | 126 | "react-native-keyboard-controller": "1.22.6", |
| 126 | "react-native-nitro-markdown": "^0.5.0", | ||
| 127 | "react-native-nitro-modules": "0.35.9", | ||
| 128 | "react-native-reanimated": "4.7.0", | 127 | "react-native-reanimated": "4.7.0", |
| 129 | "react-native-safe-area-context": "~5.9.1", | 128 | "react-native-safe-area-context": "~5.9.1", |
| 130 | "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.24.tgz", | 129 | "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.24.tgz", |
| ... | @@ -132,13 +131,17 @@ | ... | @@ -132,13 +131,17 @@ |
| 132 | "react-native-svg": "15.15.5", | 131 | "react-native-svg": "15.15.5", |
| 133 | "react-native-webview": "14.0.1", | 132 | "react-native-webview": "14.0.1", |
| 134 | "react-native-worklets": "0.13.0", | 133 | "react-native-worklets": "0.13.0", |
| 134 | "remark-gfm": "catalog:", | ||
| 135 | "remark-parse": "catalog:", | ||
| 135 | "shiki": "4.2.0", | 136 | "shiki": "4.2.0", |
| 136 | "tailwind-merge": "^3.5.0", | 137 | "tailwind-merge": "^3.5.0", |
| 138 | "unified": "catalog:", | ||
| 137 | "uniwind": "1.12.1" | 139 | "uniwind": "1.12.1" |
| 138 | }, | 140 | }, |
| 139 | "devDependencies": { | 141 | "devDependencies": { |
| 140 | "@effect/vitest": "catalog:", | 142 | "@effect/vitest": "catalog:", |
| 141 | "@pierre/trees": "1.0.0-beta.6", | 143 | "@pierre/trees": "1.0.0-beta.6", |
| 144 | "@types/mdast": "^4.0.4", | ||
| 142 | "@types/react": "~19.3.0", | 145 | "@types/react": "~19.3.0", |
| 143 | "@types/react-dom": "~19.2.3", | 146 | "@types/react-dom": "~19.2.3", |
| 144 | "babel-preset-expo": "~58.0.8", | 147 | "babel-preset-expo": "~58.0.8", |
| ... | @@ -146,12 +149,17 @@ | ... | @@ -146,12 +149,17 @@ |
| 146 | "tailwindcss": "^4.0.0", | 149 | "tailwindcss": "^4.0.0", |
| 147 | "typescript": "catalog:" | 150 | "typescript": "catalog:" |
| 148 | }, | 151 | }, |
| 152 | "enriched-markdown": { | ||
| 153 | "enableMath": false, | ||
| 154 | "enableVideo": false | ||
| 155 | }, | ||
| 149 | "expo": { | 156 | "expo": { |
| 150 | "install": { | 157 | "install": { |
| 151 | "exclude": [ | 158 | "exclude": [ |
| 152 | "react-native-keyboard-controller", | 159 | "react-native-keyboard-controller", |
| 153 | "typescript", | 160 | "typescript", |
| 154 | "react-native-screens" | 161 | "react-native-screens", |
| 162 | "react-native-enriched-markdown" | ||
| 155 | ] | 163 | ] |
| 156 | }, | 164 | }, |
| 157 | "autolinking": { | 165 | "autolinking": { |
apps/mobile/scripts/sync-pierre-file-icons.mjs created+134| ... | @@ -0,0 +1,134 @@ | ||
| 1 | import * as NodeChildProcess from "node:child_process"; | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodePath from "node:path"; | ||
| 4 | import * as NodeURL from "node:url"; | ||
| 5 | |||
| 6 | import { getBuiltInSpriteSheet } from "@pierre/trees"; | ||
| 7 | |||
| 8 | const scriptDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | ||
| 9 | const appDirectory = NodePath.resolve(scriptDirectory, ".."); | ||
| 10 | const repositoryRoot = NodePath.resolve(appDirectory, "../.."); | ||
| 11 | const outputDirectory = NodePath.join(appDirectory, "assets/file-icons"); | ||
| 12 | const generatedModulePath = NodePath.join(appDirectory, "src/lib/markdownFileIcons.generated.ts"); | ||
| 13 | const webIconSource = NodeFS.readFileSync( | ||
| 14 | NodePath.join(repositoryRoot, "apps/web/src/pierre-icons.ts"), | ||
| 15 | "utf8", | ||
| 16 | ); | ||
| 17 | const customSprite = webIconSource.match(/const T3_FILE_ICON_SPRITE = `([\s\S]*?)`;/)?.[1]; | ||
| 18 | |||
| 19 | if (!customSprite) { | ||
| 20 | throw new Error("Could not read the T3 Pierre icon sprite from apps/web/src/pierre-icons.ts"); | ||
| 21 | } | ||
| 22 | |||
| 23 | const colors = { | ||
| 24 | astro: "#a631be", | ||
| 25 | babel: "#d5a910", | ||
| 26 | bash: "#199f43", | ||
| 27 | biome: "#1a85d4", | ||
| 28 | bootstrap: "#693acf", | ||
| 29 | browserslist: "#d5a910", | ||
| 30 | bun: "#594c5b", | ||
| 31 | c: "#1a85d4", | ||
| 32 | claude: "#d47628", | ||
| 33 | cpp: "#1a85d4", | ||
| 34 | css: "#693acf", | ||
| 35 | database: "#a631be", | ||
| 36 | default: "#84848a", | ||
| 37 | docker: "#1a85d4", | ||
| 38 | eslint: "#693acf", | ||
| 39 | font: "#84848a", | ||
| 40 | git: "#ff8c5b", | ||
| 41 | go: "#1ca1c7", | ||
| 42 | graphql: "#d32a61", | ||
| 43 | html: "#d47628", | ||
| 44 | image: "#d32a61", | ||
| 45 | javascript: "#d5a910", | ||
| 46 | json: "#d47628", | ||
| 47 | markdown: "#199f43", | ||
| 48 | mcp: "#17a5af", | ||
| 49 | nextjs: "#84848a", | ||
| 50 | npm: "#d52c36", | ||
| 51 | oxc: "#1ca1c7", | ||
| 52 | postcss: "#d52c36", | ||
| 53 | prettier: "#17a5af", | ||
| 54 | python: "#1a85d4", | ||
| 55 | react: "#1ca1c7", | ||
| 56 | ruby: "#d52c36", | ||
| 57 | rust: "#d47628", | ||
| 58 | sass: "#d32a61", | ||
| 59 | stylelint: "#84848a", | ||
| 60 | svelte: "#d52c36", | ||
| 61 | svg: "#d47628", | ||
| 62 | svgo: "#199f43", | ||
| 63 | swift: "#d47628", | ||
| 64 | table: "#17a5af", | ||
| 65 | tailwind: "#1ca1c7", | ||
| 66 | terraform: "#693acf", | ||
| 67 | text: "#84848a", | ||
| 68 | typescript: "#1a85d4", | ||
| 69 | video: "#a631be", | ||
| 70 | vite: "#a631be", | ||
| 71 | vscode: "#1a85d4", | ||
| 72 | vue: "#199f43", | ||
| 73 | wasm: "#693acf", | ||
| 74 | webpack: "#1a85d4", | ||
| 75 | yml: "#d52c36", | ||
| 76 | zig: "#d47628", | ||
| 77 | zip: "#d47628", | ||
| 78 | }; | ||
| 79 | |||
| 80 | const customIcons = { | ||
| 81 | agents: "t3-file-icon-agents", | ||
| 82 | pnpm: "t3-file-icon-pnpm", | ||
| 83 | video: "t3-file-icon-video", | ||
| 84 | }; | ||
| 85 | |||
| 86 | function symbolFromSprite(sprite, id) { | ||
| 87 | const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | ||
| 88 | const match = sprite.match( | ||
| 89 | new RegExp(`<symbol id="${escapedId}"([^>]*)>([\\s\\S]*?)<\\/symbol>`), | ||
| 90 | ); | ||
| 91 | if (!match) throw new Error(`Missing Pierre icon symbol: ${id}`); | ||
| 92 | return { | ||
| 93 | body: match[2], | ||
| 94 | viewBox: match[1].match(/viewBox="([^"]+)"/)?.[1] ?? "0 0 16 16", | ||
| 95 | }; | ||
| 96 | } | ||
| 97 | |||
| 98 | function renderIcon(token, symbol, color) { | ||
| 99 | const svgPath = NodePath.join(outputDirectory, `.pierre-${token}.svg`); | ||
| 100 | const pngPath = NodePath.join(outputDirectory, `pierre_${token}.png`); | ||
| 101 | NodeFS.writeFileSync( | ||
| 102 | svgPath, | ||
| 103 | `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="${symbol.viewBox}" style="color:${color}">${symbol.body}</svg>`, | ||
| 104 | ); | ||
| 105 | NodeChildProcess.execFileSync("sips", ["-s", "format", "png", svgPath, "--out", pngPath], { | ||
| 106 | stdio: "ignore", | ||
| 107 | }); | ||
| 108 | NodeFS.rmSync(svgPath); | ||
| 109 | } | ||
| 110 | |||
| 111 | NodeFS.rmSync(outputDirectory, { recursive: true, force: true }); | ||
| 112 | NodeFS.mkdirSync(outputDirectory, { recursive: true }); | ||
| 113 | |||
| 114 | const builtInSprite = getBuiltInSpriteSheet("complete"); | ||
| 115 | const builtInTokens = [...builtInSprite.matchAll(/<symbol id="file-tree-builtin-([^"]+)"/g)] | ||
| 116 | .map((match) => match[1]) | ||
| 117 | .sort(); | ||
| 118 | |||
| 119 | for (const token of builtInTokens) { | ||
| 120 | renderIcon( | ||
| 121 | token, | ||
| 122 | symbolFromSprite(builtInSprite, `file-tree-builtin-${token}`), | ||
| 123 | colors[token] ?? colors.default, | ||
| 124 | ); | ||
| 125 | } | ||
| 126 | for (const [token, symbolId] of Object.entries(customIcons)) { | ||
| 127 | renderIcon(token, symbolFromSprite(customSprite, symbolId), colors[token] ?? colors.default); | ||
| 128 | } | ||
| 129 | |||
| 130 | const tokens = [...new Set([...builtInTokens, ...Object.keys(customIcons)])].sort(); | ||
| 131 | const generatedSource = `import type { ImageSourcePropType } from "react-native";\n\nexport const MARKDOWN_FILE_ICON_SOURCES = {\n${tokens | ||
| 132 | .map((token) => ` ${token}: require("../../assets/file-icons/pierre_${token}.png"),`) | ||
| 133 | .join("\n")}\n} as const satisfies Readonly<Record<string, ImageSourcePropType>>;\n`; | ||
| 134 | NodeFS.writeFileSync(generatedModulePath, generatedSource); | ||
apps/mobile/src/components/AppSheet.tsx created+80| ... | @@ -0,0 +1,80 @@ | ||
| 1 | import { useId, useState, type ReactNode } from "react"; | ||
| 2 | import { Platform, View } from "react-native"; | ||
| 3 | import { FormSheet, Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; | ||
| 4 | import { useResolveClassNames, withUniwind } from "uniwind"; | ||
| 5 | |||
| 6 | import { AndroidSheetHeader } from "./AndroidScreenHeader"; | ||
| 7 | import { nativeHeaderScrollEdgeEffects } from "../native/StackHeader"; | ||
| 8 | |||
| 9 | const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); | ||
| 10 | const NativeScreen = withUniwind(Screen); | ||
| 11 | const NativeHeader = withUniwind(ScreenStackHeaderConfig, { | ||
| 12 | color: { fromClassName: "tintColorClassName", styleProperty: "accentColor" }, | ||
| 13 | titleColor: { fromClassName: "titleColorClassName", styleProperty: "accentColor" }, | ||
| 14 | }); | ||
| 15 | |||
| 16 | /** Native presentation for local context whose owner supplies its dismissal callback. */ | ||
| 17 | export function AppSheet(props: { | ||
| 18 | readonly title: string; | ||
| 19 | readonly children: ReactNode; | ||
| 20 | readonly onClose: () => void; | ||
| 21 | }) { | ||
| 22 | const containerStyle = useResolveClassNames("bg-sheet"); | ||
| 23 | const screenId = useId(); | ||
| 24 | const [isOpen, setIsOpen] = useState(true); | ||
| 25 | const close = () => setIsOpen(false); | ||
| 26 | |||
| 27 | return ( | ||
| 28 | <FormSheet | ||
| 29 | isOpen={isOpen} | ||
| 30 | detents={[0.5, 0.9]} | ||
| 31 | prefersGrabberVisible | ||
| 32 | nativeContainerStyle={containerStyle} | ||
| 33 | onDismiss={props.onClose} | ||
| 34 | onNativeDismiss={props.onClose} | ||
| 35 | > | ||
| 36 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 37 | {Platform.OS === "ios" ? ( | ||
| 38 | <ScreenStack style={{ flex: 1 }}> | ||
| 39 | <NativeScreen | ||
| 40 | activityState={2} | ||
| 41 | enabled | ||
| 42 | isNativeStack | ||
| 43 | screenId={`app-sheet-${screenId}`} | ||
| 44 | scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS} | ||
| 45 | className="flex-1 bg-sheet" | ||
| 46 | > | ||
| 47 | {props.children} | ||
| 48 | <NativeHeader | ||
| 49 | backgroundColor="rgba(0,0,0,0)" | ||
| 50 | tintColorClassName="accent-foreground" | ||
| 51 | hideBackButton | ||
| 52 | hideShadow={false} | ||
| 53 | title={props.title} | ||
| 54 | titleColorClassName="accent-foreground" | ||
| 55 | titleFontSize={18} | ||
| 56 | titleFontWeight="800" | ||
| 57 | translucent | ||
| 58 | headerRightBarButtonItems={[ | ||
| 59 | { | ||
| 60 | type: "button", | ||
| 61 | title: "Done", | ||
| 62 | variant: "done", | ||
| 63 | accessibilityLabel: `Close ${props.title}`, | ||
| 64 | identifier: `app-sheet-done-${screenId}`, | ||
| 65 | onPress: close, | ||
| 66 | }, | ||
| 67 | ]} | ||
| 68 | /> | ||
| 69 | </NativeScreen> | ||
| 70 | </ScreenStack> | ||
| 71 | ) : ( | ||
| 72 | <> | ||
| 73 | <AndroidSheetHeader title={props.title} onBack={close} /> | ||
| 74 | {props.children} | ||
| 75 | </> | ||
| 76 | )} | ||
| 77 | </View> | ||
| 78 | </FormSheet> | ||
| 79 | ); | ||
| 80 | } | ||
apps/mobile/src/components/ComposerContextSheet.tsx+192-267| ... | @@ -10,16 +10,7 @@ import type { | ... | @@ -10,16 +10,7 @@ import type { |
| 10 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; | 11 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 12 | import { videoMimeType } from "@t3tools/shared/video"; | 12 | import { videoMimeType } from "@t3tools/shared/video"; |
| 13 | import { useState } from "react"; | 13 | import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; |
| 14 | import { | ||
| 15 | Alert, | ||
| 16 | Modal, | ||
| 17 | Platform, | ||
| 18 | Pressable, | ||
| 19 | ScrollView, | ||
| 20 | useWindowDimensions, | ||
| 21 | View, | ||
| 22 | } from "react-native"; | ||
| 23 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 24 | import { REVIEW_MONO_FONT_FAMILY } from "../features/review/reviewDiffRendering"; | 15 | import { REVIEW_MONO_FONT_FAMILY } from "../features/review/reviewDiffRendering"; |
| 25 | import { ReviewCommentCard, useReviewCommentColors } from "../features/review/ReviewCommentCard"; | 16 | import { ReviewCommentCard, useReviewCommentColors } from "../features/review/ReviewCommentCard"; |
| ... | @@ -33,8 +24,7 @@ import { FilePreviewModal } from "./FilePreviewModal"; | ... | @@ -33,8 +24,7 @@ import { FilePreviewModal } from "./FilePreviewModal"; |
| 33 | import { VideoPreviewModal } from "./VideoPreviewModal"; | 24 | import { VideoPreviewModal } from "./VideoPreviewModal"; |
| 34 | import { ComposerContextAttachment } from "./ComposerContextAttachment"; | 25 | import { ComposerContextAttachment } from "./ComposerContextAttachment"; |
| 35 | import { AppText as Text } from "./AppText"; | 26 | import { AppText as Text } from "./AppText"; |
| 36 | import { SymbolView } from "./AppSymbol"; | 27 | import { AppSheet } from "./AppSheet"; |
| 37 | import { ContextSheetSize } from "./ContextSheetSize"; | ||
| 38 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | 28 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 39 | import { getMobileTerminalTheme } from "../features/terminal/terminalTheme"; | 29 | import { getMobileTerminalTheme } from "../features/terminal/terminalTheme"; |
| 40 | 30 | ||
| ... | @@ -106,12 +96,8 @@ export function ComposerContextSheet(props: { | ... | @@ -106,12 +96,8 @@ export function ComposerContextSheet(props: { |
| 106 | }); | 96 | }); |
| 107 | const reviewColors = useReviewCommentColors(); | 97 | const reviewColors = useReviewCommentColors(); |
| 108 | const insets = useSafeAreaInsets(); | 98 | const insets = useSafeAreaInsets(); |
| 109 | const { height: windowHeight } = useWindowDimensions(); | ||
| 110 | const { themeId, themeAppearance } = useAppearancePreferences(); | 99 | const { themeId, themeAppearance } = useAppearancePreferences(); |
| 111 | const terminalTheme = getMobileTerminalTheme(themeId, themeAppearance); | 100 | const terminalTheme = getMobileTerminalTheme(themeId, themeAppearance); |
| 112 | const [headerHeight, setHeaderHeight] = useState(0); | ||
| 113 | const [bodyHeight, setBodyHeight] = useState(0); | ||
| 114 | const measuredHeight = headerHeight + bodyHeight; | ||
| 115 | const record = props.record; | 101 | const record = props.record; |
| 116 | const localAttachment = | 102 | const localAttachment = |
| 117 | record && "attachmentId" in record | 103 | record && "attachmentId" in record |
| ... | @@ -197,274 +183,213 @@ export function ComposerContextSheet(props: { | ... | @@ -197,274 +183,213 @@ export function ComposerContextSheet(props: { |
| 197 | : undefined; | 183 | : undefined; |
| 198 | const terminal = record?.kind === "terminal" && !("payload" in record) ? record : null; | 184 | const terminal = record?.kind === "terminal" && !("payload" in record) ? record : null; |
| 199 | return ( | 185 | return ( |
| 200 | <Modal | 186 | <AppSheet title={terminal?.terminalLabel ?? props.label} onClose={props.onClose}> |
| 201 | animationType="slide" | 187 | <ScrollView |
| 202 | presentationStyle={Platform.OS === "android" ? "overFullScreen" : "pageSheet"} | 188 | className="flex-1" |
| 203 | transparent={Platform.OS === "android"} | 189 | contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} |
| 204 | onRequestClose={props.onClose} | 190 | contentContainerStyle={{ |
| 205 | > | 191 | paddingHorizontal: 20, |
| 206 | <View | 192 | paddingTop: 12, |
| 207 | className={ | 193 | gap: 16, |
| 208 | Platform.OS === "android" ? "flex-1 justify-end bg-backdrop" : "flex-1 justify-end" | 194 | paddingBottom: Math.max(20, insets.bottom), |
| 209 | } | 195 | }} |
| 210 | > | 196 | > |
| 211 | {Platform.OS === "android" ? ( | 197 | {terminal ? ( |
| 212 | <Pressable | 198 | <Text className="text-sm text-foreground-muted"> |
| 213 | accessibilityRole="button" | 199 | Lines {terminal.lineStart}–{terminal.lineEnd} |
| 214 | accessibilityLabel="Dismiss context" | 200 | </Text> |
| 215 | onPress={props.onClose} | ||
| 216 | style={{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0 }} | ||
| 217 | /> | ||
| 218 | ) : null} | 201 | ) : null} |
| 219 | <View | 202 | {!record ? ( |
| 220 | className="overflow-hidden rounded-t-3xl bg-sheet-solid" | 203 | <Text className="text-foreground"> |
| 221 | style={ | 204 | Context unavailable. The reference was copied without its payload. Copy it again from |
| 222 | Platform.OS === "android" | 205 | the original message or remove it. |
| 223 | ? { | 206 | </Text> |
| 224 | height: Math.min( | 207 | ) : "payload" in record ? ( |
| 225 | measuredHeight || windowHeight * 0.5, | 208 | <Text className="text-foreground"> |
| 226 | windowHeight - insets.top - 24, | 209 | This context type is not supported by this version of the app. Its payload will be |
| 227 | ), | 210 | preserved when sent. |
| 228 | } | 211 | </Text> |
| 229 | : { flex: 1 } | 212 | ) : ( |
| 230 | } | 213 | <> |
| 231 | > | 214 | {record.kind === "terminal" ? ( |
| 232 | <ContextSheetSize height={measuredHeight} /> | 215 | <View |
| 233 | <View | 216 | className="overflow-hidden rounded-xl" |
| 234 | onLayout={(event) => setHeaderHeight(event.nativeEvent.layout.height)} | 217 | style={{ backgroundColor: terminalTheme.background }} |
| 235 | className="flex-row items-center justify-between gap-3 border-b border-border px-4 pb-2 pt-4" | 218 | > |
| 236 | > | 219 | <ScrollView |
| 237 | {terminal ? ( | 220 | horizontal |
| 238 | <SymbolView name="terminal" size={20} tintColor={terminalTheme.palette[2]} /> | 221 | showsHorizontalScrollIndicator |
| 222 | contentContainerStyle={{ padding: 12 }} | ||
| 223 | > | ||
| 224 | <Text | ||
| 225 | selectable | ||
| 226 | className="text-sm text-foreground" | ||
| 227 | style={{ | ||
| 228 | fontFamily: REVIEW_MONO_FONT_FAMILY, | ||
| 229 | fontSize: 13, | ||
| 230 | lineHeight: 20, | ||
| 231 | color: terminalTheme.foreground, | ||
| 232 | }} | ||
| 233 | > | ||
| 234 | {record.text} | ||
| 235 | </Text> | ||
| 236 | </ScrollView> | ||
| 237 | </View> | ||
| 239 | ) : null} | 238 | ) : null} |
| 240 | <View className="min-w-0 flex-1"> | 239 | {record.kind === "review-comment" ? ( |
| 241 | <Text className="text-base font-t3-semibold text-foreground" numberOfLines={2}> | ||
| 242 | {terminal?.terminalLabel ?? props.label} | ||
| 243 | </Text> | ||
| 244 | {terminal ? ( | ||
| 245 | <Text className="text-xs text-foreground-muted"> | ||
| 246 | Lines {terminal.lineStart}–{terminal.lineEnd} | ||
| 247 | </Text> | ||
| 248 | ) : null} | ||
| 249 | </View> | ||
| 250 | <Pressable | ||
| 251 | accessibilityRole="button" | ||
| 252 | accessibilityLabel="Close context" | ||
| 253 | onPress={props.onClose} | ||
| 254 | className="p-3" | ||
| 255 | > | ||
| 256 | <Text className="text-foreground">Done</Text> | ||
| 257 | </Pressable> | ||
| 258 | </View> | ||
| 259 | <ScrollView | ||
| 260 | style={{ flexShrink: 1 }} | ||
| 261 | onContentSizeChange={(_width, height) => setBodyHeight(height)} | ||
| 262 | contentContainerStyle={{ | ||
| 263 | padding: 16, | ||
| 264 | gap: 16, | ||
| 265 | paddingBottom: Math.max(20, insets.bottom), | ||
| 266 | }} | ||
| 267 | > | ||
| 268 | {!record ? ( | ||
| 269 | <Text className="text-foreground"> | ||
| 270 | Context unavailable. The reference was copied without its payload. Copy it again | ||
| 271 | from the original message or remove it. | ||
| 272 | </Text> | ||
| 273 | ) : "payload" in record ? ( | ||
| 274 | <Text className="text-foreground"> | ||
| 275 | This context type is not supported by this version of the app. Its payload will be | ||
| 276 | preserved when sent. | ||
| 277 | </Text> | ||
| 278 | ) : ( | ||
| 279 | <> | 240 | <> |
| 280 | {record.kind === "terminal" ? ( | 241 | {record.pullRequest ? ( |
| 281 | <View | 242 | <ContextField |
| 282 | className="overflow-hidden rounded-xl border border-border" | 243 | label={`#${record.pullRequest.number} · ${record.pullRequest.isDraft ? "draft" : record.pullRequest.state}`} |
| 283 | style={{ backgroundColor: terminalTheme.background }} | 244 | value={`${record.pullRequest.title}\n${record.pullRequest.headBranch} → ${record.pullRequest.baseBranch}`} |
| 284 | > | 245 | /> |
| 285 | <ScrollView | ||
| 286 | horizontal | ||
| 287 | showsHorizontalScrollIndicator | ||
| 288 | contentContainerStyle={{ padding: 12 }} | ||
| 289 | > | ||
| 290 | <Text | ||
| 291 | selectable | ||
| 292 | className="text-sm text-foreground" | ||
| 293 | style={{ | ||
| 294 | fontFamily: REVIEW_MONO_FONT_FAMILY, | ||
| 295 | fontSize: 13, | ||
| 296 | lineHeight: 20, | ||
| 297 | }} | ||
| 298 | > | ||
| 299 | {record.text} | ||
| 300 | </Text> | ||
| 301 | </ScrollView> | ||
| 302 | </View> | ||
| 303 | ) : null} | ||
| 304 | {record.kind === "review-comment" ? ( | ||
| 305 | <> | ||
| 306 | {record.pullRequest ? ( | ||
| 307 | <ContextField | ||
| 308 | label={`#${record.pullRequest.number} · ${record.pullRequest.isDraft ? "draft" : record.pullRequest.state}`} | ||
| 309 | value={`${record.pullRequest.title}\n${record.pullRequest.headBranch} → ${record.pullRequest.baseBranch}`} | ||
| 310 | /> | ||
| 311 | ) : null} | ||
| 312 | {!record.sectionId.startsWith("pull-request:") ? ( | ||
| 313 | <> | ||
| 314 | <ReviewCommentCard | ||
| 315 | comment={{ ...record, id: record.contextId }} | ||
| 316 | colors={reviewColors} | ||
| 317 | /> | ||
| 318 | </> | ||
| 319 | ) : null} | ||
| 320 | </> | ||
| 321 | ) : null} | 246 | ) : null} |
| 322 | {record.kind === "preview-annotation" ? ( | 247 | {!record.sectionId.startsWith("pull-request:") ? ( |
| 323 | <> | 248 | <> |
| 324 | <ContextField label="Page" value={record.pageTitle ?? record.pageUrl} /> | 249 | <ReviewCommentCard |
| 325 | <ContextField label="URL" value={record.pageUrl} /> | 250 | comment={{ ...record, id: record.contextId }} |
| 326 | <ContextField label="Comment" value={record.comment} /> | 251 | colors={reviewColors} |
| 327 | <ContextField label="Selection" value={record.targetSummary} /> | ||
| 328 | <ContextField | ||
| 329 | label="Requested changes" | ||
| 330 | value={record.styleChanges.join("\n")} | ||
| 331 | /> | 252 | /> |
| 332 | {record.elements?.map((element, index) => ( | ||
| 333 | <View | ||
| 334 | key={`${element.selector ?? element.tagName}:${index}`} | ||
| 335 | className="gap-3" | ||
| 336 | > | ||
| 337 | <ContextField | ||
| 338 | label="Element" | ||
| 339 | value={element.componentName ?? element.tagName} | ||
| 340 | /> | ||
| 341 | <ContextField label="Selector" value={element.selector} code /> | ||
| 342 | <ContextSource source={element.source} /> | ||
| 343 | <ContextField label="HTML" value={element.htmlPreview} code /> | ||
| 344 | <ContextField label="Styles" value={element.styles} code /> | ||
| 345 | </View> | ||
| 346 | ))} | ||
| 347 | </> | ||
| 348 | ) : null} | ||
| 349 | {record.kind === "element" ? ( | ||
| 350 | <> | ||
| 351 | <ContextField label="Page" value={record.pageUrl} /> | ||
| 352 | <ContextField label="Element" value={record.componentName ?? record.tagName} /> | ||
| 353 | <ContextField label="Selector" value={record.selector} code /> | ||
| 354 | <ContextSource source={record.source} /> | ||
| 355 | <ContextField label="HTML" value={record.htmlPreview} code /> | ||
| 356 | <ContextField label="Styles" value={record.styles} code /> | ||
| 357 | </> | 253 | </> |
| 358 | ) : null} | 254 | ) : null} |
| 359 | {record.kind === "image" ? ( | 255 | </> |
| 360 | <ContextField | 256 | ) : null} |
| 361 | label="File" | 257 | {record.kind === "preview-annotation" ? ( |
| 362 | value={`${record.mimeType} · ${formatAttachmentSize(record.sizeBytes)}`} | 258 | <> |
| 363 | /> | 259 | <ContextField label="Page" value={record.pageTitle ?? record.pageUrl} /> |
| 364 | ) : null} | 260 | <ContextField label="URL" value={record.pageUrl} /> |
| 365 | {record.kind === "mention" ? ( | 261 | <ContextField label="Comment" value={record.comment} /> |
| 366 | <ContextField label="Path" value={record.path} code /> | 262 | <ContextField label="Selection" value={record.targetSummary} /> |
| 367 | ) : null} | 263 | <ContextField label="Requested changes" value={record.styleChanges.join("\n")} /> |
| 368 | {record.kind === "thread" ? ( | 264 | {record.elements?.map((element, index) => ( |
| 369 | <View className="gap-3"> | 265 | <View key={`${element.selector ?? element.tagName}:${index}`} className="gap-3"> |
| 370 | <ContextField label="Thread" value={record.title} /> | ||
| 371 | {props.onOpenThread ? ( | ||
| 372 | <Pressable | ||
| 373 | accessibilityRole="button" | ||
| 374 | onPress={() => | ||
| 375 | props.onOpenThread?.({ | ||
| 376 | environmentId: record.environmentId, | ||
| 377 | threadId: record.threadId, | ||
| 378 | }) | ||
| 379 | } | ||
| 380 | className="rounded-xl bg-subtle p-4" | ||
| 381 | > | ||
| 382 | <Text className="text-foreground">Open thread</Text> | ||
| 383 | </Pressable> | ||
| 384 | ) : null} | ||
| 385 | </View> | ||
| 386 | ) : null} | ||
| 387 | {record.kind === "skill" ? ( | ||
| 388 | <View className="gap-3"> | ||
| 389 | <ContextField label="Skill" value={record.name} /> | ||
| 390 | <ContextField | 266 | <ContextField |
| 391 | label="Description" | 267 | label="Element" |
| 392 | value={ | 268 | value={element.componentName ?? element.tagName} |
| 393 | props.skillDescription ?? "No description is available for this skill." | ||
| 394 | } | ||
| 395 | /> | 269 | /> |
| 396 | {props.onOpenSkill ? ( | 270 | <ContextField label="Selector" value={element.selector} code /> |
| 397 | <Pressable | 271 | <ContextSource source={element.source} /> |
| 398 | accessibilityRole="button" | 272 | <ContextField label="HTML" value={element.htmlPreview} code /> |
| 399 | onPress={props.onOpenSkill} | 273 | <ContextField label="Styles" value={element.styles} code /> |
| 400 | className="rounded-xl bg-subtle p-4" | ||
| 401 | > | ||
| 402 | <Text className="text-foreground">View instructions</Text> | ||
| 403 | </Pressable> | ||
| 404 | ) : null} | ||
| 405 | </View> | 274 | </View> |
| 406 | ) : null} | 275 | ))} |
| 407 | </> | 276 | </> |
| 408 | )} | ||
| 409 | {attachmentRecord && "attachmentId" in attachmentRecord ? ( | ||
| 410 | <ComposerContextAttachment | ||
| 411 | key={JSON.stringify([ | ||
| 412 | props.environmentId, | ||
| 413 | attachmentRecord.attachmentId, | ||
| 414 | props.attachments?.find((entry) => entry.id === attachmentRecord.attachmentId) | ||
| 415 | ?.fileUri, | ||
| 416 | ])} | ||
| 417 | record={attachmentRecord} | ||
| 418 | environmentId={props.environmentId} | ||
| 419 | attachment={props.attachments?.find( | ||
| 420 | (entry) => entry.id === attachmentRecord.attachmentId, | ||
| 421 | )} | ||
| 422 | /> | ||
| 423 | ) : null} | 277 | ) : null} |
| 424 | {pullRequestUrl && /^https?:\/\//i.test(pullRequestUrl) ? ( | 278 | {record.kind === "element" ? ( |
| 425 | <Pressable | 279 | <> |
| 426 | accessibilityRole="link" | 280 | <ContextField label="Page" value={record.pageUrl} /> |
| 427 | onPress={() => { | 281 | <ContextField label="Element" value={record.componentName ?? record.tagName} /> |
| 428 | props.onClose(); | 282 | <ContextField label="Selector" value={record.selector} code /> |
| 429 | void openPullRequest(pullRequestUrl).catch(() => | 283 | <ContextSource source={record.source} /> |
| 430 | Alert.alert("Could not open pull request", "Try again when connected."), | 284 | <ContextField label="HTML" value={record.htmlPreview} code /> |
| 431 | ); | 285 | <ContextField label="Styles" value={record.styles} code /> |
| 432 | }} | 286 | </> |
| 433 | className="rounded-xl bg-subtle p-4" | ||
| 434 | > | ||
| 435 | <Text className="text-foreground">Open pull request</Text> | ||
| 436 | </Pressable> | ||
| 437 | ) : null} | 287 | ) : null} |
| 438 | {props.onOpenAttachment ? ( | 288 | {record.kind === "image" ? ( |
| 439 | <Pressable | 289 | <ContextField |
| 440 | accessibilityRole="button" | 290 | label="File" |
| 441 | onPress={props.onOpenAttachment} | 291 | value={`${record.mimeType} · ${formatAttachmentSize(record.sizeBytes)}`} |
| 442 | className="rounded-xl bg-subtle p-4" | 292 | /> |
| 443 | > | ||
| 444 | <Text className="text-foreground">Open attachment</Text> | ||
| 445 | </Pressable> | ||
| 446 | ) : null} | 293 | ) : null} |
| 447 | {props.onOpenPullRequest && !pullRequestUrl ? ( | 294 | {record.kind === "mention" ? ( |
| 448 | <Pressable | 295 | <ContextField label="Path" value={record.path} code /> |
| 449 | accessibilityRole="button" | ||
| 450 | onPress={props.onOpenPullRequest} | ||
| 451 | className="rounded-xl bg-subtle p-4" | ||
| 452 | > | ||
| 453 | <Text className="text-foreground">Open pull request</Text> | ||
| 454 | </Pressable> | ||
| 455 | ) : null} | 296 | ) : null} |
| 456 | {props.onRemove ? ( | 297 | {record.kind === "thread" ? ( |
| 457 | <Pressable | 298 | <View className="gap-3"> |
| 458 | accessibilityRole="button" | 299 | <ContextField label="Thread" value={record.title} /> |
| 459 | onPress={props.onRemove} | 300 | {props.onOpenThread ? ( |
| 460 | className="rounded-xl bg-subtle p-4" | 301 | <Pressable |
| 461 | > | 302 | accessibilityRole="button" |
| 462 | <Text className="text-foreground">Remove from draft</Text> | 303 | onPress={() => |
| 463 | </Pressable> | 304 | props.onOpenThread?.({ |
| 305 | environmentId: record.environmentId, | ||
| 306 | threadId: record.threadId, | ||
| 307 | }) | ||
| 308 | } | ||
| 309 | className="rounded-xl bg-subtle p-4" | ||
| 310 | > | ||
| 311 | <Text className="text-foreground">Open thread</Text> | ||
| 312 | </Pressable> | ||
| 313 | ) : null} | ||
| 314 | </View> | ||
| 464 | ) : null} | 315 | ) : null} |
| 465 | </ScrollView> | 316 | {record.kind === "skill" ? ( |
| 466 | </View> | 317 | <View className="gap-3"> |
| 467 | </View> | 318 | <ContextField label="Skill" value={record.name} /> |
| 468 | </Modal> | 319 | <ContextField |
| 320 | label="Description" | ||
| 321 | value={props.skillDescription ?? "No description is available for this skill."} | ||
| 322 | /> | ||
| 323 | {props.onOpenSkill ? ( | ||
| 324 | <Pressable | ||
| 325 | accessibilityRole="button" | ||
| 326 | onPress={props.onOpenSkill} | ||
| 327 | className="rounded-xl bg-subtle p-4" | ||
| 328 | > | ||
| 329 | <Text className="text-foreground">View instructions</Text> | ||
| 330 | </Pressable> | ||
| 331 | ) : null} | ||
| 332 | </View> | ||
| 333 | ) : null} | ||
| 334 | </> | ||
| 335 | )} | ||
| 336 | {attachmentRecord && "attachmentId" in attachmentRecord ? ( | ||
| 337 | <ComposerContextAttachment | ||
| 338 | key={JSON.stringify([ | ||
| 339 | props.environmentId, | ||
| 340 | attachmentRecord.attachmentId, | ||
| 341 | props.attachments?.find((entry) => entry.id === attachmentRecord.attachmentId) | ||
| 342 | ?.fileUri, | ||
| 343 | ])} | ||
| 344 | record={attachmentRecord} | ||
| 345 | environmentId={props.environmentId} | ||
| 346 | attachment={props.attachments?.find( | ||
| 347 | (entry) => entry.id === attachmentRecord.attachmentId, | ||
| 348 | )} | ||
| 349 | /> | ||
| 350 | ) : null} | ||
| 351 | {pullRequestUrl && /^https?:\/\//i.test(pullRequestUrl) ? ( | ||
| 352 | <Pressable | ||
| 353 | accessibilityRole="link" | ||
| 354 | onPress={() => { | ||
| 355 | props.onClose(); | ||
| 356 | void openPullRequest(pullRequestUrl).catch(() => | ||
| 357 | Alert.alert("Could not open pull request", "Try again when connected."), | ||
| 358 | ); | ||
| 359 | }} | ||
| 360 | className="rounded-xl bg-subtle p-4" | ||
| 361 | > | ||
| 362 | <Text className="text-foreground">Open pull request</Text> | ||
| 363 | </Pressable> | ||
| 364 | ) : null} | ||
| 365 | {props.onOpenAttachment ? ( | ||
| 366 | <Pressable | ||
| 367 | accessibilityRole="button" | ||
| 368 | onPress={props.onOpenAttachment} | ||
| 369 | className="rounded-xl bg-subtle p-4" | ||
| 370 | > | ||
| 371 | <Text className="text-foreground">Open attachment</Text> | ||
| 372 | </Pressable> | ||
| 373 | ) : null} | ||
| 374 | {props.onOpenPullRequest && !pullRequestUrl ? ( | ||
| 375 | <Pressable | ||
| 376 | accessibilityRole="button" | ||
| 377 | onPress={props.onOpenPullRequest} | ||
| 378 | className="rounded-xl bg-subtle p-4" | ||
| 379 | > | ||
| 380 | <Text className="text-foreground">Open pull request</Text> | ||
| 381 | </Pressable> | ||
| 382 | ) : null} | ||
| 383 | {props.onRemove ? ( | ||
| 384 | <Pressable | ||
| 385 | accessibilityRole="button" | ||
| 386 | onPress={props.onRemove} | ||
| 387 | className="rounded-xl bg-subtle p-4" | ||
| 388 | > | ||
| 389 | <Text className="text-foreground">Remove from draft</Text> | ||
| 390 | </Pressable> | ||
| 391 | ) : null} | ||
| 392 | </ScrollView> | ||
| 393 | </AppSheet> | ||
| 469 | ); | 394 | ); |
| 470 | } | 395 | } |
apps/mobile/src/components/ContextSheetSize.ios.tsx deleted-17| ... | @@ -1,17 +0,0 @@ | ||
| 1 | import { requireNativeView } from "expo"; | ||
| 2 | import type { ViewProps } from "react-native"; | ||
| 3 | |||
| 4 | const NativeSheetSize = requireNativeView<ViewProps & { contentHeight: number }>( | ||
| 5 | "T3NativeControls", | ||
| 6 | "ContextSheetSize", | ||
| 7 | ); | ||
| 8 | |||
| 9 | export function ContextSheetSize({ height }: { height: number }) { | ||
| 10 | return ( | ||
| 11 | <NativeSheetSize | ||
| 12 | contentHeight={height} | ||
| 13 | pointerEvents="none" | ||
| 14 | style={{ position: "absolute", width: 0, height: 0 }} | ||
| 15 | /> | ||
| 16 | ); | ||
| 17 | } | ||
apps/mobile/src/components/ContextSheetSize.tsx deleted-4| ... | @@ -1,4 +0,0 @@ | ||
| 1 | /** Native iOS detent configuration; Android sizes the sheet through its layout. */ | ||
| 2 | export function ContextSheetSize(_props: { height: number }) { | ||
| 3 | return null; | ||
| 4 | } | ||
apps/mobile/src/components/PierreEntryIcon.tsx+2-2| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 1 | import { SymbolView } from "../components/AppSymbol"; | 1 | import { SymbolView } from "../components/AppSymbol"; |
| 2 | import { Image, type ImageStyle, type StyleProp } from "react-native"; | 2 | import { Image, type ImageStyle, type StyleProp } from "react-native"; |
| 3 | 3 | ||
| 4 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; | 4 | import { markdownFileIconSource } from "../lib/markdownFileIcons"; |
| 5 | import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; | 5 | import { resolveMarkdownFileIcon } from "../lib/markdownLinks"; |
| 6 | 6 | ||
| 7 | export function PierreEntryIcon(props: { | 7 | export function PierreEntryIcon(props: { |
| 8 | readonly path: string; | 8 | readonly path: string; |
apps/mobile/src/features/files/FileMarkdownPreview.tsx+102-147| ... | @@ -1,29 +1,26 @@ | ... | @@ -1,29 +1,26 @@ |
| 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; | 2 | import { resolveMobileMarkdownMediaSource } from "../../lib/markdownMediaSource"; |
| 3 | import { getBrowseDirectoryPath } from "@t3tools/client-runtime/state/projects"; | 3 | import { getBrowseDirectoryPath } from "@t3tools/client-runtime/state/projects"; |
| 4 | import { useCallback, useMemo, useState } from "react"; | 4 | import { useCallback, useMemo, useState } from "react"; |
| 5 | import { | 5 | import { RefreshControl, ScrollView, View } from "react-native"; |
| 6 | Markdown, | ||
| 7 | type CustomRenderers, | ||
| 8 | type NodeStyleOverrides, | ||
| 9 | type PartialMarkdownTheme, | ||
| 10 | } from "react-native-nitro-markdown"; | ||
| 11 | import { RefreshControl, ScrollView, Text as NativeText, View } from "react-native"; | ||
| 12 | 6 | ||
| 13 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | 7 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 14 | import { useFontFamily } from "../../lib/useFontFamily"; | 8 | import { useFontFamily } from "../../lib/useFontFamily"; |
| 15 | import { | 9 | import { resolveNativeMarkdownTypography } from "../../lib/appearancePreferences"; |
| 16 | resolveMarkdownFontSizes, | ||
| 17 | resolveNativeMarkdownTypography, | ||
| 18 | } from "../../lib/appearancePreferences"; | ||
| 19 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 10 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 20 | import { | 11 | import { |
| 21 | ThreadMarkdownImage, | 12 | ThreadMarkdownImage, |
| 13 | ThreadMarkdownImageView, | ||
| 22 | ThreadMarkdownImageUnavailable, | 14 | ThreadMarkdownImageUnavailable, |
| 23 | } from "../threads/ThreadMarkdownImage"; | 15 | } from "../threads/ThreadMarkdownImage"; |
| 16 | import { ThreadMarkdownVideo } from "../threads/ThreadMarkdownVideo"; | ||
| 17 | import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; | ||
| 18 | import { normalizeNativeMarkdownUrl } from "../../lib/markdownLinks"; | ||
| 19 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; | ||
| 20 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | ||
| 21 | import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; | ||
| 24 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 22 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 25 | import { | 23 | import { |
| 26 | hasNativeSelectableMarkdownText, | ||
| 27 | SelectableMarkdownText, | 24 | SelectableMarkdownText, |
| 28 | type MarkdownImageRenderer, | 25 | type MarkdownImageRenderer, |
| 29 | type NativeMarkdownTextStyle, | 26 | type NativeMarkdownTextStyle, |
| ... | @@ -31,20 +28,11 @@ import { | ... | @@ -31,20 +28,11 @@ import { |
| 31 | import { resolveWorkspaceFilePath } from "./filePath"; | 28 | import { resolveWorkspaceFilePath } from "./filePath"; |
| 32 | 29 | ||
| 33 | interface MarkdownPreviewStyles { | 30 | interface MarkdownPreviewStyles { |
| 34 | readonly theme: PartialMarkdownTheme; | ||
| 35 | readonly styles: NodeStyleOverrides; | ||
| 36 | readonly renderers: CustomRenderers; | ||
| 37 | readonly nativeTextStyle: NativeMarkdownTextStyle; | 31 | readonly nativeTextStyle: NativeMarkdownTextStyle; |
| 38 | } | 32 | } |
| 39 | 33 | ||
| 40 | export function useMarkdownPreviewStyles( | 34 | export function useMarkdownPreviewStyles(): MarkdownPreviewStyles { |
| 41 | renderImage?: MarkdownImageRenderer, | ||
| 42 | ): MarkdownPreviewStyles { | ||
| 43 | const { appearance } = useAppearancePreferences(); | 35 | const { appearance } = useAppearancePreferences(); |
| 44 | const markdownFontSizes = useMemo( | ||
| 45 | () => resolveMarkdownFontSizes(appearance.baseFontSize), | ||
| 46 | [appearance.baseFontSize], | ||
| 47 | ); | ||
| 48 | const nativeMarkdownTypography = useMemo( | 36 | const nativeMarkdownTypography = useMemo( |
| 49 | () => resolveNativeMarkdownTypography(appearance.baseFontSize), | 37 | () => resolveNativeMarkdownTypography(appearance.baseFontSize), |
| 50 | [appearance.baseFontSize], | 38 | [appearance.baseFontSize], |
| ... | @@ -54,104 +42,14 @@ export function useMarkdownPreviewStyles( | ... | @@ -54,104 +42,14 @@ export function useMarkdownPreviewStyles( |
| 54 | const strong = theme["--color-md-strong"]; | 42 | const strong = theme["--color-md-strong"]; |
| 55 | const link = theme["--color-md-link"]; | 43 | const link = theme["--color-md-link"]; |
| 56 | const blockquoteBorder = theme["--color-md-blockquote-border"]; | 44 | const blockquoteBorder = theme["--color-md-blockquote-border"]; |
| 57 | const blockquoteBackground = theme["--color-md-blockquote-bg"]; | ||
| 58 | const codeBackground = theme["--color-md-code-bg"]; | 45 | const codeBackground = theme["--color-md-code-bg"]; |
| 59 | const codeText = theme["--color-md-code-text"]; | 46 | const codeText = theme["--color-md-code-text"]; |
| 60 | const horizontalRule = theme["--color-md-hr"]; | 47 | const horizontalRule = theme["--color-md-hr"]; |
| 61 | const regularFontFamily = useFontFamily("regular"); | 48 | const regularFontFamily = useFontFamily("regular"); |
| 62 | const mediumFontFamily = useFontFamily("medium"); | ||
| 63 | const boldFontFamily = useFontFamily("bold"); | 49 | const boldFontFamily = useFontFamily("bold"); |
| 64 | 50 | ||
| 65 | return useMemo(() => { | 51 | return useMemo(() => { |
| 66 | const renderers: CustomRenderers = { | ||
| 67 | link: ({ href, children }) => ( | ||
| 68 | <NativeText | ||
| 69 | className="font-t3-medium" | ||
| 70 | onPress={() => { | ||
| 71 | if (href) { | ||
| 72 | void tryOpenExternalUrl(href, "markdown-link"); | ||
| 73 | } | ||
| 74 | }} | ||
| 75 | style={{ | ||
| 76 | color: link, | ||
| 77 | textDecorationLine: "none", | ||
| 78 | }} | ||
| 79 | > | ||
| 80 | {children} | ||
| 81 | </NativeText> | ||
| 82 | ), | ||
| 83 | image: ({ node }) => | ||
| 84 | node.href && renderImage | ||
| 85 | ? (renderImage({ | ||
| 86 | href: node.href, | ||
| 87 | alt: node.alt ?? null, | ||
| 88 | title: node.title ?? null, | ||
| 89 | }) ?? undefined) | ||
| 90 | : undefined, | ||
| 91 | }; | ||
| 92 | |||
| 93 | return { | 52 | return { |
| 94 | theme: { | ||
| 95 | colors: { | ||
| 96 | text: body, | ||
| 97 | heading: strong, | ||
| 98 | link, | ||
| 99 | blockquote: blockquoteBorder, | ||
| 100 | border: horizontalRule, | ||
| 101 | surface: "transparent", | ||
| 102 | surfaceLight: blockquoteBackground, | ||
| 103 | accent: link, | ||
| 104 | tableBorder: horizontalRule, | ||
| 105 | tableHeader: blockquoteBackground, | ||
| 106 | tableHeaderText: strong, | ||
| 107 | tableRowOdd: blockquoteBackground, | ||
| 108 | tableRowEven: "transparent", | ||
| 109 | code: codeText, | ||
| 110 | codeBackground, | ||
| 111 | }, | ||
| 112 | }, | ||
| 113 | styles: { | ||
| 114 | text: { | ||
| 115 | color: body, | ||
| 116 | fontFamily: regularFontFamily, | ||
| 117 | fontSize: markdownFontSizes.m, | ||
| 118 | lineHeight: markdownFontSizes.bodyLineHeight, | ||
| 119 | }, | ||
| 120 | heading: { | ||
| 121 | color: strong, | ||
| 122 | fontFamily: boldFontFamily, | ||
| 123 | }, | ||
| 124 | strong: { | ||
| 125 | color: strong, | ||
| 126 | fontFamily: boldFontFamily, | ||
| 127 | }, | ||
| 128 | link: { | ||
| 129 | color: link, | ||
| 130 | fontFamily: mediumFontFamily, | ||
| 131 | }, | ||
| 132 | blockquote: { | ||
| 133 | backgroundColor: blockquoteBackground, | ||
| 134 | borderLeftColor: blockquoteBorder, | ||
| 135 | borderLeftWidth: 3, | ||
| 136 | paddingLeft: 12, | ||
| 137 | }, | ||
| 138 | code: { | ||
| 139 | backgroundColor: codeBackground, | ||
| 140 | color: codeText, | ||
| 141 | fontFamily: "ui-monospace", | ||
| 142 | }, | ||
| 143 | codeBlock: { | ||
| 144 | backgroundColor: codeBackground, | ||
| 145 | borderRadius: 12, | ||
| 146 | color: codeText, | ||
| 147 | fontFamily: "ui-monospace", | ||
| 148 | padding: 12, | ||
| 149 | }, | ||
| 150 | hr: { | ||
| 151 | backgroundColor: horizontalRule, | ||
| 152 | }, | ||
| 153 | }, | ||
| 154 | renderers, | ||
| 155 | nativeTextStyle: { | 53 | nativeTextStyle: { |
| 156 | color: body, | 54 | color: body, |
| 157 | strongColor: strong, | 55 | strongColor: strong, |
| ... | @@ -174,18 +72,14 @@ export function useMarkdownPreviewStyles( | ... | @@ -174,18 +72,14 @@ export function useMarkdownPreviewStyles( |
| 174 | }, | 72 | }, |
| 175 | }; | 73 | }; |
| 176 | }, [ | 74 | }, [ |
| 177 | blockquoteBackground, | ||
| 178 | blockquoteBorder, | 75 | blockquoteBorder, |
| 179 | body, | 76 | body, |
| 180 | codeBackground, | 77 | codeBackground, |
| 181 | codeText, | 78 | codeText, |
| 182 | horizontalRule, | 79 | horizontalRule, |
| 183 | link, | 80 | link, |
| 184 | markdownFontSizes, | ||
| 185 | mediumFontFamily, | ||
| 186 | nativeMarkdownTypography, | 81 | nativeMarkdownTypography, |
| 187 | regularFontFamily, | 82 | regularFontFamily, |
| 188 | renderImage, | ||
| 189 | strong, | 83 | strong, |
| 190 | boldFontFamily, | 84 | boldFontFamily, |
| 191 | ]); | 85 | ]); |
| ... | @@ -202,6 +96,8 @@ export function FileMarkdownPreview(props: { | ... | @@ -202,6 +96,8 @@ export function FileMarkdownPreview(props: { |
| 202 | readonly onRefresh?: () => Promise<void> | void; | 96 | readonly onRefresh?: () => Promise<void> | void; |
| 203 | }) { | 97 | }) { |
| 204 | const [isPullRefreshing, setIsPullRefreshing] = useState(false); | 98 | const [isPullRefreshing, setIsPullRefreshing] = useState(false); |
| 99 | const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null); | ||
| 100 | const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null); | ||
| 205 | const handlePullToRefresh = useCallback(async () => { | 101 | const handlePullToRefresh = useCallback(async () => { |
| 206 | if (!props.onRefresh) { | 102 | if (!props.onRefresh) { |
| 207 | return; | 103 | return; |
| ... | @@ -219,42 +115,108 @@ export function FileMarkdownPreview(props: { | ... | @@ -219,42 +115,108 @@ export function FileMarkdownPreview(props: { |
| 219 | ); | 115 | ); |
| 220 | const renderImage = useCallback<MarkdownImageRenderer>( | 116 | const renderImage = useCallback<MarkdownImageRenderer>( |
| 221 | (image) => { | 117 | (image) => { |
| 222 | const media = resolveMediaSource(image.href, { | 118 | const media = resolveMobileMarkdownMediaSource(image.href, { |
| 223 | threadId: props.threadId ?? undefined, | 119 | threadId: props.threadId ?? undefined, |
| 224 | workspaceRoot: markdownDirectory, | 120 | workspaceRoot: markdownDirectory, |
| 225 | imageEmbed: true, | 121 | imageEmbed: true, |
| 226 | }); | 122 | }); |
| 123 | const preview = resolveMarkdownMediaPreview(image.href, { | ||
| 124 | environmentId: props.environmentId, | ||
| 125 | threadId: props.threadId ?? undefined, | ||
| 126 | workspaceRoot: markdownDirectory, | ||
| 127 | captured: props.captured, | ||
| 128 | imageEmbed: true, | ||
| 129 | }); | ||
| 227 | if (media?.access === "direct") { | 130 | if (media?.access === "direct") { |
| 228 | return null; | 131 | if (media.kind === "video") { |
| 132 | return ( | ||
| 133 | <ThreadMarkdownVideo | ||
| 134 | source={{ | ||
| 135 | type: "media", | ||
| 136 | name: media.name, | ||
| 137 | mimeType: media.mimeType, | ||
| 138 | uri: normalizeNativeMarkdownUrl(media.uri), | ||
| 139 | actionsSource: preview?.source.actionsSource, | ||
| 140 | }} | ||
| 141 | thumbnailVisible | ||
| 142 | /> | ||
| 143 | ); | ||
| 144 | } | ||
| 145 | return ( | ||
| 146 | <ThreadMarkdownImageView | ||
| 147 | uri={normalizeNativeMarkdownUrl(media.uri)} | ||
| 148 | sourceKey={media.uri} | ||
| 149 | unavailable={false} | ||
| 150 | alt={image.alt} | ||
| 151 | format={media.mimeType === "image/svg+xml" ? "svg" : undefined} | ||
| 152 | actionsSource={preview?.source.actionsSource} | ||
| 153 | onPressPreview={setExpandedFile} | ||
| 154 | /> | ||
| 155 | ); | ||
| 229 | } | 156 | } |
| 230 | if ( | 157 | if (props.captured || media === null || media.access === "unavailable") { |
| 231 | props.captured || | ||
| 232 | media === null || | ||
| 233 | media.kind !== "image" || | ||
| 234 | media.access === "unavailable" | ||
| 235 | ) { | ||
| 236 | return <ThreadMarkdownImageUnavailable alt={image.alt} />; | 158 | return <ThreadMarkdownImageUnavailable alt={image.alt} />; |
| 237 | } | 159 | } |
| 160 | if (media.kind === "video") { | ||
| 161 | return preview?.kind === "video" ? ( | ||
| 162 | <ThreadMarkdownVideo source={preview.source} thumbnailVisible /> | ||
| 163 | ) : ( | ||
| 164 | <ThreadMarkdownImageUnavailable alt={image.alt} /> | ||
| 165 | ); | ||
| 166 | } | ||
| 238 | return ( | 167 | return ( |
| 239 | <ThreadMarkdownImage | 168 | <ThreadMarkdownImage |
| 240 | environmentId={props.environmentId} | 169 | environmentId={props.environmentId} |
| 241 | resource={media.resource} | 170 | resource={media.resource} |
| 242 | alt={image.alt} | 171 | alt={image.alt} |
| 243 | srcFragment={media.srcFragment} | 172 | srcFragment={media.srcFragment} |
| 244 | onPressPreview={() => undefined} | 173 | actionsSource={preview?.source.actionsSource} |
| 174 | onPressPreview={setExpandedFile} | ||
| 245 | /> | 175 | /> |
| 246 | ); | 176 | ); |
| 247 | }, | 177 | }, |
| 248 | [markdownDirectory, props.environmentId, props.threadId, props.captured], | 178 | [markdownDirectory, props.environmentId, props.threadId, props.captured], |
| 249 | ); | 179 | ); |
| 250 | const styles = useMarkdownPreviewStyles(renderImage); | 180 | const styles = useMarkdownPreviewStyles(); |
| 251 | const onLinkPress = useCallback((href: string) => { | 181 | const resolveImageSource = useMarkdownImageSource({ |
| 252 | void tryOpenExternalUrl(href, "markdown-link"); | 182 | environmentId: props.environmentId, |
| 253 | }, []); | 183 | threadId: props.threadId, |
| 184 | workspaceRoot: markdownDirectory, | ||
| 185 | captured: props.captured, | ||
| 186 | }); | ||
| 187 | const openMedia = useCallback( | ||
| 188 | (href: string, imageEmbed = false) => { | ||
| 189 | const media = resolveMarkdownMediaPreview(href, { | ||
| 190 | environmentId: props.environmentId, | ||
| 191 | threadId: props.threadId ?? undefined, | ||
| 192 | workspaceRoot: markdownDirectory, | ||
| 193 | captured: props.captured, | ||
| 194 | imageEmbed, | ||
| 195 | }); | ||
| 196 | if (!media) return false; | ||
| 197 | if (media.kind === "video") setExpandedVideo(media.source); | ||
| 198 | else setExpandedFile(media.source); | ||
| 199 | return true; | ||
| 200 | }, | ||
| 201 | [markdownDirectory, props.environmentId, props.threadId, props.captured], | ||
| 202 | ); | ||
| 203 | const onImagePress = useCallback( | ||
| 204 | (href: string) => { | ||
| 205 | openMedia(href, true); | ||
| 206 | }, | ||
| 207 | [openMedia], | ||
| 208 | ); | ||
| 209 | const onLinkPress = useCallback( | ||
| 210 | (href: string) => { | ||
| 211 | if (!openMedia(href)) void tryOpenExternalUrl(href, "markdown-link"); | ||
| 212 | }, | ||
| 213 | [openMedia], | ||
| 214 | ); | ||
| 254 | 215 | ||
| 255 | return ( | 216 | return ( |
| 256 | <ScrollView | 217 | <ScrollView |
| 257 | className="flex-1 bg-sheet" | 218 | className="flex-1 bg-sheet" |
| 219 | contentInsetAdjustmentBehavior="automatic" | ||
| 258 | contentContainerStyle={{ padding: 18 }} | 220 | contentContainerStyle={{ padding: 18 }} |
| 259 | refreshControl={ | 221 | refreshControl={ |
| 260 | props.onRefresh ? ( | 222 | props.onRefresh ? ( |
| ... | @@ -266,23 +228,16 @@ export function FileMarkdownPreview(props: { | ... | @@ -266,23 +228,16 @@ export function FileMarkdownPreview(props: { |
| 266 | } | 228 | } |
| 267 | > | 229 | > |
| 268 | <View className="mx-auto w-full max-w-[760px]"> | 230 | <View className="mx-auto w-full max-w-[760px]"> |
| 269 | {hasNativeSelectableMarkdownText() ? ( | 231 | <SelectableMarkdownText |
| 270 | <SelectableMarkdownText | 232 | markdown={props.markdown} |
| 271 | markdown={props.markdown} | 233 | onLinkPress={onLinkPress} |
| 272 | onLinkPress={onLinkPress} | 234 | renderImage={renderImage} |
| 273 | renderImage={renderImage} | 235 | resolveImageSource={resolveImageSource} |
| 274 | textStyle={styles.nativeTextStyle} | 236 | onImagePress={onImagePress} |
| 275 | /> | 237 | textStyle={styles.nativeTextStyle} |
| 276 | ) : ( | 238 | /> |
| 277 | <Markdown | 239 | <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} /> |
| 278 | options={{ gfm: true }} | 240 | <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} /> |
| 279 | renderers={styles.renderers} | ||
| 280 | styles={styles.styles} | ||
| 281 | theme={styles.theme} | ||
| 282 | > | ||
| 283 | {props.markdown} | ||
| 284 | </Markdown> | ||
| 285 | )} | ||
| 286 | </View> | 241 | </View> |
| 287 | </ScrollView> | 242 | </ScrollView> |
| 288 | ); | 243 | ); |
apps/mobile/src/features/files/SourceFileSurface.tsx+7-8| ... | @@ -32,7 +32,7 @@ import { | ... | @@ -32,7 +32,7 @@ import { |
| 32 | NATIVE_SOURCE_CONTENT_WIDTH, | 32 | NATIVE_SOURCE_CONTENT_WIDTH, |
| 33 | nativeSourceRowId, | 33 | nativeSourceRowId, |
| 34 | } from "./nativeSourceFileAdapter"; | 34 | } from "./nativeSourceFileAdapter"; |
| 35 | import { MarkdownTextPrimitive } from "@t3tools/mobile-markdown-text/primitive"; | 35 | import { SourceText } from "@t3tools/mobile-source-text"; |
| 36 | 36 | ||
| 37 | import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document"; | 37 | import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document"; |
| 38 | import { sourceHighlightAtom } from "./sourceHighlightingState"; | 38 | import { sourceHighlightAtom } from "./sourceHighlightingState"; |
| ... | @@ -287,8 +287,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { | ... | @@ -287,8 +287,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 287 | // way "select all" takes the file rather than a line, which a `FlatList` row can never do | 287 | // way "select all" takes the file rather than a line, which a `FlatList` row can never do |
| 288 | // because each row is its own selection scope. | 288 | // because each row is its own selection scope. |
| 289 | const selectableBlock = props.selectable ? ( | 289 | const selectableBlock = props.selectable ? ( |
| 290 | <MarkdownTextPrimitive | 290 | <SourceText |
| 291 | uiTextView | ||
| 292 | selectable | 291 | selectable |
| 293 | selectionColorClassName={Platform.OS === "android" ? "accent-focus/32" : undefined} | 292 | selectionColorClassName={Platform.OS === "android" ? "accent-focus/32" : undefined} |
| 294 | style={{ | 293 | style={{ |
| ... | @@ -304,7 +303,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { | ... | @@ -304,7 +303,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 304 | const body = | 303 | const body = |
| 305 | lineTokens && lineTokens.length > 0 | 304 | lineTokens && lineTokens.length > 0 |
| 306 | ? lineTokens.map((token, tokenIndex) => ( | 305 | ? lineTokens.map((token, tokenIndex) => ( |
| 307 | <MarkdownTextPrimitive | 306 | <SourceText |
| 308 | key={`${index}:${tokenIndex}`} | 307 | key={`${index}:${tokenIndex}`} |
| 309 | style={{ | 308 | style={{ |
| 310 | color: token.color ?? foreground, | 309 | color: token.color ?? foreground, |
| ... | @@ -317,18 +316,18 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { | ... | @@ -317,18 +316,18 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 317 | }} | 316 | }} |
| 318 | > | 317 | > |
| 319 | {token.content} | 318 | {token.content} |
| 320 | </MarkdownTextPrimitive> | 319 | </SourceText> |
| 321 | )) | 320 | )) |
| 322 | : line; | 321 | : line; |
| 323 | return ( | 322 | return ( |
| 324 | <MarkdownTextPrimitive key={index}> | 323 | <SourceText key={index}> |
| 325 | {body} | 324 | {body} |
| 326 | {index < lines.length - 1 ? "\n" : ""} | 325 | {index < lines.length - 1 ? "\n" : ""} |
| 327 | </MarkdownTextPrimitive> | 326 | </SourceText> |
| 328 | ); | 327 | ); |
| 329 | }) | 328 | }) |
| 330 | : normalizedContents} | 329 | : normalizedContents} |
| 331 | </MarkdownTextPrimitive> | 330 | </SourceText> |
| 332 | ) : null; | 331 | ) : null; |
| 333 | 332 | ||
| 334 | const list = ( | 333 | const list = ( |
apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx+8-1| ... | @@ -303,9 +303,13 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean | ... | @@ -303,9 +303,13 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean |
| 303 | const primaryDescriptor = props.descriptors[primaryRoute.key]!; | 303 | const primaryDescriptor = props.descriptors[primaryRoute.key]!; |
| 304 | const descriptors = props.descriptors; | 304 | const descriptors = props.descriptors; |
| 305 | const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" }; | 305 | const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" }; |
| 306 | const visibleRoute = projection.detail.at(-1) ?? primaryRoute; | ||
| 306 | const baseDescriptor: Descriptor = { | 307 | const baseDescriptor: Descriptor = { |
| 307 | ...primaryDescriptor, | 308 | ...primaryDescriptor, |
| 308 | route: baseRoute, | 309 | navigation: props.descriptors[visibleRoute.key]!.navigation, |
| 310 | // The native envelope keeps a stable key, but its NavigationProvider must | ||
| 311 | // identify the real visible route or useIsFocused closes media viewers. | ||
| 312 | route: visibleRoute, | ||
| 309 | options: { headerShown: false }, | 313 | options: { headerShown: false }, |
| 310 | render: () => ( | 314 | render: () => ( |
| 311 | <WorkspaceNativeTabs | 315 | <WorkspaceNativeTabs |
| ... | @@ -359,10 +363,13 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean | ... | @@ -359,10 +363,13 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean |
| 359 | overlays.map((group) => { | 363 | overlays.map((group) => { |
| 360 | const route = group[0]!; | 364 | const route = group[0]!; |
| 361 | const descriptor = props.descriptors[route.key]!; | 365 | const descriptor = props.descriptors[route.key]!; |
| 366 | const visibleRoute = group.at(-1)!; | ||
| 362 | return [ | 367 | return [ |
| 363 | route.key, | 368 | route.key, |
| 364 | { | 369 | { |
| 365 | ...descriptor, | 370 | ...descriptor, |
| 371 | navigation: props.descriptors[visibleRoute.key]!.navigation, | ||
| 372 | route: visibleRoute, | ||
| 366 | options: modalEnvelopeOptions(descriptor.options), | 373 | options: modalEnvelopeOptions(descriptor.options), |
| 367 | render: () => ( | 374 | render: () => ( |
| 368 | <V5CardStackView | 375 | <V5CardStackView |
apps/mobile/src/features/pull-requests/PullRequestDescription.tsx+2-6| ... | @@ -9,8 +9,7 @@ import { useContext, useMemo, useState } from "react"; | ... | @@ -9,8 +9,7 @@ import { useContext, useMemo, useState } from "react"; |
| 9 | import { Pressable, ScrollView, StyleSheet, View } from "react-native"; | 9 | import { Pressable, ScrollView, StyleSheet, View } from "react-native"; |
| 10 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; | 10 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 11 | import { pullRequestImageSource } from "./pullRequestMediaSource"; | 11 | import { pullRequestImageSource } from "./pullRequestMediaSource"; |
| 12 | import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; | 12 | import { parseMarkdownDocument } from "../../lib/markdownDocument"; |
| 13 | import { nativeMarkdownWithHtml } from "@t3tools/mobile-markdown-text/markdown"; | ||
| 14 | import { SymbolView } from "../../components/AppSymbol"; | 13 | import { SymbolView } from "../../components/AppSymbol"; |
| 15 | import { AppText as Text } from "../../components/AppText"; | 14 | import { AppText as Text } from "../../components/AppText"; |
| 16 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 15 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| ... | @@ -32,10 +31,7 @@ export function PullRequestDescription(props: { | ... | @@ -32,10 +31,7 @@ export function PullRequestDescription(props: { |
| 32 | const mediaContext = useContext(PullRequestMediaContext); | 31 | const mediaContext = useContext(PullRequestMediaContext); |
| 33 | const { themeVariables: colors } = useAppearancePreferences(); | 32 | const { themeVariables: colors } = useAppearancePreferences(); |
| 34 | const content = useMemo(() => { | 33 | const content = useMemo(() => { |
| 35 | const document = nativeMarkdownWithHtml( | 34 | const document = parseMarkdownDocument(props.markdown); |
| 36 | parseMarkdownWithOptions(props.markdown, { gfm: true, html: true, math: false }), | ||
| 37 | (markdown) => parseMarkdownWithOptions(markdown, { gfm: true, html: false, math: false }), | ||
| 38 | ); | ||
| 39 | const content = pullRequestDescriptionContent(document, splitPullRequestBody(props.markdown)); | 35 | const content = pullRequestDescriptionContent(document, splitPullRequestBody(props.markdown)); |
| 40 | return { | 36 | return { |
| 41 | ...content, | 37 | ...content, |
apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx+9-71| ... | @@ -11,19 +11,9 @@ import { | ... | @@ -11,19 +11,9 @@ import { |
| 11 | import { videoMimeType } from "@t3tools/shared/video"; | 11 | import { videoMimeType } from "@t3tools/shared/video"; |
| 12 | import { createContext, useContext, useCallback, useMemo, useState } from "react"; | 12 | import { createContext, useContext, useCallback, useMemo, useState } from "react"; |
| 13 | import { githubMarkdownLinks } from "@t3tools/shared/githubMarkdownLinks"; | 13 | import { githubMarkdownLinks } from "@t3tools/shared/githubMarkdownLinks"; |
| 14 | import { Text as NativeText } from "react-native"; | ||
| 15 | import { | ||
| 16 | Markdown, | ||
| 17 | type CustomRenderers, | ||
| 18 | type CustomRendererProps, | ||
| 19 | } from "react-native-nitro-markdown"; | ||
| 20 | import { markdownHtmlContent } from "@t3tools/mobile-markdown-text/markdown"; | ||
| 21 | 14 | ||
| 22 | import { useOpenPullRequest } from "../../native/useOpenPullRequest"; | 15 | import { useOpenPullRequest } from "../../native/useOpenPullRequest"; |
| 23 | import { | 16 | import { SelectableMarkdownText } from "../../native/SelectableMarkdownText"; |
| 24 | hasNativeSelectableMarkdownText, | ||
| 25 | SelectableMarkdownText, | ||
| 26 | } from "../../native/SelectableMarkdownText"; | ||
| 27 | import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview"; | 17 | import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview"; |
| 28 | import { markdownImageSources } from "../../lib/markdownImageSources"; | 18 | import { markdownImageSources } from "../../lib/markdownImageSources"; |
| 29 | import { FilePreviewGallery, useFilePreviewGallery } from "../../components/FilePreviewGallery"; | 19 | import { FilePreviewGallery, useFilePreviewGallery } from "../../components/FilePreviewGallery"; |
| ... | @@ -109,40 +99,6 @@ export function PullRequestMarkdown(props: { | ... | @@ -109,40 +99,6 @@ export function PullRequestMarkdown(props: { |
| 109 | ); | 99 | ); |
| 110 | } | 100 | } |
| 111 | 101 | ||
| 112 | function renderLink( | ||
| 113 | { href, children }: CustomRendererProps, | ||
| 114 | styles: ReturnType<typeof useMarkdownPreviewStyles>, | ||
| 115 | open: (href: string) => void, | ||
| 116 | ) { | ||
| 117 | return ( | ||
| 118 | <NativeText | ||
| 119 | className="font-t3-medium" | ||
| 120 | onPress={() => href && open(href)} | ||
| 121 | style={{ color: styles.theme.colors?.link }} | ||
| 122 | > | ||
| 123 | {children} | ||
| 124 | </NativeText> | ||
| 125 | ); | ||
| 126 | } | ||
| 127 | |||
| 128 | function renderHtmlBlock( | ||
| 129 | { node }: CustomRendererProps, | ||
| 130 | styles: ReturnType<typeof useMarkdownPreviewStyles>, | ||
| 131 | renderers: CustomRenderers, | ||
| 132 | ) { | ||
| 133 | const content = markdownHtmlContent(node.content ?? "").trim(); | ||
| 134 | return content ? ( | ||
| 135 | <Markdown | ||
| 136 | options={{ gfm: true, html: false }} | ||
| 137 | renderers={renderers} | ||
| 138 | styles={styles.styles} | ||
| 139 | theme={styles.theme} | ||
| 140 | > | ||
| 141 | {content} | ||
| 142 | </Markdown> | ||
| 143 | ) : null; | ||
| 144 | } | ||
| 145 | |||
| 146 | function PullRequestMarkdownContent(props: { | 102 | function PullRequestMarkdownContent(props: { |
| 147 | readonly markdown: string; | 103 | readonly markdown: string; |
| 148 | readonly imageGroupMarkdown?: string; | 104 | readonly imageGroupMarkdown?: string; |
| ... | @@ -176,34 +132,16 @@ function PullRequestMarkdownContent(props: { | ... | @@ -176,34 +132,16 @@ function PullRequestMarkdownContent(props: { |
| 176 | (image) => <MarkdownImage href={image.href} alt={image.alt} onPreview={setPreview} />, | 132 | (image) => <MarkdownImage href={image.href} alt={image.alt} onPreview={setPreview} />, |
| 177 | [], | 133 | [], |
| 178 | ); | 134 | ); |
| 179 | const styles = useMarkdownPreviewStyles(renderImage); | 135 | const styles = useMarkdownPreviewStyles(); |
| 180 | const renderers = useMemo<CustomRenderers>(() => { | ||
| 181 | const renderers: CustomRenderers = { | ||
| 182 | ...styles.renderers, | ||
| 183 | link: (props) => renderLink(props, styles, openLink), | ||
| 184 | }; | ||
| 185 | renderers.html_block = (props) => renderHtmlBlock(props, styles, renderers); | ||
| 186 | return renderers; | ||
| 187 | }, [styles, openLink]); | ||
| 188 | return ( | 136 | return ( |
| 189 | <FilePreviewGallery value={gallery}> | 137 | <FilePreviewGallery value={gallery}> |
| 190 | {hasNativeSelectableMarkdownText() ? ( | 138 | <SelectableMarkdownText |
| 191 | <SelectableMarkdownText | 139 | markdown={markdown} |
| 192 | markdown={markdown} | 140 | textStyle={styles.nativeTextStyle} |
| 193 | textStyle={styles.nativeTextStyle} | 141 | onLinkPress={openLink} |
| 194 | onLinkPress={openLink} | 142 | renderImage={renderImage} |
| 195 | renderImage={renderImage} | 143 | onImagePress={(href) => setPreview(pullRequestImageSource(href, mediaContext))} |
| 196 | /> | 144 | /> |
| 197 | ) : ( | ||
| 198 | <Markdown | ||
| 199 | options={{ gfm: true, html: true }} | ||
| 200 | renderers={renderers} | ||
| 201 | styles={styles.styles} | ||
| 202 | theme={styles.theme} | ||
| 203 | > | ||
| 204 | {markdown} | ||
| 205 | </Markdown> | ||
| 206 | )} | ||
| 207 | <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} /> | 145 | <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} /> |
| 208 | </FilePreviewGallery> | 146 | </FilePreviewGallery> |
| 209 | ); | 147 | ); |
apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx+2-13| ... | @@ -6,8 +6,7 @@ import type { PullRequestActivity, PullRequestComment } from "@t3tools/contracts | ... | @@ -6,8 +6,7 @@ import type { PullRequestActivity, PullRequestComment } from "@t3tools/contracts |
| 6 | import { StackActions, type NavigationProp } from "@react-navigation/native"; | 6 | import { StackActions, type NavigationProp } from "@react-navigation/native"; |
| 7 | import { useMemo, useState, type ReactNode } from "react"; | 7 | import { useMemo, useState, type ReactNode } from "react"; |
| 8 | import { Pressable, StyleSheet, View } from "react-native"; | 8 | import { Pressable, StyleSheet, View } from "react-native"; |
| 9 | import { nativeMarkdownWithHtml } from "@t3tools/mobile-markdown-text/markdown"; | 9 | import { parseMarkdownDocument } from "../../lib/markdownDocument"; |
| 10 | import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; | ||
| 11 | import { AppText as Text } from "../../components/AppText"; | 10 | import { AppText as Text } from "../../components/AppText"; |
| 12 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; | 11 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; |
| 13 | import { relativeTime } from "../../lib/time"; | 12 | import { relativeTime } from "../../lib/time"; |
| ... | @@ -63,17 +62,7 @@ function TimelineComment({ comment }: { comment: PullRequestComment }) { | ... | @@ -63,17 +62,7 @@ function TimelineComment({ comment }: { comment: PullRequestComment }) { |
| 63 | const { themeVariables: colors } = useAppearancePreferences(); | 62 | const { themeVariables: colors } = useAppearancePreferences(); |
| 64 | const long = comment.body.length > 450 || comment.body.split("\n").length > 8; | 63 | const long = comment.body.length > 450 || comment.body.split("\n").length > 8; |
| 65 | const content = useMemo( | 64 | const content = useMemo( |
| 66 | () => | 65 | () => pullRequestDescriptionContent(parseMarkdownDocument(comment.body)), |
| 67 | pullRequestDescriptionContent( | ||
| 68 | nativeMarkdownWithHtml( | ||
| 69 | parseMarkdownWithOptions(comment.body.replace(/<!--[\s\S]*?-->/g, ""), { | ||
| 70 | gfm: true, | ||
| 71 | html: true, | ||
| 72 | math: false, | ||
| 73 | }), | ||
| 74 | (markdown) => parseMarkdownWithOptions(markdown, { gfm: true, html: false, math: false }), | ||
| 75 | ), | ||
| 76 | ), | ||
| 77 | [comment.body], | 66 | [comment.body], |
| 78 | ); | 67 | ); |
| 79 | const preview = content.preview; | 68 | const preview = content.preview; |
apps/mobile/src/features/pull-requests/pullRequestDescription.test.ts+21-36| ... | @@ -1,19 +1,21 @@ | ... | @@ -1,19 +1,21 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { parseMarkdownDocument } from "../../lib/markdownDocument"; | ||
| 2 | import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; | 3 | import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; |
| 3 | 4 | ||
| 4 | describe("PR description overview", () => { | 5 | describe("pull request description content", () => { |
| 5 | it("keeps screenshots in source order, deduplicates embeds, and ignores code and local paths", () => { | 6 | it("keeps screenshot attachments and does not turn code examples or local paths into media", () => { |
| 6 | const result = pullRequestDescriptionContent({ | 7 | const result = pullRequestDescriptionContent( |
| 7 | type: "document", | 8 | parseMarkdownDocument( |
| 8 | children: [ | 9 | [ |
| 9 | { type: "paragraph", children: [{ type: "text", content: "Fixes reconnects." }] }, | 10 | "Fixes reconnects.", |
| 10 | { type: "code_block", content: "" }, | 11 | "```markdown\n\n```", |
| 11 | { type: "image", href: "https://example.com/before.png", alt: "Before" }, | 12 | "", |
| 12 | { type: "image", href: "https://example.com/after.png", alt: "After" }, | 13 | "", |
| 13 | { type: "image", href: "https://example.com/before.png", alt: "Duplicate" }, | 14 | "", |
| 14 | { type: "image", href: "screenshots/local.png" }, | 15 | "", |
| 15 | ], | 16 | ].join("\n\n"), |
| 16 | }); | 17 | ), |
| 18 | ); | ||
| 17 | expect(result.attachments).toEqual([ | 19 | expect(result.attachments).toEqual([ |
| 18 | { uri: "https://example.com/before.png", caption: "Before", kind: "image" }, | 20 | { uri: "https://example.com/before.png", caption: "Before", kind: "image" }, |
| 19 | { uri: "https://example.com/after.png", caption: "After", kind: "image" }, | 21 | { uri: "https://example.com/after.png", caption: "After", kind: "image" }, |
| ... | @@ -23,28 +25,11 @@ describe("PR description overview", () => { | ... | @@ -23,28 +25,11 @@ describe("PR description overview", () => { |
| 23 | }); | 25 | }); |
| 24 | 26 | ||
| 25 | it("reads formatted prose and list items without leaking hidden HTML metadata", () => { | 27 | it("reads formatted prose and list items without leaking hidden HTML metadata", () => { |
| 26 | const result = pullRequestDescriptionContent({ | 28 | const result = pullRequestDescriptionContent( |
| 27 | type: "document", | 29 | parseMarkdownDocument( |
| 28 | children: [ | 30 | "<!-- bot metadata -->\n\nKeeps **sessions**\nalive.\n\n- Reconnect\n- Restore output", |
| 29 | { type: "html_block", content: "<!-- bot metadata -->" }, | 31 | ), |
| 30 | { | 32 | ); |
| 31 | type: "paragraph", | 33 | expect(result.preview).toBe("Keeps sessions\nalive.\nReconnect\n\nRestore output"); |
| 32 | children: [ | ||
| 33 | { type: "text", content: "Keeps " }, | ||
| 34 | { type: "bold", children: [{ type: "text", content: "sessions" }] }, | ||
| 35 | { type: "soft_break" }, | ||
| 36 | { type: "text", content: "alive." }, | ||
| 37 | ], | ||
| 38 | }, | ||
| 39 | { | ||
| 40 | type: "list", | ||
| 41 | children: [ | ||
| 42 | { type: "list_item", children: [{ type: "text", content: "Reconnect" }] }, | ||
| 43 | { type: "list_item", children: [{ type: "text", content: "Restore output" }] }, | ||
| 44 | ], | ||
| 45 | }, | ||
| 46 | ], | ||
| 47 | }); | ||
| 48 | expect(result.preview).toBe("Keeps sessions alive.\nReconnect\nRestore output"); | ||
| 49 | }); | 34 | }); |
| 50 | }); | 35 | }); |
apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.test.ts+5-5| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | 2 | import type { MarkdownNode } from "../../lib/markdownDocument"; |
| 3 | import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown"; | 3 | import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown"; |
| 4 | import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; | 4 | import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; |
| 5 | 5 | ||
| ... | @@ -7,10 +7,10 @@ describe("PR attachments", () => { | ... | @@ -7,10 +7,10 @@ describe("PR attachments", () => { |
| 7 | it("combines embedded images and detected media without duplicate URLs", () => { | 7 | it("combines embedded images and detected media without duplicate URLs", () => { |
| 8 | const uri = "https://drop.example.com/shot.png"; | 8 | const uri = "https://drop.example.com/shot.png"; |
| 9 | const document = { | 9 | const document = { |
| 10 | type: "document", | 10 | type: "root", |
| 11 | children: [ | 11 | children: [ |
| 12 | { type: "image", href: uri, alt: "Screenshot" }, | 12 | { type: "image", url: uri, alt: "Screenshot" }, |
| 13 | { type: "image", href: uri, alt: "Duplicate" }, | 13 | { type: "image", url: uri, alt: "Duplicate" }, |
| 14 | ], | 14 | ], |
| 15 | } as MarkdownNode; | 15 | } as MarkdownNode; |
| 16 | const media = splitPullRequestBody( | 16 | const media = splitPullRequestBody( |
| ... | @@ -32,7 +32,7 @@ describe("PR attachments", () => { | ... | @@ -32,7 +32,7 @@ describe("PR attachments", () => { |
| 32 | "https://example.com/docs\n\n```\nhttps://drop.example.com/demo.mp4\n```", | 32 | "https://example.com/docs\n\n```\nhttps://drop.example.com/demo.mp4\n```", |
| 33 | ); | 33 | ); |
| 34 | expect( | 34 | expect( |
| 35 | pullRequestDescriptionContent({ type: "document" } as MarkdownNode, media).attachments, | 35 | pullRequestDescriptionContent({ type: "root" } as MarkdownNode, media).attachments, |
| 36 | ).toEqual([]); | 36 | ).toEqual([]); |
| 37 | }); | 37 | }); |
| 38 | }); | 38 | }); |
apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.ts+9-9| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import type { PullRequestBodySegment } from "@t3tools/client-runtime/pull-request-markdown"; | 1 | import type { PullRequestBodySegment } from "@t3tools/client-runtime/pull-request-markdown"; |
| 2 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | 2 | import type { MarkdownNode } from "../../lib/markdownDocument"; |
| 3 | 3 | ||
| 4 | /** Read media from the parsed document so code examples never become attachments. */ | 4 | /** Read media from the parsed document so code examples never become attachments. */ |
| 5 | export function pullRequestDescriptionContent( | 5 | export function pullRequestDescriptionContent( |
| ... | @@ -13,20 +13,20 @@ export function pullRequestDescriptionContent( | ... | @@ -13,20 +13,20 @@ export function pullRequestDescriptionContent( |
| 13 | const text: string[] = []; | 13 | const text: string[] = []; |
| 14 | const visit = (node: MarkdownNode) => { | 14 | const visit = (node: MarkdownNode) => { |
| 15 | if (node.type === "image") { | 15 | if (node.type === "image") { |
| 16 | if (node.href && /^https?:\/\//i.test(node.href) && !attachments.has(node.href)) { | 16 | if (node.url && /^https?:\/\//i.test(node.url) && !attachments.has(node.url)) { |
| 17 | attachments.set(node.href, { | 17 | attachments.set(node.url, { |
| 18 | uri: node.href, | 18 | uri: node.url, |
| 19 | caption: node.alt || node.title || "Image", | 19 | caption: node.alt || node.title || "Image", |
| 20 | kind: "image", | 20 | kind: "image", |
| 21 | }); | 21 | }); |
| 22 | } | 22 | } |
| 23 | return; | 23 | return; |
| 24 | } | 24 | } |
| 25 | if (node.type === "html_block" || node.type === "html_inline") return; | 25 | if (node.type === "html") return; |
| 26 | if (node.content) text.push(node.content); | 26 | if ("value" in node) text.push(node.value); |
| 27 | node.children?.forEach(visit); | 27 | if ("children" in node) node.children.forEach(visit); |
| 28 | if (["paragraph", "heading", "list_item", "code_block"].includes(node.type)) text.push("\n"); | 28 | if (["paragraph", "heading", "listItem", "code"].includes(node.type)) text.push("\n"); |
| 29 | if (node.type === "soft_break" || node.type === "line_break") text.push(" "); | 29 | if (node.type === "break") text.push(" "); |
| 30 | }; | 30 | }; |
| 31 | visit(document); | 31 | visit(document); |
| 32 | for (const segment of media) { | 32 | for (const segment of media) { |
apps/mobile/src/features/terminal/TerminalContextSheet.tsx+36-29| ... | @@ -6,10 +6,12 @@ import { | ... | @@ -6,10 +6,12 @@ import { |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; | 7 | import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; |
| 8 | import { useState } from "react"; | 8 | import { useState } from "react"; |
| 9 | import { Alert, Modal, Platform, Pressable, ScrollView, View } from "react-native"; | 9 | import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; |
| 10 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 10 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 11 | import { REVIEW_MONO_FONT_FAMILY } from "../review/reviewDiffRendering"; | 11 | import { REVIEW_MONO_FONT_FAMILY } from "../review/reviewDiffRendering"; |
| 12 | import { AppText as Text } from "../../components/AppText"; | 12 | import { AppText as Text } from "../../components/AppText"; |
| 13 | import { AppSheet } from "../../components/AppSheet"; | ||
| 14 | import { MaterialButton } from "../../components/MaterialButton"; | ||
| 13 | import { uuidv4 } from "../../lib/uuid"; | 15 | import { uuidv4 } from "../../lib/uuid"; |
| 14 | import { insertComposerDraftContext } from "../../state/use-composer-drafts"; | 16 | import { insertComposerDraftContext } from "../../state/use-composer-drafts"; |
| 15 | 17 | ||
| ... | @@ -54,25 +56,16 @@ export function TerminalContextSheet(props: { | ... | @@ -54,25 +56,16 @@ export function TerminalContextSheet(props: { |
| 54 | props.onAttach(); | 56 | props.onAttach(); |
| 55 | }; | 57 | }; |
| 56 | return ( | 58 | return ( |
| 57 | <Modal presentationStyle="pageSheet" animationType="slide" onRequestClose={props.onClose}> | 59 | <AppSheet title="Terminal output" onClose={props.onClose}> |
| 58 | <View | 60 | <ScrollView |
| 59 | className="flex-1 bg-sheet-solid" | 61 | className="flex-1" |
| 60 | style={ | 62 | contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} |
| 61 | Platform.OS === "android" | 63 | contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 12, paddingBottom: 20 }} |
| 62 | ? { paddingTop: insets.top, paddingBottom: insets.bottom } | ||
| 63 | : undefined | ||
| 64 | } | ||
| 65 | > | 64 | > |
| 66 | <View className="flex-row items-center justify-between p-4"> | 65 | <Text className="pb-4 text-sm text-foreground-muted"> |
| 67 | <Text className="text-lg text-foreground">Visible terminal output</Text> | ||
| 68 | <Pressable accessibilityRole="button" onPress={props.onClose} className="p-3"> | ||
| 69 | <Text className="text-foreground">Cancel</Text> | ||
| 70 | </Pressable> | ||
| 71 | </View> | ||
| 72 | <Text className="px-4 pb-3 text-foreground-muted"> | ||
| 73 | Tap the first and last line to select a range. | 66 | Tap the first and last line to select a range. |
| 74 | </Text> | 67 | </Text> |
| 75 | <ScrollView className="flex-1" contentContainerStyle={{ padding: 16 }}> | 68 | <View className="overflow-hidden rounded-xl bg-card-alt"> |
| 76 | {lines.map((line, index) => ( | 69 | {lines.map((line, index) => ( |
| 77 | <Pressable | 70 | <Pressable |
| 78 | key={index} | 71 | key={index} |
| ... | @@ -88,31 +81,45 @@ export function TerminalContextSheet(props: { | ... | @@ -88,31 +81,45 @@ export function TerminalContextSheet(props: { |
| 88 | setAnchor(null); | 81 | setAnchor(null); |
| 89 | } | 82 | } |
| 90 | }} | 83 | }} |
| 91 | className={index >= range.start && index <= range.end ? "bg-subtle py-1" : "py-1"} | 84 | className={ |
| 85 | index >= range.start && index <= range.end | ||
| 86 | ? "flex-row gap-3 bg-primary/10 px-3 py-2" | ||
| 87 | : "flex-row gap-3 px-3 py-2" | ||
| 88 | } | ||
| 92 | > | 89 | > |
| 93 | <Text | 90 | <Text |
| 94 | className="text-sm text-foreground" | 91 | className="w-8 text-right text-sm text-foreground-muted" |
| 95 | style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} | 92 | style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} |
| 96 | > | 93 | > |
| 97 | {index + 1} {line || " "} | 94 | {index + 1} |
| 95 | </Text> | ||
| 96 | <Text | ||
| 97 | className="flex-1 text-sm text-foreground" | ||
| 98 | style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} | ||
| 99 | > | ||
| 100 | {line || " "} | ||
| 98 | </Text> | 101 | </Text> |
| 99 | </Pressable> | 102 | </Pressable> |
| 100 | ))} | 103 | ))} |
| 101 | </ScrollView> | 104 | </View> |
| 105 | </ScrollView> | ||
| 106 | <View | ||
| 107 | className="gap-3 border-t border-border px-5 pt-4" | ||
| 108 | style={{ paddingBottom: Math.max(16, insets.bottom) }} | ||
| 109 | > | ||
| 102 | {tooLarge ? ( | 110 | {tooLarge ? ( |
| 103 | <Text className="px-4 text-foreground-muted"> | 111 | <Text className="text-sm text-foreground-muted"> |
| 104 | Select fewer lines to fit the context limit. | 112 | Select fewer lines to fit the context limit. |
| 105 | </Text> | 113 | </Text> |
| 106 | ) : null} | 114 | ) : null} |
| 107 | <Pressable | 115 | <MaterialButton |
| 108 | accessibilityRole="button" | 116 | label="Attach selected output" |
| 117 | tone="primary" | ||
| 118 | fullWidth | ||
| 109 | disabled={!selectedText.trim() || tooLarge} | 119 | disabled={!selectedText.trim() || tooLarge} |
| 110 | onPress={attach} | 120 | onPress={attach} |
| 111 | className="m-4 mb-10 rounded-xl bg-subtle p-4" | 121 | /> |
| 112 | > | ||
| 113 | <Text className="text-center text-foreground">Attach selected output</Text> | ||
| 114 | </Pressable> | ||
| 115 | </View> | 122 | </View> |
| 116 | </Modal> | 123 | </AppSheet> |
| 117 | ); | 124 | ); |
| 118 | } | 125 | } |
apps/mobile/src/features/threads/ThreadFeed.tsx+113-689| ... | @@ -1,4 +1,7 @@ | ... | @@ -1,4 +1,7 @@ |
| 1 | import { useOpenPullRequest } from "../../native/useOpenPullRequest"; | 1 | import { useOpenPullRequest } from "../../native/useOpenPullRequest"; |
| 2 | import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo"; | ||
| 3 | import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; | ||
| 4 | import { enrichedContextLinkPresentation } from "../../lib/enrichedLinkPresentation"; | ||
| 2 | import { ThreadContextDivider } from "./thread-context-divider"; | 5 | import { ThreadContextDivider } from "./thread-context-divider"; |
| 3 | import { ThreadHandoffRow } from "./thread-handoff-row"; | 6 | import { ThreadHandoffRow } from "./thread-handoff-row"; |
| 4 | import { SecretRequestCard } from "./SecretRequestCard"; | 7 | import { SecretRequestCard } from "./SecretRequestCard"; |
| ... | @@ -32,6 +35,7 @@ import { | ... | @@ -32,6 +35,7 @@ import { |
| 32 | parseComposerContextHref, | 35 | parseComposerContextHref, |
| 33 | collectComposerContextReferences, | 36 | collectComposerContextReferences, |
| 34 | replaceComposerContextReferences, | 37 | replaceComposerContextReferences, |
| 38 | formatComposerContextReference, | ||
| 35 | } from "@t3tools/shared/composerContextReferences"; | 39 | } from "@t3tools/shared/composerContextReferences"; |
| 36 | import { ComposerContextSheet } from "../../components/ComposerContextSheet"; | 40 | import { ComposerContextSheet } from "../../components/ComposerContextSheet"; |
| 37 | import { writeComposerContextClipboard } from "../../lib/composerContextClipboard"; | 41 | import { writeComposerContextClipboard } from "../../lib/composerContextClipboard"; |
| ... | @@ -41,7 +45,6 @@ import { | ... | @@ -41,7 +45,6 @@ import { |
| 41 | } from "@t3tools/shared/codexArtifactTemplates"; | 45 | } from "@t3tools/shared/codexArtifactTemplates"; |
| 42 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | 46 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 43 | import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; | 47 | import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; |
| 44 | import { getTextContent, type MarkdownNode } from "react-native-nitro-markdown/headless"; | ||
| 45 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; | 48 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 46 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 49 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 47 | import { | 50 | import { |
| ... | @@ -75,12 +78,7 @@ import { | ... | @@ -75,12 +78,7 @@ import { |
| 75 | type RefObject, | 78 | type RefObject, |
| 76 | } from "react"; | 79 | } from "react"; |
| 77 | import { | 80 | import { |
| 78 | Markdown, | 81 | ActivityIndicator, |
| 79 | type CustomRenderers, | ||
| 80 | type NodeStyleOverrides, | ||
| 81 | type PartialMarkdownTheme, | ||
| 82 | } from "react-native-nitro-markdown"; | ||
| 83 | import { | ||
| 84 | Alert, | 82 | Alert, |
| 85 | Image, | 83 | Image, |
| 86 | Platform, | 84 | Platform, |
| ... | @@ -114,10 +112,11 @@ import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; | ... | @@ -114,10 +112,11 @@ import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; |
| 114 | import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; | 112 | import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; |
| 115 | import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; | 113 | import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; |
| 116 | import { | 114 | import { |
| 117 | hasNativeSelectableMarkdownText, | ||
| 118 | SelectableMarkdownText, | 115 | SelectableMarkdownText, |
| 119 | type MarkdownFileContextMenu, | 116 | type MarkdownFileContextMenu, |
| 120 | type MarkdownImageRenderer, | 117 | type MarkdownImageRenderer, |
| 118 | type MarkdownImageSourceResolver, | ||
| 119 | type MarkdownLinkCustomization, | ||
| 121 | type NativeMarkdownTextStyle, | 120 | type NativeMarkdownTextStyle, |
| 122 | type SelectableMarkdownSkill, | 121 | type SelectableMarkdownSkill, |
| 123 | } from "../../native/SelectableMarkdownText"; | 122 | } from "../../native/SelectableMarkdownText"; |
| ... | @@ -154,15 +153,15 @@ import { | ... | @@ -154,15 +153,15 @@ import { |
| 154 | resolveNativeMarkdownTypography, | 153 | resolveNativeMarkdownTypography, |
| 155 | } from "../../lib/appearancePreferences"; | 154 | } from "../../lib/appearancePreferences"; |
| 156 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 155 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 157 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; | 156 | import { markdownFileIconSource } from "../../lib/markdownFileIcons"; |
| 158 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; | 157 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; |
| 159 | import { markdownLinkIconSource } from "@t3tools/mobile-markdown-text/link-icons"; | 158 | import { markdownLinkIconSource } from "../../lib/markdownLinkIcons"; |
| 160 | import { | 159 | import { |
| 161 | normalizeNativeMarkdownUrl, | 160 | normalizeNativeMarkdownUrl, |
| 162 | resolveMarkdownInlineCodePresentation, | 161 | resolveMarkdownInlineCodePresentation, |
| 163 | resolveMarkdownLinkIcon, | 162 | resolveMarkdownLinkIcon, |
| 164 | resolveMarkdownLinkPresentation, | 163 | resolveMarkdownLinkPresentation, |
| 165 | } from "@t3tools/mobile-markdown-text/links"; | 164 | } from "../../lib/markdownLinks"; |
| 166 | import { | 165 | import { |
| 167 | failedFeedRunIds, | 166 | failedFeedRunIds, |
| 168 | deriveThreadFeedPresentation, | 167 | deriveThreadFeedPresentation, |
| ... | @@ -212,7 +211,6 @@ import { environmentThreadShells, threadEnvironment } from "../../state/threads" | ... | @@ -212,7 +211,6 @@ import { environmentThreadShells, threadEnvironment } from "../../state/threads" |
| 212 | import { useAtomCommand } from "../../state/use-atom-command"; | 211 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 213 | import { useV2ItemSupport } from "../../state/v2-item-support"; | 212 | import { useV2ItemSupport } from "../../state/v2-item-support"; |
| 214 | import { | 213 | import { |
| 215 | basename, | ||
| 216 | fileRoutePathSegments, | 214 | fileRoutePathSegments, |
| 217 | isAbsolutePath, | 215 | isAbsolutePath, |
| 218 | resolveWorkspaceRelativeFilePath, | 216 | resolveWorkspaceRelativeFilePath, |
| ... | @@ -677,7 +675,6 @@ function MessageAttachmentUnknown(props: { readonly name: string }) { | ... | @@ -677,7 +675,6 @@ function MessageAttachmentUnknown(props: { readonly name: string }) { |
| 677 | ); | 675 | ); |
| 678 | } | 676 | } |
| 679 | 677 | ||
| 680 | const ThreadMediaVisibleContext = createContext(false); | ||
| 681 | // LegendList only computes hook visibility when the list has a viewability config. | 678 | // LegendList only computes hook visibility when the list has a viewability config. |
| 682 | const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; | 679 | const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; |
| 683 | 680 | ||
| ... | @@ -689,164 +686,24 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) { | ... | @@ -689,164 +686,24 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) { |
| 689 | return <ThreadMediaVisibleContext value={visible}>{props.children}</ThreadMediaVisibleContext>; | 686 | return <ThreadMediaVisibleContext value={visible}>{props.children}</ThreadMediaVisibleContext>; |
| 690 | } | 687 | } |
| 691 | 688 | ||
| 692 | function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource }) { | 689 | interface MarkdownLinkHandlers { |
| 693 | const { source } = props; | 690 | readonly onLinkPress: (href: string) => void; |
| 694 | const visible = useContext(ThreadMediaVisibleContext); | 691 | readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; |
| 695 | const thumbnailKey = mediaVideoThumbnailKey(source); | 692 | readonly onFileContextMenuAction: (href: string, actionId: string) => void; |
| 696 | const asset = useAssetUrlState( | 693 | readonly onImagePress?: (href: string) => void; |
| 697 | "environmentId" in source ? source.environmentId : null, | 694 | readonly resolveImageSource?: MarkdownImageSourceResolver; |
| 698 | "resource" in source ? source.resource : null, | 695 | readonly linkCustomization?: (href: string) => MarkdownLinkCustomization | undefined; |
| 699 | ); | ||
| 700 | const refreshAssetUrl = useRefreshAssetUrl( | ||
| 701 | "environmentId" in source ? source.environmentId : null, | ||
| 702 | "resource" in source ? source.resource : null, | ||
| 703 | ); | ||
| 704 | const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); | ||
| 705 | return ( | ||
| 706 | <MediaVideoPlayer | ||
| 707 | key={thumbnailKey} | ||
| 708 | uri={uri} | ||
| 709 | resolvePlaybackUri={ | ||
| 710 | "resource" in source | ||
| 711 | ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) | ||
| 712 | : undefined | ||
| 713 | } | ||
| 714 | name={source.name} | ||
| 715 | thumbnailKey={thumbnailKey} | ||
| 716 | thumbnailVisible={visible} | ||
| 717 | unavailable={"resource" in source && asset._tag === "Failure"} | ||
| 718 | actionsSource={source.actionsSource} | ||
| 719 | /> | ||
| 720 | ); | ||
| 721 | } | 696 | } |
| 722 | 697 | ||
| 723 | const MARKDOWN_MONO_FONT = Platform.select({ | ||
| 724 | ios: "ui-monospace", | ||
| 725 | android: "monospace", | ||
| 726 | default: "monospace", | ||
| 727 | }); | ||
| 728 | |||
| 729 | interface MarkdownStyleSets { | 698 | interface MarkdownStyleSets { |
| 730 | readonly user: MarkdownStyleSet; | 699 | readonly user: MarkdownStyleSet; |
| 731 | readonly assistant: MarkdownStyleSet; | 700 | readonly assistant: MarkdownStyleSet; |
| 732 | } | 701 | } |
| 733 | 702 | ||
| 734 | interface MarkdownStyleSet { | 703 | interface MarkdownStyleSet { |
| 735 | readonly theme: PartialMarkdownTheme; | ||
| 736 | readonly styles: NodeStyleOverrides; | ||
| 737 | readonly renderers: CustomRenderers; | ||
| 738 | readonly nativeTextStyle: NativeMarkdownTextStyle; | 704 | readonly nativeTextStyle: NativeMarkdownTextStyle; |
| 739 | } | 705 | } |
| 740 | 706 | ||
| 741 | const failedMarkdownFaviconHosts = new Set<string>(); | ||
| 742 | const MarkdownLinkLabelContext = createContext<"file" | "other" | null>(null); | ||
| 743 | const markdownLinkStyles = StyleSheet.create({ | ||
| 744 | inlineIcon: { | ||
| 745 | width: 14, | ||
| 746 | height: 14, | ||
| 747 | marginHorizontal: 3, | ||
| 748 | transform: [{ translateY: 2 }], | ||
| 749 | flexShrink: 0, | ||
| 750 | }, | ||
| 751 | favicon: { | ||
| 752 | borderRadius: 3, | ||
| 753 | }, | ||
| 754 | }); | ||
| 755 | |||
| 756 | const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { | ||
| 757 | readonly children: ReactNode; | ||
| 758 | readonly color: string; | ||
| 759 | readonly host: string; | ||
| 760 | readonly href: string; | ||
| 761 | readonly onPress: (href: string) => void; | ||
| 762 | }) { | ||
| 763 | const [failedHost, setFailedHost] = useState<string | null>(null); | ||
| 764 | const linkIcon = resolveMarkdownLinkIcon(props.host); | ||
| 765 | const faviconUrl = linkIcon ? null : faviconUrlForOrigin(`https://${props.host}`); | ||
| 766 | |||
| 767 | return ( | ||
| 768 | <NativeText | ||
| 769 | className="font-sans" | ||
| 770 | onPress={() => props.onPress(props.href)} | ||
| 771 | style={{ | ||
| 772 | color: props.color, | ||
| 773 | textDecorationLine: "none", | ||
| 774 | }} | ||
| 775 | > | ||
| 776 | {linkIcon ? ( | ||
| 777 | <Image | ||
| 778 | source={markdownLinkIconSource(linkIcon)} | ||
| 779 | style={markdownLinkStyles.inlineIcon} | ||
| 780 | tintColor={props.color} | ||
| 781 | /> | ||
| 782 | ) : faviconUrl !== null && | ||
| 783 | failedHost !== props.host && | ||
| 784 | !failedMarkdownFaviconHosts.has(props.host) ? ( | ||
| 785 | <Image | ||
| 786 | source={{ | ||
| 787 | uri: faviconUrl, | ||
| 788 | }} | ||
| 789 | style={[markdownLinkStyles.inlineIcon, markdownLinkStyles.favicon]} | ||
| 790 | onError={() => { | ||
| 791 | failedMarkdownFaviconHosts.add(props.host); | ||
| 792 | setFailedHost(props.host); | ||
| 793 | }} | ||
| 794 | /> | ||
| 795 | ) : ( | ||
| 796 | <NativeText style={{ color: props.color }}>{" ◉ "}</NativeText> | ||
| 797 | )} | ||
| 798 | {props.children} | ||
| 799 | </NativeText> | ||
| 800 | ); | ||
| 801 | }); | ||
| 802 | |||
| 803 | function MarkdownInlineCode(props: { | ||
| 804 | readonly content: string; | ||
| 805 | readonly textColor: string; | ||
| 806 | readonly codeColor: string; | ||
| 807 | readonly fontSize: number; | ||
| 808 | readonly lineHeight: number; | ||
| 809 | readonly onLinkPress: (href: string) => void; | ||
| 810 | }) { | ||
| 811 | const insideLink = useContext(MarkdownLinkLabelContext); | ||
| 812 | const presentation = insideLink ? null : resolveMarkdownInlineCodePresentation(props.content); | ||
| 813 | return ( | ||
| 814 | <NativeText | ||
| 815 | className={presentation ? "font-t3-bold" : "font-mono"} | ||
| 816 | onPress={presentation ? () => props.onLinkPress(presentation.href) : undefined} | ||
| 817 | style={{ | ||
| 818 | color: presentation ? props.textColor : props.codeColor, | ||
| 819 | fontSize: props.fontSize, | ||
| 820 | lineHeight: props.lineHeight, | ||
| 821 | }} | ||
| 822 | > | ||
| 823 | {presentation ? ( | ||
| 824 | <Image | ||
| 825 | source={markdownFileIconSource(presentation.icon)} | ||
| 826 | style={markdownLinkStyles.inlineIcon} | ||
| 827 | /> | ||
| 828 | ) : null} | ||
| 829 | {presentation?.label ?? props.content} | ||
| 830 | </NativeText> | ||
| 831 | ); | ||
| 832 | } | ||
| 833 | |||
| 834 | function MarkdownImage(props: { | ||
| 835 | readonly node: MarkdownNode; | ||
| 836 | readonly renderImage: MarkdownImageRenderer; | ||
| 837 | }) { | ||
| 838 | const insideLink = useContext(MarkdownLinkLabelContext); | ||
| 839 | if (insideLink === "file") | ||
| 840 | return <NativeText>{props.node.alt ?? props.node.title ?? ""}</NativeText>; | ||
| 841 | return props.node.href | ||
| 842 | ? props.renderImage({ | ||
| 843 | href: props.node.href, | ||
| 844 | alt: props.node.alt ?? null, | ||
| 845 | title: props.node.title ?? null, | ||
| 846 | }) | ||
| 847 | : null; | ||
| 848 | } | ||
| 849 | |||
| 850 | const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record< | 707 | const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record< |
| 851 | CodexArtifactTemplate["artifactKind"], | 708 | CodexArtifactTemplate["artifactKind"], |
| 852 | AppSymbolName | 709 | AppSymbolName |
| ... | @@ -907,13 +764,6 @@ function ArtifactTemplateCard(props: { | ... | @@ -907,13 +764,6 @@ function ArtifactTemplateCard(props: { |
| 907 | ); | 764 | ); |
| 908 | } | 765 | } |
| 909 | 766 | ||
| 910 | /** Tap opens a link; long-press on a native file chip shows its menu. Built once per feed. */ | ||
| 911 | interface MarkdownLinkHandlers { | ||
| 912 | readonly onLinkPress: (href: string) => void; | ||
| 913 | readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; | ||
| 914 | readonly onFileContextMenuAction: (href: string, actionId: string) => void; | ||
| 915 | } | ||
| 916 | |||
| 917 | const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { | 767 | const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { |
| 918 | readonly markdown: string; | 768 | readonly markdown: string; |
| 919 | readonly environmentId: EnvironmentId; | 769 | readonly environmentId: EnvironmentId; |
| ... | @@ -939,7 +789,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { | ... | @@ -939,7 +789,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { |
| 939 | if (segment.markdown.trim().length === 0) return null; | 789 | if (segment.markdown.trim().length === 0) return null; |
| 940 | 790 | ||
| 941 | const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown); | 791 | const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown); |
| 942 | return hasNativeSelectableMarkdownText() ? ( | 792 | return ( |
| 943 | <SelectableMarkdownText | 793 | <SelectableMarkdownText |
| 944 | key={`markdown:${segment.sourceOffset}`} | 794 | key={`markdown:${segment.sourceOffset}`} |
| 945 | markdown={markdown} | 795 | markdown={markdown} |
| ... | @@ -948,156 +798,20 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { | ... | @@ -948,156 +798,20 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { |
| 948 | {...props.linkHandlers} | 798 | {...props.linkHandlers} |
| 949 | renderImage={props.renderImage} | 799 | renderImage={props.renderImage} |
| 950 | /> | 800 | /> |
| 951 | ) : ( | ||
| 952 | <Markdown | ||
| 953 | key={`markdown:${segment.sourceOffset}`} | ||
| 954 | options={{ gfm: true }} | ||
| 955 | renderers={props.markdownStyles.renderers} | ||
| 956 | styles={props.markdownStyles.styles} | ||
| 957 | theme={props.markdownStyles.theme} | ||
| 958 | > | ||
| 959 | {markdown} | ||
| 960 | </Markdown> | ||
| 961 | ); | 801 | ); |
| 962 | }); | 802 | }); |
| 963 | }); | 803 | }); |
| 964 | 804 | ||
| 965 | function MarkdownCodeBlock(props: { | 805 | function useMarkdownStyles(): MarkdownStyleSets { |
| 966 | readonly backgroundColor: string; | 806 | const { appearance } = useAppearancePreferences(); |
| 967 | readonly borderColor: string; | ||
| 968 | readonly content: string; | ||
| 969 | readonly copyTintColor: ColorValue; | ||
| 970 | readonly headerTextColor: string; | ||
| 971 | readonly fontSize: number; | ||
| 972 | readonly highlightCode: boolean; | ||
| 973 | readonly language?: string | null; | ||
| 974 | readonly lineHeight: number; | ||
| 975 | readonly textColor: string; | ||
| 976 | readonly theme: ReviewDiffTheme; | ||
| 977 | }) { | ||
| 978 | const content = props.content.replace(/\n$/, ""); | ||
| 979 | const languageLabel = props.language?.trim() || "text"; | ||
| 980 | const highlighted = useMarkdownCodeHighlight({ | ||
| 981 | code: content, | ||
| 982 | enabled: props.highlightCode && Boolean(props.language?.trim()), | ||
| 983 | language: props.language, | ||
| 984 | theme: props.theme, | ||
| 985 | }); | ||
| 986 | let tokenOffset = 0; | ||
| 987 | |||
| 988 | return ( | ||
| 989 | <View | ||
| 990 | className="my-3 min-w-0 max-w-full self-stretch overflow-hidden rounded-lg border" | ||
| 991 | style={{ backgroundColor: props.backgroundColor, borderColor: props.borderColor }} | ||
| 992 | > | ||
| 993 | <View | ||
| 994 | className="flex-row items-center justify-between gap-2 border-b py-1 pr-1.5 pl-3.5" | ||
| 995 | style={{ borderBottomColor: props.borderColor }} | ||
| 996 | > | ||
| 997 | <NativeText | ||
| 998 | className="flex-1 font-mono uppercase opacity-70" | ||
| 999 | numberOfLines={1} | ||
| 1000 | style={{ | ||
| 1001 | color: props.headerTextColor, | ||
| 1002 | fontSize: props.fontSize, | ||
| 1003 | ...(Platform.OS === "android" ? { includeFontPadding: false } : null), | ||
| 1004 | }} | ||
| 1005 | > | ||
| 1006 | {languageLabel} | ||
| 1007 | </NativeText> | ||
| 1008 | <CopyTextButton | ||
| 1009 | accessibilityLabel="Copy code" | ||
| 1010 | text={content} | ||
| 1011 | tintColor={props.copyTintColor} | ||
| 1012 | buttonSize={32} | ||
| 1013 | iconSize={16} | ||
| 1014 | /> | ||
| 1015 | </View> | ||
| 1016 | <ScrollView | ||
| 1017 | horizontal | ||
| 1018 | bounces={false} | ||
| 1019 | nestedScrollEnabled={Platform.OS === "android"} | ||
| 1020 | showsHorizontalScrollIndicator={false} | ||
| 1021 | contentContainerClassName="px-3.5 py-3" | ||
| 1022 | > | ||
| 1023 | <NativeText | ||
| 1024 | selectable | ||
| 1025 | selectionColorClassName={Platform.OS === "android" ? "accent-focus/32" : undefined} | ||
| 1026 | className="font-mono" | ||
| 1027 | style={{ | ||
| 1028 | color: props.textColor, | ||
| 1029 | fontSize: props.fontSize, | ||
| 1030 | lineHeight: props.lineHeight, | ||
| 1031 | ...(Platform.OS === "android" ? { includeFontPadding: false } : null), | ||
| 1032 | }} | ||
| 1033 | > | ||
| 1034 | {highlighted | ||
| 1035 | ? highlighted.map((line, lineIndex) => { | ||
| 1036 | const lineStartOffset = tokenOffset; | ||
| 1037 | const lineText = line.map((token) => token.content).join(""); | ||
| 1038 | const renderedLine = ( | ||
| 1039 | <NativeText key={`line:${lineStartOffset}:${lineText}`}> | ||
| 1040 | {line.map((token) => { | ||
| 1041 | const startOffset = tokenOffset; | ||
| 1042 | tokenOffset += token.content.length; | ||
| 1043 | const fontStyle = | ||
| 1044 | token.fontStyle !== null && (token.fontStyle & 1) === 1 | ||
| 1045 | ? ("italic" as const) | ||
| 1046 | : ("normal" as const); | ||
| 1047 | const fontWeight = | ||
| 1048 | token.fontStyle !== null && (token.fontStyle & 2) === 2 | ||
| 1049 | ? ("700" as const) | ||
| 1050 | : ("400" as const); | ||
| 1051 | |||
| 1052 | return ( | ||
| 1053 | <NativeText | ||
| 1054 | key={`${startOffset}:${token.content}:${token.color ?? ""}:${ | ||
| 1055 | token.fontStyle ?? "" | ||
| 1056 | }`} | ||
| 1057 | style={{ | ||
| 1058 | color: token.color ?? props.textColor, | ||
| 1059 | fontStyle, | ||
| 1060 | fontWeight, | ||
| 1061 | }} | ||
| 1062 | > | ||
| 1063 | {token.content} | ||
| 1064 | </NativeText> | ||
| 1065 | ); | ||
| 1066 | })} | ||
| 1067 | {lineIndex + 1 < highlighted.length ? "\n" : ""} | ||
| 1068 | </NativeText> | ||
| 1069 | ); | ||
| 1070 | if (lineIndex + 1 < highlighted.length) { | ||
| 1071 | tokenOffset += 1; | ||
| 1072 | } | ||
| 1073 | return renderedLine; | ||
| 1074 | }) | ||
| 1075 | : content} | ||
| 1076 | </NativeText> | ||
| 1077 | </ScrollView> | ||
| 1078 | </View> | ||
| 1079 | ); | ||
| 1080 | } | ||
| 1081 | |||
| 1082 | function useMarkdownStyles( | ||
| 1083 | onLinkPress: (href: string) => void, | ||
| 1084 | renderImage: MarkdownImageRenderer, | ||
| 1085 | ): MarkdownStyleSets { | ||
| 1086 | const { appearance, themeAppearance } = useAppearancePreferences(); | ||
| 1087 | const markdownFontSizes = useMemo( | ||
| 1088 | () => resolveMarkdownFontSizes(appearance.baseFontSize), | ||
| 1089 | [appearance.baseFontSize], | ||
| 1090 | ); | ||
| 1091 | const nativeMarkdownTypography = useMemo( | 807 | const nativeMarkdownTypography = useMemo( |
| 1092 | () => resolveNativeMarkdownTypography(appearance.baseFontSize), | 808 | () => resolveNativeMarkdownTypography(appearance.baseFontSize), |
| 1093 | [appearance.baseFontSize], | 809 | [appearance.baseFontSize], |
| 1094 | ); | 810 | ); |
| 1095 | const themeMode = themeAppearance; | ||
| 1096 | const theme = useUniwindTheme(); | 811 | const theme = useUniwindTheme(); |
| 1097 | const markdownBodyColor = theme["--color-md-body"]; | 812 | const markdownBodyColor = theme["--color-md-body"]; |
| 1098 | const markdownStrongColor = theme["--color-md-strong"]; | 813 | const markdownStrongColor = theme["--color-md-strong"]; |
| 1099 | const markdownLinkColor = theme["--color-md-link"]; | 814 | const markdownLinkColor = theme["--color-md-link"]; |
| 1100 | const markdownBlockquoteBg = theme["--color-md-blockquote-bg"]; | ||
| 1101 | const markdownBlockquoteBorder = theme["--color-md-blockquote-border"]; | 815 | const markdownBlockquoteBorder = theme["--color-md-blockquote-border"]; |
| 1102 | const markdownCodeBg = theme["--color-md-code-bg"]; | 816 | const markdownCodeBg = theme["--color-md-code-bg"]; |
| 1103 | const markdownCodeText = theme["--color-md-code-text"]; | 817 | const markdownCodeText = theme["--color-md-code-text"]; |
| ... | @@ -1113,291 +827,14 @@ function useMarkdownStyles( | ... | @@ -1113,291 +827,14 @@ function useMarkdownStyles( |
| 1113 | const markdownUserCodeText = theme["--color-md-user-code-text"]; | 827 | const markdownUserCodeText = theme["--color-md-user-code-text"]; |
| 1114 | const markdownUserInlineCodeText = theme["--color-user-bubble-foreground-muted"]; | 828 | const markdownUserInlineCodeText = theme["--color-user-bubble-foreground-muted"]; |
| 1115 | const markdownUserFenceBg = theme["--color-md-user-fence-bg"]; | 829 | const markdownUserFenceBg = theme["--color-md-user-fence-bg"]; |
| 1116 | const markdownUserFenceText = theme["--color-md-user-fence-text"]; | ||
| 1117 | const iconSubtleColor = theme["--color-icon-subtle"]; | ||
| 1118 | const inlineSkillForeground = theme["--color-inline-skill-foreground"]; | 830 | const inlineSkillForeground = theme["--color-inline-skill-foreground"]; |
| 1119 | const userBubbleSkillForeground = theme["--color-user-bubble-skill-foreground"]; | 831 | const userBubbleSkillForeground = theme["--color-user-bubble-skill-foreground"]; |
| 1120 | const userBubbleForegroundMuted = theme["--color-user-bubble-foreground-muted"]; | ||
| 1121 | const regularFontFamily = useFontFamily("regular"); | 832 | const regularFontFamily = useFontFamily("regular"); |
| 1122 | const boldFontFamily = useFontFamily("bold"); | 833 | const boldFontFamily = useFontFamily("bold"); |
| 1123 | 834 | ||
| 1124 | return useMemo(() => { | 835 | return useMemo(() => { |
| 1125 | const baseTheme: PartialMarkdownTheme = { | ||
| 1126 | colors: { | ||
| 1127 | text: markdownBodyColor, | ||
| 1128 | heading: markdownStrongColor, | ||
| 1129 | link: markdownLinkColor, | ||
| 1130 | blockquote: markdownBlockquoteBorder, | ||
| 1131 | border: markdownHrColor, | ||
| 1132 | surface: "transparent", | ||
| 1133 | surfaceLight: markdownBlockquoteBg, | ||
| 1134 | accent: markdownLinkColor, | ||
| 1135 | tableBorder: markdownHrColor, | ||
| 1136 | tableHeader: markdownBlockquoteBg, | ||
| 1137 | tableHeaderText: markdownStrongColor, | ||
| 1138 | tableRowOdd: "transparent", | ||
| 1139 | tableRowEven: "transparent", | ||
| 1140 | }, | ||
| 1141 | spacing: { | ||
| 1142 | xs: 4, | ||
| 1143 | s: 4, | ||
| 1144 | m: 8, | ||
| 1145 | l: 8, | ||
| 1146 | xl: 16, | ||
| 1147 | }, | ||
| 1148 | fontSizes: { | ||
| 1149 | s: markdownFontSizes.s, | ||
| 1150 | m: markdownFontSizes.m, | ||
| 1151 | h1: markdownFontSizes.h1, | ||
| 1152 | h2: markdownFontSizes.h2, | ||
| 1153 | h3: markdownFontSizes.h3, | ||
| 1154 | h4: markdownFontSizes.h4, | ||
| 1155 | h5: markdownFontSizes.h5, | ||
| 1156 | h6: markdownFontSizes.h6, | ||
| 1157 | }, | ||
| 1158 | fontFamilies: { | ||
| 1159 | regular: regularFontFamily, | ||
| 1160 | heading: boldFontFamily, | ||
| 1161 | mono: MARKDOWN_MONO_FONT, | ||
| 1162 | }, | ||
| 1163 | headingWeight: "700", | ||
| 1164 | borderRadius: { | ||
| 1165 | s: 4, | ||
| 1166 | m: 8, | ||
| 1167 | l: 12, | ||
| 1168 | }, | ||
| 1169 | showCodeLanguage: false, | ||
| 1170 | }; | ||
| 1171 | |||
| 1172 | const baseStyles: NodeStyleOverrides = { | ||
| 1173 | document: { flexShrink: 1 }, | ||
| 1174 | paragraph: { marginTop: 0, marginBottom: 10 }, | ||
| 1175 | list: { marginTop: 4, marginBottom: 8 }, | ||
| 1176 | list_item: { marginTop: 0, marginBottom: 4 }, | ||
| 1177 | task_list_item: { marginTop: 0, marginBottom: 4 }, | ||
| 1178 | text: { lineHeight: markdownFontSizes.bodyLineHeight }, | ||
| 1179 | bold: { | ||
| 1180 | fontWeight: "700", | ||
| 1181 | color: markdownStrongColor, | ||
| 1182 | fontFamily: boldFontFamily, | ||
| 1183 | }, | ||
| 1184 | italic: { fontStyle: "italic" }, | ||
| 1185 | link: { | ||
| 1186 | color: markdownLinkColor, | ||
| 1187 | textDecorationLine: "underline" as const, | ||
| 1188 | }, | ||
| 1189 | blockquote: { | ||
| 1190 | borderLeftWidth: 2, | ||
| 1191 | borderLeftColor: markdownBlockquoteBorder, | ||
| 1192 | paddingLeft: 11, | ||
| 1193 | paddingVertical: 2, | ||
| 1194 | marginLeft: 0, | ||
| 1195 | marginVertical: 10, | ||
| 1196 | }, | ||
| 1197 | heading: { | ||
| 1198 | fontFamily: boldFontFamily, | ||
| 1199 | color: markdownStrongColor, | ||
| 1200 | marginTop: 18, | ||
| 1201 | marginBottom: 8, | ||
| 1202 | }, | ||
| 1203 | horizontal_rule: { | ||
| 1204 | backgroundColor: markdownHrColor, | ||
| 1205 | height: 1, | ||
| 1206 | marginVertical: 12, | ||
| 1207 | }, | ||
| 1208 | }; | ||
| 1209 | |||
| 1210 | const createMarkdownRenderers = ( | ||
| 1211 | inlineTextColor: string, | ||
| 1212 | inlineCodeTextColor: string, | ||
| 1213 | blockBackgroundColor: string, | ||
| 1214 | blockTextColor: string, | ||
| 1215 | copyTintColor: ColorValue, | ||
| 1216 | preserveSoftBreaks: boolean, | ||
| 1217 | highlightCode: boolean, | ||
| 1218 | ): CustomRenderers => ({ | ||
| 1219 | link: ({ children, node, href = "" }) => { | ||
| 1220 | const presentation = resolveMarkdownLinkPresentation(href); | ||
| 1221 | if (presentation.kind === "file") { | ||
| 1222 | return ( | ||
| 1223 | <MarkdownLinkLabelContext.Provider value="file"> | ||
| 1224 | <NativeText onPress={() => onLinkPress(href)} style={{ color: inlineTextColor }}> | ||
| 1225 | {!isMarkdownFileLinkLabel(getTextContent(node), href) && <>{children} </>} | ||
| 1226 | <NativeText | ||
| 1227 | className="font-t3-bold" | ||
| 1228 | onPress={() => onLinkPress(href)} | ||
| 1229 | style={{ color: inlineTextColor }} | ||
| 1230 | > | ||
| 1231 | <Image | ||
| 1232 | source={markdownFileIconSource(presentation.icon)} | ||
| 1233 | style={markdownLinkStyles.inlineIcon} | ||
| 1234 | /> | ||
| 1235 | {presentation.label} | ||
| 1236 | </NativeText> | ||
| 1237 | </NativeText> | ||
| 1238 | </MarkdownLinkLabelContext.Provider> | ||
| 1239 | ); | ||
| 1240 | } | ||
| 1241 | if (presentation.kind === "external") { | ||
| 1242 | return ( | ||
| 1243 | <MarkdownLinkLabelContext.Provider value="other"> | ||
| 1244 | <MarkdownExternalLink | ||
| 1245 | href={presentation.href} | ||
| 1246 | host={presentation.host} | ||
| 1247 | color={markdownLinkColor} | ||
| 1248 | onPress={onLinkPress} | ||
| 1249 | > | ||
| 1250 | {children} | ||
| 1251 | </MarkdownExternalLink> | ||
| 1252 | </MarkdownLinkLabelContext.Provider> | ||
| 1253 | ); | ||
| 1254 | } | ||
| 1255 | const linkHref = presentation.href; | ||
| 1256 | // A thread link opens the thread in the app, through the feed's link handler. | ||
| 1257 | const isThreadLink = parseThreadLinkHref(href) !== null; | ||
| 1258 | return ( | ||
| 1259 | <MarkdownLinkLabelContext.Provider value="other"> | ||
| 1260 | <NativeText | ||
| 1261 | className="underline" | ||
| 1262 | onPress={ | ||
| 1263 | isThreadLink | ||
| 1264 | ? () => onLinkPress(href) | ||
| 1265 | : linkHref | ||
| 1266 | ? () => { | ||
| 1267 | void tryOpenExternalUrl(linkHref, "markdown-link"); | ||
| 1268 | } | ||
| 1269 | : undefined | ||
| 1270 | } | ||
| 1271 | style={{ color: markdownLinkColor }} | ||
| 1272 | > | ||
| 1273 | {children} | ||
| 1274 | </NativeText> | ||
| 1275 | </MarkdownLinkLabelContext.Provider> | ||
| 1276 | ); | ||
| 1277 | }, | ||
| 1278 | list: ({ node, Renderer, ordered = false, start = 1 }) => ( | ||
| 1279 | <View className="mt-0.5 mb-2"> | ||
| 1280 | {node.children?.map((child, index) => { | ||
| 1281 | const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`; | ||
| 1282 | if (child.type === "task_list_item") { | ||
| 1283 | return ( | ||
| 1284 | <Renderer key={childKey} node={child} depth={1} inListItem parentIsText={false} /> | ||
| 1285 | ); | ||
| 1286 | } | ||
| 1287 | return ( | ||
| 1288 | <View className="mb-[3px] flex-row items-start" key={childKey}> | ||
| 1289 | <NativeText | ||
| 1290 | className="font-sans" | ||
| 1291 | style={{ | ||
| 1292 | width: ordered ? 22 : 12, | ||
| 1293 | marginRight: 5, | ||
| 1294 | color: inlineTextColor, | ||
| 1295 | fontSize: markdownFontSizes.m, | ||
| 1296 | lineHeight: markdownFontSizes.bodyLineHeight, | ||
| 1297 | textAlign: ordered ? "right" : "center", | ||
| 1298 | }} | ||
| 1299 | > | ||
| 1300 | {ordered ? `${start + index}.` : "•"} | ||
| 1301 | </NativeText> | ||
| 1302 | <View className="min-w-0 flex-1 shrink overflow-hidden"> | ||
| 1303 | <Renderer node={child} depth={1} inListItem parentIsText={false} /> | ||
| 1304 | </View> | ||
| 1305 | </View> | ||
| 1306 | ); | ||
| 1307 | })} | ||
| 1308 | </View> | ||
| 1309 | ), | ||
| 1310 | image: ({ node }) => <MarkdownImage node={node} renderImage={renderImage} />, | ||
| 1311 | code_inline: ({ content }) => ( | ||
| 1312 | <MarkdownInlineCode | ||
| 1313 | content={content ?? ""} | ||
| 1314 | textColor={inlineTextColor} | ||
| 1315 | codeColor={inlineCodeTextColor} | ||
| 1316 | fontSize={markdownFontSizes.codeBlockFontSize} | ||
| 1317 | lineHeight={markdownFontSizes.bodyLineHeight} | ||
| 1318 | onLinkPress={onLinkPress} | ||
| 1319 | /> | ||
| 1320 | ), | ||
| 1321 | ...(preserveSoftBreaks | ||
| 1322 | ? { | ||
| 1323 | soft_break: () => <NativeText>{"\n"}</NativeText>, | ||
| 1324 | } | ||
| 1325 | : {}), | ||
| 1326 | code_block: ({ content = "", language }) => ( | ||
| 1327 | <MarkdownCodeBlock | ||
| 1328 | backgroundColor={blockBackgroundColor} | ||
| 1329 | borderColor={markdownHrColor} | ||
| 1330 | content={content} | ||
| 1331 | copyTintColor={copyTintColor} | ||
| 1332 | fontSize={markdownFontSizes.codeBlockFontSize} | ||
| 1333 | headerTextColor={blockTextColor} | ||
| 1334 | highlightCode={highlightCode} | ||
| 1335 | language={language} | ||
| 1336 | lineHeight={markdownFontSizes.codeBlockLineHeight} | ||
| 1337 | textColor={blockTextColor} | ||
| 1338 | theme={themeMode} | ||
| 1339 | /> | ||
| 1340 | ), | ||
| 1341 | }); | ||
| 1342 | |||
| 1343 | const userTheme: PartialMarkdownTheme = { | ||
| 1344 | ...baseTheme, | ||
| 1345 | colors: { | ||
| 1346 | ...baseTheme.colors, | ||
| 1347 | text: markdownUserBodyColor, | ||
| 1348 | heading: markdownUserBodyColor, | ||
| 1349 | link: markdownUserBodyColor, | ||
| 1350 | code: markdownUserCodeText, | ||
| 1351 | codeBackground: markdownUserCodeBg, | ||
| 1352 | border: markdownUserFenceBg, | ||
| 1353 | }, | ||
| 1354 | }; | ||
| 1355 | const userStyles: NodeStyleOverrides = { | ||
| 1356 | ...baseStyles, | ||
| 1357 | paragraph: { marginTop: 0, marginBottom: 0 }, | ||
| 1358 | bold: { | ||
| 1359 | fontWeight: "700", | ||
| 1360 | color: markdownUserBodyColor, | ||
| 1361 | fontFamily: boldFontFamily, | ||
| 1362 | }, | ||
| 1363 | heading: { | ||
| 1364 | ...baseStyles.heading, | ||
| 1365 | color: markdownUserBodyColor, | ||
| 1366 | marginTop: 8, | ||
| 1367 | marginBottom: 4, | ||
| 1368 | }, | ||
| 1369 | link: { | ||
| 1370 | color: markdownUserBodyColor, | ||
| 1371 | textDecorationLine: "underline" as const, | ||
| 1372 | }, | ||
| 1373 | }; | ||
| 1374 | |||
| 1375 | const assistantTheme: PartialMarkdownTheme = { | ||
| 1376 | ...baseTheme, | ||
| 1377 | colors: { | ||
| 1378 | ...baseTheme.colors, | ||
| 1379 | code: markdownCodeText, | ||
| 1380 | codeBackground: markdownCodeBg, | ||
| 1381 | border: markdownCodeBg, | ||
| 1382 | }, | ||
| 1383 | }; | ||
| 1384 | const assistantStyles: NodeStyleOverrides = { | ||
| 1385 | ...baseStyles, | ||
| 1386 | }; | ||
| 1387 | |||
| 1388 | return { | 836 | return { |
| 1389 | user: { | 837 | user: { |
| 1390 | theme: userTheme, | ||
| 1391 | styles: userStyles, | ||
| 1392 | renderers: createMarkdownRenderers( | ||
| 1393 | markdownUserCodeText, | ||
| 1394 | markdownUserInlineCodeText, | ||
| 1395 | markdownUserFenceBg, | ||
| 1396 | markdownUserFenceText, | ||
| 1397 | userBubbleForegroundMuted, | ||
| 1398 | true, | ||
| 1399 | false, | ||
| 1400 | ), | ||
| 1401 | nativeTextStyle: { | 838 | nativeTextStyle: { |
| 1402 | color: markdownUserBodyColor, | 839 | color: markdownUserBodyColor, |
| 1403 | strongColor: markdownUserBodyColor, | 840 | strongColor: markdownUserBodyColor, |
| ... | @@ -1421,17 +858,6 @@ function useMarkdownStyles( | ... | @@ -1421,17 +858,6 @@ function useMarkdownStyles( |
| 1421 | }, | 858 | }, |
| 1422 | }, | 859 | }, |
| 1423 | assistant: { | 860 | assistant: { |
| 1424 | theme: assistantTheme, | ||
| 1425 | styles: assistantStyles, | ||
| 1426 | renderers: createMarkdownRenderers( | ||
| 1427 | markdownCodeText, | ||
| 1428 | markdownInlineCodeText, | ||
| 1429 | markdownCodeBg, | ||
| 1430 | markdownCodeText, | ||
| 1431 | iconSubtleColor, | ||
| 1432 | false, | ||
| 1433 | true, | ||
| 1434 | ), | ||
| 1435 | nativeTextStyle: { | 861 | nativeTextStyle: { |
| 1436 | color: markdownBodyColor, | 862 | color: markdownBodyColor, |
| 1437 | strongColor: markdownStrongColor, | 863 | strongColor: markdownStrongColor, |
| ... | @@ -1458,14 +884,11 @@ function useMarkdownStyles( | ... | @@ -1458,14 +884,11 @@ function useMarkdownStyles( |
| 1458 | }, [ | 884 | }, [ |
| 1459 | boldFontFamily, | 885 | boldFontFamily, |
| 1460 | contextChipBorderColor, | 886 | contextChipBorderColor, |
| 1461 | iconSubtleColor, | ||
| 1462 | inlineSkillForeground, | 887 | inlineSkillForeground, |
| 1463 | markdownBlockquoteBg, | ||
| 1464 | markdownBlockquoteBorder, | 888 | markdownBlockquoteBorder, |
| 1465 | markdownBodyColor, | 889 | markdownBodyColor, |
| 1466 | markdownCodeBg, | 890 | markdownCodeBg, |
| 1467 | markdownCodeText, | 891 | markdownCodeText, |
| 1468 | markdownFontSizes, | ||
| 1469 | markdownHrColor, | 892 | markdownHrColor, |
| 1470 | markdownInlineCodeText, | 893 | markdownInlineCodeText, |
| 1471 | markdownLinkColor, | 894 | markdownLinkColor, |
| ... | @@ -1474,14 +897,9 @@ function useMarkdownStyles( | ... | @@ -1474,14 +897,9 @@ function useMarkdownStyles( |
| 1474 | markdownUserCodeBg, | 897 | markdownUserCodeBg, |
| 1475 | markdownUserCodeText, | 898 | markdownUserCodeText, |
| 1476 | markdownUserFenceBg, | 899 | markdownUserFenceBg, |
| 1477 | markdownUserFenceText, | ||
| 1478 | markdownUserInlineCodeText, | 900 | markdownUserInlineCodeText, |
| 1479 | nativeMarkdownTypography, | 901 | nativeMarkdownTypography, |
| 1480 | onLinkPress, | ||
| 1481 | renderImage, | ||
| 1482 | regularFontFamily, | 902 | regularFontFamily, |
| 1483 | themeMode, | ||
| 1484 | userBubbleForegroundMuted, | ||
| 1485 | userBubbleSkillForeground, | 903 | userBubbleSkillForeground, |
| 1486 | ]); | 904 | ]); |
| 1487 | } | 905 | } |
| ... | @@ -2077,10 +1495,58 @@ function UserMessageContent(props: UserMessageContentProps) { | ... | @@ -2077,10 +1495,58 @@ function UserMessageContent(props: UserMessageContentProps) { |
| 2077 | }; | 1495 | }; |
| 2078 | return ( | 1496 | return ( |
| 2079 | <> | 1497 | <> |
| 2080 | <LegacyUserMessageContent | 1498 | <UserMessageMarkdownContent |
| 2081 | {...props} | 1499 | {...props} |
| 2082 | text={text} | 1500 | text={text} |
| 2083 | linkHandlers={{ ...props.linkHandlers, onLinkPress }} | 1501 | linkHandlers={{ |
| 1502 | ...props.linkHandlers, | ||
| 1503 | onLinkPress, | ||
| 1504 | fileContextMenu: (href) => { | ||
| 1505 | const reference = parseComposerContextHref(href); | ||
| 1506 | if (!reference) return props.linkHandlers.fileContextMenu(href); | ||
| 1507 | const record = props.context?.records.find( | ||
| 1508 | (item) => item.contextId === reference.contextId, | ||
| 1509 | ); | ||
| 1510 | return { | ||
| 1511 | title: record?.label ?? "Context unavailable", | ||
| 1512 | actions: [ | ||
| 1513 | { id: "open-context", title: "Open context" }, | ||
| 1514 | { id: "copy-context", title: "Copy context", disabled: !record }, | ||
| 1515 | ], | ||
| 1516 | }; | ||
| 1517 | }, | ||
| 1518 | onFileContextMenuAction: (href, actionId) => { | ||
| 1519 | const reference = parseComposerContextHref(href); | ||
| 1520 | if (!reference) return props.linkHandlers.onFileContextMenuAction(href, actionId); | ||
| 1521 | if (actionId === "open-context") return onLinkPress(href); | ||
| 1522 | const record = props.context?.records.find( | ||
| 1523 | (item) => item.contextId === reference.contextId, | ||
| 1524 | ); | ||
| 1525 | if (actionId === "copy-context" && record) { | ||
| 1526 | void writeComposerContextClipboard( | ||
| 1527 | formatComposerContextReference({ | ||
| 1528 | ...reference, | ||
| 1529 | label: record.label, | ||
| 1530 | }), | ||
| 1531 | { | ||
| 1532 | version: 1, | ||
| 1533 | source: { environmentId: props.environmentId }, | ||
| 1534 | records: [record], | ||
| 1535 | }, | ||
| 1536 | ) | ||
| 1537 | .then(() => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)) | ||
| 1538 | .catch(() => { | ||
| 1539 | Alert.alert("Could not copy context", "Try copying this context again."); | ||
| 1540 | }); | ||
| 1541 | } | ||
| 1542 | }, | ||
| 1543 | linkCustomization: (href) => { | ||
| 1544 | const presentation = enrichedContextLinkPresentation(href, props.context?.records); | ||
| 1545 | return presentation | ||
| 1546 | ? { color: presentation.color, icon: markdownFileIconSource(presentation.icon) } | ||
| 1547 | : props.linkHandlers.linkCustomization?.(href); | ||
| 1548 | }, | ||
| 1549 | }} | ||
| 2084 | /> | 1550 | /> |
| 2085 | {selected ? ( | 1551 | {selected ? ( |
| 2086 | <ComposerContextSheet | 1552 | <ComposerContextSheet |
| ... | @@ -2095,12 +1561,10 @@ function UserMessageContent(props: UserMessageContentProps) { | ... | @@ -2095,12 +1561,10 @@ function UserMessageContent(props: UserMessageContentProps) { |
| 2095 | ); | 1561 | ); |
| 2096 | } | 1562 | } |
| 2097 | 1563 | ||
| 2098 | function LegacyUserMessageContent(props: UserMessageContentProps) { | 1564 | function UserMessageMarkdownContent(props: UserMessageContentProps) { |
| 2099 | const text = props.text; | 1565 | const text = props.text; |
| 2100 | const segments = parseReviewCommentMessageSegments(text); | 1566 | const segments = parseReviewCommentMessageSegments(text); |
| 2101 | const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); | 1567 | const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); |
| 2102 | // A message can hold both a review comment and context chips. The fragment travels with every | ||
| 2103 | // text run, so copying from the segmented branch carries the same context as the plain one. | ||
| 2104 | const contextClipboardFragment = props.context | 1568 | const contextClipboardFragment = props.context |
| 2105 | ? (encodeComposerContextFragment({ | 1569 | ? (encodeComposerContextFragment({ |
| 2106 | version: 1, | 1570 | version: 1, |
| ... | @@ -2109,28 +1573,16 @@ function LegacyUserMessageContent(props: UserMessageContentProps) { | ... | @@ -2109,28 +1573,16 @@ function LegacyUserMessageContent(props: UserMessageContentProps) { |
| 2109 | }) ?? undefined) | 1573 | }) ?? undefined) |
| 2110 | : undefined; | 1574 | : undefined; |
| 2111 | if (!hasReviewComment) { | 1575 | if (!hasReviewComment) { |
| 2112 | if (hasNativeSelectableMarkdownText()) { | ||
| 2113 | return ( | ||
| 2114 | <SelectableMarkdownText | ||
| 2115 | markdown={text} | ||
| 2116 | contextClipboardFragment={contextClipboardFragment} | ||
| 2117 | skills={props.skills} | ||
| 2118 | textStyle={props.markdownStyles.nativeTextStyle} | ||
| 2119 | preserveSoftBreaks | ||
| 2120 | {...props.linkHandlers} | ||
| 2121 | renderImage={props.renderImage} | ||
| 2122 | /> | ||
| 2123 | ); | ||
| 2124 | } | ||
| 2125 | return ( | 1576 | return ( |
| 2126 | <Markdown | 1577 | <SelectableMarkdownText |
| 2127 | options={{ gfm: true }} | 1578 | markdown={text} |
| 2128 | renderers={props.markdownStyles.renderers} | 1579 | contextClipboardFragment={contextClipboardFragment} |
| 2129 | styles={props.markdownStyles.styles} | 1580 | skills={props.skills} |
| 2130 | theme={props.markdownStyles.theme} | 1581 | textStyle={props.markdownStyles.nativeTextStyle} |
| 2131 | > | 1582 | preserveSoftBreaks |
| 2132 | {text} | 1583 | {...props.linkHandlers} |
| 2133 | </Markdown> | 1584 | renderImage={props.renderImage} |
| 1585 | /> | ||
| 2134 | ); | 1586 | ); |
| 2135 | } | 1587 | } |
| 2136 | 1588 | ||
| ... | @@ -2152,7 +1604,7 @@ function LegacyUserMessageContent(props: UserMessageContentProps) { | ... | @@ -2152,7 +1604,7 @@ function LegacyUserMessageContent(props: UserMessageContentProps) { |
| 2152 | return null; | 1604 | return null; |
| 2153 | } | 1605 | } |
| 2154 | 1606 | ||
| 2155 | return hasNativeSelectableMarkdownText() ? ( | 1607 | return ( |
| 2156 | <SelectableMarkdownText | 1608 | <SelectableMarkdownText |
| 2157 | key={segment.id} | 1609 | key={segment.id} |
| 2158 | markdown={text} | 1610 | markdown={text} |
| ... | @@ -2163,16 +1615,6 @@ function LegacyUserMessageContent(props: UserMessageContentProps) { | ... | @@ -2163,16 +1615,6 @@ function LegacyUserMessageContent(props: UserMessageContentProps) { |
| 2163 | {...props.linkHandlers} | 1615 | {...props.linkHandlers} |
| 2164 | renderImage={props.renderImage} | 1616 | renderImage={props.renderImage} |
| 2165 | /> | 1617 | /> |
| 2166 | ) : ( | ||
| 2167 | <Markdown | ||
| 2168 | key={segment.id} | ||
| 2169 | options={{ gfm: true }} | ||
| 2170 | renderers={props.markdownStyles.renderers} | ||
| 2171 | styles={props.markdownStyles.styles} | ||
| 2172 | theme={props.markdownStyles.theme} | ||
| 2173 | > | ||
| 2174 | {text} | ||
| 2175 | </Markdown> | ||
| 2176 | ); | 1618 | ); |
| 2177 | })} | 1619 | })} |
| 2178 | </View> | 1620 | </View> |
| ... | @@ -2333,7 +1775,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2333,7 +1775,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2333 | const screenColor = theme["--color-screen"]; | 1775 | const screenColor = theme["--color-screen"]; |
| 2334 | const userBubbleColor = theme["--color-user-bubble"]; | 1776 | const userBubbleColor = theme["--color-user-bubble"]; |
| 2335 | const onMarkdownLinkPress = useCallback( | 1777 | const onMarkdownLinkPress = useCallback( |
| 2336 | (href: string) => { | 1778 | (href: string, imageEmbed = false) => { |
| 2337 | // A thread link names a thread in this feed's environment. | 1779 | // A thread link names a thread in this feed's environment. |
| 2338 | const linkedThreadId = parseThreadLinkHref(href); | 1780 | const linkedThreadId = parseThreadLinkHref(href); |
| 2339 | if (linkedThreadId) { | 1781 | if (linkedThreadId) { |
| ... | @@ -2343,6 +1785,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2343,6 +1785,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2343 | }); | 1785 | }); |
| 2344 | return; | 1786 | return; |
| 2345 | } | 1787 | } |
| 1788 | const media = resolveMarkdownMediaPreview(href, { | ||
| 1789 | environmentId: props.environmentId, | ||
| 1790 | threadId: props.threadId, | ||
| 1791 | workspaceRoot: props.workspaceRoot, | ||
| 1792 | imageEmbed, | ||
| 1793 | }); | ||
| 1794 | if (media) { | ||
| 1795 | void Haptics.selectionAsync(); | ||
| 1796 | if (media.kind === "video") { | ||
| 1797 | setExpandedVideo((current) => current ?? media.source); | ||
| 1798 | } else { | ||
| 1799 | setExpandedFile((current) => current ?? media.source); | ||
| 1800 | } | ||
| 1801 | return; | ||
| 1802 | } | ||
| 1803 | |||
| 2346 | const presentation = resolveMarkdownLinkPresentation(href); | 1804 | const presentation = resolveMarkdownLinkPresentation(href); |
| 2347 | if (presentation.kind === "file") { | 1805 | if (presentation.kind === "file") { |
| 2348 | const relativePath = resolveWorkspaceRelativeFilePath( | 1806 | const relativePath = resolveWorkspaceRelativeFilePath( |
| ... | @@ -2351,22 +1809,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2351,22 +1809,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2351 | ); | 1809 | ); |
| 2352 | if (relativePath) { | 1810 | if (relativePath) { |
| 2353 | void Haptics.selectionAsync(); | 1811 | void Haptics.selectionAsync(); |
| 2354 | if (isPdfFile({ name: relativePath })) { | ||
| 2355 | setExpandedFile( | ||
| 2356 | (current) => | ||
| 2357 | current ?? { | ||
| 2358 | kind: "pdf", | ||
| 2359 | name: relativePath.split("/").at(-1), | ||
| 2360 | environmentId: props.environmentId, | ||
| 2361 | resource: { | ||
| 2362 | _tag: "workspace-file", | ||
| 2363 | threadId: props.threadId, | ||
| 2364 | path: relativePath, | ||
| 2365 | }, | ||
| 2366 | }, | ||
| 2367 | ); | ||
| 2368 | return; | ||
| 2369 | } | ||
| 2370 | navigation.navigate("ThreadFile", { | 1812 | navigation.navigate("ThreadFile", { |
| 2371 | environmentId: String(props.environmentId), | 1813 | environmentId: String(props.environmentId), |
| 2372 | threadId: String(props.threadId), | 1814 | threadId: String(props.threadId), |
| ... | @@ -2377,41 +1819,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2377,41 +1819,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2377 | } | 1819 | } |
| 2378 | } | 1820 | } |
| 2379 | 1821 | ||
| 2380 | const media = resolveMarkdownMediaPreview(href, { | ||
| 2381 | environmentId: props.environmentId, | ||
| 2382 | threadId: props.threadId, | ||
| 2383 | workspaceRoot: props.workspaceRoot, | ||
| 2384 | }); | ||
| 2385 | if (media) { | ||
| 2386 | void Haptics.selectionAsync(); | ||
| 2387 | if (media.kind === "video") { | ||
| 2388 | setExpandedVideo((current) => current ?? media.source); | ||
| 2389 | } else { | ||
| 2390 | setExpandedFile((current) => current ?? media.source); | ||
| 2391 | } | ||
| 2392 | return; | ||
| 2393 | } | ||
| 2394 | |||
| 2395 | // A host file outside the workspace, such as a report an agent wrote to | 1822 | // A host file outside the workspace, such as a report an agent wrote to |
| 2396 | // a temp directory, opens read-only in the file screen. | 1823 | // a temp directory, opens read-only in the file screen. |
| 2397 | if (presentation.kind === "file" && isAbsolutePath(presentation.path)) { | 1824 | if (presentation.kind === "file" && isAbsolutePath(presentation.path)) { |
| 2398 | void Haptics.selectionAsync(); | 1825 | void Haptics.selectionAsync(); |
| 2399 | if (isPdfFile({ name: presentation.path })) { | ||
| 2400 | setExpandedFile( | ||
| 2401 | (current) => | ||
| 2402 | current ?? { | ||
| 2403 | kind: "pdf", | ||
| 2404 | name: basename(presentation.path), | ||
| 2405 | environmentId: props.environmentId, | ||
| 2406 | resource: { | ||
| 2407 | _tag: "media-file", | ||
| 2408 | threadId: props.threadId, | ||
| 2409 | path: presentation.path, | ||
| 2410 | }, | ||
| 2411 | }, | ||
| 2412 | ); | ||
| 2413 | return; | ||
| 2414 | } | ||
| 2415 | navigation.navigate("ThreadFile", { | 1826 | navigation.navigate("ThreadFile", { |
| 2416 | environmentId: String(props.environmentId), | 1827 | environmentId: String(props.environmentId), |
| 2417 | threadId: String(props.threadId), | 1828 | threadId: String(props.threadId), |
| ... | @@ -2433,9 +1844,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2433,9 +1844,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2433 | }, | 1844 | }, |
| 2434 | [props.environmentId, props.threadId, props.workspaceRoot, navigation, openPullRequest], | 1845 | [props.environmentId, props.threadId, props.workspaceRoot, navigation, openPullRequest], |
| 2435 | ); | 1846 | ); |
| 1847 | const resolveImageSource = useMarkdownImageSource({ | ||
| 1848 | environmentId: props.environmentId, | ||
| 1849 | threadId: props.threadId, | ||
| 1850 | workspaceRoot: props.workspaceRoot ?? null, | ||
| 1851 | }); | ||
| 2436 | const markdownLinkHandlers = useMemo<MarkdownLinkHandlers>( | 1852 | const markdownLinkHandlers = useMemo<MarkdownLinkHandlers>( |
| 2437 | () => ({ | 1853 | () => ({ |
| 2438 | onLinkPress: onMarkdownLinkPress, | 1854 | onLinkPress: onMarkdownLinkPress, |
| 1855 | onImagePress: (href) => onMarkdownLinkPress(href, true), | ||
| 1856 | resolveImageSource, | ||
| 2439 | fileContextMenu: (href) => { | 1857 | fileContextMenu: (href) => { |
| 2440 | const target = resolveFileChipTarget(href, props.workspaceRoot); | 1858 | const target = resolveFileChipTarget(href, props.workspaceRoot); |
| 2441 | return target ? fileChipMenu(target) : undefined; | 1859 | return target ? fileChipMenu(target) : undefined; |
| ... | @@ -2459,7 +1877,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2459,7 +1877,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2459 | } | 1877 | } |
| 2460 | }, | 1878 | }, |
| 2461 | }), | 1879 | }), |
| 2462 | [onMarkdownLinkPress, props.workspaceRoot, shareFileChip], | 1880 | [onMarkdownLinkPress, props.workspaceRoot, shareFileChip, resolveImageSource], |
| 2463 | ); | 1881 | ); |
| 2464 | const renderMarkdownImage = useCallback<MarkdownImageRenderer>( | 1882 | const renderMarkdownImage = useCallback<MarkdownImageRenderer>( |
| 2465 | (image) => { | 1883 | (image) => { |
| ... | @@ -2483,6 +1901,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2483,6 +1901,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2483 | <ThreadMarkdownImageView | 1901 | <ThreadMarkdownImageView |
| 2484 | uri={normalizeNativeMarkdownUrl(imageSource.uri)} | 1902 | uri={normalizeNativeMarkdownUrl(imageSource.uri)} |
| 2485 | sourceKey={imageSource.uri} | 1903 | sourceKey={imageSource.uri} |
| 1904 | format={ | ||
| 1905 | /\.svg(?:[?#]|$)/i.test(imageSource.uri) || | ||
| 1906 | imageSource.uri.startsWith("data:image/svg+xml") | ||
| 1907 | ? "svg" | ||
| 1908 | : undefined | ||
| 1909 | } | ||
| 2486 | unavailable={false} | 1910 | unavailable={false} |
| 2487 | alt={image.alt} | 1911 | alt={image.alt} |
| 2488 | actionsSource={media?.source.actionsSource} | 1912 | actionsSource={media?.source.actionsSource} |
| ... | @@ -2542,7 +1966,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2542,7 +1966,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2542 | }, | 1966 | }, |
| 2543 | [props.environmentId, props.threadId, props.workspaceRoot], | 1967 | [props.environmentId, props.threadId, props.workspaceRoot], |
| 2544 | ); | 1968 | ); |
| 2545 | const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage); | 1969 | const markdownStyles = useMarkdownStyles(); |
| 2546 | const renderReasoning = useCallback( | 1970 | const renderReasoning = useCallback( |
| 2547 | (text: string) => ( | 1971 | (text: string) => ( |
| 2548 | <AssistantMarkdownContent | 1972 | <AssistantMarkdownContent |
apps/mobile/src/features/threads/ThreadMarkdownImage.tsx+81-16| ... | @@ -1,7 +1,9 @@ | ... | @@ -1,7 +1,9 @@ |
| 1 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | 1 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { Image } from "expo-image"; | 2 | import { Image } from "expo-image"; |
| 3 | import { createContext, useContext, useEffect, useId, useState } from "react"; | 3 | import { createContext, useContext, useEffect, useEffectEvent, useId, useState } from "react"; |
| 4 | import { Pressable, StyleSheet, View, type ViewStyle } from "react-native"; | 4 | import { fetch } from "expo/fetch"; |
| 5 | import { parse, SvgAst, type JsxAST } from "react-native-svg"; | ||
| 6 | import { ActivityIndicator, Pressable, StyleSheet, View, type ViewStyle } from "react-native"; | ||
| 5 | 7 | ||
| 6 | import { AppActivityIndicator } from "../../components/AppActivityIndicator"; | 8 | import { AppActivityIndicator } from "../../components/AppActivityIndicator"; |
| 7 | import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; | 9 | import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; |
| ... | @@ -10,12 +12,13 @@ import type { FilePreviewSource } from "../../components/FilePreviewModal"; | ... | @@ -10,12 +12,13 @@ import type { FilePreviewSource } from "../../components/FilePreviewModal"; |
| 10 | import { MediaActionsMenu } from "../../components/MediaActionsMenu"; | 12 | import { MediaActionsMenu } from "../../components/MediaActionsMenu"; |
| 11 | import { PresentationSource } from "../../components/NativePresentation"; | 13 | import { PresentationSource } from "../../components/NativePresentation"; |
| 12 | import { useMediaActions } from "../../state/mediaActions"; | 14 | import { useMediaActions } from "../../state/mediaActions"; |
| 13 | import { type MediaActionsSource } from "../../lib/mediaActionsSource"; | 15 | import type { MediaActionsSource } from "../../lib/mediaActionsSource"; |
| 14 | import { useAssetUrlState } from "../../state/assets"; | 16 | import { useAssetUrlState } from "../../state/assets"; |
| 15 | import { | 17 | import { |
| 16 | MARKDOWN_IMAGE_MAX_WIDTH, | 18 | MARKDOWN_IMAGE_MAX_WIDTH, |
| 17 | type MarkdownImageDisplaySize, | 19 | type MarkdownImageDisplaySize, |
| 18 | resolveMarkdownImageDisplaySize, | 20 | resolveMarkdownImageDisplaySize, |
| 21 | resolveSvgImageSize, | ||
| 19 | } from "./markdownImageSize"; | 22 | } from "./markdownImageSize"; |
| 20 | 23 | ||
| 21 | /** | 24 | /** |
| ... | @@ -35,6 +38,7 @@ export function ThreadMarkdownImageView(props: { | ... | @@ -35,6 +38,7 @@ export function ThreadMarkdownImageView(props: { |
| 35 | readonly alt: string | null; | 38 | readonly alt: string | null; |
| 36 | /** Pixel size from the server, when it could read the header; the frame is final from the first render. */ | 39 | /** Pixel size from the server, when it could read the header; the frame is final from the first render. */ |
| 37 | readonly knownSize?: { readonly width: number; readonly height: number } | undefined; | 40 | readonly knownSize?: { readonly width: number; readonly height: number } | undefined; |
| 41 | readonly format?: "svg"; | ||
| 38 | readonly actionsSource?: MediaActionsSource; | 42 | readonly actionsSource?: MediaActionsSource; |
| 39 | readonly onPressPreview: (source: FilePreviewSource) => void; | 43 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 40 | }) { | 44 | }) { |
| ... | @@ -126,6 +130,7 @@ export function ThreadMarkdownImageView(props: { | ... | @@ -126,6 +130,7 @@ export function ThreadMarkdownImageView(props: { |
| 126 | <ThreadMarkdownImageRequest | 130 | <ThreadMarkdownImageRequest |
| 127 | key={props.uri} | 131 | key={props.uri} |
| 128 | uri={props.uri} | 132 | uri={props.uri} |
| 133 | format={props.format} | ||
| 129 | onLoad={setDecodedSize} | 134 | onLoad={setDecodedSize} |
| 130 | onError={() => setFailedUri(props.uri)} | 135 | onError={() => setFailedUri(props.uri)} |
| 131 | /> | 136 | /> |
| ... | @@ -147,23 +152,35 @@ function ThreadMarkdownImageRequest(props: { | ... | @@ -147,23 +152,35 @@ function ThreadMarkdownImageRequest(props: { |
| 147 | readonly uri: string; | 152 | readonly uri: string; |
| 148 | readonly onLoad: (sourceSize: { width: number; height: number }) => void; | 153 | readonly onLoad: (sourceSize: { width: number; height: number }) => void; |
| 149 | readonly onError: () => void; | 154 | readonly onError: () => void; |
| 155 | readonly format?: "svg"; | ||
| 150 | }) { | 156 | }) { |
| 151 | const [loaded, setLoaded] = useState(false); | 157 | const [loaded, setLoaded] = useState(false); |
| 152 | 158 | ||
| 153 | return ( | 159 | return ( |
| 154 | <> | 160 | <> |
| 155 | <Image | 161 | {props.format === "svg" ? ( |
| 156 | source={{ uri: props.uri }} | 162 | <ThreadMarkdownSvg |
| 157 | contentFit="contain" | 163 | uri={props.uri} |
| 158 | cachePolicy="memory-disk" | 164 | onLoad={(size) => { |
| 159 | accessible={false} | 165 | setLoaded(true); |
| 160 | onLoad={(event) => { | 166 | if (size) props.onLoad(size); |
| 161 | setLoaded(true); | 167 | }} |
| 162 | props.onLoad(event.source); | 168 | onError={props.onError} |
| 163 | }} | 169 | /> |
| 164 | onError={props.onError} | 170 | ) : ( |
| 165 | style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} | 171 | <Image |
| 166 | /> | 172 | source={{ uri: props.uri }} |
| 173 | contentFit="contain" | ||
| 174 | cachePolicy="memory-disk" | ||
| 175 | accessible={false} | ||
| 176 | onLoad={(event) => { | ||
| 177 | setLoaded(true); | ||
| 178 | props.onLoad(event.source); | ||
| 179 | }} | ||
| 180 | onError={props.onError} | ||
| 181 | style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} | ||
| 182 | /> | ||
| 183 | )} | ||
| 167 | {loaded ? null : ( | 184 | {loaded ? null : ( |
| 168 | <View | 185 | <View |
| 169 | pointerEvents="none" | 186 | pointerEvents="none" |
| ... | @@ -176,12 +193,54 @@ function ThreadMarkdownImageRequest(props: { | ... | @@ -176,12 +193,54 @@ function ThreadMarkdownImageRequest(props: { |
| 176 | ); | 193 | ); |
| 177 | } | 194 | } |
| 178 | 195 | ||
| 196 | function ThreadMarkdownSvg(props: { | ||
| 197 | readonly uri: string; | ||
| 198 | readonly onLoad: (size: { width: number; height: number } | null) => void; | ||
| 199 | readonly onError: () => void; | ||
| 200 | }) { | ||
| 201 | const [ast, setAst] = useState<JsxAST | null>(null); | ||
| 202 | const onLoad = useEffectEvent(props.onLoad); | ||
| 203 | const onError = useEffectEvent(props.onError); | ||
| 204 | useEffect(() => { | ||
| 205 | const controller = new AbortController(); | ||
| 206 | const load = async () => { | ||
| 207 | if (props.uri.startsWith("data:image/svg+xml")) { | ||
| 208 | const comma = props.uri.indexOf(","); | ||
| 209 | if (comma < 0) throw new Error("SVG data URI has no content"); | ||
| 210 | const content = decodeURIComponent(props.uri.slice(comma + 1)); | ||
| 211 | return props.uri.slice(0, comma).includes(";base64") | ||
| 212 | ? new TextDecoder().decode( | ||
| 213 | Uint8Array.from(atob(content), (character) => character.charCodeAt(0)), | ||
| 214 | ) | ||
| 215 | : content; | ||
| 216 | } | ||
| 217 | const response = await fetch(props.uri, { signal: controller.signal }); | ||
| 218 | if (!response.ok && !(response.status === 0 && props.uri.startsWith("file://"))) | ||
| 219 | throw new Error(`SVG request failed: ${response.status}`); | ||
| 220 | return response.text(); | ||
| 221 | }; | ||
| 222 | void load() | ||
| 223 | .then((xml) => { | ||
| 224 | const parsed = parse(xml); | ||
| 225 | if (!parsed) throw new Error("SVG has no root element"); | ||
| 226 | if (controller.signal.aborted) return; | ||
| 227 | setAst(parsed); | ||
| 228 | onLoad(resolveSvgImageSize(parsed.props)); | ||
| 229 | }) | ||
| 230 | .catch(() => { | ||
| 231 | if (!controller.signal.aborted) onError(); | ||
| 232 | }); | ||
| 233 | return () => controller.abort(); | ||
| 234 | }, [props.uri]); | ||
| 235 | return <SvgAst ast={ast} override={{ width: "100%", height: "100%", accessible: false }} />; | ||
| 236 | } | ||
| 237 | |||
| 179 | /** Environment-hosted image that loads through a signed asset URL. */ | 238 | /** Environment-hosted image that loads through a signed asset URL. */ |
| 180 | export function ThreadMarkdownImage(props: { | 239 | export function ThreadMarkdownImage(props: { |
| 181 | readonly environmentId: EnvironmentId; | 240 | readonly environmentId: EnvironmentId; |
| 182 | readonly resource: Extract< | 241 | readonly resource: Extract< |
| 183 | AssetResource, | 242 | AssetResource, |
| 184 | { readonly _tag: "attachment" | "media-file" | "tool-output-image" } | 243 | { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" | "tool-output-image" } |
| 185 | >; | 244 | >; |
| 186 | readonly alt: string | null; | 245 | readonly alt: string | null; |
| 187 | readonly srcFragment?: string; | 246 | readonly srcFragment?: string; |
| ... | @@ -203,6 +262,12 @@ export function ThreadMarkdownImage(props: { | ... | @@ -203,6 +262,12 @@ export function ThreadMarkdownImage(props: { |
| 203 | } | 262 | } |
| 204 | unavailable={assetUrl._tag === "Failure"} | 263 | unavailable={assetUrl._tag === "Failure"} |
| 205 | knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined} | 264 | knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined} |
| 265 | format={ | ||
| 266 | (props.resource._tag === "media-file" || props.resource._tag === "draft-workspace-file") && | ||
| 267 | /\.svg$/i.test(props.resource.path) | ||
| 268 | ? "svg" | ||
| 269 | : undefined | ||
| 270 | } | ||
| 206 | alt={props.alt} | 271 | alt={props.alt} |
| 207 | actionsSource={props.actionsSource} | 272 | actionsSource={props.actionsSource} |
| 208 | onPressPreview={(source) => { | 273 | onPressPreview={(source) => { |
apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx created+45| ... | @@ -0,0 +1,45 @@ | ||
| 1 | import { createContext, useContext } from "react"; | ||
| 2 | import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; | ||
| 3 | import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; | ||
| 4 | import { | ||
| 5 | mediaVideoPreviewUri, | ||
| 6 | mediaVideoThumbnailKey, | ||
| 7 | type MediaVideoPreviewSource, | ||
| 8 | } from "../../lib/videoPreviewSource"; | ||
| 9 | |||
| 10 | export const ThreadMediaVisibleContext = createContext(false); | ||
| 11 | |||
| 12 | export function ThreadMarkdownVideo(props: { | ||
| 13 | readonly source: MediaVideoPreviewSource; | ||
| 14 | readonly thumbnailVisible?: boolean; | ||
| 15 | }) { | ||
| 16 | const { source } = props; | ||
| 17 | const feedVisible = useContext(ThreadMediaVisibleContext); | ||
| 18 | const visible = props.thumbnailVisible ?? feedVisible; | ||
| 19 | const thumbnailKey = mediaVideoThumbnailKey(source); | ||
| 20 | const asset = useAssetUrlState( | ||
| 21 | "environmentId" in source ? source.environmentId : null, | ||
| 22 | "resource" in source ? source.resource : null, | ||
| 23 | ); | ||
| 24 | const refreshAssetUrl = useRefreshAssetUrl( | ||
| 25 | "environmentId" in source ? source.environmentId : null, | ||
| 26 | "resource" in source ? source.resource : null, | ||
| 27 | ); | ||
| 28 | const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); | ||
| 29 | return ( | ||
| 30 | <MediaVideoPlayer | ||
| 31 | key={thumbnailKey} | ||
| 32 | uri={uri} | ||
| 33 | resolvePlaybackUri={ | ||
| 34 | "resource" in source | ||
| 35 | ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) | ||
| 36 | : undefined | ||
| 37 | } | ||
| 38 | name={source.name} | ||
| 39 | thumbnailKey={thumbnailKey} | ||
| 40 | thumbnailVisible={visible} | ||
| 41 | unavailable={"resource" in source && asset._tag === "Failure"} | ||
| 42 | actionsSource={source.actionsSource} | ||
| 43 | /> | ||
| 44 | ); | ||
| 45 | } | ||
apps/mobile/src/features/threads/fileChipMenu.ts+2-2| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { fileBasename } from "@t3tools/shared/path"; | 1 | import { fileBasename } from "@t3tools/shared/path"; |
| 2 | import type { ThreadId } from "@t3tools/contracts"; | 2 | import type { ThreadId } from "@t3tools/contracts"; |
| 3 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; | 3 | import { resolveMarkdownLinkPresentation } from "../../lib/markdownLinks"; |
| 4 | import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types"; | 4 | import type { MarkdownFileContextMenu } from "../../native/SelectableMarkdownText.types"; |
| 5 | import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview"; | 5 | import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview"; |
| 6 | 6 | ||
| 7 | import { | 7 | import { |
apps/mobile/src/features/threads/markdownImageSize.test.ts+18| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | MARKDOWN_IMAGE_MAX_HEIGHT, | 4 | MARKDOWN_IMAGE_MAX_HEIGHT, |
| 5 | MARKDOWN_IMAGE_MAX_WIDTH, | 5 | MARKDOWN_IMAGE_MAX_WIDTH, |
| 6 | resolveMarkdownImageDisplaySize, | 6 | resolveMarkdownImageDisplaySize, |
| 7 | resolveSvgImageSize, | ||
| 7 | } from "./markdownImageSize"; | 8 | } from "./markdownImageSize"; |
| 8 | 9 | ||
| 9 | describe("resolveMarkdownImageDisplaySize", () => { | 10 | describe("resolveMarkdownImageDisplaySize", () => { |
| ... | @@ -60,3 +61,20 @@ describe("resolveMarkdownImageDisplaySize", () => { | ... | @@ -60,3 +61,20 @@ describe("resolveMarkdownImageDisplaySize", () => { |
| 60 | ).toBeNull(); | 61 | ).toBeNull(); |
| 61 | }); | 62 | }); |
| 62 | }); | 63 | }); |
| 64 | |||
| 65 | describe("resolveSvgImageSize", () => { | ||
| 66 | it("uses pixel dimensions before the viewBox", () => { | ||
| 67 | expect(resolveSvgImageSize({ width: "32px", height: "48", viewBox: "0 0 100 100" })).toEqual({ | ||
| 68 | width: 32, | ||
| 69 | height: 48, | ||
| 70 | }); | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("uses the viewBox for responsive SVGs", () => { | ||
| 74 | expect( | ||
| 75 | resolveSvgImageSize({ width: "100%", height: "100%", viewBox: "-10, -20, 120, 240" }), | ||
| 76 | ).toEqual({ width: 120, height: 240 }); | ||
| 77 | expect(resolveSvgImageSize({ viewBox: "0 0 100 NaN" })).toBeNull(); | ||
| 78 | expect(resolveSvgImageSize({ viewBox: "0 0 0 100" })).toBeNull(); | ||
| 79 | }); | ||
| 80 | }); |
apps/mobile/src/features/threads/markdownImageSize.ts+28| ... | @@ -6,6 +6,34 @@ export interface MarkdownImageDisplaySize { | ... | @@ -6,6 +6,34 @@ export interface MarkdownImageDisplaySize { |
| 6 | readonly height: number; | 6 | readonly height: number; |
| 7 | } | 7 | } |
| 8 | 8 | ||
| 9 | /** SVG percentages need a viewport; use the intrinsic viewBox in that case. */ | ||
| 10 | export function resolveSvgImageSize(props: Readonly<Record<string, unknown>>) { | ||
| 11 | const dimension = (value: unknown) => { | ||
| 12 | if (typeof value === "number") return value; | ||
| 13 | if (typeof value !== "string" || !/^\s*\d+(?:\.\d+)?(?:px)?\s*$/.test(value)) return NaN; | ||
| 14 | return Number.parseFloat(value); | ||
| 15 | }; | ||
| 16 | const width = dimension(props.width); | ||
| 17 | const height = dimension(props.height); | ||
| 18 | if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) | ||
| 19 | return { width, height }; | ||
| 20 | const viewBox = | ||
| 21 | typeof props.viewBox === "string" | ||
| 22 | ? props.viewBox | ||
| 23 | .trim() | ||
| 24 | .split(/[\s,]+/) | ||
| 25 | .map(Number) | ||
| 26 | : []; | ||
| 27 | if ( | ||
| 28 | viewBox.length !== 4 || | ||
| 29 | !viewBox.every(Number.isFinite) || | ||
| 30 | viewBox[2]! <= 0 || | ||
| 31 | viewBox[3]! <= 0 | ||
| 32 | ) | ||
| 33 | return null; | ||
| 34 | return { width: viewBox[2]!, height: viewBox[3]! }; | ||
| 35 | } | ||
| 36 | |||
| 9 | /** Keeps small images intrinsic while fitting larger images inside the chat viewport. */ | 37 | /** Keeps small images intrinsic while fitting larger images inside the chat viewport. */ |
| 10 | export function resolveMarkdownImageDisplaySize(input: { | 38 | export function resolveMarkdownImageDisplaySize(input: { |
| 11 | readonly sourceWidth: number; | 39 | readonly sourceWidth: number; |
apps/mobile/src/features/threads/worktree-setup-card.tsx+1| ... | @@ -217,6 +217,7 @@ function SetupDetailsSheet({ | ... | @@ -217,6 +217,7 @@ function SetupDetailsSheet({ |
| 217 | return ( | 217 | return ( |
| 218 | <WorktreeSetupSheet height={bodyHeight} onClose={onClose}> | 218 | <WorktreeSetupSheet height={bodyHeight} onClose={onClose}> |
| 219 | <ScrollView | 219 | <ScrollView |
| 220 | contentInsetAdjustmentBehavior="automatic" | ||
| 220 | bounces={false} | 221 | bounces={false} |
| 221 | onContentSizeChange={(_width, height) => setBodyHeight(height)} | 222 | onContentSizeChange={(_width, height) => setBodyHeight(height)} |
| 222 | contentContainerStyle={{ | 223 | contentContainerStyle={{ |
apps/mobile/src/features/threads/worktree-setup-sheet.tsx+6-56| ... | @@ -1,15 +1,5 @@ | ... | @@ -1,15 +1,5 @@ |
| 1 | import { useState, type ReactElement } from "react"; | 1 | import type { ReactElement } from "react"; |
| 2 | import { Modal, View } from "react-native"; | 2 | import { AppSheet } from "../../components/AppSheet"; |
| 3 | import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; | ||
| 4 | import { withUniwind } from "uniwind"; | ||
| 5 | import { ContextSheetSize } from "../../components/ContextSheetSize"; | ||
| 6 | |||
| 7 | const NativeScreen = withUniwind(Screen); | ||
| 8 | const NativeHeader = withUniwind(ScreenStackHeaderConfig, { | ||
| 9 | backgroundColor: { fromClassName: "backgroundColorClassName", styleProperty: "backgroundColor" }, | ||
| 10 | color: { fromClassName: "tintColorClassName", styleProperty: "accentColor" }, | ||
| 11 | titleColor: { fromClassName: "titleColorClassName", styleProperty: "accentColor" }, | ||
| 12 | }); | ||
| 13 | 3 | ||
| 14 | export interface WorktreeSetupSheetProps { | 4 | export interface WorktreeSetupSheetProps { |
| 15 | children: ReactElement; | 5 | children: ReactElement; |
| ... | @@ -17,50 +7,10 @@ export interface WorktreeSetupSheetProps { | ... | @@ -17,50 +7,10 @@ export interface WorktreeSetupSheetProps { |
| 17 | onClose: () => void; | 7 | onClose: () => void; |
| 18 | } | 8 | } |
| 19 | 9 | ||
| 20 | export function WorktreeSetupSheet({ children, height, onClose }: WorktreeSetupSheetProps) { | 10 | export function WorktreeSetupSheet({ children, onClose }: WorktreeSetupSheetProps) { |
| 21 | const [headerHeight, setHeaderHeight] = useState(44); | ||
| 22 | return ( | 11 | return ( |
| 23 | <Modal | 12 | <AppSheet title="Worktree setup" onClose={onClose}> |
| 24 | animationType="slide" | 13 | {children} |
| 25 | presentationStyle="pageSheet" | 14 | </AppSheet> |
| 26 | allowSwipeDismissal | ||
| 27 | onRequestClose={onClose} | ||
| 28 | > | ||
| 29 | <View collapsable={false} className="flex-1 bg-sheet-solid"> | ||
| 30 | <ContextSheetSize height={height + headerHeight} /> | ||
| 31 | {/* The nested stack supplies UIKit's navigation bar inside the sheet. */} | ||
| 32 | <ScreenStack style={{ flex: 1 }}> | ||
| 33 | <NativeScreen | ||
| 34 | activityState={2} | ||
| 35 | enabled | ||
| 36 | isNativeStack | ||
| 37 | screenId="worktree-setup-details" | ||
| 38 | onHeaderHeightChange={(event) => setHeaderHeight(event.nativeEvent.headerHeight)} | ||
| 39 | className="flex-1 bg-sheet-solid" | ||
| 40 | > | ||
| 41 | {children} | ||
| 42 | <NativeHeader | ||
| 43 | title="Worktree setup" | ||
| 44 | titleColorClassName="accent-foreground" | ||
| 45 | tintColorClassName="accent-foreground" | ||
| 46 | backgroundColorClassName="bg-sheet-solid" | ||
| 47 | hideBackButton | ||
| 48 | hideShadow | ||
| 49 | translucent={false} | ||
| 50 | headerRightBarButtonItems={[ | ||
| 51 | { | ||
| 52 | type: "button", | ||
| 53 | title: "Done", | ||
| 54 | variant: "done", | ||
| 55 | accessibilityLabel: "Close setup details", | ||
| 56 | identifier: "worktree-setup-done", | ||
| 57 | onPress: onClose, | ||
| 58 | }, | ||
| 59 | ]} | ||
| 60 | /> | ||
| 61 | </NativeScreen> | ||
| 62 | </ScreenStack> | ||
| 63 | </View> | ||
| 64 | </Modal> | ||
| 65 | ); | 15 | ); |
| 66 | } | 16 | } |
apps/mobile/src/lib/composerChipPresentation.test.ts created+85| ... | @@ -0,0 +1,85 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | describe("composerChipSizeSuffix", () => { | ||
| 4 | it("labels attachment records with a human size, matching web's chip", async () => { | ||
| 5 | const { composerChipSizeSuffix } = await import("./composerChipPresentation"); | ||
| 6 | expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 1024 })).toBe("1 KB"); | ||
| 7 | expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 3_700_000 })).toBe("3.5 MB"); | ||
| 8 | expect(composerChipSizeSuffix({ kind: "image", sizeBytes: 2048 })).toBe("2 KB"); | ||
| 9 | }); | ||
| 10 | |||
| 11 | it("adds nothing for records that carry no bytes", async () => { | ||
| 12 | const { composerChipSizeSuffix } = await import("./composerChipPresentation"); | ||
| 13 | // Terminal/review/PR chips have no size to show. | ||
| 14 | expect(composerChipSizeSuffix({ kind: "terminal" })).toBe(""); | ||
| 15 | expect(composerChipSizeSuffix({ kind: "file" })).toBe(""); | ||
| 16 | expect(composerChipSizeSuffix(undefined)).toBe(""); | ||
| 17 | }); | ||
| 18 | }); | ||
| 19 | |||
| 20 | describe("contextChipPresentation image detection", () => { | ||
| 21 | it("treats a picture attached through the file picker as an image", async () => { | ||
| 22 | const { contextChipPresentation } = await import("./composerChipPresentation"); | ||
| 23 | // The document picker types every pick as `file`, so the name has to carry the intent. | ||
| 24 | expect( | ||
| 25 | contextChipPresentation("file", { kind: "file", name: "IMG_4997.PNG", mimeType: "" }), | ||
| 26 | ).toEqual({ accent: "#d55665", symbol: "photo" }); | ||
| 27 | expect( | ||
| 28 | contextChipPresentation("file", { | ||
| 29 | kind: "file", | ||
| 30 | name: "shot", | ||
| 31 | mimeType: "image/jpeg", | ||
| 32 | }), | ||
| 33 | ).toEqual({ accent: "#d55665", symbol: "photo" }); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("leaves genuine documents and videos alone", async () => { | ||
| 37 | const { contextChipPresentation } = await import("./composerChipPresentation"); | ||
| 38 | expect( | ||
| 39 | contextChipPresentation("file", { kind: "file", name: "notes.txt", mimeType: "text/plain" }), | ||
| 40 | ).toEqual({ accent: "#0090cd", symbol: "doc" }); | ||
| 41 | expect( | ||
| 42 | contextChipPresentation("file", { kind: "file", name: "clip.mp4", mimeType: "video/mp4" }), | ||
| 43 | ).toEqual({ accent: "#d06217", symbol: "play.rectangle" }); | ||
| 44 | }); | ||
| 45 | }); | ||
| 46 | |||
| 47 | describe("pull request chip status", () => { | ||
| 48 | const chip = async (state: string, isDraft = false) => { | ||
| 49 | const { contextChipPresentation } = await import("./composerChipPresentation"); | ||
| 50 | return contextChipPresentation("review-comment", { | ||
| 51 | kind: "review-comment", | ||
| 52 | sectionId: "pull-request:10978", | ||
| 53 | pullRequest: { state, isDraft }, | ||
| 54 | }); | ||
| 55 | }; | ||
| 56 | |||
| 57 | it("colours a pull request by its state, the way web and the forge do", async () => { | ||
| 58 | expect((await chip("open")).accent).toBe("#009f6e"); | ||
| 59 | expect((await chip("open", true)).accent).toBe("#7f8793"); | ||
| 60 | expect((await chip("merged")).accent).toBe("#8a70dd"); | ||
| 61 | expect((await chip("closed")).accent).toBe("#d55665"); | ||
| 62 | }); | ||
| 63 | |||
| 64 | it("keeps one glyph across every state, so only colour carries the status", async () => { | ||
| 65 | // Web draws a fixed `git-pull-request` and encodes state in colour alone. A per-state glyph | ||
| 66 | // here would put mobile out of step with it. | ||
| 67 | const symbols = await Promise.all( | ||
| 68 | [chip("open"), chip("open", true), chip("merged"), chip("closed")].map( | ||
| 69 | async (pending) => (await pending).symbol, | ||
| 70 | ), | ||
| 71 | ); | ||
| 72 | expect(new Set(symbols)).toEqual(new Set(["git-pull-request"])); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("falls back to the generic pull request chip when the state is unknown", async () => { | ||
| 76 | const { contextChipPresentation } = await import("./composerChipPresentation"); | ||
| 77 | // An older server may send no metadata at all; the chip still has to render. | ||
| 78 | expect( | ||
| 79 | contextChipPresentation("review-comment", { | ||
| 80 | kind: "review-comment", | ||
| 81 | sectionId: "pull-request:1", | ||
| 82 | }).accent, | ||
| 83 | ).toBe("#7079e4"); | ||
| 84 | }); | ||
| 85 | }); | ||
apps/mobile/src/lib/composerChipPresentation.ts created+87| ... | @@ -0,0 +1,87 @@ | ||
| 1 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; | ||
| 2 | import { imageMimeType } from "@t3tools/shared/image"; | ||
| 3 | import { videoMimeType } from "@t3tools/shared/video"; | ||
| 4 | /** | ||
| 5 | * Every accent shares a lightness so no kind reads heavier than another; only hue carries | ||
| 6 | * identity. These are the sRGB form of the same OKLCH set web uses, so a chip looks the | ||
| 7 | * same on every surface. See `composerInlineChip.ts`. | ||
| 8 | */ | ||
| 9 | const CONTEXT_CHIP_PRESENTATIONS = { | ||
| 10 | image: { accent: "#d55665", symbol: "photo" }, | ||
| 11 | video: { accent: "#d06217", symbol: "play.rectangle" }, | ||
| 12 | file: { accent: "#0090cd", symbol: "doc" }, | ||
| 13 | mention: { accent: "#0096af", symbol: "doc" }, | ||
| 14 | terminal: { accent: "#009f6e", symbol: "terminal" }, | ||
| 15 | element: { accent: "#b87501", symbol: "cursorarrow.click" }, | ||
| 16 | "preview-annotation": { accent: "#b87501", symbol: "cursorarrow.click" }, | ||
| 17 | "review-comment": { accent: "#8a70dd", symbol: "text.bubble" }, | ||
| 18 | "pull-request": { accent: "#7079e4", symbol: "git-pull-request" }, | ||
| 19 | skill: { accent: "#b261be", symbol: "cube" }, | ||
| 20 | } as const; | ||
| 21 | |||
| 22 | /** | ||
| 23 | * The size an attachment chip reports beside its name, matching web. Rendered as its own | ||
| 24 | * smaller run, so it carries no separator. Only attachment-backed records have bytes. | ||
| 25 | */ | ||
| 26 | export function composerChipSizeSuffix(record?: { | ||
| 27 | readonly kind?: string; | ||
| 28 | readonly sizeBytes?: number; | ||
| 29 | }): string { | ||
| 30 | if (record?.kind !== "file" && record?.kind !== "image") return ""; | ||
| 31 | return typeof record.sizeBytes === "number" ? formatAttachmentSize(record.sizeBytes) : ""; | ||
| 32 | } | ||
| 33 | |||
| 34 | /** | ||
| 35 | * A pull request chip is coloured by what the pull request *is*, the way web colours it and | ||
| 36 | * the way the forge itself does: green open, grey draft, purple merged, red closed. The glyph | ||
| 37 | * stays the same across all four, as it does on web — state is carried by colour alone. | ||
| 38 | */ | ||
| 39 | const PULL_REQUEST_CHIP_PRESENTATIONS = { | ||
| 40 | open: { accent: "#009f6e", symbol: "git-pull-request" }, | ||
| 41 | draft: { accent: "#7f8793", symbol: "git-pull-request" }, | ||
| 42 | merged: { accent: "#8a70dd", symbol: "git-pull-request" }, | ||
| 43 | closed: { accent: "#d55665", symbol: "git-pull-request" }, | ||
| 44 | } as const; | ||
| 45 | |||
| 46 | export function contextChipPresentation( | ||
| 47 | kind: string, | ||
| 48 | record?: { | ||
| 49 | readonly kind?: string; | ||
| 50 | readonly name?: string; | ||
| 51 | readonly mimeType?: string; | ||
| 52 | readonly sectionId?: string; | ||
| 53 | readonly pullRequest?: { | ||
| 54 | readonly state?: string; | ||
| 55 | readonly isDraft?: boolean; | ||
| 56 | }; | ||
| 57 | }, | ||
| 58 | ) { | ||
| 59 | const presentationKind = | ||
| 60 | kind === "file" && | ||
| 61 | videoMimeType({ | ||
| 62 | name: record?.name ?? "", | ||
| 63 | mimeType: record?.mimeType ?? "", | ||
| 64 | }) | ||
| 65 | ? "video" | ||
| 66 | : // A picture chosen through the file picker is typed `file`, but it is still a | ||
| 67 | // picture: it reads as one to the user and should not wear the generic file chip. | ||
| 68 | kind === "file" && | ||
| 69 | imageMimeType({ name: record?.name ?? "", mimeType: record?.mimeType ?? "" }) !== null | ||
| 70 | ? "image" | ||
| 71 | : kind === "review-comment" && record?.sectionId?.startsWith("pull-request:") | ||
| 72 | ? "pull-request" | ||
| 73 | : kind; | ||
| 74 | if (presentationKind === "pull-request") { | ||
| 75 | const pullRequest = record?.pullRequest; | ||
| 76 | const state = | ||
| 77 | pullRequest?.state === "open" && pullRequest.isDraft === true | ||
| 78 | ? "draft" | ||
| 79 | : (pullRequest?.state ?? ""); | ||
| 80 | if (Object.hasOwn(PULL_REQUEST_CHIP_PRESENTATIONS, state)) { | ||
| 81 | return PULL_REQUEST_CHIP_PRESENTATIONS[state as keyof typeof PULL_REQUEST_CHIP_PRESENTATIONS]; | ||
| 82 | } | ||
| 83 | } | ||
| 84 | return Object.hasOwn(CONTEXT_CHIP_PRESENTATIONS, presentationKind) | ||
| 85 | ? CONTEXT_CHIP_PRESENTATIONS[presentationKind as keyof typeof CONTEXT_CHIP_PRESENTATIONS] | ||
| 86 | : CONTEXT_CHIP_PRESENTATIONS.file; | ||
| 87 | } | ||
apps/mobile/src/lib/enrichedLinkPresentation.test.ts created+112| ... | @@ -0,0 +1,112 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import type { ComposerContextId, ReviewCommentContextRecord } from "@t3tools/contracts"; | ||
| 3 | import { | ||
| 4 | enrichedContextLinkPresentation, | ||
| 5 | enrichedLinkVariantPattern, | ||
| 6 | enrichedSkillLinkRegex, | ||
| 7 | enrichedSkillReference, | ||
| 8 | ENRICHED_INLINE_FILE_LINK_REGEX, | ||
| 9 | } from "./enrichedLinkPresentation"; | ||
| 10 | |||
| 11 | describe("Enriched link presentation", () => { | ||
| 12 | it("recognizes file-shaped code without turning commands or URLs into links", () => { | ||
| 13 | for (const path of [ | ||
| 14 | "src/app.tsx", | ||
| 15 | "./README.md", | ||
| 16 | "/tmp/report.md", | ||
| 17 | "C:\\repo\\app.tsx", | ||
| 18 | "app.tsx:12:3", | ||
| 19 | ]) { | ||
| 20 | expect(ENRICHED_INLINE_FILE_LINK_REGEX.test(path), path).toBe(true); | ||
| 21 | } | ||
| 22 | for (const text of [ | ||
| 23 | "npm run dev", | ||
| 24 | "port:3000", | ||
| 25 | "TODO:12", | ||
| 26 | "example.com", | ||
| 27 | "https://example.com/app.tsx", | ||
| 28 | "x + y", | ||
| 29 | "README.md", | ||
| 30 | "/app/settings", | ||
| 31 | ]) { | ||
| 32 | expect(ENRICHED_INLINE_FILE_LINK_REGEX.test(text), text).toBe(false); | ||
| 33 | } | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("only recognizes complete known skill names and escapes their punctuation", () => { | ||
| 37 | const regex = enrichedSkillLinkRegex([{ name: "check" }, { name: "check.mobile" }]); | ||
| 38 | expect(regex?.exec("Try $check.mobile next")?.[0]).toBe("$check.mobile"); | ||
| 39 | expect(regex?.exec("Try $check-mobile next")).toBeNull(); | ||
| 40 | expect(regex?.exec("Try $checkXmobile next")).toBeNull(); | ||
| 41 | expect(regex?.exec("prefix$check next")).toBeNull(); | ||
| 42 | expect(enrichedSkillLinkRegex([])).toBeNull(); | ||
| 43 | }); | ||
| 44 | it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( | ||
| 45 | "recognizes and presents known skills with %s while rejecting adjacent tokens", | ||
| 46 | (sigil) => { | ||
| 47 | const skills = [{ name: "check", displayName: "Check" }]; | ||
| 48 | const regex = enrichedSkillLinkRegex(skills)!; | ||
| 49 | expect(regex.exec(`Try ${sigil}check now`)?.[0]).toBe(`${sigil}check`); | ||
| 50 | expect(enrichedSkillReference(`${sigil}check`, skills)).toBe(skills[0]); | ||
| 51 | expect(regex.test(`prefix${sigil}check`)).toBe(false); | ||
| 52 | expect(regex.test(`${sigil}check-extra`)).toBe(false); | ||
| 53 | expect(regex.test(`${sigil}unknown`)).toBe(false); | ||
| 54 | expect(regex.test(`${sigil}${sigil}check`)).toBe(false); | ||
| 55 | }, | ||
| 56 | ); | ||
| 57 | it("matches only the complete URL, including literal regex metacharacters", () => { | ||
| 58 | const url = "file:///workspace/app[1].tsx?line=3+4"; | ||
| 59 | const pattern = new RegExp(enrichedLinkVariantPattern(url)); | ||
| 60 | expect(pattern.test(url)).toBe(true); | ||
| 61 | expect(pattern.test(`${url}/child`)).toBe(false); | ||
| 62 | expect(pattern.test("file:///workspace/app1Xtsx?line=34")).toBe(false); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("uses context identity for PR status even when labels are identical", () => { | ||
| 66 | const record: ReviewCommentContextRecord = { | ||
| 67 | version: 1, | ||
| 68 | contextId: "review_1" as ComposerContextId, | ||
| 69 | kind: "review-comment", | ||
| 70 | label: "Review", | ||
| 71 | sectionId: "pull-request:7", | ||
| 72 | sectionTitle: "Review", | ||
| 73 | filePath: "app.tsx", | ||
| 74 | startIndex: 0, | ||
| 75 | endIndex: 1, | ||
| 76 | rangeLabel: "line 1", | ||
| 77 | text: "Comment", | ||
| 78 | diff: "", | ||
| 79 | pullRequest: { | ||
| 80 | number: 7, | ||
| 81 | title: "Review", | ||
| 82 | url: "https://github.com/example/repo/pull/7", | ||
| 83 | headBranch: "feature", | ||
| 84 | baseBranch: "main", | ||
| 85 | state: "merged", | ||
| 86 | isDraft: false, | ||
| 87 | }, | ||
| 88 | }; | ||
| 89 | expect( | ||
| 90 | enrichedContextLinkPresentation("t3-context://v1/review-comment/review_1", [record]), | ||
| 91 | ).toEqual({ color: "#8a70dd", icon: "git" }); | ||
| 92 | expect( | ||
| 93 | enrichedContextLinkPresentation("t3-context://v1/review-comment/review_2", [record]), | ||
| 94 | ).toEqual({ color: "#8a70dd", icon: "markdown" }); | ||
| 95 | }); | ||
| 96 | |||
| 97 | it("retains recognizable presentation for unavailable and future context kinds", () => { | ||
| 98 | expect(enrichedContextLinkPresentation("t3-context://v1/terminal/missing")).toEqual({ | ||
| 99 | color: "#009f6e", | ||
| 100 | icon: "bash", | ||
| 101 | }); | ||
| 102 | expect(enrichedContextLinkPresentation("t3-context://v1/skill/missing")).toEqual({ | ||
| 103 | color: "#b261be", | ||
| 104 | icon: "mcp", | ||
| 105 | }); | ||
| 106 | expect(enrichedContextLinkPresentation("t3-context://v1/future/missing")).toEqual({ | ||
| 107 | color: "#0090cd", | ||
| 108 | icon: "default", | ||
| 109 | }); | ||
| 110 | expect(enrichedContextLinkPresentation("https://example.com/terminal/missing")).toBeUndefined(); | ||
| 111 | }); | ||
| 112 | }); | ||
apps/mobile/src/lib/enrichedLinkPresentation.ts created+69| ... | @@ -0,0 +1,69 @@ | ||
| 1 | import type { ComposerContextRecord } from "@t3tools/contracts"; | ||
| 2 | import { contextChipPresentation } from "./composerChipPresentation"; | ||
| 3 | import type { MarkdownFileIcon } from "./markdownLinks"; | ||
| 4 | import type { SelectableMarkdownSkill } from "../native/SelectableMarkdownText.types"; | ||
| 5 | import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; | ||
| 6 | |||
| 7 | const CONTEXT_ICONS: Readonly<Record<string, MarkdownFileIcon>> = { | ||
| 8 | photo: "image", | ||
| 9 | "play.rectangle": "video", | ||
| 10 | terminal: "bash", | ||
| 11 | "cursorarrow.click": "react", | ||
| 12 | "text.bubble": "markdown", | ||
| 13 | "git-pull-request": "git", | ||
| 14 | cube: "mcp", | ||
| 15 | doc: "default", | ||
| 16 | }; | ||
| 17 | |||
| 18 | /** Context identity comes from the URL, even when different chips share a label. */ | ||
| 19 | export function enrichedContextLinkPresentation( | ||
| 20 | href: string, | ||
| 21 | records?: ReadonlyArray<ComposerContextRecord>, | ||
| 22 | ) { | ||
| 23 | const reference = parseComposerContextHref(href); | ||
| 24 | if (!reference) return undefined; | ||
| 25 | const record = records?.find((candidate) => candidate.contextId === reference.contextId); | ||
| 26 | const presentation = contextChipPresentation(reference.kind, record); | ||
| 27 | return { | ||
| 28 | color: presentation.accent, | ||
| 29 | icon: CONTEXT_ICONS[presentation.symbol] ?? "default", | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | |||
| 33 | export function enrichedLinkVariantPattern(url: string) { | ||
| 34 | return `^${url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`; | ||
| 35 | } | ||
| 36 | |||
| 37 | /** Conservative recognition for Enriched's whole-span inline-code link hook. */ | ||
| 38 | export const ENRICHED_INLINE_FILE_LINK_REGEX = | ||
| 39 | /^(?!\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+)?)$/; | ||
| 40 | |||
| 41 | export function enrichedSkillLinkRegex(skills?: ReadonlyArray<SelectableMarkdownSkill>) { | ||
| 42 | const names = [...new Set(skills?.map((skill) => skill.name) ?? [])].filter(Boolean); | ||
| 43 | if (names.length === 0) return null; | ||
| 44 | names.sort((a, b) => b.length - a.length); | ||
| 45 | const alternatives = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); | ||
| 46 | return new RegExp( | ||
| 47 | `(?<![A-Za-z0-9_-])(?<!\\p{Sc})\\p{Sc}(?:${alternatives.join("|")})(?![A-Za-z0-9_-])`, | ||
| 48 | "u", | ||
| 49 | ); | ||
| 50 | } | ||
| 51 | |||
| 52 | export function enrichedSkillDisplayName(skill: SelectableMarkdownSkill) { | ||
| 53 | return ( | ||
| 54 | skill.displayName?.trim() || | ||
| 55 | skill.name | ||
| 56 | .split(/[\s:_-]+/) | ||
| 57 | .filter(Boolean) | ||
| 58 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) | ||
| 59 | .join(" ") | ||
| 60 | ); | ||
| 61 | } | ||
| 62 | |||
| 63 | export function enrichedSkillReference( | ||
| 64 | url: string, | ||
| 65 | skills?: ReadonlyArray<SelectableMarkdownSkill>, | ||
| 66 | ) { | ||
| 67 | const match = /^\p{Sc}(.+)$/u.exec(url); | ||
| 68 | return match ? skills?.find((skill) => skill.name === match[1]) : undefined; | ||
| 69 | } | ||
apps/mobile/src/lib/markdownDocument.ts created+146| ... | @@ -0,0 +1,146 @@ | ||
| 1 | import type { Root, RootContent } from "mdast"; | ||
| 2 | import { unified } from "unified"; | ||
| 3 | import remarkParse from "remark-parse"; | ||
| 4 | import remarkGfm from "remark-gfm"; | ||
| 5 | |||
| 6 | const parser = unified().use(remarkParse).use(remarkGfm); | ||
| 7 | export type MarkdownNode = Root | RootContent; | ||
| 8 | |||
| 9 | function decodeCodePoint(codePoint: number, entity: string): string { | ||
| 10 | if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { | ||
| 11 | return entity; | ||
| 12 | } | ||
| 13 | return String.fromCodePoint(codePoint); | ||
| 14 | } | ||
| 15 | |||
| 16 | function decodeHtmlEntities(value: string): string { | ||
| 17 | return value.replace( | ||
| 18 | /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi, | ||
| 19 | (entity, decimal: string | undefined, hexadecimal: string | undefined) => { | ||
| 20 | if (decimal) { | ||
| 21 | return decodeCodePoint(Number.parseInt(decimal, 10), entity); | ||
| 22 | } | ||
| 23 | if (hexadecimal) { | ||
| 24 | return decodeCodePoint(Number.parseInt(hexadecimal, 16), entity); | ||
| 25 | } | ||
| 26 | switch (entity.toLowerCase()) { | ||
| 27 | case "&amp;": | ||
| 28 | return "&"; | ||
| 29 | case "&apos;": | ||
| 30 | return "'"; | ||
| 31 | case "&gt;": | ||
| 32 | return ">"; | ||
| 33 | case "&lt;": | ||
| 34 | return "<"; | ||
| 35 | case "&nbsp;": | ||
| 36 | return "\u00a0"; | ||
| 37 | case "&quot;": | ||
| 38 | return '"'; | ||
| 39 | default: | ||
| 40 | return entity; | ||
| 41 | } | ||
| 42 | }, | ||
| 43 | ); | ||
| 44 | } | ||
| 45 | |||
| 46 | const HTML_TAG = /<!--[\s\S]*?-->|<\/?[a-z](?:[^>"']|"[^"]*"|'[^']*')*>/gi; | ||
| 47 | |||
| 48 | function attribute(tag: string, name: string): string | null { | ||
| 49 | const attributes = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g; | ||
| 50 | for (const match of tag.matchAll(attributes)) { | ||
| 51 | if (match[1]?.toLowerCase() === name) { | ||
| 52 | return decodeHtmlEntities(match[2] ?? match[3] ?? match[4] ?? ""); | ||
| 53 | } | ||
| 54 | } | ||
| 55 | return null; | ||
| 56 | } | ||
| 57 | |||
| 58 | /** Called only for parsed HTML nodes, so Markdown code examples keep their literal tags. */ | ||
| 59 | export function markdownHtmlContent(content: string): string { | ||
| 60 | // An HTML block such as <details> can also contain Markdown fences and code spans. | ||
| 61 | const codeRanges: Array<{ start: number; end: number }> = []; | ||
| 62 | const tags = [...content.matchAll(HTML_TAG)]; | ||
| 63 | let tagIndex = 0; | ||
| 64 | const delimiters = [...content.matchAll(/`+|~{3,}/g)].filter((delimiter) => { | ||
| 65 | while (tags[tagIndex] && tags[tagIndex]!.index + tags[tagIndex]![0].length <= delimiter.index) | ||
| 66 | tagIndex += 1; | ||
| 67 | const tag = tags[tagIndex]; | ||
| 68 | return !tag || delimiter.index < tag.index; | ||
| 69 | }); | ||
| 70 | for (let index = 0; index < delimiters.length; index += 1) { | ||
| 71 | const opening = delimiters[index]!; | ||
| 72 | const lineStart = content.lastIndexOf("\n", opening.index - 1) + 1; | ||
| 73 | const fenced = | ||
| 74 | opening[0].length >= 3 && /^ {0,3}$/.test(content.slice(lineStart, opening.index)); | ||
| 75 | if (opening[0][0] === "~" && !fenced) continue; | ||
| 76 | let closingIndex = -1; | ||
| 77 | for (let candidate = index + 1; candidate < delimiters.length; candidate += 1) { | ||
| 78 | const closing = delimiters[candidate]!; | ||
| 79 | if (!fenced && closing[0] === opening[0]) { | ||
| 80 | closingIndex = candidate; | ||
| 81 | break; | ||
| 82 | } | ||
| 83 | if (!fenced || closing[0][0] !== opening[0][0] || closing[0].length < opening[0].length) | ||
| 84 | continue; | ||
| 85 | const start = content.lastIndexOf("\n", closing.index - 1) + 1; | ||
| 86 | const nextLine = content.indexOf("\n", closing.index); | ||
| 87 | const end = nextLine === -1 ? content.length : nextLine; | ||
| 88 | if ( | ||
| 89 | /^ {0,3}$/.test(content.slice(start, closing.index)) && | ||
| 90 | /^\s*$/.test(content.slice(closing.index + closing[0].length, end)) | ||
| 91 | ) { | ||
| 92 | closingIndex = candidate; | ||
| 93 | break; | ||
| 94 | } | ||
| 95 | } | ||
| 96 | if (closingIndex === -1) { | ||
| 97 | if (fenced) codeRanges.push({ start: opening.index, end: content.length }); | ||
| 98 | continue; | ||
| 99 | } | ||
| 100 | const closing = delimiters[closingIndex]!; | ||
| 101 | codeRanges.push({ start: opening.index, end: closing.index + closing[0].length }); | ||
| 102 | index = closingIndex; | ||
| 103 | } | ||
| 104 | return content.replace(HTML_TAG, (tag, offset: number) => { | ||
| 105 | if (codeRanges.some((range) => offset >= range.start && offset < range.end)) return tag; | ||
| 106 | if (tag.startsWith("<!--")) return ""; | ||
| 107 | if (/^<img\b/i.test(tag)) { | ||
| 108 | const src = attribute(tag, "src"); | ||
| 109 | if (!src) return ""; | ||
| 110 | const alt = (attribute(tag, "alt") ?? "").replace(/[\\[\]&]/g, "\\$&"); | ||
| 111 | const href = src | ||
| 112 | .replace(/&/g, "\\&") | ||
| 113 | .replace(/</g, "%3C") | ||
| 114 | .replace(/>/g, "%3E") | ||
| 115 | .replace(/\n/g, ""); | ||
| 116 | return ``; | ||
| 117 | } | ||
| 118 | if (/^<summary\b/i.test(tag)) return "\n\n**"; | ||
| 119 | if (/^<\/summary\s*>/i.test(tag)) return "**\n\n"; | ||
| 120 | if (/^<br\b/i.test(tag)) return " \n"; | ||
| 121 | if (/^<\/?(?:p|div|details)\b/i.test(tag)) return "\n\n"; | ||
| 122 | return ""; | ||
| 123 | }); | ||
| 124 | } | ||
| 125 | |||
| 126 | /** Convert parsed HTML fragments only, preserving Markdown code and source around them. */ | ||
| 127 | export function markdownWithHtml(markdown: string): string { | ||
| 128 | if (!/<(?:!--|\/?[a-z])/i.test(markdown)) return markdown; | ||
| 129 | const edits: Array<{ start: number; end: number; content: string }> = []; | ||
| 130 | const visit = (node: MarkdownNode) => { | ||
| 131 | if (node.type === "html") { | ||
| 132 | const start = node.position?.start.offset; | ||
| 133 | const end = node.position?.end.offset; | ||
| 134 | if (start !== undefined && end !== undefined) | ||
| 135 | edits.push({ start, end, content: markdownHtmlContent(node.value) }); | ||
| 136 | } else if ("children" in node) node.children.forEach(visit); | ||
| 137 | }; | ||
| 138 | visit(parser.parse(markdown)); | ||
| 139 | for (const edit of edits.reverse()) | ||
| 140 | markdown = markdown.slice(0, edit.start) + edit.content + markdown.slice(edit.end); | ||
| 141 | return markdown; | ||
| 142 | } | ||
| 143 | |||
| 144 | export function parseMarkdownDocument(markdown: string): Root { | ||
| 145 | return parser.parse(markdownWithHtml(markdown)); | ||
| 146 | } | ||
apps/mobile/src/lib/markdownFileIcons.generated.ts created+60| ... | @@ -0,0 +1,60 @@ | ||
| 1 | import type { ImageSourcePropType } from "react-native"; | ||
| 2 | |||
| 3 | export const MARKDOWN_FILE_ICON_SOURCES = { | ||
| 4 | agents: require("../../assets/file-icons/pierre_agents.png"), | ||
| 5 | astro: require("../../assets/file-icons/pierre_astro.png"), | ||
| 6 | babel: require("../../assets/file-icons/pierre_babel.png"), | ||
| 7 | bash: require("../../assets/file-icons/pierre_bash.png"), | ||
| 8 | biome: require("../../assets/file-icons/pierre_biome.png"), | ||
| 9 | bootstrap: require("../../assets/file-icons/pierre_bootstrap.png"), | ||
| 10 | browserslist: require("../../assets/file-icons/pierre_browserslist.png"), | ||
| 11 | bun: require("../../assets/file-icons/pierre_bun.png"), | ||
| 12 | c: require("../../assets/file-icons/pierre_c.png"), | ||
| 13 | claude: require("../../assets/file-icons/pierre_claude.png"), | ||
| 14 | cpp: require("../../assets/file-icons/pierre_cpp.png"), | ||
| 15 | css: require("../../assets/file-icons/pierre_css.png"), | ||
| 16 | database: require("../../assets/file-icons/pierre_database.png"), | ||
| 17 | default: require("../../assets/file-icons/pierre_default.png"), | ||
| 18 | docker: require("../../assets/file-icons/pierre_docker.png"), | ||
| 19 | eslint: require("../../assets/file-icons/pierre_eslint.png"), | ||
| 20 | font: require("../../assets/file-icons/pierre_font.png"), | ||
| 21 | git: require("../../assets/file-icons/pierre_git.png"), | ||
| 22 | go: require("../../assets/file-icons/pierre_go.png"), | ||
| 23 | graphql: require("../../assets/file-icons/pierre_graphql.png"), | ||
| 24 | html: require("../../assets/file-icons/pierre_html.png"), | ||
| 25 | image: require("../../assets/file-icons/pierre_image.png"), | ||
| 26 | javascript: require("../../assets/file-icons/pierre_javascript.png"), | ||
| 27 | json: require("../../assets/file-icons/pierre_json.png"), | ||
| 28 | markdown: require("../../assets/file-icons/pierre_markdown.png"), | ||
| 29 | mcp: require("../../assets/file-icons/pierre_mcp.png"), | ||
| 30 | nextjs: require("../../assets/file-icons/pierre_nextjs.png"), | ||
| 31 | npm: require("../../assets/file-icons/pierre_npm.png"), | ||
| 32 | oxc: require("../../assets/file-icons/pierre_oxc.png"), | ||
| 33 | pnpm: require("../../assets/file-icons/pierre_pnpm.png"), | ||
| 34 | postcss: require("../../assets/file-icons/pierre_postcss.png"), | ||
| 35 | prettier: require("../../assets/file-icons/pierre_prettier.png"), | ||
| 36 | python: require("../../assets/file-icons/pierre_python.png"), | ||
| 37 | react: require("../../assets/file-icons/pierre_react.png"), | ||
| 38 | ruby: require("../../assets/file-icons/pierre_ruby.png"), | ||
| 39 | rust: require("../../assets/file-icons/pierre_rust.png"), | ||
| 40 | sass: require("../../assets/file-icons/pierre_sass.png"), | ||
| 41 | stylelint: require("../../assets/file-icons/pierre_stylelint.png"), | ||
| 42 | svelte: require("../../assets/file-icons/pierre_svelte.png"), | ||
| 43 | svg: require("../../assets/file-icons/pierre_svg.png"), | ||
| 44 | svgo: require("../../assets/file-icons/pierre_svgo.png"), | ||
| 45 | swift: require("../../assets/file-icons/pierre_swift.png"), | ||
| 46 | table: require("../../assets/file-icons/pierre_table.png"), | ||
| 47 | tailwind: require("../../assets/file-icons/pierre_tailwind.png"), | ||
| 48 | terraform: require("../../assets/file-icons/pierre_terraform.png"), | ||
| 49 | text: require("../../assets/file-icons/pierre_text.png"), | ||
| 50 | typescript: require("../../assets/file-icons/pierre_typescript.png"), | ||
| 51 | video: require("../../assets/file-icons/pierre_video.png"), | ||
| 52 | vite: require("../../assets/file-icons/pierre_vite.png"), | ||
| 53 | vscode: require("../../assets/file-icons/pierre_vscode.png"), | ||
| 54 | vue: require("../../assets/file-icons/pierre_vue.png"), | ||
| 55 | wasm: require("../../assets/file-icons/pierre_wasm.png"), | ||
| 56 | webpack: require("../../assets/file-icons/pierre_webpack.png"), | ||
| 57 | yml: require("../../assets/file-icons/pierre_yml.png"), | ||
| 58 | zig: require("../../assets/file-icons/pierre_zig.png"), | ||
| 59 | zip: require("../../assets/file-icons/pierre_zip.png"), | ||
| 60 | } as const satisfies Readonly<Record<string, ImageSourcePropType>>; | ||
apps/mobile/src/lib/markdownFileIcons.ts created+13| ... | @@ -0,0 +1,13 @@ | ||
| 1 | import { Image, type ImageSourcePropType } from "react-native"; | ||
| 2 | |||
| 3 | import type { MarkdownFileIcon } from "./markdownLinks"; | ||
| 4 | import { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; | ||
| 5 | |||
| 6 | export function markdownFileIconSource(icon: MarkdownFileIcon): ImageSourcePropType { | ||
| 7 | return MARKDOWN_FILE_ICON_SOURCES[icon]; | ||
| 8 | } | ||
| 9 | |||
| 10 | /** URI for bundled icons rendered by native views. */ | ||
| 11 | export function markdownIconAssetUri(source: ImageSourcePropType): string | undefined { | ||
| 12 | return Image.resolveAssetSource(source)?.uri; | ||
| 13 | } | ||
apps/mobile/src/lib/markdownHtml.test.ts+46-52| ... | @@ -1,10 +1,6 @@ | ... | @@ -1,10 +1,6 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | 2 | import { markdownHtmlContent, markdownWithHtml, parseMarkdownDocument } from "./markdownDocument"; |
| 3 | import { | 3 | import { markdownImageSources } from "./markdownImageSources"; |
| 4 | markdownHtmlContent, | ||
| 5 | nativeMarkdownWithHtml, | ||
| 6 | nativeMarkdownDocumentChunks, | ||
| 7 | } from "@t3tools/mobile-markdown-text/markdown"; | ||
| 8 | 4 | ||
| 9 | describe("HTML in native Markdown", () => { | 5 | describe("HTML in native Markdown", () => { |
| 10 | it("leaves code inside an HTML details block literal", () => { | 6 | it("leaves code inside an HTML details block literal", () => { |
| ... | @@ -20,7 +16,7 @@ describe("HTML in native Markdown", () => { | ... | @@ -20,7 +16,7 @@ describe("HTML in native Markdown", () => { |
| 20 | markdownHtmlContent( | 16 | markdownHtmlContent( |
| 21 | '<img width="900" alt="Before > after [settings]" src="https://example.com/before.png?a=1&amp;b=2" />', | 17 | '<img width="900" alt="Before > after [settings]" src="https://example.com/before.png?a=1&amp;b=2" />', |
| 22 | ), | 18 | ), |
| 23 | ).toBe("![Before > after \\[settings\\]](<https://example.com/before.png?a=1&b=2>)"); | 19 | ).toBe("![Before > after \\[settings\\]](<https://example.com/before.png?a=1\\&b=2>)"); |
| 24 | expect( | 20 | expect( |
| 25 | markdownHtmlContent( | 21 | markdownHtmlContent( |
| 26 | "<a href='https://example.com'><img src='https://example.com/banner.svg' alt='Review' /></a>", | 22 | "<a href='https://example.com'><img src='https://example.com/banner.svg' alt='Review' /></a>", |
| ... | @@ -28,52 +24,50 @@ describe("HTML in native Markdown", () => { | ... | @@ -28,52 +24,50 @@ describe("HTML in native Markdown", () => { |
| 28 | ).toBe(""); | 24 | ).toBe(""); |
| 29 | }); | 25 | }); |
| 30 | 26 | ||
| 31 | it("removes bot comments and empty details wrappers without leaving display chunks", () => { | 27 | it("decodes HTML attributes once and preserves entity-shaped query values through Markdown", () => { |
| 32 | const document: MarkdownNode = { | 28 | const markdown = |
| 33 | type: "document", | 29 | '<img alt="&amp;lt;" src="https://example.com/shot.png?label=&amp;lt;&amp;sig=a%26b">'; |
| 34 | children: [ | 30 | expect(markdownWithHtml(markdown)).toBe( |
| 35 | { type: "html_block", content: "<!-- generated bot metadata -->" }, | 31 | "", |
| 36 | { type: "html_block", content: "<details>" }, | 32 | ); |
| 37 | { type: "heading", level: 2, children: [{ type: "text", content: "Walkthrough" }] }, | 33 | expect(markdownImageSources(markdown)).toEqual([ |
| 38 | { type: "html_block", content: "</details>" }, | 34 | "https://example.com/shot.png?label=&lt;&sig=a%26b", |
| 39 | ], | 35 | ]); |
| 40 | }; | 36 | const paragraph = parseMarkdownDocument(markdown).children[0]; |
| 41 | const result = nativeMarkdownWithHtml(document, () => { | 37 | expect(paragraph?.type).toBe("paragraph"); |
| 42 | throw new Error("Empty HTML must not be parsed"); | 38 | if (paragraph?.type === "paragraph") |
| 43 | }); | 39 | expect(paragraph.children[0]).toMatchObject({ type: "image", alt: "&lt;" }); |
| 44 | expect(result.children).toEqual([document.children![2]]); | ||
| 45 | expect(nativeMarkdownDocumentChunks(result)).toHaveLength(1); | ||
| 46 | expect(markdownHtmlContent("<summary>Walkthrough</summary>").trim()).toBe("**Walkthrough**"); | ||
| 47 | }); | 40 | }); |
| 48 | 41 | ||
| 49 | it("keeps literal HTML code examples and gives HTML image fragments unique chunk keys", () => { | 42 | it("ignores code delimiters in HTML attributes and comments when collecting screenshots", () => { |
| 50 | const code: MarkdownNode = { | 43 | const markdown = |
| 51 | type: "code_block", | 44 | '<details data-example="`">\n<summary>Proof</summary>\n<img src="https://example.com/shot.png" alt="Screenshot">\n<!-- ` -->\n</details>'; |
| 52 | content: '<img src="literal.png">', | 45 | expect(markdownHtmlContent(markdown)).toContain( |
| 53 | language: "html", | 46 | "", |
| 54 | }; | 47 | ); |
| 55 | const document: MarkdownNode = { | 48 | expect(markdownImageSources(markdown)).toEqual(["https://example.com/shot.png"]); |
| 56 | type: "document", | 49 | expect(markdownWithHtml(markdown)).not.toContain("<img"); |
| 57 | children: [ | 50 | }); |
| 58 | code, | 51 | |
| 59 | { type: "html_block", content: '<img src="one.png">' }, | 52 | it("removes bot metadata and details wrappers while keeping the content", () => { |
| 60 | { type: "html_block", content: '<img src="two.png">' }, | 53 | const markdown = |
| 61 | ], | 54 | "<!-- generated bot metadata -->\n<details>\n<summary>Walkthrough</summary>\n\n## Changes\n\nText\n\n</details>"; |
| 62 | }; | 55 | const result = markdownWithHtml(markdown); |
| 63 | const result = nativeMarkdownWithHtml(document, (content) => ({ | 56 | expect(result).not.toContain("<!--"); |
| 64 | type: "document", | 57 | expect(result).not.toContain("<details>"); |
| 65 | children: [ | 58 | expect(result).toContain("**Walkthrough**"); |
| 66 | { | 59 | expect(result).toContain("## Changes"); |
| 67 | type: "paragraph", | 60 | expect(parseMarkdownDocument(markdown).children.some((node) => node.type === "html")).toBe( |
| 68 | beg: 0, | 61 | false, |
| 69 | end: content.length, | 62 | ); |
| 70 | children: [{ type: "image", href: content, beg: 0 }], | 63 | }); |
| 71 | }, | 64 | |
| 72 | ], | 65 | it("collects HTML and Markdown screenshots while leaving inline and fenced code literal", () => { |
| 73 | })); | 66 | const markdown = |
| 74 | expect(result.children![0]).toBe(code); | 67 | '```html\n<img src="literal.png">\n```\n\nUse `<img src="inline.png">` here.\n\n<img src="one.png">\n\n\n\n<img src="one.png">'; |
| 75 | const keys = nativeMarkdownDocumentChunks(result).map((chunk) => chunk.key); | 68 | expect(markdownImageSources(markdown)).toEqual(["one.png", "two.png"]); |
| 76 | expect(new Set(keys).size).toBe(keys.length); | 69 | const result = markdownWithHtml(markdown); |
| 77 | expect(result.children![1]?.beg).toBeUndefined(); | 70 | expect(result).toContain('```html\n<img src="literal.png">\n```'); |
| 71 | expect(result).toContain('`<img src="inline.png">`'); | ||
| 78 | }); | 72 | }); |
| 79 | }); | 73 | }); |
apps/mobile/src/lib/markdownImageSources.ts+4-10| ... | @@ -1,18 +1,12 @@ | ... | @@ -1,18 +1,12 @@ |
| 1 | import { nativeMarkdownWithHtml } from "@t3tools/mobile-markdown-text/markdown"; | 1 | import { parseMarkdownDocument, type MarkdownNode } from "./markdownDocument"; |
| 2 | import { parseMarkdownWithOptions, type MarkdownNode } from "react-native-nitro-markdown/headless"; | ||
| 3 | 2 | ||
| 4 | export function markdownImageSources(markdown: string): string[] { | 3 | export function markdownImageSources(markdown: string): string[] { |
| 5 | if (!markdown.includes("![") && !/<img[\s>]/i.test(markdown)) return []; | 4 | if (!markdown.includes("![") && !/<img[\s>]/i.test(markdown)) return []; |
| 6 | const sources = new Set<string>(); | 5 | const sources = new Set<string>(); |
| 7 | const visit = (node: MarkdownNode) => { | 6 | const visit = (node: MarkdownNode) => { |
| 8 | if (node.type === "image" && node.href) sources.add(node.href); | 7 | if (node.type === "image") sources.add(node.url); |
| 9 | node.children?.forEach(visit); | 8 | if ("children" in node) node.children.forEach(visit); |
| 10 | }; | 9 | }; |
| 11 | visit( | 10 | visit(parseMarkdownDocument(markdown)); |
| 12 | nativeMarkdownWithHtml( | ||
| 13 | parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false }), | ||
| 14 | (text) => parseMarkdownWithOptions(text, { gfm: true, html: false, math: false }), | ||
| 15 | ), | ||
| 16 | ); | ||
| 17 | return [...sources]; | 11 | return [...sources]; |
| 18 | } | 12 | } |
apps/mobile/src/lib/markdownLinkIcons.ts created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | import type { ImageSourcePropType } from "react-native"; | ||
| 2 | |||
| 3 | import type { MarkdownLinkIcon } from "./markdownLinks"; | ||
| 4 | |||
| 5 | // Black-on-transparent marks; callers tint them with the link color. | ||
| 6 | const MARKDOWN_LINK_ICON_SOURCES = { | ||
| 7 | github: require("../../assets/link-icons/github.png"), | ||
| 8 | } as const satisfies Readonly<Record<MarkdownLinkIcon, ImageSourcePropType>>; | ||
| 9 | |||
| 10 | export function markdownLinkIconSource(icon: MarkdownLinkIcon): ImageSourcePropType { | ||
| 11 | return MARKDOWN_LINK_ICON_SOURCES[icon]; | ||
| 12 | } | ||
apps/mobile/src/lib/markdownLinks.test.ts+6-4| ... | @@ -1,9 +1,6 @@ | ... | @@ -1,9 +1,6 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation } from "./markdownLinks"; |
| 4 | resolveMarkdownLinkIcon, | ||
| 5 | resolveMarkdownLinkPresentation, | ||
| 6 | } from "@t3tools/mobile-markdown-text/links"; | ||
| 7 | 4 | ||
| 8 | describe("resolveMarkdownLinkIcon", () => { | 5 | describe("resolveMarkdownLinkIcon", () => { |
| 9 | it("gives GitHub hosts the brand mark and everything else the generic glyph", () => { | 6 | it("gives GitHub hosts the brand mark and everything else the generic glyph", () => { |
| ... | @@ -17,6 +14,11 @@ describe("resolveMarkdownLinkIcon", () => { | ... | @@ -17,6 +14,11 @@ describe("resolveMarkdownLinkIcon", () => { |
| 17 | }); | 14 | }); |
| 18 | 15 | ||
| 19 | describe("resolveMarkdownLinkPresentation", () => { | 16 | describe("resolveMarkdownLinkPresentation", () => { |
| 17 | it("keeps thread-link hrefs for in-app navigation", () => { | ||
| 18 | const href = "t3-thread://v1/thread-1"; | ||
| 19 | expect(resolveMarkdownLinkPresentation(href)).toEqual({ kind: "link", href }); | ||
| 20 | }); | ||
| 21 | |||
| 20 | it("treats protocol-relative media as an external URL, not a filesystem path", () => { | 22 | it("treats protocol-relative media as an external URL, not a filesystem path", () => { |
| 21 | expect(resolveMarkdownLinkPresentation("//cdn.example.com/clip.mp4?sig=a%2fb#t=2")).toEqual({ | 23 | expect(resolveMarkdownLinkPresentation("//cdn.example.com/clip.mp4?sig=a%2fb#t=2")).toEqual({ |
| 22 | kind: "external", | 24 | kind: "external", |
apps/mobile/src/lib/markdownLinks.ts created+313| ... | @@ -0,0 +1,313 @@ | ||
| 1 | import { fileBasename } from "@t3tools/shared/path"; | ||
| 2 | import { formatFilePathPosition } from "@t3tools/shared/fileLinks"; | ||
| 3 | import { | ||
| 4 | inlineCodeFilePathCandidate, | ||
| 5 | normalizeMarkdownLinkDestination, | ||
| 6 | parseMarkdownFileLink, | ||
| 7 | } from "@t3tools/shared/markdownLinks"; | ||
| 8 | import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; | ||
| 9 | import { videoMimeType } from "@t3tools/shared/video"; | ||
| 10 | |||
| 11 | import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; | ||
| 12 | |||
| 13 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | ||
| 14 | |||
| 15 | export type MarkdownLinkPresentation = | ||
| 16 | | { | ||
| 17 | readonly kind: "external"; | ||
| 18 | readonly href: string; | ||
| 19 | readonly host: string; | ||
| 20 | } | ||
| 21 | | { | ||
| 22 | readonly kind: "file"; | ||
| 23 | readonly href: string; | ||
| 24 | readonly icon: MarkdownFileIcon; | ||
| 25 | readonly label: string; | ||
| 26 | readonly path: string; | ||
| 27 | readonly line?: number; | ||
| 28 | readonly column?: number; | ||
| 29 | } | ||
| 30 | | { | ||
| 31 | readonly kind: "link"; | ||
| 32 | readonly href: string | null; | ||
| 33 | }; | ||
| 34 | |||
| 35 | export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; | ||
| 36 | |||
| 37 | export type MarkdownLinkIcon = "github"; | ||
| 38 | |||
| 39 | /** | ||
| 40 | * Sites whose brand mark replaces the generic external-link glyph. The marks | ||
| 41 | * are monochrome and tinted with the link color, so they follow the theme. | ||
| 42 | */ | ||
| 43 | export function resolveMarkdownLinkIcon(host: string): MarkdownLinkIcon | null { | ||
| 44 | const hostname = host.toLowerCase(); | ||
| 45 | if (hostname === "github.com" || hostname.endsWith(".github.com")) return "github"; | ||
| 46 | return null; | ||
| 47 | } | ||
| 48 | |||
| 49 | const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = { | ||
| 50 | ".babelrc": "babel", | ||
| 51 | ".babelrc.json": "babel", | ||
| 52 | ".bash_profile": "bash", | ||
| 53 | ".bashrc": "bash", | ||
| 54 | ".browserslistrc": "browserslist", | ||
| 55 | ".dockerignore": "docker", | ||
| 56 | ".eslintignore": "eslint", | ||
| 57 | ".eslintrc": "eslint", | ||
| 58 | ".eslintrc.cjs": "eslint", | ||
| 59 | ".eslintrc.js": "eslint", | ||
| 60 | ".eslintrc.json": "eslint", | ||
| 61 | ".eslintrc.yaml": "eslint", | ||
| 62 | ".eslintrc.yml": "eslint", | ||
| 63 | ".gitattributes": "git", | ||
| 64 | ".gitignore": "git", | ||
| 65 | ".gitkeep": "git", | ||
| 66 | ".gitmodules": "git", | ||
| 67 | ".oxlintrc.json": "oxc", | ||
| 68 | ".postcssrc": "postcss", | ||
| 69 | ".postcssrc.json": "postcss", | ||
| 70 | ".postcssrc.yaml": "postcss", | ||
| 71 | ".postcssrc.yml": "postcss", | ||
| 72 | ".prettierignore": "prettier", | ||
| 73 | ".prettierrc": "prettier", | ||
| 74 | ".prettierrc.json": "prettier", | ||
| 75 | ".prettierrc.cjs": "prettier", | ||
| 76 | ".prettierrc.js": "prettier", | ||
| 77 | ".prettierrc.mjs": "prettier", | ||
| 78 | ".prettierrc.toml": "prettier", | ||
| 79 | ".prettierrc.yaml": "prettier", | ||
| 80 | ".prettierrc.yml": "prettier", | ||
| 81 | ".stylelintignore": "stylelint", | ||
| 82 | ".stylelintrc": "stylelint", | ||
| 83 | ".stylelintrc.cjs": "stylelint", | ||
| 84 | ".stylelintrc.js": "stylelint", | ||
| 85 | ".stylelintrc.json": "stylelint", | ||
| 86 | ".stylelintrc.mjs": "stylelint", | ||
| 87 | ".stylelintrc.yaml": "stylelint", | ||
| 88 | ".stylelintrc.yml": "stylelint", | ||
| 89 | ".terraform.lock.hcl": "terraform", | ||
| 90 | ".zprofile": "bash", | ||
| 91 | ".zshenv": "bash", | ||
| 92 | ".zshrc": "bash", | ||
| 93 | "agents.md": "agents", | ||
| 94 | "babel.config.js": "babel", | ||
| 95 | "babel.config.cjs": "babel", | ||
| 96 | "babel.config.json": "babel", | ||
| 97 | "babel.config.mjs": "babel", | ||
| 98 | "biome.json": "biome", | ||
| 99 | "biome.jsonc": "biome", | ||
| 100 | "bun.lock": "bun", | ||
| 101 | "bun.lockb": "bun", | ||
| 102 | "bunfig.toml": "bun", | ||
| 103 | "claude.md": "claude", | ||
| 104 | "compose.yaml": "docker", | ||
| 105 | "compose.yml": "docker", | ||
| 106 | "docker-compose.yaml": "docker", | ||
| 107 | "docker-compose.yml": "docker", | ||
| 108 | "docker-compose.override.yml": "docker", | ||
| 109 | dockerfile: "docker", | ||
| 110 | "eslint.config.js": "eslint", | ||
| 111 | "eslint.config.cjs": "eslint", | ||
| 112 | "eslint.config.mjs": "eslint", | ||
| 113 | "eslint.config.mts": "eslint", | ||
| 114 | "eslint.config.ts": "eslint", | ||
| 115 | gemfile: "ruby", | ||
| 116 | "next.config.js": "nextjs", | ||
| 117 | "next.config.mjs": "nextjs", | ||
| 118 | "next.config.mts": "nextjs", | ||
| 119 | "next.config.ts": "nextjs", | ||
| 120 | "package.json": "npm", | ||
| 121 | "pnpm-lock.yaml": "pnpm", | ||
| 122 | "pnpm-workspace.yaml": "pnpm", | ||
| 123 | "postcss.config.js": "postcss", | ||
| 124 | "postcss.config.cjs": "postcss", | ||
| 125 | "postcss.config.mjs": "postcss", | ||
| 126 | "postcss.config.ts": "postcss", | ||
| 127 | "prettier.config.js": "prettier", | ||
| 128 | "prettier.config.cjs": "prettier", | ||
| 129 | "prettier.config.mjs": "prettier", | ||
| 130 | rakefile: "ruby", | ||
| 131 | "readme.md": "markdown", | ||
| 132 | "stylelint.config.js": "stylelint", | ||
| 133 | "stylelint.config.cjs": "stylelint", | ||
| 134 | "stylelint.config.mjs": "stylelint", | ||
| 135 | "svgo.config.js": "svgo", | ||
| 136 | "svgo.config.cjs": "svgo", | ||
| 137 | "svgo.config.mjs": "svgo", | ||
| 138 | "svgo.config.ts": "svgo", | ||
| 139 | "tailwind.config.js": "tailwind", | ||
| 140 | "tailwind.config.cjs": "tailwind", | ||
| 141 | "tailwind.config.mjs": "tailwind", | ||
| 142 | "tailwind.config.ts": "tailwind", | ||
| 143 | "tsconfig.json": "typescript", | ||
| 144 | "vite.config.js": "vite", | ||
| 145 | "vite.config.mjs": "vite", | ||
| 146 | "vite.config.mts": "vite", | ||
| 147 | "vite.config.ts": "vite", | ||
| 148 | "webpack.config.js": "webpack", | ||
| 149 | "webpack.config.babel.js": "webpack", | ||
| 150 | "webpack.config.cjs": "webpack", | ||
| 151 | "webpack.config.mjs": "webpack", | ||
| 152 | "webpack.config.ts": "webpack", | ||
| 153 | }; | ||
| 154 | |||
| 155 | const FILE_ICON_BY_EXTENSION: Readonly<Record<string, MarkdownFileIcon>> = { | ||
| 156 | "7z": "zip", | ||
| 157 | astro: "astro", | ||
| 158 | avif: "image", | ||
| 159 | "code-workspace": "vscode", | ||
| 160 | bash: "bash", | ||
| 161 | bmp: "image", | ||
| 162 | bz2: "zip", | ||
| 163 | c: "c", | ||
| 164 | cc: "cpp", | ||
| 165 | cpp: "cpp", | ||
| 166 | cxx: "cpp", | ||
| 167 | css: "css", | ||
| 168 | csv: "table", | ||
| 169 | cts: "typescript", | ||
| 170 | db: "database", | ||
| 171 | env: "text", | ||
| 172 | "env.development": "text", | ||
| 173 | "env.local": "text", | ||
| 174 | "env.production": "text", | ||
| 175 | eot: "font", | ||
| 176 | erb: "ruby", | ||
| 177 | fish: "bash", | ||
| 178 | gif: "image", | ||
| 179 | go: "go", | ||
| 180 | gql: "graphql", | ||
| 181 | graphql: "graphql", | ||
| 182 | gz: "zip", | ||
| 183 | h: "c", | ||
| 184 | hh: "cpp", | ||
| 185 | hpp: "cpp", | ||
| 186 | hxx: "cpp", | ||
| 187 | htm: "html", | ||
| 188 | html: "html", | ||
| 189 | ico: "image", | ||
| 190 | icns: "image", | ||
| 191 | ini: "text", | ||
| 192 | inl: "cpp", | ||
| 193 | jar: "zip", | ||
| 194 | jpeg: "image", | ||
| 195 | jpg: "image", | ||
| 196 | js: "javascript", | ||
| 197 | jsx: "react", | ||
| 198 | json: "json", | ||
| 199 | jsonc: "json", | ||
| 200 | less: "css", | ||
| 201 | md: "markdown", | ||
| 202 | mdx: "markdown", | ||
| 203 | "mdx.tsx": "markdown", | ||
| 204 | mjs: "javascript", | ||
| 205 | mts: "typescript", | ||
| 206 | png: "image", | ||
| 207 | postcss: "css", | ||
| 208 | py: "python", | ||
| 209 | pyi: "python", | ||
| 210 | pyw: "python", | ||
| 211 | pyx: "python", | ||
| 212 | rake: "ruby", | ||
| 213 | rar: "zip", | ||
| 214 | rb: "ruby", | ||
| 215 | rs: "rust", | ||
| 216 | sass: "sass", | ||
| 217 | scss: "sass", | ||
| 218 | sh: "bash", | ||
| 219 | sql: "database", | ||
| 220 | sqlite: "database", | ||
| 221 | sqlite3: "database", | ||
| 222 | svelte: "svelte", | ||
| 223 | svg: "svg", | ||
| 224 | swift: "swift", | ||
| 225 | tar: "zip", | ||
| 226 | tf: "terraform", | ||
| 227 | tfstate: "terraform", | ||
| 228 | tfvars: "terraform", | ||
| 229 | tgz: "zip", | ||
| 230 | ts: "typescript", | ||
| 231 | tsv: "table", | ||
| 232 | tsx: "react", | ||
| 233 | txt: "text", | ||
| 234 | woff: "font", | ||
| 235 | woff2: "font", | ||
| 236 | vue: "vue", | ||
| 237 | wasm: "wasm", | ||
| 238 | webp: "image", | ||
| 239 | yml: "yml", | ||
| 240 | yaml: "yml", | ||
| 241 | zig: "zig", | ||
| 242 | zip: "zip", | ||
| 243 | zsh: "bash", | ||
| 244 | }; | ||
| 245 | |||
| 246 | /** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */ | ||
| 247 | export function normalizeNativeMarkdownUrl(value: string): string { | ||
| 248 | return value.startsWith("//") ? `https:${value}` : value; | ||
| 249 | } | ||
| 250 | |||
| 251 | export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { | ||
| 252 | const basename = fileBasename(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); | ||
| 253 | if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video"; | ||
| 254 | const exactIcon = FILE_ICON_BY_NAME[basename]; | ||
| 255 | if (exactIcon) return exactIcon; | ||
| 256 | if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { | ||
| 257 | return "typescript"; | ||
| 258 | } | ||
| 259 | const segments = basename.split("."); | ||
| 260 | for (let index = 1; index < segments.length; index += 1) { | ||
| 261 | const icon = FILE_ICON_BY_EXTENSION[segments.slice(index).join(".")]; | ||
| 262 | if (icon) return icon; | ||
| 263 | } | ||
| 264 | return "default"; | ||
| 265 | } | ||
| 266 | |||
| 267 | export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { | ||
| 268 | const normalized = normalizeMarkdownLinkDestination(href); | ||
| 269 | try { | ||
| 270 | const parsed = new URL(normalizeNativeMarkdownUrl(normalized)); | ||
| 271 | if (parsed.protocol === "http:" || parsed.protocol === "https:") { | ||
| 272 | return { | ||
| 273 | kind: "external", | ||
| 274 | href: parsed.toString(), | ||
| 275 | host: parsed.hostname, | ||
| 276 | }; | ||
| 277 | } | ||
| 278 | } catch { | ||
| 279 | // Relative paths and non-URL link destinations are handled below. | ||
| 280 | } | ||
| 281 | |||
| 282 | const target = parseMarkdownFileLink(normalized); | ||
| 283 | if (target) { | ||
| 284 | return { | ||
| 285 | kind: "file", | ||
| 286 | href: normalized, | ||
| 287 | icon: resolveMarkdownFileIcon(target.path), | ||
| 288 | label: fileBasename(formatFilePathPosition(target)), | ||
| 289 | path: target.path, | ||
| 290 | ...(target.line ? { line: target.line } : {}), | ||
| 291 | ...(target.column ? { column: target.column } : {}), | ||
| 292 | }; | ||
| 293 | } | ||
| 294 | |||
| 295 | return { | ||
| 296 | kind: "link", | ||
| 297 | // A thread link keeps its href so a press reaches the feed, which opens the thread. | ||
| 298 | href: | ||
| 299 | /^(?:mailto|tel):/i.test(normalized) || parseThreadLinkHref(normalized) !== null | ||
| 300 | ? normalized | ||
| 301 | : null, | ||
| 302 | }; | ||
| 303 | } | ||
| 304 | |||
| 305 | /** Backticks become file references only when the shared path heuristic recognizes the whole span. */ | ||
| 306 | export function resolveMarkdownInlineCodePresentation( | ||
| 307 | content: string, | ||
| 308 | ): Extract<MarkdownLinkPresentation, { readonly kind: "file" }> | null { | ||
| 309 | const candidate = inlineCodeFilePathCandidate(content); | ||
| 310 | if (candidate === null) return null; | ||
| 311 | const presentation = resolveMarkdownLinkPresentation(candidate); | ||
| 312 | return presentation.kind === "file" ? presentation : null; | ||
| 313 | } | ||
apps/mobile/src/lib/markdownMedia.test.ts+55| ... | @@ -72,6 +72,61 @@ describe("resolveMarkdownMediaPreview", () => { | ... | @@ -72,6 +72,61 @@ describe("resolveMarkdownMediaPreview", () => { |
| 72 | }); | 72 | }); |
| 73 | }); | 73 | }); |
| 74 | 74 | ||
| 75 | it.each(["./image.png", "./clip.mp4", "./report.pdf"])( | ||
| 76 | "opens relative media through the environment: %s", | ||
| 77 | (href) => { | ||
| 78 | const preview = resolveMarkdownMediaPreview(href, input); | ||
| 79 | expect(preview?.source).toMatchObject({ | ||
| 80 | environmentId: input.environmentId, | ||
| 81 | resource: { _tag: "media-file", threadId: input.threadId, path: `/repo/${href}` }, | ||
| 82 | }); | ||
| 83 | }, | ||
| 84 | ); | ||
| 85 | |||
| 86 | it("preserves a signed PDF URL and its authored name", () => { | ||
| 87 | const href = "//cdn.example.com/report%20one.pdf?signature=a%2fb#page=2"; | ||
| 88 | expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({ | ||
| 89 | kind: "pdf", | ||
| 90 | source: { uri: `https:${href}`, name: "report one.pdf", mimeType: "application/pdf" }, | ||
| 91 | }); | ||
| 92 | }); | ||
| 93 | |||
| 94 | it("recognizes a URL-encoded PDF extension and filename without rewriting the URL", () => { | ||
| 95 | const href = "https://cdn.example.com/report%23one%2520%2Epdf?signature=a%2fb"; | ||
| 96 | expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({ | ||
| 97 | kind: "pdf", | ||
| 98 | source: { uri: href, name: "report#one%20.pdf" }, | ||
| 99 | }); | ||
| 100 | }); | ||
| 101 | |||
| 102 | it("opens encoded host PDFs without interpreting literal filename characters twice", () => { | ||
| 103 | expect(resolveMarkdownMediaPreview("/tmp/report%23one.pdf:12#page=2", input)).toMatchObject({ | ||
| 104 | kind: "pdf", | ||
| 105 | source: { resource: { path: "/tmp/report#one.pdf" }, srcFragment: "#page=2" }, | ||
| 106 | }); | ||
| 107 | }); | ||
| 108 | |||
| 109 | it.each(["./image.png", "./clip.mp4", "./report.pdf"])( | ||
| 110 | "uses the draft workspace when no thread exists: %s", | ||
| 111 | (href) => { | ||
| 112 | expect( | ||
| 113 | resolveMarkdownMediaPreview(href, { ...input, threadId: undefined })?.source, | ||
| 114 | ).toMatchObject({ | ||
| 115 | resource: { _tag: "draft-workspace-file", cwd: "/repo", path: `/repo/${href}` }, | ||
| 116 | }); | ||
| 117 | }, | ||
| 118 | ); | ||
| 119 | |||
| 120 | it("keeps captured host media unavailable while allowing direct media", () => { | ||
| 121 | const captured = { ...input, captured: true }; | ||
| 122 | for (const href of ["./image.png", "./clip.mp4", "./report.pdf"]) { | ||
| 123 | expect(resolveMarkdownMediaPreview(href, captured)).toBeNull(); | ||
| 124 | } | ||
| 125 | expect(resolveMarkdownMediaPreview("https://cdn.example.com/report.pdf", captured)?.kind).toBe( | ||
| 126 | "pdf", | ||
| 127 | ); | ||
| 128 | }); | ||
| 129 | |||
| 75 | it("resolves protocol-relative media for native APIs without rewriting its signed query", () => { | 130 | it("resolves protocol-relative media for native APIs without rewriting its signed query", () => { |
| 76 | expect( | 131 | expect( |
| 77 | resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input), | 132 | resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input), |
apps/mobile/src/lib/markdownMedia.ts+71-4| ... | @@ -1,6 +1,16 @@ | ... | @@ -1,6 +1,16 @@ |
| 1 | import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; | 1 | import { |
| 2 | classifyMarkdownImageSource, | ||
| 3 | markdownImageSourceFragment, | ||
| 4 | } from "@t3tools/client-runtime/markdown-images"; | ||
| 5 | import { splitFilePathPosition } from "@t3tools/shared/fileLinks"; | ||
| 6 | import { | ||
| 7 | mediaFileReference, | ||
| 8 | mediaUrlReference, | ||
| 9 | mediaReferenceFileName, | ||
| 10 | } from "@t3tools/client-runtime/media-reference"; | ||
| 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 11 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; | 12 | import { normalizeNativeMarkdownUrl } from "./markdownLinks"; |
| 13 | import { resolveMobileMarkdownMediaSource } from "./markdownMediaSource"; | ||
| 4 | 14 | ||
| 5 | import type { FilePreviewSource } from "../components/FilePreviewModal"; | 15 | import type { FilePreviewSource } from "../components/FilePreviewModal"; |
| 6 | import type { MediaVideoPreviewSource } from "./videoPreviewSource"; | 16 | import type { MediaVideoPreviewSource } from "./videoPreviewSource"; |
| ... | @@ -11,17 +21,74 @@ export function resolveMarkdownMediaPreview( | ... | @@ -11,17 +21,74 @@ export function resolveMarkdownMediaPreview( |
| 11 | href: string, | 21 | href: string, |
| 12 | input: { | 22 | input: { |
| 13 | readonly environmentId: EnvironmentId; | 23 | readonly environmentId: EnvironmentId; |
| 14 | readonly threadId: ThreadId; | 24 | readonly threadId: ThreadId | undefined; |
| 15 | readonly workspaceRoot: string | null | undefined; | 25 | readonly workspaceRoot: string | null | undefined; |
| 26 | /** Captured markdown may only open media with a direct URL. */ | ||
| 27 | readonly captured?: boolean; | ||
| 16 | /** Image syntax can target an endpoint without a recognizable extension. */ | 28 | /** Image syntax can target an endpoint without a recognizable extension. */ |
| 17 | readonly imageEmbed?: boolean; | 29 | readonly imageEmbed?: boolean; |
| 18 | }, | 30 | }, |
| 19 | ): | 31 | ): |
| 20 | | { readonly kind: "image"; readonly source: FilePreviewSource } | 32 | | { readonly kind: "image"; readonly source: FilePreviewSource } |
| 33 | | { readonly kind: "pdf"; readonly source: FilePreviewSource } | ||
| 21 | | { readonly kind: "video"; readonly source: MediaVideoPreviewSource } | 34 | | { readonly kind: "video"; readonly source: MediaVideoPreviewSource } |
| 22 | | null { | 35 | | null { |
| 23 | const media = resolveMediaSource(href, input); | 36 | const classified = classifyMarkdownImageSource(href, input.workspaceRoot); |
| 37 | if (classified._tag !== "Blocked") { | ||
| 38 | const path = | ||
| 39 | classified._tag === "Direct" ? classified.uri : splitFilePathPosition(classified.path).path; | ||
| 40 | const reference = | ||
| 41 | classified._tag === "Direct" | ||
| 42 | ? mediaUrlReference(path) | ||
| 43 | : mediaFileReference(path, input.workspaceRoot); | ||
| 44 | const name = reference && mediaReferenceFileName(reference); | ||
| 45 | if (name && /\.pdf$/i.test(name)) { | ||
| 46 | const common = { kind: "pdf" as const, name, mimeType: "application/pdf" }; | ||
| 47 | if (classified._tag === "Direct") { | ||
| 48 | const uri = normalizeNativeMarkdownUrl(path); | ||
| 49 | return { | ||
| 50 | kind: "pdf", | ||
| 51 | source: { | ||
| 52 | ...common, | ||
| 53 | uri, | ||
| 54 | actionsSource: { | ||
| 55 | ...(reference ? { reference } : {}), | ||
| 56 | uri, | ||
| 57 | name, | ||
| 58 | mimeType: common.mimeType, | ||
| 59 | }, | ||
| 60 | }, | ||
| 61 | }; | ||
| 62 | } | ||
| 63 | if (input.captured) return null; | ||
| 64 | const resource = input.threadId | ||
| 65 | ? { _tag: "media-file" as const, threadId: input.threadId, path } | ||
| 66 | : input.workspaceRoot | ||
| 67 | ? { _tag: "draft-workspace-file" as const, cwd: input.workspaceRoot, path } | ||
| 68 | : null; | ||
| 69 | if (!resource) return null; | ||
| 70 | return { | ||
| 71 | kind: "pdf", | ||
| 72 | source: { | ||
| 73 | ...common, | ||
| 74 | environmentId: input.environmentId, | ||
| 75 | resource, | ||
| 76 | srcFragment: markdownImageSourceFragment(href), | ||
| 77 | actionsSource: { | ||
| 78 | ...(reference ? { reference } : {}), | ||
| 79 | environmentId: input.environmentId, | ||
| 80 | threadId: input.threadId, | ||
| 81 | resource, | ||
| 82 | name, | ||
| 83 | mimeType: common.mimeType, | ||
| 84 | }, | ||
| 85 | }, | ||
| 86 | }; | ||
| 87 | } | ||
| 88 | } | ||
| 89 | const media = resolveMobileMarkdownMediaSource(href, input); | ||
| 24 | if (media === null || media.access === "unavailable") return null; | 90 | if (media === null || media.access === "unavailable") return null; |
| 91 | if (input.captured && media.access !== "direct") return null; | ||
| 25 | const { kind, name, mimeType, reference, srcFragment } = media; | 92 | const { kind, name, mimeType, reference, srcFragment } = media; |
| 26 | 93 | ||
| 27 | const target = | 94 | const target = |
apps/mobile/src/lib/markdownMediaSource.test.ts created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | import { ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | import { resolveMobileMarkdownMediaSource } from "./markdownMediaSource"; | ||
| 4 | |||
| 5 | describe("mobile Markdown draft media", () => { | ||
| 6 | it("authorizes relative images and videos against the draft directory", () => { | ||
| 7 | for (const name of ["diagram.svg", "clip.mp4"]) { | ||
| 8 | expect( | ||
| 9 | resolveMobileMarkdownMediaSource(`./${name}`, { | ||
| 10 | threadId: undefined, | ||
| 11 | workspaceRoot: "/project/docs", | ||
| 12 | imageEmbed: true, | ||
| 13 | }), | ||
| 14 | ).toMatchObject({ | ||
| 15 | access: "environment", | ||
| 16 | resource: { | ||
| 17 | _tag: "draft-workspace-file", | ||
| 18 | cwd: "/project/docs", | ||
| 19 | path: `/project/docs/./${name}`, | ||
| 20 | }, | ||
| 21 | }); | ||
| 22 | } | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("keeps thread authorization when a thread exists", () => { | ||
| 26 | const threadId = ThreadId.make("markdown-thread"); | ||
| 27 | expect( | ||
| 28 | resolveMobileMarkdownMediaSource("image.png", { threadId, workspaceRoot: "/project" }), | ||
| 29 | ).toMatchObject({ resource: { _tag: "media-file", threadId, path: "/project/image.png" } }); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("keeps direct URLs direct and rejects unresolvable or blocked references", () => { | ||
| 33 | const input = { threadId: undefined, workspaceRoot: "/project" }; | ||
| 34 | expect(resolveMobileMarkdownMediaSource("https://example.com/image.png", input)).toMatchObject({ | ||
| 35 | access: "direct", | ||
| 36 | uri: "https://example.com/image.png", | ||
| 37 | }); | ||
| 38 | expect(resolveMobileMarkdownMediaSource("javascript:alert(1)", input)).toBeNull(); | ||
| 39 | expect(resolveMobileMarkdownMediaSource("image.png", { threadId: undefined })).toBeNull(); | ||
| 40 | }); | ||
| 41 | }); | ||
apps/mobile/src/lib/markdownMediaSource.ts created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; | ||
| 2 | import { | ||
| 3 | resolveMediaSource, | ||
| 4 | type ResolveMediaSourceInput, | ||
| 5 | } from "@t3tools/client-runtime/media-source"; | ||
| 6 | |||
| 7 | /** A project draft names its workspace explicitly because it has no thread. */ | ||
| 8 | export function resolveMobileMarkdownMediaSource(href: string, input: ResolveMediaSourceInput) { | ||
| 9 | const media = resolveMediaSource(href, input); | ||
| 10 | if (media?.access !== "unavailable" || !input.workspaceRoot) return media; | ||
| 11 | const source = classifyMarkdownImageSource(href, input.workspaceRoot); | ||
| 12 | if (source._tag !== "WorkspaceFile") return media; | ||
| 13 | return { | ||
| 14 | ...media, | ||
| 15 | access: "environment" as const, | ||
| 16 | resource: { | ||
| 17 | _tag: "draft-workspace-file" as const, | ||
| 18 | cwd: input.workspaceRoot, | ||
| 19 | path: source.path, | ||
| 20 | }, | ||
| 21 | }; | ||
| 22 | } | ||
apps/mobile/src/lib/mediaActions.test.ts+1-1| ... | @@ -26,7 +26,7 @@ vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); | ... | @@ -26,7 +26,7 @@ vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); |
| 26 | vi.mock("@react-navigation/native", () => ({ | 26 | vi.mock("@react-navigation/native", () => ({ |
| 27 | useNavigation: () => ({ navigate: state.navigate }), | 27 | useNavigation: () => ({ navigate: state.navigate }), |
| 28 | })); | 28 | })); |
| 29 | vi.mock("@t3tools/mobile-markdown-text/links", () => ({ | 29 | vi.mock("./markdownLinks", () => ({ |
| 30 | normalizeNativeMarkdownUrl: (uri: string) => uri, | 30 | normalizeNativeMarkdownUrl: (uri: string) => uri, |
| 31 | })); | 31 | })); |
| 32 | vi.mock("../state/assets", () => ({ | 32 | vi.mock("../state/assets", () => ({ |
apps/mobile/src/lib/nativeMarkdownText.test.ts deleted-1337| ... | @@ -1,1337 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | nativeMarkdownChunkSpacing, | ||
| 6 | nativeMarkdownDocumentChunks, | ||
| 7 | nativeMarkdownDocumentRuns, | ||
| 8 | nativeMarkdownListItemBlocks, | ||
| 9 | nativeMarkdownTextRuns, | ||
| 10 | nativeMarkdownWithAuthoredWindowsPaths, | ||
| 11 | nativeMarkdownWithPreservedSoftBreaks, | ||
| 12 | nativeMarkdownContextCopyRanges, | ||
| 13 | contextChipPresentation, | ||
| 14 | } from "@t3tools/mobile-markdown-text/markdown"; | ||
| 15 | |||
| 16 | describe("nativeMarkdownTextRuns", () => { | ||
| 17 | it("distinguishes video and pull-request context from generic file and review chips", () => { | ||
| 18 | expect( | ||
| 19 | contextChipPresentation("file", { | ||
| 20 | name: "recording.webm", | ||
| 21 | mimeType: "application/octet-stream", | ||
| 22 | }), | ||
| 23 | ).toEqual(contextChipPresentation("video")); | ||
| 24 | expect(contextChipPresentation("review-comment", { sectionId: "pull-request:284" })).toEqual( | ||
| 25 | contextChipPresentation("pull-request"), | ||
| 26 | ); | ||
| 27 | expect(contextChipPresentation("review-comment", { sectionId: "git:working-tree" })).toEqual( | ||
| 28 | contextChipPresentation("review-comment"), | ||
| 29 | ); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("renders a video-named file with a declared document MIME type as a file chip", () => { | ||
| 33 | expect( | ||
| 34 | contextChipPresentation("file", { | ||
| 35 | name: "recording.mp4", | ||
| 36 | mimeType: "application/pdf", | ||
| 37 | }), | ||
| 38 | ).toEqual(contextChipPresentation("file")); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it("maps rendered selection offsets back to canonical references without losing repeated chips", () => { | ||
| 42 | const href = "t3-context://v1/image/screenshot"; | ||
| 43 | expect( | ||
| 44 | nativeMarkdownContextCopyRanges([ | ||
| 45 | { run: { text: "😀 " }, text: "😀 ", inlineImageLength: 0 }, | ||
| 46 | { run: { href, text: "Checkout" }, text: "Checkout", inlineImageLength: 1 }, | ||
| 47 | { run: { text: " then " }, text: " then ", inlineImageLength: 0 }, | ||
| 48 | { run: { href, text: "Checkout" }, text: "\uFFFC\u00A0Checkout", inlineImageLength: 0 }, | ||
| 49 | ]), | ||
| 50 | ).toEqual([ | ||
| 51 | { start: 3, end: 12, text: "" }, | ||
| 52 | { start: 18, end: 28, text: "" }, | ||
| 53 | ]); | ||
| 54 | }); | ||
| 55 | it("restores canonical skill and context text from Android's single-image chips", () => { | ||
| 56 | expect( | ||
| 57 | nativeMarkdownContextCopyRanges([ | ||
| 58 | { run: { text: "Use " }, text: "Use ", inlineImageLength: 0 }, | ||
| 59 | { run: { text: "Playwright", skillName: "playwright" }, text: "", inlineImageLength: 1 }, | ||
| 60 | { run: { text: " on " }, text: " on ", inlineImageLength: 0 }, | ||
| 61 | { | ||
| 62 | run: { text: "Screenshot", href: "t3-context://v1/image/screenshot" }, | ||
| 63 | text: "", | ||
| 64 | inlineImageLength: 1, | ||
| 65 | }, | ||
| 66 | ]), | ||
| 67 | ).toEqual([ | ||
| 68 | { start: 4, end: 5, text: "$playwright" }, | ||
| 69 | { start: 9, end: 10, text: "" }, | ||
| 70 | ]); | ||
| 71 | }); | ||
| 72 | it("links a path-shaped code span without changing the same path in prose", () => { | ||
| 73 | expect( | ||
| 74 | nativeMarkdownTextRuns({ | ||
| 75 | type: "paragraph", | ||
| 76 | children: [ | ||
| 77 | { type: "text", content: "/tmp/frame.png " }, | ||
| 78 | { type: "code_inline", content: "/tmp/frame.png" }, | ||
| 79 | ], | ||
| 80 | }), | ||
| 81 | ).toEqual([ | ||
| 82 | { text: "/tmp/frame.png " }, | ||
| 83 | { text: "frame.png", href: "/tmp/frame.png", fileIcon: "image" }, | ||
| 84 | ]); | ||
| 85 | }); | ||
| 86 | |||
| 87 | it("keeps a thread link's href so a press can open the thread", () => { | ||
| 88 | expect( | ||
| 89 | nativeMarkdownTextRuns({ | ||
| 90 | type: "paragraph", | ||
| 91 | children: [ | ||
| 92 | { | ||
| 93 | type: "link", | ||
| 94 | href: "t3-thread://v1/thread-1", | ||
| 95 | children: [{ type: "text", content: "Fix the build" }], | ||
| 96 | }, | ||
| 97 | ], | ||
| 98 | }), | ||
| 99 | ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/thread-1" }]); | ||
| 100 | }); | ||
| 101 | |||
| 102 | it("preserves the destination of a link with a code-formatted label", () => { | ||
| 103 | expect( | ||
| 104 | nativeMarkdownTextRuns({ | ||
| 105 | type: "paragraph", | ||
| 106 | children: [ | ||
| 107 | { | ||
| 108 | type: "link", | ||
| 109 | href: "https://example.com/docs", | ||
| 110 | children: [{ type: "code_inline", content: "src/main.ts" }], | ||
| 111 | }, | ||
| 112 | ], | ||
| 113 | }), | ||
| 114 | ).toEqual([ | ||
| 115 | { | ||
| 116 | text: "src/main.ts", | ||
| 117 | code: true, | ||
| 118 | href: "https://example.com/docs", | ||
| 119 | externalHost: "example.com", | ||
| 120 | }, | ||
| 121 | ]); | ||
| 122 | }); | ||
| 123 | |||
| 124 | it("preserves inline emphasis and code styles", () => { | ||
| 125 | const node: MarkdownNode = { | ||
| 126 | type: "paragraph", | ||
| 127 | children: [ | ||
| 128 | { type: "text", content: "plain " }, | ||
| 129 | { type: "bold", children: [{ type: "text", content: "bold" }] }, | ||
| 130 | { type: "text", content: " " }, | ||
| 131 | { type: "code_inline", content: "const value = 1" }, | ||
| 132 | ], | ||
| 133 | }; | ||
| 134 | |||
| 135 | expect(nativeMarkdownTextRuns(node)).toEqual([ | ||
| 136 | { text: "plain " }, | ||
| 137 | { text: "bold", bold: true }, | ||
| 138 | { text: " " }, | ||
| 139 | { text: "const value = 1", code: true }, | ||
| 140 | ]); | ||
| 141 | }); | ||
| 142 | |||
| 143 | it("normalizes external and file links for native presentation", () => { | ||
| 144 | const node: MarkdownNode = { | ||
| 145 | type: "paragraph", | ||
| 146 | children: [ | ||
| 147 | { | ||
| 148 | type: "link", | ||
| 149 | href: "https://example.com/docs", | ||
| 150 | children: [{ type: "text", content: "Docs" }], | ||
| 151 | }, | ||
| 152 | { type: "text", content: " " }, | ||
| 153 | { | ||
| 154 | type: "link", | ||
| 155 | href: "file:///repo/README.md#L12", | ||
| 156 | children: [{ type: "text", content: "validates the input" }], | ||
| 157 | }, | ||
| 158 | ], | ||
| 159 | }; | ||
| 160 | |||
| 161 | expect(nativeMarkdownTextRuns(node)).toEqual([ | ||
| 162 | { | ||
| 163 | text: "Docs", | ||
| 164 | href: "https://example.com/docs", | ||
| 165 | externalHost: "example.com", | ||
| 166 | }, | ||
| 167 | { text: " " }, | ||
| 168 | { | ||
| 169 | text: "validates the input ", | ||
| 170 | href: "file:///repo/README.md#L12", | ||
| 171 | sourceText: "[validates the input](<file:///repo/README.md#L12>)", | ||
| 172 | }, | ||
| 173 | { | ||
| 174 | text: "README.md:12", | ||
| 175 | href: "file:///repo/README.md#L12", | ||
| 176 | fileIcon: "markdown", | ||
| 177 | sourceText: "[validates the input](<file:///repo/README.md#L12>)", | ||
| 178 | }, | ||
| 179 | ]); | ||
| 180 | }); | ||
| 181 | |||
| 182 | it.each([true, false])("copies descriptive file links with collapsed chips=%s", (collapsed) => { | ||
| 183 | const link: MarkdownNode = { | ||
| 184 | type: "link", | ||
| 185 | href: "/repo/src/example.ts:12", | ||
| 186 | children: [ | ||
| 187 | { type: "text", content: "validates " }, | ||
| 188 | { type: "bold", children: [{ type: "text", content: "the input" }] }, | ||
| 189 | ], | ||
| 190 | }; | ||
| 191 | const runs = nativeMarkdownTextRuns({ type: "paragraph", children: [link, link] }); | ||
| 192 | expect(runs.map((run) => run.text).join("")).toBe( | ||
| 193 | "validates the input example.ts:12validates the input example.ts:12", | ||
| 194 | ); | ||
| 195 | expect(runs.filter((run) => run.bold).map((run) => run.text)).toEqual([ | ||
| 196 | "the input", | ||
| 197 | "the input", | ||
| 198 | ]); | ||
| 199 | const ranges = nativeMarkdownContextCopyRanges( | ||
| 200 | runs.map((run) => ({ | ||
| 201 | run, | ||
| 202 | text: collapsed && run.fileIcon ? "" : run.text, | ||
| 203 | inlineImageLength: run.fileIcon ? 1 : 0, | ||
| 204 | })), | ||
| 205 | ); | ||
| 206 | const length = "validates the input ".length + (collapsed ? 1 : "example.ts:12".length + 1); | ||
| 207 | expect(ranges).toEqual([ | ||
| 208 | { start: 0, end: length, text: "[validates **the input**](</repo/src/example.ts:12>)" }, | ||
| 209 | { | ||
| 210 | start: length, | ||
| 211 | end: length * 2, | ||
| 212 | text: "[validates **the input**](</repo/src/example.ts:12>)", | ||
| 213 | }, | ||
| 214 | ]); | ||
| 215 | }); | ||
| 216 | |||
| 217 | it("keeps filename-labelled links as chips", () => { | ||
| 218 | expect( | ||
| 219 | nativeMarkdownTextRuns({ | ||
| 220 | type: "paragraph", | ||
| 221 | children: [ | ||
| 222 | { | ||
| 223 | type: "link", | ||
| 224 | href: "/repo/src/example.ts:12", | ||
| 225 | children: [{ type: "code_inline", content: "src/example.ts:12" }], | ||
| 226 | }, | ||
| 227 | ], | ||
| 228 | }), | ||
| 229 | ).toEqual([{ text: "example.ts:12", href: "/repo/src/example.ts:12", fileIcon: "typescript" }]); | ||
| 230 | }); | ||
| 231 | |||
| 232 | it.each([ | ||
| 233 | [{ type: "italic", children: [{ type: "text", content: "details" }] }, "*details*"], | ||
| 234 | [{ type: "strikethrough", children: [{ type: "text", content: "details" }] }, "~~details~~"], | ||
| 235 | [{ type: "code_inline", content: "`details`" }, "`` `details` ``"], | ||
| 236 | [{ type: "code_inline", content: " details " }, "` details `"], | ||
| 237 | [{ type: "text", content: "[details] *literal*" }, "\\[details\\] \\*literal\\*"], | ||
| 238 | [{ type: "soft_break" }, "\n"], | ||
| 239 | [{ type: "line_break" }, " \n"], | ||
| 240 | [ | ||
| 241 | { type: "image", alt: "details", href: "https://example.com/icon.png" }, | ||
| 242 | "", | ||
| 243 | ], | ||
| 244 | ] satisfies ReadonlyArray<readonly [MarkdownNode, string]>)( | ||
| 245 | "keeps descriptive label markup when copying %j", | ||
| 246 | (child, source) => { | ||
| 247 | const runs = nativeMarkdownTextRuns({ | ||
| 248 | type: "paragraph", | ||
| 249 | children: [ | ||
| 250 | { | ||
| 251 | type: "link", | ||
| 252 | href: "/repo/a.ts", | ||
| 253 | children: [{ type: "text", content: "see " }, child], | ||
| 254 | }, | ||
| 255 | ], | ||
| 256 | }); | ||
| 257 | expect( | ||
| 258 | nativeMarkdownContextCopyRanges( | ||
| 259 | runs.map((run) => ({ | ||
| 260 | run, | ||
| 261 | text: run.fileIcon ? "" : run.text, | ||
| 262 | inlineImageLength: run.fileIcon ? 1 : 0, | ||
| 263 | })), | ||
| 264 | ), | ||
| 265 | ).toEqual([ | ||
| 266 | { | ||
| 267 | start: 0, | ||
| 268 | end: | ||
| 269 | runs | ||
| 270 | .filter((run) => !run.fileIcon) | ||
| 271 | .reduce((length, run) => length + run.text.length, 0) + 1, | ||
| 272 | text: `[see ${source}](</repo/a.ts>)`, | ||
| 273 | }, | ||
| 274 | ]); | ||
| 275 | }, | ||
| 276 | ); | ||
| 277 | |||
| 278 | it("keeps hard breaks and collapses soft breaks", () => { | ||
| 279 | const node: MarkdownNode = { | ||
| 280 | type: "paragraph", | ||
| 281 | children: [ | ||
| 282 | { type: "text", content: "first" }, | ||
| 283 | { type: "soft_break" }, | ||
| 284 | { type: "text", content: "second" }, | ||
| 285 | { type: "line_break" }, | ||
| 286 | { type: "text", content: "third" }, | ||
| 287 | ], | ||
| 288 | }; | ||
| 289 | |||
| 290 | expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "first second\nthird" }]); | ||
| 291 | }); | ||
| 292 | |||
| 293 | it("can preserve soft breaks for authored user messages", () => { | ||
| 294 | const node: MarkdownNode = { | ||
| 295 | type: "paragraph", | ||
| 296 | children: [ | ||
| 297 | { type: "text", content: "first" }, | ||
| 298 | { type: "soft_break" }, | ||
| 299 | { type: "text", content: "second" }, | ||
| 300 | ], | ||
| 301 | }; | ||
| 302 | |||
| 303 | expect(nativeMarkdownTextRuns(nativeMarkdownWithPreservedSoftBreaks(node))).toEqual([ | ||
| 304 | { text: "first\nsecond" }, | ||
| 305 | ]); | ||
| 306 | }); | ||
| 307 | |||
| 308 | it("keeps Windows path backslashes the parser reads as escapes", () => { | ||
| 309 | const markdown = [ | ||
| 310 | String.raw``, | ||
| 311 | String.raw`[settings](C:\Users\me\.claude\settings.json) and [site](https://example.com/a\.b)`, | ||
| 312 | "![ref][ref]", | ||
| 313 | String.raw`[ref]: \\wsl.localhost\Ubuntu\.t3\ref.png`, | ||
| 314 | ].join("\n\n"); | ||
| 315 | // md4c drops each backslash that precedes punctuation. | ||
| 316 | const node: MarkdownNode = { | ||
| 317 | type: "document", | ||
| 318 | children: [ | ||
| 319 | { type: "image", href: String.raw`C:\Users\me.t3_build\shot.png` }, | ||
| 320 | { type: "link", href: String.raw`C:\Users\me.claude\settings.json` }, | ||
| 321 | { type: "link", href: "https://example.com/a.b" }, | ||
| 322 | { type: "image", href: String.raw`\wsl.localhost\Ubuntu.t3\ref.png` }, | ||
| 323 | ], | ||
| 324 | }; | ||
| 325 | |||
| 326 | expect( | ||
| 327 | nativeMarkdownWithAuthoredWindowsPaths(node, markdown).children?.map(({ href }) => href), | ||
| 328 | ).toEqual([ | ||
| 329 | String.raw`C:\Users\me\.t3\_build\shot.png`, | ||
| 330 | String.raw`C:\Users\me\.claude\settings.json`, | ||
| 331 | "https://example.com/a.b", | ||
| 332 | String.raw`\\wsl.localhost\Ubuntu\.t3\ref.png`, | ||
| 333 | ]); | ||
| 334 | }); | ||
| 335 | |||
| 336 | it("leaves a Windows path as parsed when two written paths could have produced it", () => { | ||
| 337 | const markdown = [ | ||
| 338 | String.raw`\`\``, | ||
| 339 | String.raw``, | ||
| 340 | ].join("\n\n"); | ||
| 341 | const node: MarkdownNode = { | ||
| 342 | type: "document", | ||
| 343 | children: [{ type: "image", href: String.raw`C:\Users\me.t3\shot.png` }], | ||
| 344 | }; | ||
| 345 | |||
| 346 | expect(nativeMarkdownWithAuthoredWindowsPaths(node, markdown).children?.[0]?.href).toBe( | ||
| 347 | String.raw`C:\Users\me.t3\shot.png`, | ||
| 348 | ); | ||
| 349 | }); | ||
| 350 | |||
| 351 | it("normalizes common inline HTML and entities", () => { | ||
| 352 | const node: MarkdownNode = { | ||
| 353 | type: "paragraph", | ||
| 354 | children: [ | ||
| 355 | { type: "text", content: "Less than: &lt; " }, | ||
| 356 | { type: "html_inline", content: "<kbd>" }, | ||
| 357 | { type: "text", content: "⌘" }, | ||
| 358 | { type: "html_inline", content: "</kbd>" }, | ||
| 359 | { type: "html_inline", content: "<br />" }, | ||
| 360 | { type: "html_inline", content: "<mark>highlighted</mark>" }, | ||
| 361 | ], | ||
| 362 | }; | ||
| 363 | |||
| 364 | expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "Less than: < ⌘\nhighlighted" }]); | ||
| 365 | }); | ||
| 366 | |||
| 367 | it("normalizes double-encoded entities and inline tags emitted as text", () => { | ||
| 368 | const node: MarkdownNode = { | ||
| 369 | type: "paragraph", | ||
| 370 | children: [ | ||
| 371 | { | ||
| 372 | type: "text", | ||
| 373 | content: | ||
| 374 | "Keyboard: <kbd>⌘</kbd> + <kbd>K</kbd>; Less than: &amp;lt;; Greater than: &amp;gt;", | ||
| 375 | }, | ||
| 376 | ], | ||
| 377 | }; | ||
| 378 | |||
| 379 | expect(nativeMarkdownTextRuns(node)).toEqual([ | ||
| 380 | { text: "Keyboard: ⌘ + K; Less than: <; Greater than: >" }, | ||
| 381 | ]); | ||
| 382 | }); | ||
| 383 | |||
| 384 | it.each([ | ||
| 385 | ["&#128512;", "😀"], | ||
| 386 | ["&#x1f680;", "🚀"], | ||
| 387 | ["&#9999999999;", "&#9999999999;"], | ||
| 388 | ["&#x110000;", "&#x110000;"], | ||
| 389 | ["&amp;#9999999999;", "&#9999999999;"], | ||
| 390 | ["&amp;#x110000;", "&#x110000;"], | ||
| 391 | ])("normalizes numeric entity %s without throwing", (content, expected) => { | ||
| 392 | const node: MarkdownNode = { | ||
| 393 | type: "paragraph", | ||
| 394 | children: [{ type: "text", content }], | ||
| 395 | }; | ||
| 396 | |||
| 397 | expect(nativeMarkdownTextRuns(node)).toEqual([{ text: expected }]); | ||
| 398 | }); | ||
| 399 | |||
| 400 | it("reads inline content from nested text nodes", () => { | ||
| 401 | const node: MarkdownNode = { | ||
| 402 | type: "paragraph", | ||
| 403 | children: [ | ||
| 404 | { | ||
| 405 | type: "text", | ||
| 406 | children: [{ type: "text", content: "Plain text" }], | ||
| 407 | }, | ||
| 408 | { type: "text", content: " and " }, | ||
| 409 | { | ||
| 410 | type: "code_inline", | ||
| 411 | children: [{ type: "text", content: "inline code" }], | ||
| 412 | }, | ||
| 413 | ], | ||
| 414 | }; | ||
| 415 | |||
| 416 | expect(nativeMarkdownTextRuns(node)).toEqual([ | ||
| 417 | { text: "Plain text and " }, | ||
| 418 | { text: "inline code", code: true }, | ||
| 419 | ]); | ||
| 420 | }); | ||
| 421 | }); | ||
| 422 | |||
| 423 | describe("nativeMarkdownDocumentRuns", () => { | ||
| 424 | it("renders a file mention without swallowing sentence punctuation or changing package references", () => { | ||
| 425 | const runs = nativeMarkdownDocumentRuns({ | ||
| 426 | type: "document", | ||
| 427 | children: [ | ||
| 428 | { | ||
| 429 | type: "paragraph", | ||
| 430 | children: [ | ||
| 431 | { type: "text", content: "Inspect @src/Checkout.tsx. Use @t3tools/contracts." }, | ||
| 432 | ], | ||
| 433 | }, | ||
| 434 | ], | ||
| 435 | }); | ||
| 436 | expect(runs).toEqual([ | ||
| 437 | { text: "Inspect ", role: "body" }, | ||
| 438 | { | ||
| 439 | text: "Checkout.tsx", | ||
| 440 | role: "body", | ||
| 441 | href: "src/Checkout.tsx", | ||
| 442 | fileIcon: "react", | ||
| 443 | sourceText: "@src/Checkout.tsx", | ||
| 444 | }, | ||
| 445 | { text: ". Use @t3tools/contracts.", role: "body" }, | ||
| 446 | ]); | ||
| 447 | }); | ||
| 448 | |||
| 449 | it("copies collapsed skill and file chips back to their original references", () => { | ||
| 450 | expect( | ||
| 451 | nativeMarkdownContextCopyRanges([ | ||
| 452 | { run: { text: "$ui", skillName: "ui" }, text: "\uFFFC", inlineImageLength: 0 }, | ||
| 453 | { run: { text: " and " }, text: " and ", inlineImageLength: 0 }, | ||
| 454 | { | ||
| 455 | run: { | ||
| 456 | text: "Checkout.tsx", | ||
| 457 | href: "src/Checkout.tsx", | ||
| 458 | fileIcon: "react", | ||
| 459 | sourceText: "@src/Checkout.tsx", | ||
| 460 | }, | ||
| 461 | text: "\uFFFC", | ||
| 462 | inlineImageLength: 0, | ||
| 463 | }, | ||
| 464 | ]), | ||
| 465 | ).toEqual([ | ||
| 466 | { start: 0, end: 1, text: "$ui" }, | ||
| 467 | { start: 6, end: 7, text: "@src/Checkout.tsx" }, | ||
| 468 | ]); | ||
| 469 | }); | ||
| 470 | |||
| 471 | it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( | ||
| 472 | "decorates %s skill references as selectable skill links", | ||
| 473 | (prefix) => { | ||
| 474 | const node: MarkdownNode = { | ||
| 475 | type: "document", | ||
| 476 | children: [ | ||
| 477 | { | ||
| 478 | type: "paragraph", | ||
| 479 | children: [{ type: "text", content: `Use ${prefix}ui for this.` }], | ||
| 480 | }, | ||
| 481 | ], | ||
| 482 | }; | ||
| 483 | |||
| 484 | expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([ | ||
| 485 | { text: "Use ", role: "body" }, | ||
| 486 | { | ||
| 487 | text: `${prefix}ui`, | ||
| 488 | role: "body", | ||
| 489 | skillName: "ui", | ||
| 490 | skillLabel: "UI", | ||
| 491 | }, | ||
| 492 | { text: " for this.", role: "body" }, | ||
| 493 | ]); | ||
| 494 | }, | ||
| 495 | ); | ||
| 496 | |||
| 497 | it("decorates known skill references that begin with a digit", () => { | ||
| 498 | const node: MarkdownNode = { | ||
| 499 | type: "document", | ||
| 500 | children: [ | ||
| 501 | { | ||
| 502 | type: "paragraph", | ||
| 503 | children: [{ type: "text", content: "Use $2spec for this." }], | ||
| 504 | }, | ||
| 505 | ], | ||
| 506 | }; | ||
| 507 | |||
| 508 | expect(nativeMarkdownDocumentRuns(node, [{ name: "2spec", displayName: "2Spec" }])).toEqual([ | ||
| 509 | { text: "Use ", role: "body" }, | ||
| 510 | { | ||
| 511 | text: "$2spec", | ||
| 512 | role: "body", | ||
| 513 | skillName: "2spec", | ||
| 514 | skillLabel: "2Spec", | ||
| 515 | }, | ||
| 516 | { text: " for this.", role: "body" }, | ||
| 517 | ]); | ||
| 518 | }); | ||
| 519 | |||
| 520 | it("decorates known skill references inside blockquotes", () => { | ||
| 521 | const node: MarkdownNode = { | ||
| 522 | type: "blockquote", | ||
| 523 | children: [ | ||
| 524 | { | ||
| 525 | type: "paragraph", | ||
| 526 | children: [{ type: "text", content: "Use $ui for this." }], | ||
| 527 | }, | ||
| 528 | ], | ||
| 529 | }; | ||
| 530 | |||
| 531 | expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toContainEqual({ | ||
| 532 | text: "$ui", | ||
| 533 | role: "body", | ||
| 534 | skillName: "ui", | ||
| 535 | skillLabel: "UI", | ||
| 536 | }); | ||
| 537 | }); | ||
| 538 | |||
| 539 | it("leaves unknown skill-like text unchanged", () => { | ||
| 540 | const node: MarkdownNode = { | ||
| 541 | type: "document", | ||
| 542 | children: [ | ||
| 543 | { | ||
| 544 | type: "paragraph", | ||
| 545 | children: [{ type: "text", content: "Use $unknown for this." }], | ||
| 546 | }, | ||
| 547 | ], | ||
| 548 | }; | ||
| 549 | |||
| 550 | expect(nativeMarkdownDocumentRuns(node, [])).toEqual([ | ||
| 551 | { text: "Use $unknown for this.", role: "body" }, | ||
| 552 | ]); | ||
| 553 | }); | ||
| 554 | |||
| 555 | it("keeps headings, paragraphs, and lists in one continuous document", () => { | ||
| 556 | const node: MarkdownNode = { | ||
| 557 | type: "document", | ||
| 558 | children: [ | ||
| 559 | { | ||
| 560 | type: "heading", | ||
| 561 | level: 1, | ||
| 562 | children: [{ type: "text", content: "Header One" }], | ||
| 563 | }, | ||
| 564 | { | ||
| 565 | type: "paragraph", | ||
| 566 | children: [ | ||
| 567 | { type: "text", content: "A paragraph with " }, | ||
| 568 | { type: "bold", children: [{ type: "text", content: "bold text" }] }, | ||
| 569 | { type: "text", content: "." }, | ||
| 570 | ], | ||
| 571 | }, | ||
| 572 | { | ||
| 573 | type: "list", | ||
| 574 | ordered: false, | ||
| 575 | children: [ | ||
| 576 | { | ||
| 577 | type: "list_item", | ||
| 578 | children: [ | ||
| 579 | { | ||
| 580 | type: "paragraph", | ||
| 581 | children: [{ type: "text", content: "First item" }], | ||
| 582 | }, | ||
| 583 | ], | ||
| 584 | }, | ||
| 585 | { | ||
| 586 | type: "list_item", | ||
| 587 | children: [ | ||
| 588 | { | ||
| 589 | type: "paragraph", | ||
| 590 | children: [{ type: "text", content: "Second item" }], | ||
| 591 | }, | ||
| 592 | ], | ||
| 593 | }, | ||
| 594 | ], | ||
| 595 | }, | ||
| 596 | ], | ||
| 597 | }; | ||
| 598 | |||
| 599 | const runs = nativeMarkdownDocumentRuns(node); | ||
| 600 | expect(runs.map((run) => run.text).join("")).toBe( | ||
| 601 | "Header One\n\nA paragraph with bold text.\n\n•\tFirst item\n•\tSecond item", | ||
| 602 | ); | ||
| 603 | expect(runs).toContainEqual({ | ||
| 604 | text: "Header One\n", | ||
| 605 | role: "heading", | ||
| 606 | headingLevel: 1, | ||
| 607 | }); | ||
| 608 | expect(runs).toContainEqual({ | ||
| 609 | text: "bold text", | ||
| 610 | bold: true, | ||
| 611 | role: "body", | ||
| 612 | }); | ||
| 613 | expect(runs).toContainEqual({ | ||
| 614 | text: "•\t", | ||
| 615 | role: "list-marker", | ||
| 616 | depth: 1, | ||
| 617 | firstLineHeadIndent: 0, | ||
| 618 | headIndent: 24, | ||
| 619 | paragraphSpacing: 2, | ||
| 620 | }); | ||
| 621 | }); | ||
| 622 | |||
| 623 | it("uses distinct section, heading-content, and body spacing", () => { | ||
| 624 | const node: MarkdownNode = { | ||
| 625 | type: "document", | ||
| 626 | children: [ | ||
| 627 | { | ||
| 628 | type: "paragraph", | ||
| 629 | children: [{ type: "text", content: "Intro" }], | ||
| 630 | }, | ||
| 631 | { | ||
| 632 | type: "heading", | ||
| 633 | level: 2, | ||
| 634 | children: [{ type: "text", content: "Section" }], | ||
| 635 | }, | ||
| 636 | { | ||
| 637 | type: "paragraph", | ||
| 638 | children: [{ type: "text", content: "First paragraph" }], | ||
| 639 | }, | ||
| 640 | { | ||
| 641 | type: "paragraph", | ||
| 642 | children: [{ type: "text", content: "Second paragraph" }], | ||
| 643 | }, | ||
| 644 | ], | ||
| 645 | }; | ||
| 646 | |||
| 647 | expect( | ||
| 648 | nativeMarkdownDocumentRuns(node) | ||
| 649 | .filter((run) => run.role === "spacer") | ||
| 650 | .map((run) => run.spacing), | ||
| 651 | ).toEqual([20, 10, 12]); | ||
| 652 | }); | ||
| 653 | |||
| 654 | it("renders tight list items whose inline nodes are direct children", () => { | ||
| 655 | const node: MarkdownNode = { | ||
| 656 | type: "document", | ||
| 657 | children: [ | ||
| 658 | { | ||
| 659 | type: "list", | ||
| 660 | children: [ | ||
| 661 | { | ||
| 662 | type: "list_item", | ||
| 663 | children: [ | ||
| 664 | { | ||
| 665 | type: "bold", | ||
| 666 | children: [{ type: "text", content: "Finding:" }], | ||
| 667 | }, | ||
| 668 | { type: "text", content: " details with " }, | ||
| 669 | { type: "code_inline", content: "inline code" }, | ||
| 670 | { type: "text", content: "." }, | ||
| 671 | ], | ||
| 672 | }, | ||
| 673 | ], | ||
| 674 | }, | ||
| 675 | ], | ||
| 676 | }; | ||
| 677 | |||
| 678 | expect(nativeMarkdownDocumentRuns(node)).toEqual([ | ||
| 679 | { | ||
| 680 | text: "•\t", | ||
| 681 | role: "list-marker", | ||
| 682 | depth: 1, | ||
| 683 | firstLineHeadIndent: 0, | ||
| 684 | headIndent: 24, | ||
| 685 | paragraphSpacing: 2, | ||
| 686 | }, | ||
| 687 | { text: "Finding:", bold: true, role: "body", depth: 1 }, | ||
| 688 | { text: " details with ", role: "body", depth: 1 }, | ||
| 689 | { text: "inline code", code: true, role: "body", depth: 1 }, | ||
| 690 | { text: ".", role: "body", depth: 1 }, | ||
| 691 | ]); | ||
| 692 | }); | ||
| 693 | |||
| 694 | it("preserves quotes and fenced code in document runs", () => { | ||
| 695 | const node: MarkdownNode = { | ||
| 696 | type: "document", | ||
| 697 | children: [ | ||
| 698 | { | ||
| 699 | type: "blockquote", | ||
| 700 | children: [ | ||
| 701 | { | ||
| 702 | type: "paragraph", | ||
| 703 | children: [{ type: "text", content: "Read this" }], | ||
| 704 | }, | ||
| 705 | ], | ||
| 706 | }, | ||
| 707 | { | ||
| 708 | type: "code_block", | ||
| 709 | language: "ts", | ||
| 710 | content: "const answer = 42;", | ||
| 711 | }, | ||
| 712 | ], | ||
| 713 | }; | ||
| 714 | |||
| 715 | const runs = nativeMarkdownDocumentRuns(node); | ||
| 716 | expect(runs.map((run) => run.text).join("")).toBe("│\u00a0Read this\n\nTS\nconst answer = 42;"); | ||
| 717 | expect(runs).toContainEqual({ | ||
| 718 | text: "const answer = 42;", | ||
| 719 | code: true, | ||
| 720 | role: "code-block", | ||
| 721 | }); | ||
| 722 | }); | ||
| 723 | |||
| 724 | it("reads fenced code content from child text nodes", () => { | ||
| 725 | const node: MarkdownNode = { | ||
| 726 | type: "document", | ||
| 727 | children: [ | ||
| 728 | { | ||
| 729 | type: "code_block", | ||
| 730 | language: "bash", | ||
| 731 | children: [{ type: "text", content: "pnpm install\n" }], | ||
| 732 | }, | ||
| 733 | ], | ||
| 734 | }; | ||
| 735 | |||
| 736 | expect( | ||
| 737 | nativeMarkdownDocumentRuns(node) | ||
| 738 | .map((run) => run.text) | ||
| 739 | .join(""), | ||
| 740 | ).toBe("BASH\npnpm install"); | ||
| 741 | }); | ||
| 742 | |||
| 743 | it("keeps adjacent context links with the same href in separate runs", () => { | ||
| 744 | const href = "t3-context://v1/terminal/ctx-1"; | ||
| 745 | const link = (content: string): MarkdownNode => ({ | ||
| 746 | type: "link", | ||
| 747 | href, | ||
| 748 | children: [{ type: "text", content }], | ||
| 749 | }); | ||
| 750 | const runs = nativeMarkdownDocumentRuns({ | ||
| 751 | type: "document", | ||
| 752 | children: [{ type: "paragraph", children: [link("First"), link("Second")] }], | ||
| 753 | }); | ||
| 754 | |||
| 755 | // Merging these would render one chip and emit one copy range with a | ||
| 756 | // combined label for two distinct references. | ||
| 757 | expect(runs).toEqual([ | ||
| 758 | { text: "First", role: "body", href, fileIcon: "bash" }, | ||
| 759 | { text: "Second", role: "body", href, fileIcon: "bash" }, | ||
| 760 | ]); | ||
| 761 | }); | ||
| 762 | }); | ||
| 763 | |||
| 764 | describe("nativeMarkdownListItemBlocks", () => { | ||
| 765 | it("groups consecutive inline nodes into one paragraph block", () => { | ||
| 766 | const item: MarkdownNode = { | ||
| 767 | type: "list_item", | ||
| 768 | children: [ | ||
| 769 | { type: "text", content: "Finding: " }, | ||
| 770 | { type: "bold", children: [{ type: "text", content: "important" }] }, | ||
| 771 | { type: "text", content: " details." }, | ||
| 772 | { | ||
| 773 | type: "list", | ||
| 774 | children: [ | ||
| 775 | { | ||
| 776 | type: "list_item", | ||
| 777 | children: [{ type: "text", content: "Nested" }], | ||
| 778 | }, | ||
| 779 | ], | ||
| 780 | }, | ||
| 781 | { type: "text", content: "Trailing prose." }, | ||
| 782 | ], | ||
| 783 | }; | ||
| 784 | |||
| 785 | expect(nativeMarkdownListItemBlocks(item)).toEqual([ | ||
| 786 | { | ||
| 787 | type: "paragraph", | ||
| 788 | children: item.children?.slice(0, 3), | ||
| 789 | }, | ||
| 790 | item.children?.[3], | ||
| 791 | { | ||
| 792 | type: "paragraph", | ||
| 793 | children: [item.children?.[4]], | ||
| 794 | }, | ||
| 795 | ]); | ||
| 796 | }); | ||
| 797 | }); | ||
| 798 | |||
| 799 | describe("nativeMarkdownDocumentChunks", () => { | ||
| 800 | it("renders plain blockquotes as rich blocks so their marker spans wrapped lines", () => { | ||
| 801 | const blockquote: MarkdownNode = { | ||
| 802 | type: "blockquote", | ||
| 803 | beg: 0, | ||
| 804 | end: 120, | ||
| 805 | children: [ | ||
| 806 | { | ||
| 807 | type: "paragraph", | ||
| 808 | children: [ | ||
| 809 | { | ||
| 810 | type: "text", | ||
| 811 | content: | ||
| 812 | "Persistent random per-result keys are the strongest design, even when this text wraps.", | ||
| 813 | }, | ||
| 814 | ], | ||
| 815 | }, | ||
| 816 | ], | ||
| 817 | }; | ||
| 818 | |||
| 819 | expect( | ||
| 820 | nativeMarkdownDocumentChunks({ | ||
| 821 | type: "document", | ||
| 822 | children: [blockquote], | ||
| 823 | }), | ||
| 824 | ).toEqual([ | ||
| 825 | { | ||
| 826 | kind: "rich", | ||
| 827 | key: "rich:blockquote:offset:0", | ||
| 828 | node: blockquote, | ||
| 829 | }, | ||
| 830 | ]); | ||
| 831 | }); | ||
| 832 | |||
| 833 | it("keeps headings and plain lists in one selectable document", () => { | ||
| 834 | const document: MarkdownNode = { | ||
| 835 | type: "document", | ||
| 836 | children: [ | ||
| 837 | { | ||
| 838 | type: "heading", | ||
| 839 | level: 2, | ||
| 840 | children: [{ type: "text", content: "Tasks" }], | ||
| 841 | }, | ||
| 842 | { | ||
| 843 | type: "list", | ||
| 844 | children: [ | ||
| 845 | { | ||
| 846 | type: "task_list_item", | ||
| 847 | checked: true, | ||
| 848 | children: [ | ||
| 849 | { | ||
| 850 | type: "paragraph", | ||
| 851 | children: [{ type: "text", content: "Completed" }], | ||
| 852 | }, | ||
| 853 | ], | ||
| 854 | }, | ||
| 855 | { | ||
| 856 | type: "list_item", | ||
| 857 | children: [ | ||
| 858 | { | ||
| 859 | type: "paragraph", | ||
| 860 | children: [{ type: "text", content: "Parent" }], | ||
| 861 | }, | ||
| 862 | { | ||
| 863 | type: "list", | ||
| 864 | children: [ | ||
| 865 | { | ||
| 866 | type: "list_item", | ||
| 867 | children: [ | ||
| 868 | { | ||
| 869 | type: "paragraph", | ||
| 870 | children: [{ type: "text", content: "Nested" }], | ||
| 871 | }, | ||
| 872 | ], | ||
| 873 | }, | ||
| 874 | ], | ||
| 875 | }, | ||
| 876 | ], | ||
| 877 | }, | ||
| 878 | ], | ||
| 879 | }, | ||
| 880 | ], | ||
| 881 | }; | ||
| 882 | |||
| 883 | const chunks = nativeMarkdownDocumentChunks(document); | ||
| 884 | expect(chunks).toHaveLength(1); | ||
| 885 | expect(chunks[0]).toMatchObject({ kind: "selectable" }); | ||
| 886 | expect( | ||
| 887 | nativeMarkdownDocumentRuns(chunks[0]?.node ?? document) | ||
| 888 | .map((run) => run.text) | ||
| 889 | .join(""), | ||
| 890 | ).toBe("Tasks\n\n☑︎\tCompleted\n•\tParent\n◦\tNested"); | ||
| 891 | }); | ||
| 892 | |||
| 893 | it("aligns ordered markers while keeping the list in one selectable string", () => { | ||
| 894 | const document: MarkdownNode = { | ||
| 895 | type: "document", | ||
| 896 | children: [ | ||
| 897 | { | ||
| 898 | type: "list", | ||
| 899 | ordered: true, | ||
| 900 | start: 9, | ||
| 901 | children: [ | ||
| 902 | { | ||
| 903 | type: "list_item", | ||
| 904 | children: [{ type: "text", content: "Ninth" }], | ||
| 905 | }, | ||
| 906 | { | ||
| 907 | type: "list_item", | ||
| 908 | children: [{ type: "text", content: "Tenth" }], | ||
| 909 | }, | ||
| 910 | ], | ||
| 911 | }, | ||
| 912 | ], | ||
| 913 | }; | ||
| 914 | |||
| 915 | expect( | ||
| 916 | nativeMarkdownDocumentRuns(document) | ||
| 917 | .map((run) => run.text) | ||
| 918 | .join(""), | ||
| 919 | ).toBe("\u20079.\tNinth\n10.\tTenth"); | ||
| 920 | }); | ||
| 921 | |||
| 922 | it("keeps prose selectable while exposing rich AST blocks", () => { | ||
| 923 | const document: MarkdownNode = { | ||
| 924 | type: "document", | ||
| 925 | children: [ | ||
| 926 | { | ||
| 927 | type: "heading", | ||
| 928 | level: 1, | ||
| 929 | beg: 0, | ||
| 930 | end: 9, | ||
| 931 | children: [{ type: "text", content: "Install" }], | ||
| 932 | }, | ||
| 933 | { | ||
| 934 | type: "code_block", | ||
| 935 | language: "bash", | ||
| 936 | beg: 11, | ||
| 937 | end: 35, | ||
| 938 | children: [{ type: "text", content: "pnpm install\n" }], | ||
| 939 | }, | ||
| 940 | { | ||
| 941 | type: "paragraph", | ||
| 942 | beg: 37, | ||
| 943 | end: 42, | ||
| 944 | children: [{ type: "text", content: "Done." }], | ||
| 945 | }, | ||
| 946 | ], | ||
| 947 | }; | ||
| 948 | |||
| 949 | const chunks = nativeMarkdownDocumentChunks(document); | ||
| 950 | expect(chunks).toHaveLength(3); | ||
| 951 | expect(chunks[0]).toMatchObject({ kind: "selectable" }); | ||
| 952 | expect(chunks[1]).toEqual({ | ||
| 953 | kind: "rich", | ||
| 954 | key: "rich:code_block:offset:11", | ||
| 955 | node: document.children?.[1], | ||
| 956 | }); | ||
| 957 | expect(chunks[2]).toMatchObject({ kind: "selectable" }); | ||
| 958 | }); | ||
| 959 | |||
| 960 | it("keeps a list containing fenced code as one rich AST container", () => { | ||
| 961 | const document: MarkdownNode = { | ||
| 962 | type: "document", | ||
| 963 | children: [ | ||
| 964 | { | ||
| 965 | type: "list", | ||
| 966 | beg: 0, | ||
| 967 | end: 45, | ||
| 968 | children: [ | ||
| 969 | { | ||
| 970 | type: "list_item", | ||
| 971 | children: [ | ||
| 972 | { | ||
| 973 | type: "paragraph", | ||
| 974 | children: [{ type: "text", content: "Install" }], | ||
| 975 | }, | ||
| 976 | { | ||
| 977 | type: "code_block", | ||
| 978 | language: "bash", | ||
| 979 | children: [{ type: "text", content: "pnpm install\n" }], | ||
| 980 | }, | ||
| 981 | ], | ||
| 982 | }, | ||
| 983 | ], | ||
| 984 | }, | ||
| 985 | ], | ||
| 986 | }; | ||
| 987 | |||
| 988 | expect(nativeMarkdownDocumentChunks(document)).toEqual([ | ||
| 989 | { | ||
| 990 | kind: "rich", | ||
| 991 | key: "rich:list:offset:0", | ||
| 992 | node: document.children?.[0], | ||
| 993 | }, | ||
| 994 | ]); | ||
| 995 | }); | ||
| 996 | |||
| 997 | it("keeps surrounding prose selectable when rich nodes have no source offsets", () => { | ||
| 998 | const document: MarkdownNode = { | ||
| 999 | type: "document", | ||
| 1000 | children: [ | ||
| 1001 | { | ||
| 1002 | type: "heading", | ||
| 1003 | level: 1, | ||
| 1004 | children: [{ type: "text", content: "Before" }], | ||
| 1005 | }, | ||
| 1006 | { type: "horizontal_rule" }, | ||
| 1007 | { | ||
| 1008 | type: "paragraph", | ||
| 1009 | children: [{ type: "text", content: "After." }], | ||
| 1010 | }, | ||
| 1011 | ], | ||
| 1012 | }; | ||
| 1013 | |||
| 1014 | const chunks = nativeMarkdownDocumentChunks(document); | ||
| 1015 | expect(chunks).toHaveLength(3); | ||
| 1016 | expect(chunks[0]).toMatchObject({ kind: "selectable" }); | ||
| 1017 | expect(chunks[1]).toEqual({ | ||
| 1018 | kind: "rich", | ||
| 1019 | key: "rich:horizontal_rule:index:1", | ||
| 1020 | node: document.children?.[1], | ||
| 1021 | }); | ||
| 1022 | expect(chunks[2]).toMatchObject({ kind: "selectable" }); | ||
| 1023 | }); | ||
| 1024 | |||
| 1025 | it("keeps offset-free structural lists isolated without promoting the whole document", () => { | ||
| 1026 | const document: MarkdownNode = { | ||
| 1027 | type: "document", | ||
| 1028 | children: [ | ||
| 1029 | { | ||
| 1030 | type: "paragraph", | ||
| 1031 | children: [{ type: "text", content: "Before." }], | ||
| 1032 | }, | ||
| 1033 | { | ||
| 1034 | type: "list", | ||
| 1035 | ordered: true, | ||
| 1036 | children: [ | ||
| 1037 | { | ||
| 1038 | type: "list_item", | ||
| 1039 | children: [ | ||
| 1040 | { | ||
| 1041 | type: "paragraph", | ||
| 1042 | children: [{ type: "text", content: "Install" }], | ||
| 1043 | }, | ||
| 1044 | { | ||
| 1045 | type: "code_block", | ||
| 1046 | language: "bash", | ||
| 1047 | children: [{ type: "text", content: "pnpm install\n" }], | ||
| 1048 | }, | ||
| 1049 | ], | ||
| 1050 | }, | ||
| 1051 | ], | ||
| 1052 | }, | ||
| 1053 | { | ||
| 1054 | type: "paragraph", | ||
| 1055 | children: [{ type: "text", content: "After." }], | ||
| 1056 | }, | ||
| 1057 | ], | ||
| 1058 | }; | ||
| 1059 | |||
| 1060 | const chunks = nativeMarkdownDocumentChunks(document); | ||
| 1061 | expect(chunks).toHaveLength(3); | ||
| 1062 | expect(chunks[0]).toMatchObject({ kind: "selectable" }); | ||
| 1063 | expect(chunks[1]).toEqual({ | ||
| 1064 | kind: "rich", | ||
| 1065 | key: "rich:list:index:1", | ||
| 1066 | node: document.children?.[1], | ||
| 1067 | }); | ||
| 1068 | expect(chunks[2]).toMatchObject({ kind: "selectable" }); | ||
| 1069 | }); | ||
| 1070 | |||
| 1071 | it("never collapses a rich subtree into a second markdown parsing pass", () => { | ||
| 1072 | const document: MarkdownNode = { | ||
| 1073 | type: "document", | ||
| 1074 | children: [ | ||
| 1075 | { | ||
| 1076 | type: "paragraph", | ||
| 1077 | children: [{ type: "text", content: "Before." }], | ||
| 1078 | }, | ||
| 1079 | { | ||
| 1080 | type: "blockquote", | ||
| 1081 | children: [ | ||
| 1082 | { | ||
| 1083 | type: "list", | ||
| 1084 | children: [ | ||
| 1085 | { | ||
| 1086 | type: "list_item", | ||
| 1087 | children: [ | ||
| 1088 | { type: "text", content: "Run this" }, | ||
| 1089 | { | ||
| 1090 | type: "code_block", | ||
| 1091 | language: "sh", | ||
| 1092 | children: [{ type: "text", content: "vp check\n" }], | ||
| 1093 | }, | ||
| 1094 | ], | ||
| 1095 | }, | ||
| 1096 | ], | ||
| 1097 | }, | ||
| 1098 | ], | ||
| 1099 | }, | ||
| 1100 | { | ||
| 1101 | type: "paragraph", | ||
| 1102 | children: [{ type: "text", content: "After." }], | ||
| 1103 | }, | ||
| 1104 | ], | ||
| 1105 | }; | ||
| 1106 | |||
| 1107 | const chunks = nativeMarkdownDocumentChunks(document); | ||
| 1108 | expect(chunks.map((chunk) => chunk.kind)).toEqual(["selectable", "rich", "selectable"]); | ||
| 1109 | expect(chunks[1]).toMatchObject({ | ||
| 1110 | kind: "rich", | ||
| 1111 | node: { type: "blockquote" }, | ||
| 1112 | }); | ||
| 1113 | }); | ||
| 1114 | |||
| 1115 | it("keys positioned and offset-free siblings in separate namespaces", () => { | ||
| 1116 | const positioned: MarkdownNode = { | ||
| 1117 | type: "blockquote", | ||
| 1118 | beg: 1, | ||
| 1119 | end: 10, | ||
| 1120 | children: [{ type: "paragraph", children: [{ type: "text", content: "Positioned" }] }], | ||
| 1121 | }; | ||
| 1122 | const offsetFree: MarkdownNode = { | ||
| 1123 | type: "blockquote", | ||
| 1124 | children: [{ type: "paragraph", children: [{ type: "text", content: "Generated" }] }], | ||
| 1125 | }; | ||
| 1126 | const chunks = nativeMarkdownDocumentChunks({ | ||
| 1127 | type: "document", | ||
| 1128 | children: [ | ||
| 1129 | { type: "paragraph", beg: 0, end: 0, children: [] }, | ||
| 1130 | offsetFree, | ||
| 1131 | positioned, | ||
| 1132 | { type: "paragraph", children: [{ type: "text", content: "Tail" }] }, | ||
| 1133 | ], | ||
| 1134 | }); | ||
| 1135 | |||
| 1136 | expect(chunks.map((chunk) => chunk.key)).toEqual([ | ||
| 1137 | "selectable:offset:0", | ||
| 1138 | "rich:blockquote:index:1", | ||
| 1139 | "rich:blockquote:offset:1", | ||
| 1140 | "selectable:index:3", | ||
| 1141 | ]); | ||
| 1142 | }); | ||
| 1143 | |||
| 1144 | it("gives every offset-free selectable group its own key", () => { | ||
| 1145 | const chunks = nativeMarkdownDocumentChunks({ | ||
| 1146 | type: "document", | ||
| 1147 | children: [ | ||
| 1148 | { type: "paragraph", children: [{ type: "text", content: "One" }] }, | ||
| 1149 | { type: "horizontal_rule" }, | ||
| 1150 | { type: "paragraph", children: [{ type: "text", content: "Two" }] }, | ||
| 1151 | { type: "horizontal_rule" }, | ||
| 1152 | { type: "paragraph", children: [{ type: "text", content: "Three" }] }, | ||
| 1153 | ], | ||
| 1154 | }); | ||
| 1155 | |||
| 1156 | expect(chunks.map((chunk) => chunk.key)).toEqual([ | ||
| 1157 | "selectable:index:0", | ||
| 1158 | "rich:horizontal_rule:index:1", | ||
| 1159 | "selectable:index:2", | ||
| 1160 | "rich:horizontal_rule:index:3", | ||
| 1161 | "selectable:index:4", | ||
| 1162 | ]); | ||
| 1163 | }); | ||
| 1164 | |||
| 1165 | it("keeps positioned chunk keys stable while text streams in", () => { | ||
| 1166 | const before: MarkdownNode = { | ||
| 1167 | type: "document", | ||
| 1168 | children: [ | ||
| 1169 | { type: "paragraph", beg: 0, end: 5, children: [{ type: "text", content: "Intro" }] }, | ||
| 1170 | { | ||
| 1171 | type: "code_block", | ||
| 1172 | language: "ts", | ||
| 1173 | beg: 7, | ||
| 1174 | end: 20, | ||
| 1175 | children: [{ type: "text", content: "const a" }], | ||
| 1176 | }, | ||
| 1177 | ], | ||
| 1178 | }; | ||
| 1179 | const after: MarkdownNode = { | ||
| 1180 | type: "document", | ||
| 1181 | children: [ | ||
| 1182 | { type: "paragraph", beg: 0, end: 5, children: [{ type: "text", content: "Intro" }] }, | ||
| 1183 | { | ||
| 1184 | type: "code_block", | ||
| 1185 | language: "ts", | ||
| 1186 | beg: 7, | ||
| 1187 | end: 40, | ||
| 1188 | children: [{ type: "text", content: "const a = 1;\nconst b" }], | ||
| 1189 | }, | ||
| 1190 | ], | ||
| 1191 | }; | ||
| 1192 | |||
| 1193 | expect(nativeMarkdownDocumentChunks(after).map((chunk) => chunk.key)).toEqual( | ||
| 1194 | nativeMarkdownDocumentChunks(before).map((chunk) => chunk.key), | ||
| 1195 | ); | ||
| 1196 | }); | ||
| 1197 | |||
| 1198 | it("keeps a plain list in one selectable native text container", () => { | ||
| 1199 | const list: MarkdownNode = { | ||
| 1200 | type: "list", | ||
| 1201 | ordered: false, | ||
| 1202 | children: [ | ||
| 1203 | { | ||
| 1204 | type: "list_item", | ||
| 1205 | children: [{ type: "text", content: "First" }], | ||
| 1206 | }, | ||
| 1207 | ], | ||
| 1208 | }; | ||
| 1209 | |||
| 1210 | const chunks = nativeMarkdownDocumentChunks({ | ||
| 1211 | type: "document", | ||
| 1212 | children: [list], | ||
| 1213 | }); | ||
| 1214 | |||
| 1215 | expect(chunks).toHaveLength(1); | ||
| 1216 | expect(chunks[0]).toMatchObject({ | ||
| 1217 | kind: "selectable", | ||
| 1218 | node: { type: "document", children: [list] }, | ||
| 1219 | }); | ||
| 1220 | }); | ||
| 1221 | |||
| 1222 | it("separates sections more than related rich blocks", () => { | ||
| 1223 | const headingChunk = { | ||
| 1224 | kind: "selectable" as const, | ||
| 1225 | key: "heading", | ||
| 1226 | node: { | ||
| 1227 | type: "document", | ||
| 1228 | children: [ | ||
| 1229 | { | ||
| 1230 | type: "heading", | ||
| 1231 | level: 2, | ||
| 1232 | children: [{ type: "text", content: "Section" }], | ||
| 1233 | }, | ||
| 1234 | ], | ||
| 1235 | } satisfies MarkdownNode, | ||
| 1236 | }; | ||
| 1237 | const firstList = { | ||
| 1238 | kind: "rich" as const, | ||
| 1239 | key: "list-1", | ||
| 1240 | node: { type: "list", children: [] } satisfies MarkdownNode, | ||
| 1241 | }; | ||
| 1242 | const secondList = { | ||
| 1243 | kind: "rich" as const, | ||
| 1244 | key: "list-2", | ||
| 1245 | node: { type: "list", children: [] } satisfies MarkdownNode, | ||
| 1246 | }; | ||
| 1247 | |||
| 1248 | expect(nativeMarkdownChunkSpacing(undefined, headingChunk)).toBe(0); | ||
| 1249 | expect(nativeMarkdownChunkSpacing(headingChunk, firstList)).toBe(10); | ||
| 1250 | expect(nativeMarkdownChunkSpacing(firstList, secondList)).toBe(12); | ||
| 1251 | expect(nativeMarkdownChunkSpacing(firstList, headingChunk)).toBe(20); | ||
| 1252 | }); | ||
| 1253 | }); | ||
| 1254 | |||
| 1255 | describe("composerChipSizeSuffix", () => { | ||
| 1256 | it("labels attachment records with a human size, matching web's chip", async () => { | ||
| 1257 | const { composerChipSizeSuffix } = await import("@t3tools/mobile-markdown-text/markdown"); | ||
| 1258 | expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 1024 })).toBe("1 KB"); | ||
| 1259 | expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 3_700_000 })).toBe("3.5 MB"); | ||
| 1260 | expect(composerChipSizeSuffix({ kind: "image", sizeBytes: 2048 })).toBe("2 KB"); | ||
| 1261 | }); | ||
| 1262 | |||
| 1263 | it("adds nothing for records that carry no bytes", async () => { | ||
| 1264 | const { composerChipSizeSuffix } = await import("@t3tools/mobile-markdown-text/markdown"); | ||
| 1265 | // Terminal/review/PR chips have no size to show. | ||
| 1266 | expect(composerChipSizeSuffix({ kind: "terminal" })).toBe(""); | ||
| 1267 | expect(composerChipSizeSuffix({ kind: "file" })).toBe(""); | ||
| 1268 | expect(composerChipSizeSuffix(undefined)).toBe(""); | ||
| 1269 | }); | ||
| 1270 | }); | ||
| 1271 | |||
| 1272 | describe("contextChipPresentation image detection", () => { | ||
| 1273 | it("treats a picture attached through the file picker as an image", async () => { | ||
| 1274 | const { contextChipPresentation } = await import("@t3tools/mobile-markdown-text/markdown"); | ||
| 1275 | // The document picker types every pick as `file`, so the name has to carry the intent. | ||
| 1276 | expect( | ||
| 1277 | contextChipPresentation("file", { kind: "file", name: "IMG_4997.PNG", mimeType: "" }), | ||
| 1278 | ).toEqual({ accent: "#d55665", symbol: "photo" }); | ||
| 1279 | expect( | ||
| 1280 | contextChipPresentation("file", { | ||
| 1281 | kind: "file", | ||
| 1282 | name: "shot", | ||
| 1283 | mimeType: "image/jpeg", | ||
| 1284 | }), | ||
| 1285 | ).toEqual({ accent: "#d55665", symbol: "photo" }); | ||
| 1286 | }); | ||
| 1287 | |||
| 1288 | it("leaves genuine documents and videos alone", async () => { | ||
| 1289 | const { contextChipPresentation } = await import("@t3tools/mobile-markdown-text/markdown"); | ||
| 1290 | expect( | ||
| 1291 | contextChipPresentation("file", { kind: "file", name: "notes.txt", mimeType: "text/plain" }), | ||
| 1292 | ).toEqual({ accent: "#0090cd", symbol: "doc" }); | ||
| 1293 | expect( | ||
| 1294 | contextChipPresentation("file", { kind: "file", name: "clip.mp4", mimeType: "video/mp4" }), | ||
| 1295 | ).toEqual({ accent: "#d06217", symbol: "play.rectangle" }); | ||
| 1296 | }); | ||
| 1297 | }); | ||
| 1298 | |||
| 1299 | describe("pull request chip status", () => { | ||
| 1300 | const chip = async (state: string, isDraft = false) => { | ||
| 1301 | const { contextChipPresentation } = await import("@t3tools/mobile-markdown-text/markdown"); | ||
| 1302 | return contextChipPresentation("review-comment", { | ||
| 1303 | kind: "review-comment", | ||
| 1304 | sectionId: "pull-request:10978", | ||
| 1305 | pullRequest: { state, isDraft }, | ||
| 1306 | }); | ||
| 1307 | }; | ||
| 1308 | |||
| 1309 | it("colours a pull request by its state, the way web and the forge do", async () => { | ||
| 1310 | expect((await chip("open")).accent).toBe("#009f6e"); | ||
| 1311 | expect((await chip("open", true)).accent).toBe("#7f8793"); | ||
| 1312 | expect((await chip("merged")).accent).toBe("#8a70dd"); | ||
| 1313 | expect((await chip("closed")).accent).toBe("#d55665"); | ||
| 1314 | }); | ||
| 1315 | |||
| 1316 | it("keeps one glyph across every state, so only colour carries the status", async () => { | ||
| 1317 | // Web draws a fixed `git-pull-request` and encodes state in colour alone. A per-state glyph | ||
| 1318 | // here would put mobile out of step with it. | ||
| 1319 | const symbols = await Promise.all( | ||
| 1320 | [chip("open"), chip("open", true), chip("merged"), chip("closed")].map( | ||
| 1321 | async (pending) => (await pending).symbol, | ||
| 1322 | ), | ||
| 1323 | ); | ||
| 1324 | expect(new Set(symbols)).toEqual(new Set(["git-pull-request"])); | ||
| 1325 | }); | ||
| 1326 | |||
| 1327 | it("falls back to the generic pull request chip when the state is unknown", async () => { | ||
| 1328 | const { contextChipPresentation } = await import("@t3tools/mobile-markdown-text/markdown"); | ||
| 1329 | // An older server may send no metadata at all; the chip still has to render. | ||
| 1330 | expect( | ||
| 1331 | contextChipPresentation("review-comment", { | ||
| 1332 | kind: "review-comment", | ||
| 1333 | sectionId: "pull-request:1", | ||
| 1334 | }).accent, | ||
| 1335 | ).toBe("#7079e4"); | ||
| 1336 | }); | ||
| 1337 | }); | ||
apps/mobile/src/native/EnrichedMarkdownText.tsx created+360| ... | @@ -0,0 +1,360 @@ | ||
| 1 | import { decodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; | ||
| 2 | import { | ||
| 3 | collectComposerContextReferences, | ||
| 4 | formatComposerContextHref, | ||
| 5 | formatComposerContextReference, | ||
| 6 | } from "@t3tools/shared/composerContextReferences"; | ||
| 7 | import { markdownWithHtml } from "../lib/markdownDocument"; | ||
| 8 | import { useCallback, useMemo, useState } from "react"; | ||
| 9 | import { ActionSheetIOS, Platform, Share, StyleSheet, View } from "react-native"; | ||
| 10 | import { | ||
| 11 | EnrichedMarkdownText, | ||
| 12 | type DocumentAssetsEvent, | ||
| 13 | type MarkdownStyle, | ||
| 14 | type MarkdownMediaAsset, | ||
| 15 | } from "react-native-enriched-markdown"; | ||
| 16 | import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; | ||
| 17 | |||
| 18 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | ||
| 19 | import { AndroidAnchoredMenu } from "../components/AndroidAnchoredMenu"; | ||
| 20 | import { useEnrichedLinkVariants } from "./useEnrichedLinkVariants"; | ||
| 21 | import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; | ||
| 22 | import { | ||
| 23 | ENRICHED_INLINE_FILE_LINK_REGEX, | ||
| 24 | enrichedSkillLinkRegex, | ||
| 25 | enrichedLinkVariantPattern, | ||
| 26 | } from "../lib/enrichedLinkPresentation"; | ||
| 27 | |||
| 28 | function enrichedStyle( | ||
| 29 | style: SelectableMarkdownTextProps["textStyle"], | ||
| 30 | dark: boolean, | ||
| 31 | ): MarkdownStyle { | ||
| 32 | const monospace = Platform.OS === "ios" ? "Menlo" : "monospace"; | ||
| 33 | // Keep the radius modest: iOS clips square table-cell borders without stroking the corner arcs. | ||
| 34 | const block = { | ||
| 35 | marginTop: 4, | ||
| 36 | marginBottom: 12, | ||
| 37 | borderRadius: 8, | ||
| 38 | borderWidth: StyleSheet.hairlineWidth, | ||
| 39 | borderColor: style.dividerColor, | ||
| 40 | }; | ||
| 41 | const body = { | ||
| 42 | color: style.color, | ||
| 43 | fontFamily: style.fontFamily, | ||
| 44 | fontSize: style.fontSize, | ||
| 45 | lineHeight: style.lineHeight, | ||
| 46 | marginTop: 0, | ||
| 47 | marginBottom: 8, | ||
| 48 | }; | ||
| 49 | const heading = (level: number) => { | ||
| 50 | const fontSize = style.headingFontSizes?.[level - 1] ?? style.fontSize * (1.6 - level * 0.1); | ||
| 51 | return { | ||
| 52 | ...body, | ||
| 53 | color: style.strongColor, | ||
| 54 | fontFamily: style.headingFontFamily, | ||
| 55 | fontSize, | ||
| 56 | lineHeight: fontSize * 1.35, | ||
| 57 | marginTop: 8, | ||
| 58 | }; | ||
| 59 | }; | ||
| 60 | return { | ||
| 61 | paragraph: body, | ||
| 62 | h1: heading(1), | ||
| 63 | h2: heading(2), | ||
| 64 | h3: heading(3), | ||
| 65 | h4: heading(4), | ||
| 66 | h5: heading(5), | ||
| 67 | h6: heading(6), | ||
| 68 | strong: { color: style.strongColor, fontFamily: style.boldFontFamily, fontWeight: "normal" }, | ||
| 69 | link: { color: style.linkColor, underline: false }, | ||
| 70 | code: { | ||
| 71 | fontFamily: monospace, | ||
| 72 | color: style.inlineCodeColor, | ||
| 73 | backgroundColor: "transparent", | ||
| 74 | borderColor: "transparent", | ||
| 75 | fontSize: style.fontSize * 0.9, | ||
| 76 | }, | ||
| 77 | codeBlock: { | ||
| 78 | ...body, | ||
| 79 | color: style.codeColor, | ||
| 80 | backgroundColor: style.codeBlockBackgroundColor, | ||
| 81 | fontFamily: monospace, | ||
| 82 | fontSize: style.fontSize * 0.9, | ||
| 83 | ...block, | ||
| 84 | padding: 12, | ||
| 85 | syntaxColors: dark | ||
| 86 | ? { | ||
| 87 | keyword: "#ff7b72", | ||
| 88 | string: "#a5d6ff", | ||
| 89 | number: "#79c0ff", | ||
| 90 | constant: "#79c0ff", | ||
| 91 | comment: "#8b949e", | ||
| 92 | function: "#d2a8ff", | ||
| 93 | type: "#ffa657", | ||
| 94 | property: "#79c0ff", | ||
| 95 | tag: "#7ee787", | ||
| 96 | attribute: "#79c0ff", | ||
| 97 | } | ||
| 98 | : { | ||
| 99 | keyword: "#cf222e", | ||
| 100 | string: "#0a3069", | ||
| 101 | number: "#0550ae", | ||
| 102 | constant: "#0550ae", | ||
| 103 | comment: "#6e7781", | ||
| 104 | function: "#8250df", | ||
| 105 | type: "#953800", | ||
| 106 | property: "#0550ae", | ||
| 107 | tag: "#116329", | ||
| 108 | attribute: "#0550ae", | ||
| 109 | }, | ||
| 110 | }, | ||
| 111 | blockquote: { | ||
| 112 | ...body, | ||
| 113 | color: style.mutedColor, | ||
| 114 | backgroundColor: "transparent", | ||
| 115 | borderColor: style.quoteMarkerColor, | ||
| 116 | borderWidth: 2, | ||
| 117 | gapWidth: 12, | ||
| 118 | }, | ||
| 119 | list: { ...body, bulletColor: style.mutedColor, markerColor: style.mutedColor, gapWidth: 6 }, | ||
| 120 | thematicBreak: { color: style.dividerColor, height: 1, marginTop: 8, marginBottom: 8 }, | ||
| 121 | table: { | ||
| 122 | ...body, | ||
| 123 | ...block, | ||
| 124 | headerFontFamily: style.boldFontFamily, | ||
| 125 | headerTextColor: style.strongColor, | ||
| 126 | headerBackgroundColor: style.codeBlockBackgroundColor, | ||
| 127 | rowEvenBackgroundColor: "transparent", | ||
| 128 | rowOddBackgroundColor: "transparent", | ||
| 129 | cellPaddingHorizontal: 10, | ||
| 130 | cellPaddingVertical: 7, | ||
| 131 | }, | ||
| 132 | taskList: { | ||
| 133 | checkedColor: style.linkColor, | ||
| 134 | checkedTextColor: style.color, | ||
| 135 | borderColor: style.mutedColor, | ||
| 136 | checkedStrikethrough: false, | ||
| 137 | }, | ||
| 138 | }; | ||
| 139 | } | ||
| 140 | |||
| 141 | export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { | ||
| 142 | const { renderImage, resolveImageSource } = props; | ||
| 143 | const markdown = useMemo(() => markdownWithHtml(props.markdown), [props.markdown]); | ||
| 144 | const { themeAppearance } = useAppearancePreferences(); | ||
| 145 | const [documentAssets, setDocumentAssets] = useState<DocumentAssetsEvent["assets"]>([]); | ||
| 146 | const onDocumentAssets = useCallback(({ assets }: DocumentAssetsEvent) => { | ||
| 147 | setDocumentAssets((previous) => | ||
| 148 | previous.length === assets.length && | ||
| 149 | previous.every((asset, index) => { | ||
| 150 | const next = assets[index]!; | ||
| 151 | return ( | ||
| 152 | asset.id === next.id && | ||
| 153 | asset.kind === next.kind && | ||
| 154 | asset.url === next.url && | ||
| 155 | asset.altText === next.altText && | ||
| 156 | asset.title === next.title && | ||
| 157 | asset.placement === next.placement && | ||
| 158 | asset.eligible === next.eligible | ||
| 159 | ); | ||
| 160 | }) | ||
| 161 | ? previous | ||
| 162 | : assets, | ||
| 163 | ); | ||
| 164 | }, []); | ||
| 165 | const { linkVariants, linkPillContent } = useEnrichedLinkVariants(documentAssets, props); | ||
| 166 | const linkRecognition = useMemo( | ||
| 167 | () => ({ | ||
| 168 | text: enrichedSkillLinkRegex(props.skills) ?? undefined, | ||
| 169 | inlineCode: ENRICHED_INLINE_FILE_LINK_REGEX, | ||
| 170 | }), | ||
| 171 | [props.skills], | ||
| 172 | ); | ||
| 173 | const [activeMenu, setActiveMenu] = useState<{ | ||
| 174 | url: string; | ||
| 175 | menu: NonNullable<ReturnType<NonNullable<typeof props.fileContextMenu>>>; | ||
| 176 | } | null>(null); | ||
| 177 | const { fileContextMenu, onLinkPress, onFileContextMenuAction } = props; | ||
| 178 | const performMenuAction = useCallback( | ||
| 179 | (url: string, actionId: string) => { | ||
| 180 | if (actionId === "enriched-open-link") onLinkPress?.(url); | ||
| 181 | else if (actionId === "enriched-copy-link") copyTextWithHaptic(url); | ||
| 182 | else if (actionId === "enriched-share-link") void Share.share({ message: url }); | ||
| 183 | else onFileContextMenuAction?.(url, actionId); | ||
| 184 | }, | ||
| 185 | [onLinkPress, onFileContextMenuAction], | ||
| 186 | ); | ||
| 187 | const linkContextMenuItems = useMemo( | ||
| 188 | () => | ||
| 189 | Platform.OS === "ios" && (fileContextMenu || onLinkPress) | ||
| 190 | ? Object.fromEntries( | ||
| 191 | [ | ||
| 192 | ...new Set( | ||
| 193 | documentAssets.filter((asset) => asset.kind === "link").map((asset) => asset.url), | ||
| 194 | ), | ||
| 195 | ].map((url) => { | ||
| 196 | const menu = fileContextMenu?.(url) ?? { | ||
| 197 | title: url, | ||
| 198 | actions: [ | ||
| 199 | { id: "enriched-open-link", title: "Open link" }, | ||
| 200 | { id: "enriched-copy-link", title: "Copy link" }, | ||
| 201 | { id: "enriched-share-link", title: "Share link" }, | ||
| 202 | ], | ||
| 203 | }; | ||
| 204 | return [ | ||
| 205 | enrichedLinkVariantPattern(url), | ||
| 206 | menu.actions.map((action) => ({ | ||
| 207 | text: action.title, | ||
| 208 | disabled: "disabled" in action && action.disabled, | ||
| 209 | onPress: () => performMenuAction(url, action.id), | ||
| 210 | })), | ||
| 211 | ]; | ||
| 212 | }), | ||
| 213 | ) | ||
| 214 | : undefined, | ||
| 215 | [documentAssets, fileContextMenu, onLinkPress, performMenuAction], | ||
| 216 | ); | ||
| 217 | const selectionClipboard = useMemo(() => { | ||
| 218 | const fragment = decodeComposerContextFragment(props.contextClipboardFragment); | ||
| 219 | if (!fragment) return undefined; | ||
| 220 | const references = collectComposerContextReferences(props.markdown); | ||
| 221 | const linkTextByUrl = Object.fromEntries( | ||
| 222 | references.flatMap((reference) => { | ||
| 223 | const record = fragment.records.find((entry) => entry.contextId === reference.contextId); | ||
| 224 | return record | ||
| 225 | ? [ | ||
| 226 | [ | ||
| 227 | formatComposerContextHref(reference.kind, reference.contextId), | ||
| 228 | formatComposerContextReference({ ...reference, label: record.label }), | ||
| 229 | ], | ||
| 230 | ] | ||
| 231 | : []; | ||
| 232 | }), | ||
| 233 | ); | ||
| 234 | return { | ||
| 235 | linkTextByUrl, | ||
| 236 | htmlAttributes: { | ||
| 237 | "data-t3-context-fragment": encodeURIComponent(props.contextClipboardFragment!), | ||
| 238 | }, | ||
| 239 | mimeTypes: { "app.t3.context-fragment": props.contextClipboardFragment! }, | ||
| 240 | }; | ||
| 241 | }, [props.contextClipboardFragment, props.markdown]); | ||
| 242 | const markdownStyle = useMemo( | ||
| 243 | () => ({ ...enrichedStyle(props.textStyle, themeAppearance === "dark"), linkVariants }), | ||
| 244 | [props.textStyle, themeAppearance, linkVariants], | ||
| 245 | ); | ||
| 246 | const renderMedia = useCallback( | ||
| 247 | (asset: MarkdownMediaAsset) => | ||
| 248 | renderImage?.({ | ||
| 249 | href: asset.url, | ||
| 250 | alt: asset.altText || null, | ||
| 251 | title: asset.title || null, | ||
| 252 | }) ?? null, | ||
| 253 | [renderImage], | ||
| 254 | ); | ||
| 255 | const resolveNativeImageSource = useCallback( | ||
| 256 | (asset: MarkdownMediaAsset) => | ||
| 257 | resolveImageSource?.({ | ||
| 258 | href: asset.url, | ||
| 259 | alt: asset.altText || null, | ||
| 260 | title: asset.title || null, | ||
| 261 | }) ?? null, | ||
| 262 | [resolveImageSource], | ||
| 263 | ); | ||
| 264 | const renderMarkdown = (openAndroidMenu?: () => void) => { | ||
| 265 | const onLinkLongPress = props.fileContextMenu | ||
| 266 | ? ({ url }: { url: string }) => { | ||
| 267 | const menu = props.fileContextMenu?.(url) ?? { | ||
| 268 | title: url, | ||
| 269 | actions: [ | ||
| 270 | { id: "enriched-open-link", title: "Open link" }, | ||
| 271 | { id: "enriched-copy-link", title: "Copy link" }, | ||
| 272 | ], | ||
| 273 | }; | ||
| 274 | const actions = menu.actions; | ||
| 275 | if (Platform.OS === "ios") { | ||
| 276 | ActionSheetIOS.showActionSheetWithOptions( | ||
| 277 | { | ||
| 278 | title: menu.title, | ||
| 279 | options: [...actions.map((action) => action.title), "Cancel"], | ||
| 280 | cancelButtonIndex: actions.length, | ||
| 281 | disabledButtonIndices: actions.flatMap((action, index) => | ||
| 282 | action.disabled ? [index] : [], | ||
| 283 | ), | ||
| 284 | }, | ||
| 285 | (index) => { | ||
| 286 | const action = actions[index]; | ||
| 287 | if (action && !action.disabled) performMenuAction(url, action.id); | ||
| 288 | }, | ||
| 289 | ); | ||
| 290 | } else { | ||
| 291 | setActiveMenu({ url, menu }); | ||
| 292 | openAndroidMenu?.(); | ||
| 293 | } | ||
| 294 | } | ||
| 295 | : undefined; | ||
| 296 | |||
| 297 | return ( | ||
| 298 | <View | ||
| 299 | style={{ | ||
| 300 | flexShrink: 1, | ||
| 301 | minWidth: 0, | ||
| 302 | marginTop: props.marginTop, | ||
| 303 | marginBottom: props.marginBottom, | ||
| 304 | }} | ||
| 305 | > | ||
| 306 | <EnrichedMarkdownText | ||
| 307 | markdown={markdown} | ||
| 308 | markdownStyle={markdownStyle} | ||
| 309 | onDocumentAssets={onDocumentAssets} | ||
| 310 | renderMedia={renderImage ? renderMedia : undefined} | ||
| 311 | resolveImageSource={resolveImageSource ? resolveNativeImageSource : undefined} | ||
| 312 | onImagePress={ | ||
| 313 | props.onImagePress || props.onLinkPress | ||
| 314 | ? ({ url }) => (props.onImagePress ?? props.onLinkPress)?.(url) | ||
| 315 | : undefined | ||
| 316 | } | ||
| 317 | linkRecognition={linkRecognition} | ||
| 318 | containerStyle={{ flexShrink: 1, minWidth: 0 }} | ||
| 319 | flavor="github" | ||
| 320 | selectable | ||
| 321 | selectionClipboard={selectionClipboard} | ||
| 322 | selectionColor={props.textStyle.selectionColor} | ||
| 323 | selectionHandleColor={props.textStyle.selectionHandleColor} | ||
| 324 | md4cFlags={{ | ||
| 325 | latexMath: false, | ||
| 326 | hardSoftBreaks: props.preserveSoftBreaks ?? false, | ||
| 327 | admonitions: true, | ||
| 328 | }} | ||
| 329 | enableTaskListItemToggle={false} | ||
| 330 | spoilerOverlay="solid" | ||
| 331 | onLinkPress={props.onLinkPress ? ({ url }) => props.onLinkPress?.(url) : undefined} | ||
| 332 | onLinkLongPress={onLinkLongPress} | ||
| 333 | linkContextMenuItems={linkContextMenuItems} | ||
| 334 | linkPillContent={linkPillContent} | ||
| 335 | /> | ||
| 336 | </View> | ||
| 337 | ); | ||
| 338 | }; | ||
| 339 | |||
| 340 | return Platform.OS === "android" && props.fileContextMenu ? ( | ||
| 341 | <AndroidAnchoredMenu | ||
| 342 | title={activeMenu?.menu.title} | ||
| 343 | actions={ | ||
| 344 | activeMenu?.menu.actions.map((action) => ({ | ||
| 345 | id: action.id, | ||
| 346 | title: action.title, | ||
| 347 | attributes: { disabled: action.disabled }, | ||
| 348 | })) ?? [] | ||
| 349 | } | ||
| 350 | onPressAction={({ nativeEvent }) => { | ||
| 351 | if (activeMenu) performMenuAction(activeMenu.url, nativeEvent.event); | ||
| 352 | }} | ||
| 353 | style={{ flexShrink: 1, minWidth: 0 }} | ||
| 354 | > | ||
| 355 | {renderMarkdown} | ||
| 356 | </AndroidAnchoredMenu> | ||
| 357 | ) : ( | ||
| 358 | renderMarkdown() | ||
| 359 | ); | ||
| 360 | } | ||
apps/mobile/src/native/SelectableMarkdownText.android.tsx deleted-40| ... | @@ -1,40 +0,0 @@ | ||
| 1 | import { | ||
| 2 | SelectableMarkdownText as T3SelectableMarkdownText, | ||
| 3 | type SelectableMarkdownTextProps, | ||
| 4 | } from "@t3tools/mobile-markdown-text/renderer"; | ||
| 5 | import { useMemo } from "react"; | ||
| 6 | |||
| 7 | import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; | ||
| 8 | import { themeColorWithAlpha } from "../lib/mobileTheme"; | ||
| 9 | import { useUniwindTheme } from "../lib/useUniwindTheme"; | ||
| 10 | |||
| 11 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; | ||
| 12 | |||
| 13 | export type { | ||
| 14 | MarkdownImageRequest, | ||
| 15 | NativeMarkdownTextStyle, | ||
| 16 | SelectableMarkdownSkill, | ||
| 17 | } from "@t3tools/mobile-markdown-text/types"; | ||
| 18 | |||
| 19 | // The renderer falls back to React Native Text outside iOS, so Android can use | ||
| 20 | // the same Markdown chunking while retaining native text selection. | ||
| 21 | export function hasNativeSelectableMarkdownText(): boolean { | ||
| 22 | return true; | ||
| 23 | } | ||
| 24 | |||
| 25 | export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { | ||
| 26 | const theme = useUniwindTheme(); | ||
| 27 | const selectionColor = themeColorWithAlpha(theme["--color-focus"], 0.32); | ||
| 28 | const selectionHandleColor = theme["--color-focus"]; | ||
| 29 | const textStyle = useMemo( | ||
| 30 | () => ({ selectionColor, selectionHandleColor, ...props.textStyle }), | ||
| 31 | [props.textStyle, selectionColor, selectionHandleColor], | ||
| 32 | ); | ||
| 33 | return ( | ||
| 34 | <T3SelectableMarkdownText | ||
| 35 | {...props} | ||
| 36 | textStyle={textStyle} | ||
| 37 | highlightCode={highlightCodeSnippet} | ||
| 38 | /> | ||
| 39 | ); | ||
| 40 | } | ||
apps/mobile/src/native/SelectableMarkdownText.ios.tsx deleted-25| ... | @@ -1,25 +0,0 @@ | ||
| 1 | import { | ||
| 2 | SelectableMarkdownText as T3SelectableMarkdownText, | ||
| 3 | type SelectableMarkdownTextProps, | ||
| 4 | } from "@t3tools/mobile-markdown-text/renderer"; | ||
| 5 | |||
| 6 | import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; | ||
| 7 | |||
| 8 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; | ||
| 9 | |||
| 10 | export type { | ||
| 11 | MarkdownFileContextMenu, | ||
| 12 | MarkdownFileContextMenuAction, | ||
| 13 | MarkdownImageRenderer, | ||
| 14 | MarkdownImageRequest, | ||
| 15 | NativeMarkdownTextStyle, | ||
| 16 | SelectableMarkdownSkill, | ||
| 17 | } from "@t3tools/mobile-markdown-text/types"; | ||
| 18 | |||
| 19 | export function hasNativeSelectableMarkdownText(): boolean { | ||
| 20 | return true; | ||
| 21 | } | ||
| 22 | |||
| 23 | export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { | ||
| 24 | return <T3SelectableMarkdownText {...props} highlightCode={highlightCodeSnippet} />; | ||
| 25 | } | ||
apps/mobile/src/native/SelectableMarkdownText.tsx+21-9| ... | @@ -1,20 +1,32 @@ | ... | @@ -1,20 +1,32 @@ |
| 1 | import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/renderer"; | 1 | import { useMemo } from "react"; |
| 2 | import { Platform } from "react-native"; | ||
| 3 | import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; | ||
| 2 | 4 | ||
| 3 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; | 5 | import { MobileEnrichedMarkdownText } from "./EnrichedMarkdownText"; |
| 6 | import { themeColorWithAlpha } from "../lib/mobileTheme"; | ||
| 7 | import { useUniwindTheme } from "../lib/useUniwindTheme"; | ||
| 4 | 8 | ||
| 5 | export type { | 9 | export type { |
| 6 | MarkdownFileContextMenu, | 10 | MarkdownFileContextMenu, |
| 7 | MarkdownFileContextMenuAction, | 11 | MarkdownFileContextMenuAction, |
| 8 | MarkdownImageRenderer, | 12 | MarkdownImageRenderer, |
| 9 | MarkdownImageRequest, | 13 | MarkdownImageRequest, |
| 14 | MarkdownImageSourceResolver, | ||
| 15 | MarkdownLinkCustomization, | ||
| 10 | NativeMarkdownTextStyle, | 16 | NativeMarkdownTextStyle, |
| 11 | SelectableMarkdownSkill, | 17 | SelectableMarkdownSkill, |
| 12 | } from "@t3tools/mobile-markdown-text/types"; | 18 | } from "./SelectableMarkdownText.types"; |
| 13 | 19 | ||
| 14 | export function hasNativeSelectableMarkdownText(): boolean { | 20 | export function SelectableMarkdownText(props: SelectableMarkdownTextProps) { |
| 15 | return false; | 21 | const theme = useUniwindTheme(); |
| 16 | } | 22 | const selectionColor = themeColorWithAlpha(theme["--color-focus"], 0.32); |
| 17 | 23 | const selectionHandleColor = theme["--color-focus"]; | |
| 18 | export function SelectableMarkdownText(_props: MobileSelectableMarkdownTextProps) { | 24 | const textStyle = useMemo( |
| 19 | return null; | 25 | () => |
| 26 | Platform.OS === "android" | ||
| 27 | ? { selectionColor, selectionHandleColor, ...props.textStyle } | ||
| 28 | : props.textStyle, | ||
| 29 | [props.textStyle, selectionColor, selectionHandleColor], | ||
| 30 | ); | ||
| 31 | return <MobileEnrichedMarkdownText {...props} textStyle={textStyle} />; | ||
| 20 | } | 32 | } |
apps/mobile/src/native/SelectableMarkdownText.types.ts created+84| ... | @@ -0,0 +1,84 @@ | ||
| 1 | export interface NativeMarkdownTextStyle { | ||
| 2 | readonly selectionColor?: string; | ||
| 3 | readonly selectionHandleColor?: string; | ||
| 4 | readonly color: string; | ||
| 5 | readonly strongColor: string; | ||
| 6 | readonly mutedColor: string; | ||
| 7 | readonly linkColor: string; | ||
| 8 | readonly inlineCodeColor: string; | ||
| 9 | readonly codeColor: string; | ||
| 10 | readonly codeBackgroundColor: string; | ||
| 11 | readonly codeBlockBackgroundColor: string; | ||
| 12 | readonly fileTextColor: string; | ||
| 13 | readonly skillTextColor: string; | ||
| 14 | readonly quoteMarkerColor: string; | ||
| 15 | readonly dividerColor: string; | ||
| 16 | readonly contextChipBorderColor?: string; | ||
| 17 | readonly fontSize: number; | ||
| 18 | readonly lineHeight: number; | ||
| 19 | readonly fontFamily: string; | ||
| 20 | readonly headingFontFamily: string; | ||
| 21 | readonly boldFontFamily: string; | ||
| 22 | readonly headingFontSizes?: ReadonlyArray<number>; | ||
| 23 | } | ||
| 24 | |||
| 25 | export interface SelectableMarkdownSkill { | ||
| 26 | readonly name: string; | ||
| 27 | readonly displayName?: string | null; | ||
| 28 | } | ||
| 29 | |||
| 30 | export interface MarkdownImageRequest { | ||
| 31 | readonly href: string; | ||
| 32 | readonly alt: string | null; | ||
| 33 | readonly title: string | null; | ||
| 34 | } | ||
| 35 | |||
| 36 | /** | ||
| 37 | * App-supplied renderer for markdown images. The module cannot load | ||
| 38 | * workspace-relative image paths itself — the host app resolves them (for | ||
| 39 | * example through a signed asset URL) and returns the element to show. | ||
| 40 | * Returning null keeps Enriched's native media rendering. | ||
| 41 | */ | ||
| 42 | export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode; | ||
| 43 | |||
| 44 | export type MarkdownImageSourceResolver = ( | ||
| 45 | image: MarkdownImageRequest, | ||
| 46 | ) => | ||
| 47 | | { uri: string; headers?: Record<string, string> } | ||
| 48 | | null | ||
| 49 | | Promise<{ uri: string; headers?: Record<string, string> } | null>; | ||
| 50 | |||
| 51 | export interface MarkdownFileContextMenuAction { | ||
| 52 | readonly id: string; | ||
| 53 | readonly title: string; | ||
| 54 | readonly disabled?: boolean; | ||
| 55 | } | ||
| 56 | |||
| 57 | export interface MarkdownFileContextMenu { | ||
| 58 | readonly title?: string; | ||
| 59 | readonly actions: ReadonlyArray<MarkdownFileContextMenuAction>; | ||
| 60 | } | ||
| 61 | |||
| 62 | export interface MarkdownLinkCustomization { | ||
| 63 | readonly iconTintColor?: string; | ||
| 64 | readonly color?: string; | ||
| 65 | readonly label?: string; | ||
| 66 | readonly icon?: import("react-native").ImageSourcePropType; | ||
| 67 | } | ||
| 68 | |||
| 69 | export interface SelectableMarkdownTextProps { | ||
| 70 | readonly markdown: string; | ||
| 71 | readonly textStyle: NativeMarkdownTextStyle; | ||
| 72 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; | ||
| 73 | readonly preserveSoftBreaks?: boolean; | ||
| 74 | readonly onLinkPress?: (href: string) => void; | ||
| 75 | readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined; | ||
| 76 | readonly onFileContextMenuAction?: (href: string, actionId: string) => void; | ||
| 77 | readonly linkCustomization?: (href: string) => MarkdownLinkCustomization | undefined; | ||
| 78 | readonly contextClipboardFragment?: string; | ||
| 79 | readonly renderImage?: MarkdownImageRenderer; | ||
| 80 | readonly resolveImageSource?: MarkdownImageSourceResolver; | ||
| 81 | readonly onImagePress?: (href: string) => void; | ||
| 82 | readonly marginTop?: number; | ||
| 83 | readonly marginBottom?: number; | ||
| 84 | } | ||
apps/mobile/src/native/T3ComposerEditor.ios.tsx+3-9| ... | @@ -15,15 +15,9 @@ import { | ... | @@ -15,15 +15,9 @@ import { |
| 15 | import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; | 15 | import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; |
| 16 | import { StyleSheet } from "react-native"; | 16 | import { StyleSheet } from "react-native"; |
| 17 | 17 | ||
| 18 | import { | 18 | import { markdownFileIconSource, markdownIconAssetUri } from "../lib/markdownFileIcons"; |
| 19 | markdownFileIconSource, | 19 | import { composerChipSizeSuffix, contextChipPresentation } from "../lib/composerChipPresentation"; |
| 20 | markdownIconAssetUri, | 20 | import { resolveMarkdownFileIcon } from "../lib/markdownLinks"; |
| 21 | } from "@t3tools/mobile-markdown-text/file-icons"; | ||
| 22 | import { | ||
| 23 | composerChipSizeSuffix, | ||
| 24 | contextChipPresentation, | ||
| 25 | } from "@t3tools/mobile-markdown-text/markdown"; | ||
| 26 | import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; | ||
| 27 | import { useUniwindTheme } from "../lib/useUniwindTheme"; | 21 | import { useUniwindTheme } from "../lib/useUniwindTheme"; |
| 28 | import { createNativeComposerTheme } from "../lib/nativeComposerTheme"; | 22 | import { createNativeComposerTheme } from "../lib/nativeComposerTheme"; |
| 29 | import { useFontFamily } from "../lib/useFontFamily"; | 23 | import { useFontFamily } from "../lib/useFontFamily"; |
apps/mobile/src/native/T3ComposerEditor.native.tsx+3-9| ... | @@ -16,15 +16,9 @@ import { | ... | @@ -16,15 +16,9 @@ import { |
| 16 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; | 16 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; |
| 17 | import { Image, Platform, StyleSheet } from "react-native"; | 17 | import { Image, Platform, StyleSheet } from "react-native"; |
| 18 | 18 | ||
| 19 | import { | 19 | import { markdownFileIconSource, markdownIconAssetUri } from "../lib/markdownFileIcons"; |
| 20 | markdownFileIconSource, | 20 | import { composerChipSizeSuffix, contextChipPresentation } from "../lib/composerChipPresentation"; |
| 21 | markdownIconAssetUri, | 21 | import { resolveMarkdownFileIcon } from "../lib/markdownLinks"; |
| 22 | } from "@t3tools/mobile-markdown-text/file-icons"; | ||
| 23 | import { | ||
| 24 | composerChipSizeSuffix, | ||
| 25 | contextChipPresentation, | ||
| 26 | } from "@t3tools/mobile-markdown-text/markdown"; | ||
| 27 | import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; | ||
| 28 | import { MOBILE_TYPOGRAPHY } from "../lib/typography"; | 22 | import { MOBILE_TYPOGRAPHY } from "../lib/typography"; |
| 29 | import { useNativePaste } from "../lib/useNativePaste"; | 23 | import { useNativePaste } from "../lib/useNativePaste"; |
| 30 | import { useFontFamily } from "../lib/useFontFamily"; | 24 | import { useFontFamily } from "../lib/useFontFamily"; |
apps/mobile/src/native/createV5StackNavigator.ios.tsx+4| ... | @@ -269,10 +269,14 @@ export function V5StackView(props: V5StackViewProps) { | ... | @@ -269,10 +269,14 @@ export function V5StackView(props: V5StackViewProps) { |
| 269 | groups.map((group) => { | 269 | groups.map((group) => { |
| 270 | const first = group[0]!; | 270 | const first = group[0]!; |
| 271 | const descriptor = props.descriptors[first.key]!; | 271 | const descriptor = props.descriptors[first.key]!; |
| 272 | const visibleRoute = group.at(-1)!; | ||
| 272 | return [ | 273 | return [ |
| 273 | first.key, | 274 | first.key, |
| 274 | { | 275 | { |
| 275 | ...descriptor, | 276 | ...descriptor, |
| 277 | navigation: props.descriptors[visibleRoute.key]!.navigation, | ||
| 278 | // Focus belongs to the pushed page inside this presentation group. | ||
| 279 | route: visibleRoute, | ||
| 276 | options: modalEnvelopeOptions(descriptor.options), | 280 | options: modalEnvelopeOptions(descriptor.options), |
| 277 | render: () => ( | 281 | render: () => ( |
| 278 | <V5CardStackView | 282 | <V5CardStackView |
apps/mobile/src/native/useEnrichedLinkVariants.ts created+105| ... | @@ -0,0 +1,105 @@ | ||
| 1 | import { useMemo } from "react"; | ||
| 2 | import { Image } from "react-native"; | ||
| 3 | import type { | ||
| 4 | LinkPillContent, | ||
| 5 | MarkdownDocumentAsset, | ||
| 6 | MarkdownStyle, | ||
| 7 | } from "react-native-enriched-markdown"; | ||
| 8 | import { markdownFileIconSource } from "../lib/markdownFileIcons"; | ||
| 9 | import { markdownLinkIconSource } from "../lib/markdownLinkIcons"; | ||
| 10 | import { resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation } from "../lib/markdownLinks"; | ||
| 11 | import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; | ||
| 12 | import { | ||
| 13 | enrichedContextLinkPresentation, | ||
| 14 | enrichedLinkVariantPattern, | ||
| 15 | enrichedSkillDisplayName, | ||
| 16 | enrichedSkillReference, | ||
| 17 | } from "../lib/enrichedLinkPresentation"; | ||
| 18 | |||
| 19 | function presentation( | ||
| 20 | url: string, | ||
| 21 | props: Pick<SelectableMarkdownTextProps, "linkCustomization" | "skills" | "textStyle">, | ||
| 22 | ) { | ||
| 23 | const custom = props.linkCustomization?.(url); | ||
| 24 | if (custom) return custom; | ||
| 25 | const context = enrichedContextLinkPresentation(url); | ||
| 26 | if (context) return { color: context.color, icon: markdownFileIconSource(context.icon) }; | ||
| 27 | const skill = enrichedSkillReference(url, props.skills); | ||
| 28 | if (skill) | ||
| 29 | return { | ||
| 30 | color: props.textStyle.skillTextColor, | ||
| 31 | label: enrichedSkillDisplayName(skill), | ||
| 32 | icon: markdownFileIconSource("mcp"), | ||
| 33 | }; | ||
| 34 | const link = resolveMarkdownLinkPresentation(url); | ||
| 35 | if (link.kind === "file") | ||
| 36 | return { | ||
| 37 | color: props.textStyle.fileTextColor, | ||
| 38 | label: link.label, | ||
| 39 | icon: markdownFileIconSource(link.icon), | ||
| 40 | }; | ||
| 41 | const icon = link.kind === "external" ? resolveMarkdownLinkIcon(link.host) : null; | ||
| 42 | return icon | ||
| 43 | ? { | ||
| 44 | color: props.textStyle.linkColor, | ||
| 45 | icon: markdownLinkIconSource(icon), | ||
| 46 | iconTintColor: props.textStyle.linkColor, | ||
| 47 | } | ||
| 48 | : undefined; | ||
| 49 | } | ||
| 50 | |||
| 51 | export function useEnrichedLinkVariants( | ||
| 52 | assets: ReadonlyArray<MarkdownDocumentAsset>, | ||
| 53 | props: SelectableMarkdownTextProps, | ||
| 54 | ) { | ||
| 55 | const { linkCustomization, skills, textStyle } = props; | ||
| 56 | const links = useMemo(() => { | ||
| 57 | const byUrl = new Map<string, NonNullable<ReturnType<typeof presentation>>>(); | ||
| 58 | for (const asset of assets) { | ||
| 59 | if (asset.kind !== "link" || byUrl.has(asset.url)) continue; | ||
| 60 | const value = presentation(asset.url, { linkCustomization, skills, textStyle }); | ||
| 61 | if (value) byUrl.set(asset.url, value); | ||
| 62 | } | ||
| 63 | return byUrl; | ||
| 64 | }, [assets, linkCustomization, skills, textStyle]); | ||
| 65 | const linkVariants = useMemo<MarkdownStyle["linkVariants"]>( | ||
| 66 | () => | ||
| 67 | Object.fromEntries( | ||
| 68 | [...links].map(([url, link]) => { | ||
| 69 | const color = link.color ?? textStyle.fileTextColor; | ||
| 70 | return [ | ||
| 71 | enrichedLinkVariantPattern(url), | ||
| 72 | { | ||
| 73 | pill: { | ||
| 74 | borderColor: "transparent", | ||
| 75 | borderWidth: 0, | ||
| 76 | borderRadius: 6, | ||
| 77 | paddingHorizontal: 5, | ||
| 78 | paddingVertical: 2, | ||
| 79 | }, | ||
| 80 | fontFamily: textStyle.fontFamily, | ||
| 81 | color, | ||
| 82 | underline: false, | ||
| 83 | backgroundColor: "transparent", | ||
| 84 | }, | ||
| 85 | ]; | ||
| 86 | }), | ||
| 87 | ), | ||
| 88 | [links, textStyle], | ||
| 89 | ); | ||
| 90 | const linkPillContent = useMemo<Record<string, LinkPillContent>>( | ||
| 91 | () => | ||
| 92 | Object.fromEntries( | ||
| 93 | [...links].map(([url, link]) => [ | ||
| 94 | url, | ||
| 95 | { | ||
| 96 | label: link.label, | ||
| 97 | iconUri: link.icon ? Image.resolveAssetSource(link.icon)?.uri : undefined, | ||
| 98 | iconTintColor: link.iconTintColor, | ||
| 99 | }, | ||
| 100 | ]), | ||
| 101 | ), | ||
| 102 | [links], | ||
| 103 | ); | ||
| 104 | return { linkVariants, linkPillContent }; | ||
| 105 | } | ||
apps/mobile/src/native/useMarkdownImageSource.ts created+73| ... | @@ -0,0 +1,73 @@ | ||
| 1 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; | ||
| 2 | import { useEnvironmentPresentation } from "../state/presentation"; | ||
| 3 | import { useEnvironmentQuery } from "../state/query"; | ||
| 4 | import { resolveMobileMarkdownMediaSource } from "../lib/markdownMediaSource"; | ||
| 5 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | ||
| 6 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 7 | import type { MarkdownImageSourceResolver } from "./SelectableMarkdownText.types"; | ||
| 8 | import { normalizeNativeMarkdownUrl } from "../lib/markdownLinks"; | ||
| 9 | import { useCallback } from "react"; | ||
| 10 | import * as Option from "effect/Option"; | ||
| 11 | |||
| 12 | import { assetEnvironment } from "../state/assets"; | ||
| 13 | import { environmentSession, usePreparedConnection } from "../state/session"; | ||
| 14 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | ||
| 15 | |||
| 16 | /** Native nested images use the same signed asset queries as app-owned media. */ | ||
| 17 | export function useMarkdownImageSource(props: { | ||
| 18 | readonly environmentId: EnvironmentId; | ||
| 19 | readonly threadId: ThreadId | null; | ||
| 20 | readonly workspaceRoot: string | null; | ||
| 21 | readonly captured?: boolean; | ||
| 22 | }): MarkdownImageSourceResolver { | ||
| 23 | const { environmentId, threadId, workspaceRoot, captured } = props; | ||
| 24 | const connection = usePreparedConnection(environmentId); | ||
| 25 | const environment = useEnvironmentPresentation(environmentId); | ||
| 26 | const connectionPhase = environment.presentation?.connection.phase; | ||
| 27 | const session = useEnvironmentQuery(environmentSession.sessionStateAtom(environmentId)); | ||
| 28 | const fileAccess = resolveFilesystemReadAccess({ | ||
| 29 | isCatalogReady: environment.isReady, | ||
| 30 | connection: environment.presentation?.connection ?? null, | ||
| 31 | session: session.data, | ||
| 32 | sessionError: session.error, | ||
| 33 | }); | ||
| 34 | const httpBaseUrl = Option.isSome(connection) ? connection.value.httpBaseUrl : null; | ||
| 35 | const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false }); | ||
| 36 | return useCallback( | ||
| 37 | async (image) => { | ||
| 38 | const media = resolveMobileMarkdownMediaSource(image.href, { | ||
| 39 | threadId: threadId ?? undefined, | ||
| 40 | workspaceRoot, | ||
| 41 | imageEmbed: true, | ||
| 42 | }); | ||
| 43 | if (media?.access === "direct") return { uri: normalizeNativeMarkdownUrl(media.uri) }; | ||
| 44 | if ( | ||
| 45 | captured || | ||
| 46 | !fileAccess.canReadFiles || | ||
| 47 | media === null || | ||
| 48 | media.access === "unavailable" || | ||
| 49 | httpBaseUrl === null || | ||
| 50 | connectionPhase === "offline" || | ||
| 51 | connectionPhase === "error" || | ||
| 52 | connectionPhase === "unsupported" || | ||
| 53 | connectionPhase === "reconnecting" | ||
| 54 | ) | ||
| 55 | throw new Error("Markdown image is unavailable"); | ||
| 56 | const result = await createUrl({ environmentId, input: { resource: media.resource } }); | ||
| 57 | if (result._tag !== "Success") throw new Error("Could not authorize Markdown image"); | ||
| 58 | const uri = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); | ||
| 59 | if (uri === null) throw new Error("Markdown image URL is invalid"); | ||
| 60 | return { uri }; | ||
| 61 | }, | ||
| 62 | [ | ||
| 63 | captured, | ||
| 64 | fileAccess.canReadFiles, | ||
| 65 | connectionPhase, | ||
| 66 | createUrl, | ||
| 67 | environmentId, | ||
| 68 | httpBaseUrl, | ||
| 69 | threadId, | ||
| 70 | workspaceRoot, | ||
| 71 | ], | ||
| 72 | ); | ||
| 73 | } | ||
apps/mobile/src/state/mediaActions.ts+1-1| ... | @@ -7,7 +7,7 @@ import { | ... | @@ -7,7 +7,7 @@ import { |
| 7 | sessionGrantsScope, | 7 | sessionGrantsScope, |
| 8 | type SessionGrantInput, | 8 | type SessionGrantInput, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; | 10 | import { normalizeNativeMarkdownUrl } from "../lib/markdownLinks"; |
| 11 | import * as Option from "effect/Option"; | 11 | import * as Option from "effect/Option"; |
| 12 | import { AsyncResult } from "effect/reactivity"; | 12 | import { AsyncResult } from "effect/reactivity"; |
| 13 | import { useEffect, useRef, useState } from "react"; | 13 | import { useEffect, useRef, useState } from "react"; |
knip.jsonc+1-1| ... | @@ -88,7 +88,7 @@ | ... | @@ -88,7 +88,7 @@ |
| 88 | "@t3tools/mobile-terminal-native", | 88 | "@t3tools/mobile-terminal-native", |
| 89 | ], | 89 | ], |
| 90 | }, | 90 | }, |
| 91 | "apps/mobile/modules/t3-markdown-text": { | 91 | "apps/mobile/modules/t3-source-text": { |
| 92 | // React Native codegen discovers component specifications by filename. | 92 | // React Native codegen discovers component specifications by filename. |
| 93 | "entry": ["src/*NativeComponent.ts!"], | 93 | "entry": ["src/*NativeComponent.ts!"], |
| 94 | }, | 94 | }, |
patches/react-native-nitro-markdown@0.5.8.patch deleted-70| ... | @@ -1,70 +0,0 @@ | ||
| 1 | diff --git a/cpp/md4c/md4c.c b/cpp/md4c/md4c.c | ||
| 2 | --- a/cpp/md4c/md4c.c | ||
| 3 | +++ b/cpp/md4c/md4c.c | ||
| 4 | @@ -3908,10 +3908,10 @@ | ||
| 5 | int min_components; | ||
| 6 | const MD_CHAR optional_end_char; | ||
| 7 | } URL_MAP[] = { | ||
| 8 | - { _T('\0'), _T('.'), _T(".-_"), 2, _T('\0') }, /* host, mandatory */ | ||
| 9 | - { _T('/'), _T('/'), _T("/.-_"), 0, _T('/') }, /* path */ | ||
| 10 | - { _T('?'), _T('&'), _T("&.-+_=()"), 1, _T('\0') }, /* query */ | ||
| 11 | - { _T('#'), _T('\0'), _T(".-+_") , 1, _T('\0') } /* fragment */ | ||
| 12 | + { _T('\0'), _T('.'), _T(".-_"), 2, _T('\0') }, /* host, mandatory */ | ||
| 13 | + { _T('/'), _T('/'), _T("/.-_():~%+@&!,;"), 0, _T('/') }, /* path */ | ||
| 14 | + { _T('?'), _T('&'), _T("&.-+_=().:!~%,;@"), 1, _T('\0') }, /* query */ | ||
| 15 | + { _T('#'), _T('\0'), _T(".-+_:%~") , 1, _T('\0') } /* fragment */ | ||
| 16 | }; | ||
| 17 | |||
| 18 | MD_MARK* opener = &ctx->marks[mark_index]; | ||
| 19 | @@ -3981,8 +3981,11 @@ | ||
| 20 | } else if(end < line_end && | ||
| 21 | ISANYOF(end, URL_MAP[i].allowed_nonalnum_chars) && | ||
| 22 | md_scan_right_for_resolved_mark(ctx, right_cursor, end, &right_cursor) == NULL && | ||
| 23 | - ((end > line_beg && (ISALNUM(end-1) || CH(end-1) == _T(')'))) || CH(end) == _T('(')) && | ||
| 24 | - ((end+1 < line_end && (ISALNUM(end+1) || CH(end+1) == _T('('))) || CH(end) == _T(')'))) | ||
| 25 | + /* Scheme URLs keep '...' and ':' in the path and query. | ||
| 26 | + * Host, e-mail, and www labels stay flanked by alphanumerics. */ | ||
| 27 | + ((opener->ch == _T(':') && i > 0) || | ||
| 28 | + (((end > line_beg && (ISALNUM(end-1) || CH(end-1) == _T(')'))) || CH(end) == _T('(')) && | ||
| 29 | + ((end+1 < line_end && (ISALNUM(end+1) || CH(end+1) == _T('('))) || CH(end) == _T(')'))))) | ||
| 30 | { | ||
| 31 | if(CH(end) == URL_MAP[i].delim_char) | ||
| 32 | n_components++; | ||
| 33 | @@ -4006,16 +4009,35 @@ | ||
| 34 | CH(end) == URL_MAP[i].optional_end_char) | ||
| 35 | end++; | ||
| 36 | |||
| 37 | - if(n_components < URL_MAP[i].min_components || n_open_brackets != 0) | ||
| 38 | + /* Scheme autolinks allow one host label, so localhost and devbox link. | ||
| 39 | + * E-mail and www autolinks still require a dotted host. */ | ||
| 40 | + if(n_components < (i == 0 && opener->ch == ':' ? 1 : URL_MAP[i].min_components) || | ||
| 41 | + n_open_brackets != 0) | ||
| 42 | return; | ||
| 43 | |||
| 44 | + /* Optional port on a scheme autolink: ':' followed by digits. */ | ||
| 45 | + if(i == 0 && opener->ch == ':' && end + 1 < line_end && | ||
| 46 | + CH(end) == _T(':') && ISDIGIT(end + 1)) { | ||
| 47 | + end++; | ||
| 48 | + while(end < line_end && ISDIGIT(end)) | ||
| 49 | + end++; | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | if(opener->ch == '@') /* E-mail autolinks wants only the host. */ | ||
| 53 | break; | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + /* GFM leaves a trailing run of punctuation outside the link, including | ||
| 57 | + * the sentence dots in "https://example.com/a...". */ | ||
| 58 | + if(opener->ch == _T(':')) { | ||
| 59 | + while(end > opener->end && ISANYOF(end - 1, _T("?!.,:*"))) | ||
| 60 | + end--; | ||
| 61 | } | ||
| 62 | |||
| 63 | /* Verify there's line boundary, whitespace, allowed punctuation or | ||
| 64 | * resolved emphasis mark just after the suspected autolink. */ | ||
| 65 | - if(end == line_end || ISUNICODEWHITESPACE(end) || ISANYOF(end, _T(")}].!?,;"))) { | ||
| 66 | + if(end == line_end || ISUNICODEWHITESPACE(end) || | ||
| 67 | + ISANYOF(end, opener->ch == _T(':') ? _T(")}].!?,;:*_~'\"`<>") : _T(")}].!?,;"))) { | ||
| 68 | right_boundary_ok = TRUE; | ||
| 69 | } else { | ||
| 70 | MD_MARK* right_mark; | ||
patches/react-native-nitro-modules@0.35.9.patch deleted-32| ... | @@ -1,32 +0,0 @@ | ||
| 1 | diff --git a/ios/turbomodule/NativeNitroModules+NewArch.mm b/ios/turbomodule/NativeNitroModules+NewArch.mm | ||
| 2 | --- a/ios/turbomodule/NativeNitroModules+NewArch.mm | ||
| 3 | +++ b/ios/turbomodule/NativeNitroModules+NewArch.mm | ||
| 4 | @@ -34,6 +34,12 @@ | ||
| 5 | } | ||
| 6 | RCT_EXPORT_MODULE(NitroModules) | ||
| 7 | |||
| 8 | +// RN 0.87+ passes the CallInvoker directly when installing JSI bindings. | ||
| 9 | +- (void)installJSIBindingsWithRuntime:(jsi::Runtime&)runtime callInvoker:(const std::shared_ptr<react::CallInvoker>&)callInvoker { | ||
| 10 | + _callInvoker = callInvoker; | ||
| 11 | + [self installJSIBindingsWithRuntime:runtime]; | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | - (void)installJSIBindingsWithRuntime:(jsi::Runtime&)runtime { | ||
| 15 | // 1. Get CallInvoker we cached statically | ||
| 16 | std::shared_ptr<react::CallInvoker> callInvoker = _callInvoker.lock(); | ||
| 17 | diff --git a/package.json b/package.json | ||
| 18 | index 4e4366fb9d05a6c250acfc62e79487475d8ac05b..200e4680d77cbb4f14d22eb986efde1275a0d3e8 100644 | ||
| 19 | --- a/package.json | ||
| 20 | +++ b/package.json | ||
| 21 | @@ -88,6 +88,11 @@ | ||
| 22 | "jsSrcsDir": "./src", | ||
| 23 | "android": { | ||
| 24 | "javaPackageName": "com.margelo.nitro" | ||
| 25 | + }, | ||
| 26 | + "ios": { | ||
| 27 | + "modulesProvider": { | ||
| 28 | + "NitroModules": "NativeNitroModules" | ||
| 29 | + } | ||
| 30 | } | ||
| 31 | }, | ||
| 32 | "jest": { | ||
pnpm-lock.yaml+44-68| ... | @@ -167,8 +167,6 @@ patchedDependencies: | ... | @@ -167,8 +167,6 @@ patchedDependencies: |
| 167 | node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 | 167 | node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 |
| 168 | react-native-gesture-handler@3.2.1: 3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad | 168 | react-native-gesture-handler@3.2.1: 3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad |
| 169 | react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d | 169 | react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d |
| 170 | react-native-nitro-markdown@0.5.8: 642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c | ||
| 171 | react-native-nitro-modules@0.35.9: daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2 | ||
| 172 | react-native-webview@14.0.1: 81caa279ec3dc1f81209062133b66e8f824e13f106cca57f1fa732a72c2ab378 | 170 | react-native-webview@14.0.1: 81caa279ec3dc1f81209062133b66e8f824e13f106cca57f1fa732a72c2ab378 |
| 173 | 171 | ||
| 174 | importers: | 172 | importers: |
| ... | @@ -366,12 +364,12 @@ importers: | ... | @@ -366,12 +364,12 @@ importers: |
| 366 | '@t3tools/contracts': | 364 | '@t3tools/contracts': |
| 367 | specifier: workspace:* | 365 | specifier: workspace:* |
| 368 | version: link:../../packages/contracts | 366 | version: link:../../packages/contracts |
| 369 | '@t3tools/mobile-markdown-text': | ||
| 370 | specifier: file:./modules/t3-markdown-text | ||
| 371 | version: file:apps/mobile/modules/t3-markdown-text(afae94297d61d3a65abc2214f9b05422) | ||
| 372 | '@t3tools/mobile-review-diff-native': | 367 | '@t3tools/mobile-review-diff-native': |
| 373 | specifier: file:./modules/t3-review-diff | 368 | specifier: file:./modules/t3-review-diff |
| 374 | version: file:apps/mobile/modules/t3-review-diff | 369 | version: file:apps/mobile/modules/t3-review-diff |
| 370 | '@t3tools/mobile-source-text': | ||
| 371 | specifier: file:./modules/t3-source-text | ||
| 372 | version: file:apps/mobile/modules/t3-source-text(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) | ||
| 375 | '@t3tools/mobile-terminal-native': | 373 | '@t3tools/mobile-terminal-native': |
| 376 | specifier: file:./modules/t3-terminal | 374 | specifier: file:./modules/t3-terminal |
| 377 | version: file:apps/mobile/modules/t3-terminal | 375 | version: file:apps/mobile/modules/t3-terminal |
| ... | @@ -528,6 +526,9 @@ importers: | ... | @@ -528,6 +526,9 @@ importers: |
| 528 | react-native: | 526 | react-native: |
| 529 | specifier: 0.88.0-rc.3 | 527 | specifier: 0.88.0-rc.3 |
| 530 | version: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) | 528 | version: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) |
| 529 | react-native-enriched-markdown: | ||
| 530 | specifier: file:deps/react-native-enriched-markdown-1.2.0-t3.10.tgz | ||
| 531 | version: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz(katex@0.16.47)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) | ||
| 531 | react-native-gesture-handler: | 532 | react-native-gesture-handler: |
| 532 | specifier: ~3.2.1 | 533 | specifier: ~3.2.1 |
| 533 | version: 3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) | 534 | version: 3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) |
| ... | @@ -537,12 +538,6 @@ importers: | ... | @@ -537,12 +538,6 @@ importers: |
| 537 | react-native-keyboard-controller: | 538 | react-native-keyboard-controller: |
| 538 | specifier: 1.22.6 | 539 | specifier: 1.22.6 |
| 539 | version: 1.22.6(patch_hash=6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) | 540 | version: 1.22.6(patch_hash=6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) |
| 540 | react-native-nitro-markdown: | ||
| 541 | specifier: ^0.5.0 | ||
| 542 | version: 0.5.8(patch_hash=642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c)(react-native-nitro-modules@0.35.9(patch_hash=daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native-svg@15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) | ||
| 543 | react-native-nitro-modules: | ||
| 544 | specifier: 0.35.9 | ||
| 545 | version: 0.35.9(patch_hash=daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) | ||
| 546 | react-native-reanimated: | 541 | react-native-reanimated: |
| 547 | specifier: 4.7.0 | 542 | specifier: 4.7.0 |
| 548 | version: 4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) | 543 | version: 4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) |
| ... | @@ -564,12 +559,21 @@ importers: | ... | @@ -564,12 +559,21 @@ importers: |
| 564 | react-native-worklets: | 559 | react-native-worklets: |
| 565 | specifier: 0.13.0 | 560 | specifier: 0.13.0 |
| 566 | version: 0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) | 561 | version: 0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) |
| 562 | remark-gfm: | ||
| 563 | specifier: 'catalog:' | ||
| 564 | version: 4.0.1 | ||
| 565 | remark-parse: | ||
| 566 | specifier: 'catalog:' | ||
| 567 | version: 11.0.0 | ||
| 567 | shiki: | 568 | shiki: |
| 568 | specifier: 4.2.0 | 569 | specifier: 4.2.0 |
| 569 | version: 4.2.0 | 570 | version: 4.2.0 |
| 570 | tailwind-merge: | 571 | tailwind-merge: |
| 571 | specifier: ^3.5.0 | 572 | specifier: ^3.5.0 |
| 572 | version: 3.6.0 | 573 | version: 3.6.0 |
| 574 | unified: | ||
| 575 | specifier: 'catalog:' | ||
| 576 | version: 11.0.5 | ||
| 573 | uniwind: | 577 | uniwind: |
| 574 | specifier: 1.12.1 | 578 | specifier: 1.12.1 |
| 575 | version: 1.12.1(@expo/metro-config@58.0.6(patch_hash=90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760)(bufferutil@4.1.0)(expo@58.0.2)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.87.1)(metro-transform-worker@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(tailwindcss@4.3.3) | 579 | version: 1.12.1(@expo/metro-config@58.0.6(patch_hash=90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760)(bufferutil@4.1.0)(expo@58.0.2)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.87.1)(metro-transform-worker@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(tailwindcss@4.3.3) |
| ... | @@ -580,6 +584,9 @@ importers: | ... | @@ -580,6 +584,9 @@ importers: |
| 580 | '@pierre/trees': | 584 | '@pierre/trees': |
| 581 | specifier: 1.0.0-beta.6 | 585 | specifier: 1.0.0-beta.6 |
| 582 | version: 1.0.0-beta.6(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) | 586 | version: 1.0.0-beta.6(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) |
| 587 | '@types/mdast': | ||
| 588 | specifier: ^4.0.4 | ||
| 589 | version: 4.0.4 | ||
| 583 | '@types/react': | 590 | '@types/react': |
| 584 | specifier: ~19.3.0 | 591 | specifier: ~19.3.0 |
| 585 | version: 19.3.0 | 592 | version: 19.3.0 |
| ... | @@ -5803,22 +5810,14 @@ packages: | ... | @@ -5803,22 +5810,14 @@ packages: |
| 5803 | resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} | 5810 | resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} |
| 5804 | engines: {node: '>=10'} | 5811 | engines: {node: '>=10'} |
| 5805 | 5812 | ||
| 5806 | '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text': | 5813 | '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': |
| 5807 | resolution: {directory: apps/mobile/modules/t3-markdown-text, type: directory} | 5814 | resolution: {directory: apps/mobile/modules/t3-review-diff, type: directory} |
| 5815 | |||
| 5816 | '@t3tools/mobile-source-text@file:apps/mobile/modules/t3-source-text': | ||
| 5817 | resolution: {directory: apps/mobile/modules/t3-source-text, type: directory} | ||
| 5808 | peerDependencies: | 5818 | peerDependencies: |
| 5809 | '@t3tools/client-runtime': '*' | ||
| 5810 | '@t3tools/shared': '*' | ||
| 5811 | expo: '*' | ||
| 5812 | expo-asset: '*' | ||
| 5813 | expo-clipboard: '*' | ||
| 5814 | expo-haptics: '*' | ||
| 5815 | expo-symbols: '*' | ||
| 5816 | react: '*' | 5819 | react: '*' |
| 5817 | react-native: '*' | 5820 | react-native: '*' |
| 5818 | react-native-nitro-markdown: '*' | ||
| 5819 | |||
| 5820 | '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': | ||
| 5821 | resolution: {directory: apps/mobile/modules/t3-review-diff, type: directory} | ||
| 5822 | 5821 | ||
| 5823 | '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': | 5822 | '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': |
| 5824 | resolution: {directory: apps/mobile/modules/t3-terminal, type: directory} | 5823 | resolution: {directory: apps/mobile/modules/t3-terminal, type: directory} |
| ... | @@ -10804,6 +10803,17 @@ packages: | ... | @@ -10804,6 +10803,17 @@ packages: |
| 10804 | '@types/react': '>=18' | 10803 | '@types/react': '>=18' |
| 10805 | react: '>=18' | 10804 | react: '>=18' |
| 10806 | 10805 | ||
| 10806 | react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz: | ||
| 10807 | resolution: {integrity: sha512-n9If6J8Jy1iZJAvcLflWAgVxO9LjiwMQax//seCvNWgTdob5Hc48m2mfnjxiVzxwCHFPVcRnNpbe65oROG+gVQ==, tarball: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz} | ||
| 10808 | version: 1.2.0-t3.10 | ||
| 10809 | peerDependencies: | ||
| 10810 | katex: '>=0.16.0' | ||
| 10811 | react: '*' | ||
| 10812 | react-native: '*' | ||
| 10813 | peerDependenciesMeta: | ||
| 10814 | katex: | ||
| 10815 | optional: true | ||
| 10816 | |||
| 10807 | react-native-gesture-handler@3.2.1: | 10817 | react-native-gesture-handler@3.2.1: |
| 10808 | resolution: {integrity: sha512-VEWscxUN29aeccbD2McF0LSXrwOhSdisaSRFiEWg0O/3WrVghNJp/tOtLrCgzb9gfcZ3xdA+K2Sp8F5G3iHx/Q==} | 10818 | resolution: {integrity: sha512-VEWscxUN29aeccbD2McF0LSXrwOhSdisaSRFiEWg0O/3WrVghNJp/tOtLrCgzb9gfcZ3xdA+K2Sp8F5G3iHx/Q==} |
| 10809 | peerDependencies: | 10819 | peerDependencies: |
| ... | @@ -10829,26 +10839,6 @@ packages: | ... | @@ -10829,26 +10839,6 @@ packages: |
| 10829 | react-native: '*' | 10839 | react-native: '*' |
| 10830 | react-native-reanimated: '>=3.0.0' | 10840 | react-native-reanimated: '>=3.0.0' |
| 10831 | 10841 | ||
| 10832 | react-native-nitro-markdown@0.5.8: | ||
| 10833 | resolution: {integrity: sha512-bBKmdWgMqZoNozuHzJgc8VxWRSjLnrHAK0bRRvz7gj5FqPxLJz+KhRV0VYZDZgvsR3S8hnmmeEqluTRBTRw+yA==} | ||
| 10834 | peerDependencies: | ||
| 10835 | react: '*' | ||
| 10836 | react-native: '>=0.75.0' | ||
| 10837 | react-native-mathjax-svg: '>=0.9.0' | ||
| 10838 | react-native-nitro-modules: '>=0.35.5' | ||
| 10839 | react-native-svg: '>=13.0.0' | ||
| 10840 | peerDependenciesMeta: | ||
| 10841 | react-native-mathjax-svg: | ||
| 10842 | optional: true | ||
| 10843 | react-native-svg: | ||
| 10844 | optional: true | ||
| 10845 | |||
| 10846 | react-native-nitro-modules@0.35.9: | ||
| 10847 | resolution: {integrity: sha512-yCO6eJ85SPPUo4a4an7H5oj6wPCSIT72fbjr5WZ/20n6zswaJ2gNNpnWtg2We0AZwkAOjSqkOJ0Vjc05p6kGiA==} | ||
| 10848 | peerDependencies: | ||
| 10849 | react: '*' | ||
| 10850 | react-native: '*' | ||
| 10851 | |||
| 10852 | react-native-reanimated@4.7.0: | 10842 | react-native-reanimated@4.7.0: |
| 10853 | resolution: {integrity: sha512-8TyTrPisqz7WzmbhCz8F60Uw7NI2QeYWrY5Hk58/kurCA5L5KuHmhyQjxeq5k2YalpXUAKbdkn/mAMZWm/fU0g==} | 10843 | resolution: {integrity: sha512-8TyTrPisqz7WzmbhCz8F60Uw7NI2QeYWrY5Hk58/kurCA5L5KuHmhyQjxeq5k2YalpXUAKbdkn/mAMZWm/fU0g==} |
| 10854 | peerDependencies: | 10844 | peerDependencies: |
| ... | @@ -16361,20 +16351,12 @@ snapshots: | ... | @@ -16361,20 +16351,12 @@ snapshots: |
| 16361 | dependencies: | 16351 | dependencies: |
| 16362 | defer-to-connect: 2.0.1 | 16352 | defer-to-connect: 2.0.1 |
| 16363 | 16353 | ||
| 16364 | '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(afae94297d61d3a65abc2214f9b05422)': | 16354 | '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} |
| 16355 | |||
| 16356 | '@t3tools/mobile-source-text@file:apps/mobile/modules/t3-source-text(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)': | ||
| 16365 | dependencies: | 16357 | dependencies: |
| 16366 | '@t3tools/client-runtime': link:packages/client-runtime | ||
| 16367 | '@t3tools/shared': link:packages/shared | ||
| 16368 | expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) | ||
| 16369 | expo-asset: 58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2) | ||
| 16370 | expo-clipboard: 58.0.3(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) | ||
| 16371 | expo-haptics: 58.0.4(expo@58.0.2) | ||
| 16372 | expo-symbols: 58.0.3(expo-font@58.0.5(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) | ||
| 16373 | react: 19.3.0 | 16358 | react: 19.3.0 |
| 16374 | react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) | 16359 | react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) |
| 16375 | react-native-nitro-markdown: 0.5.8(patch_hash=642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c)(react-native-nitro-modules@0.35.9(patch_hash=daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native-svg@15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) | ||
| 16376 | |||
| 16377 | '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} | ||
| 16378 | 16360 | ||
| 16379 | '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {} | 16361 | '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {} |
| 16380 | 16362 | ||
| ... | @@ -22009,6 +21991,13 @@ snapshots: | ... | @@ -22009,6 +21991,13 @@ snapshots: |
| 22009 | transitivePeerDependencies: | 21991 | transitivePeerDependencies: |
| 22010 | - supports-color | 21992 | - supports-color |
| 22011 | 21993 | ||
| 21994 | react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz(katex@0.16.47)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): | ||
| 21995 | dependencies: | ||
| 21996 | react: 19.3.0 | ||
| 21997 | react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) | ||
| 21998 | optionalDependencies: | ||
| 21999 | katex: 0.16.47 | ||
| 22000 | |||
| 22012 | react-native-gesture-handler@3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): | 22001 | react-native-gesture-handler@3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): |
| 22013 | dependencies: | 22002 | dependencies: |
| 22014 | '@types/react-test-renderer': 19.1.0 | 22003 | '@types/react-test-renderer': 19.1.0 |
| ... | @@ -22033,19 +22022,6 @@ snapshots: | ... | @@ -22033,19 +22022,6 @@ snapshots: |
| 22033 | react-native-is-edge-to-edge: 1.3.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) | 22022 | react-native-is-edge-to-edge: 1.3.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) |
| 22034 | react-native-reanimated: 4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) | 22023 | react-native-reanimated: 4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) |
| 22035 | 22024 | ||
| 22036 | react-native-nitro-markdown@0.5.8(patch_hash=642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c)(react-native-nitro-modules@0.35.9(patch_hash=daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native-svg@15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): | ||
| 22037 | dependencies: | ||
| 22038 | react: 19.3.0 | ||
| 22039 | react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) | ||
| 22040 | react-native-nitro-modules: 0.35.9(patch_hash=daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) | ||
| 22041 | optionalDependencies: | ||
| 22042 | react-native-svg: 15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) | ||
| 22043 | |||
| 22044 | react-native-nitro-modules@0.35.9(patch_hash=daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): | ||
| 22045 | dependencies: | ||
| 22046 | react: 19.3.0 | ||
| 22047 | react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) | ||
| 22048 | |||
| 22049 | react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): | 22025 | react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): |
| 22050 | dependencies: | 22026 | dependencies: |
| 22051 | react: 19.3.0 | 22027 | react: 19.3.0 |
pnpm-workspace.yaml+2-3| ... | @@ -20,6 +20,8 @@ allowBuilds: | ... | @@ -20,6 +20,8 @@ allowBuilds: |
| 20 | node-pty: true | 20 | node-pty: true |
| 21 | # Ships N-API prebuilds; its install script only rebuilds from source. | 21 | # Ships N-API prebuilds; its install script only rebuilds from source. |
| 22 | objc-js: false | 22 | objc-js: false |
| 23 | "react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz": true | ||
| 24 | react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.9.tgz: set this to true or false | ||
| 23 | sharp: true | 25 | sharp: true |
| 24 | utf-8-validate: false | 26 | utf-8-validate: false |
| 25 | workerd: false | 27 | workerd: false |
| ... | @@ -308,11 +310,8 @@ patchedDependencies: | ... | @@ -308,11 +310,8 @@ patchedDependencies: |
| 308 | dbus-next@0.10.2: patches/dbus-next@0.10.2.patch | 310 | dbus-next@0.10.2: patches/dbus-next@0.10.2.patch |
| 309 | effect@4.0.2: patches/effect@4.0.2.patch | 311 | effect@4.0.2: patches/effect@4.0.2.patch |
| 310 | node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch | 312 | node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch |
| 311 | # Link http(s) URLs with a port or a single-label host. md4c otherwise requires a dotted host and stops at ':'. | ||
| 312 | react-native-nitro-markdown@0.5.8: patches/react-native-nitro-markdown@0.5.8.patch | ||
| 313 | react-native-gesture-handler@3.2.1: patches/react-native-gesture-handler@3.2.1.patch | 313 | react-native-gesture-handler@3.2.1: patches/react-native-gesture-handler@3.2.1.patch |
| 314 | react-native-keyboard-controller@1.22.6: patches/react-native-keyboard-controller@1.22.6.patch | 314 | react-native-keyboard-controller@1.22.6: patches/react-native-keyboard-controller@1.22.6.patch |
| 315 | react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch | ||
| 316 | # Android: a nested WebView hands a vertical drag to its scrolling parent at the page's edge. | 315 | # Android: a nested WebView hands a vertical drag to its scrolling parent at the page's edge. |
| 317 | react-native-webview@14.0.1: patches/react-native-webview@14.0.1.patch | 316 | react-native-webview@14.0.1: patches/react-native-webview@14.0.1.patch |
| 318 | 317 |
scripts/release-smoke.ts+1-1| ... | @@ -17,7 +17,7 @@ const workspaceFiles = [ | ... | @@ -17,7 +17,7 @@ const workspaceFiles = [ |
| 17 | "apps/desktop/package.json", | 17 | "apps/desktop/package.json", |
| 18 | "apps/web/package.json", | 18 | "apps/web/package.json", |
| 19 | "apps/mobile/package.json", | 19 | "apps/mobile/package.json", |
| 20 | "apps/mobile/modules/t3-markdown-text/package.json", | 20 | "apps/mobile/modules/t3-source-text/package.json", |
| 21 | "apps/mobile/modules/t3-review-diff/package.json", | 21 | "apps/mobile/modules/t3-review-diff/package.json", |
| 22 | "apps/mobile/modules/t3-terminal/package.json", | 22 | "apps/mobile/modules/t3-terminal/package.json", |
| 23 | "apps/marketing/package.json", | 23 | "apps/marketing/package.json", |
third-party-licenses.config.json+1-1| ... | @@ -137,7 +137,7 @@ | ... | @@ -137,7 +137,7 @@ |
| 137 | "bundles": ["mobile"], | 137 | "bundles": ["mobile"], |
| 138 | "license": "MIT", | 138 | "license": "MIT", |
| 139 | "name": "react-native-uitextview", | 139 | "name": "react-native-uitextview", |
| 140 | "noticeFile": "apps/mobile/modules/t3-markdown-text/LICENSE", | 140 | "noticeFile": "apps/mobile/modules/t3-source-text/LICENSE", |
| 141 | "sourceUrl": "https://github.com/bluesky-social/react-native-uitextview", | 141 | "sourceUrl": "https://github.com/bluesky-social/react-native-uitextview", |
| 142 | "version": "2.2.0" | 142 | "version": "2.2.0" |
| 143 | }, | 143 | }, |
vite.config.ts+1-1| ... | @@ -393,7 +393,7 @@ export default defineConfig({ | ... | @@ -393,7 +393,7 @@ export default defineConfig({ |
| 393 | "apps/mobile/src/lib/useMobileNavigationTheme.ts", | 393 | "apps/mobile/src/lib/useMobileNavigationTheme.ts", |
| 394 | "apps/mobile/src/native/T3ComposerEditor.ios.tsx", | 394 | "apps/mobile/src/native/T3ComposerEditor.ios.tsx", |
| 395 | "apps/mobile/src/native/T3ComposerEditor.native.tsx", | 395 | "apps/mobile/src/native/T3ComposerEditor.native.tsx", |
| 396 | "apps/mobile/src/native/SelectableMarkdownText.android.tsx", | 396 | "apps/mobile/src/native/SelectableMarkdownText.tsx", |
| 397 | ], | 397 | ], |
| 398 | rules: { | 398 | rules: { |
| 399 | "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }], | 399 | "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }], |