diff --git a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_code_block_combo_display.png index 296333b28..ff6448911 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/code_block_display.png b/.maestro/enrichedMarkdownText/screenshots/android/code_block_display.png index 3607abf2b..30ca57f4d 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/code_block_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/code_block_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/code_block_image_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/code_block_image_combo_display.png index bd9411e69..9f8b55ab9 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/code_block_image_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/code_block_image_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/code_block_math_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/code_block_math_combo_display.png index ec2c43107..9610fc76b 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/code_block_math_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/code_block_math_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/header_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/header_code_block_combo_display.png index 19b5f301d..751304f0b 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/header_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/header_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_loose_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_loose_combo_display.png index 62ce86fab..ec53b78d4 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_loose_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_loose_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_tight_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_tight_combo_display.png index 62ce86fab..ec53b78d4 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_tight_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/list_code_block_tight_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/paragraph_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/paragraph_code_block_combo_display.png index 16b36d5c4..077d9c1eb 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/paragraph_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/paragraph_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png index 0e302516f..e0098115f 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_display.png index dfae1fd45..384bbc3e1 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_image_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_image_combo_display.png index d0db2e47e..bb9a46eb8 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_image_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_image_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_math_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_math_combo_display.png index f8eafb8b6..5c4c6bc35 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_math_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_math_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/header_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/header_code_block_combo_display.png index a17509d35..01ab42bc5 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/header_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/header_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_code_block_combo_display.png index e69de29bb..3112513b9 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_code_block_combo_display.png differ diff --git a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/CodeBlock.stories.tsx b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/CodeBlock.stories.tsx index 9ca94dd94..200f953bc 100644 --- a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/CodeBlock.stories.tsx +++ b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/CodeBlock.stories.tsx @@ -5,6 +5,7 @@ import { codeBlockStyledDefaults, fontFamilyControl, fontWeightControl, + githubFlavorArgTypes, type CodeBlockStyleControls, numberControl, } from '../shared/storybookMarkdownStyles'; @@ -14,7 +15,7 @@ import { } from '../shared/storybookStyleBuilders'; import type { TextStory } from '../shared/storyTypes'; -const MARKDOWN = `\`\`\` +const MARKDOWN = `\`\`\`python sum = 0 for i in range(20): print(i % 3) @@ -24,6 +25,9 @@ print(sum) \`\`\``; const argTypes = { + ...githubFlavorArgTypes( + 'commonmark — code block rendered as spans inside the single TextView. github — code block rendered as a separate block component.' + ), fontSize: numberControl('markdownStyle.codeBlock.fontSize', { min: 10, max: 20, @@ -80,6 +84,7 @@ export default storyMeta('Block', 'Code Block'); export const Default: TextStory = { args: { markdown: MARKDOWN, + flavor: 'github', ...codeBlockStyledDefaults, }, argTypes, @@ -91,7 +96,7 @@ export const Default: TextStory = { return ( diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 35d77b0bd..509a199f0 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -233,7 +233,7 @@ Code blocks are always rendered left-to-right regardless of this prop. Per-parag ### `flavor` -Markdown flavor. Set to `'github'` to enable GitHub Flavored Markdown table support. +Markdown flavor. Set to `'github'` to enable GitHub Flavored Markdown features: tables and block-style code blocks. | Type | Default Value | Platform | | --------------------------------- | --------------- | -------- | @@ -241,7 +241,9 @@ Markdown flavor. Set to `'github'` to enable GitHub Flavored Markdown table supp > **Note:** > - **`'commonmark'`**: All Markdown content is rendered as a single TextView. Selecting text will select all content in the view. -> - **`'github'`**: The Markdown AST is split into segments. Consecutive text blocks (paragraphs, headings, lists, etc.) are grouped into separate TextView segments, while tables are rendered as separate table views. This allows for granular text selection within each segment and enables interactive table features (horizontal scrolling, context menus). Text selection cannot span across segments. +> - **`'github'`**: The Markdown AST is split into segments. Consecutive text blocks (paragraphs, headings, lists, etc.) are grouped into separate TextView segments, while tables, fenced code blocks, and math blocks are rendered as separate block views. This allows for granular text selection within each segment and enables interactive block features (horizontal table scrolling, context menus, the code block header). Text selection cannot span across segments. +> +> With `'github'`, a fenced code block renders as a dedicated component: a header bar with the language display name (` ```python ` shows "Python") and a copy-code button, a divider, and the code below. Long lines do not wrap — the code pane scrolls horizontally while the header stays fixed. Long-pressing the block opens the Copy / Copy as Markdown menu. With `'commonmark'`, code blocks stay inside the single TextView, styled via spans, and long lines wrap. ### `streamingAnimation` diff --git a/docs/COPY_OPTIONS.md b/docs/COPY_OPTIONS.md index 0367941d8..7730e1980 100644 --- a/docs/COPY_OPTIONS.md +++ b/docs/COPY_OPTIONS.md @@ -93,8 +93,11 @@ Notes: plural category (`zero`, `one`, `two`, `few`, `many`, `other`). Only `other` is required; any category left `undefined` falls back to it. The `{count}` token is replaced by the number of selected images. -- The labels apply to the main text selection menu as well as the table and math - block copy menus. +- The labels apply to the main text selection menu as well as the table, math, + and code block copy menus. The code block header's copy button also reuses + the copy label for assistive technologies: it is the button's + contentDescription on Android, and on iOS it names the VoiceOver custom + action that triggers the copy. - OS-provided actions (Look Up, Translate…) and the system **Cut / Paste / Select All** items are localized by the platform and are not affected by this config. diff --git a/docs/ELEMENTS_STRUCTURE.md b/docs/ELEMENTS_STRUCTURE.md index 7cb0bc315..2a9f6a8c3 100644 --- a/docs/ELEMENTS_STRUCTURE.md +++ b/docs/ELEMENTS_STRUCTURE.md @@ -13,7 +13,7 @@ Markdown elements in `react-native-enriched-markdown` are organized into block a | Headings | `# H1` to `###### H6` | `h1` - `h6` | Six levels of headings | | Paragraphs | Plain text | `paragraph` | Default text container | | Blockquotes | `> Quote` | `blockquote` | Quoted content with accent bar, unlimited nesting | -| Code Blocks | ` ``` code ``` ` | `codeBlock` | Multi-line code containers | +| Code Blocks | ` ``` code ``` ` | `codeBlock` | Multi-line code containers; with `flavor="github"` rendered as a block component with a language header and copy-code button | | Unordered Lists | `- Item`, `* Item`, or `+ Item` | `list` | Bullet lists with unlimited nesting | | Ordered Lists | `1. Item` | `list` | Numbered lists with unlimited nesting | | Task Lists | `- [x] Done`, `- [ ] Todo` | `taskList` | Interactive checkboxes (requires `flavor="github"`) | diff --git a/docs/STYLES.md b/docs/STYLES.md index 6c4a3a624..765d34abf 100644 --- a/docs/STYLES.md +++ b/docs/STYLES.md @@ -319,6 +319,9 @@ function App() { > [!NOTE] > Inside list items, code blocks (background included) indent to the item's content column. +> [!NOTE] +> With `flavor="github"`, code blocks render as a block component with a header bar (language name on the left, copy-code button on the right) and a divider above the code. The header derives its appearance from the code block style: the label uses the system font at 0.85 x `fontSize`, and the label, button, and divider use `color` at reduced opacity. Dedicated header style properties may be added later. + ### Inline Code-specific | Property | Type | Description | diff --git a/packages/core/EnrichedMarkdownCore.podspec b/packages/core/EnrichedMarkdownCore.podspec index 9a8e56478..2ac467536 100644 --- a/packages/core/EnrichedMarkdownCore.podspec +++ b/packages/core/EnrichedMarkdownCore.podspec @@ -13,11 +13,11 @@ Pod::Spec.new do |s| s.platforms = { :ios => min_ios_version_supported, :osx => "14.0" } - s.source_files = "cpp/md4c/*.{c,h}", "cpp/parser/*.{hpp,cpp}" + s.source_files = "cpp/md4c/*.{c,h}", "cpp/parser/*.{hpp,cpp}", "cpp/highlight/*.{hpp,cpp}" s.private_header_files = "cpp/**/*.{h,hpp}" s.pod_target_xcconfig = { - "HEADER_SEARCH_PATHS" => '"$(PODS_TARGET_SRCROOT)/cpp/md4c" "$(PODS_TARGET_SRCROOT)/cpp/parser"', + "HEADER_SEARCH_PATHS" => '"$(PODS_TARGET_SRCROOT)/cpp/md4c" "$(PODS_TARGET_SRCROOT)/cpp/parser" "$(PODS_TARGET_SRCROOT)/cpp/highlight"', "GCC_PREPROCESSOR_DEFINITIONS" => "$(inherited) MD4C_USE_UTF8=1", "CLANG_CXX_LANGUAGE_STANDARD" => "c++17" } diff --git a/packages/core/cpp/highlight/CodeBlockHighlighter.cpp b/packages/core/cpp/highlight/CodeBlockHighlighter.cpp new file mode 100644 index 000000000..bf27281e5 --- /dev/null +++ b/packages/core/cpp/highlight/CodeBlockHighlighter.cpp @@ -0,0 +1,17 @@ +#include "CodeBlockHighlighter.hpp" + +// Stub compiled when the syntax highlighting module is disabled. The real +// implementation defines the same symbol under ENRICHED_MARKDOWN_CODE_HIGHLIGHT, +// so exactly one definition exists in any build configuration. + +#if !defined(ENRICHED_MARKDOWN_CODE_HIGHLIGHT) + +namespace Markdown { + +std::vector highlightCode(const std::string & /*code*/, const std::string & /*language*/) { + return {}; +} + +} // namespace Markdown + +#endif diff --git a/packages/core/cpp/highlight/CodeBlockHighlighter.hpp b/packages/core/cpp/highlight/CodeBlockHighlighter.hpp new file mode 100644 index 000000000..cf0d23c66 --- /dev/null +++ b/packages/core/cpp/highlight/CodeBlockHighlighter.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include + +// Shared seam for optional syntax highlighting of fenced code blocks. +// +// The real implementation is compiled only when ENRICHED_MARKDOWN_CODE_HIGHLIGHT +// is defined by the build. Without the flag the stub in CodeBlockHighlighter.cpp +// returns no tokens and platform callers keep the plain uncolored rendering, so +// the absent module degrades to the default code block appearance. The same +// fallback applies at runtime when a language has no compiled grammar or +// highlighting fails. +// +// The seam returns semantic tokens instead of a platform text type on +// purpose. Platform adapters map token types to foreground colors and apply +// them to their attributed string. Since foreground color never affects text +// metrics, the block height measured from the plain string is guaranteed to +// match the drawn height regardless of what an implementation returns. +// +// Token offsets are UTF-16 code units into the code string, because both +// Android (Spannable) and iOS (NSAttributedString) address text in UTF-16. +// Implementations are responsible for converting tree-sitter byte offsets. +// +// Token types follow tree-sitter's standard highlight capture names, +// flattened to one level. Values are explicit because they cross the JNI +// boundary as plain integers. + +// TODO: phase 2 adds the real implementation next to this file: the +// tree-sitter runtime plus a vendored set of language grammars and their +// highlight queries, compiled only when ENRICHED_MARKDOWN_CODE_HIGHLIGHT is +// defined by the gradle property / podspec option. +// +// TODO: only the github flavor calls this seam; the commonmark flavor renders +// code blocks uncolored. To hook it up, the commonmark code block renderers +// (CodeBlockRenderer.kt / CodeBlockRenderer.m) would call highlightCode with +// the shared node helpers (CodeBlockNode.kt / ENRMCodeBlockContent.h) and +// apply the tokens to their content range through the platform adapters. + +namespace Markdown { + +enum class HighlightTokenType : uint8_t { + Keyword = 0, + Operator = 1, + Punctuation = 2, + String = 3, + Number = 4, + Constant = 5, + Comment = 6, + Function = 7, + Type = 8, + Variable = 9, + Property = 10, + Tag = 11, + Attribute = 12, + Embedded = 13, +}; + +struct HighlightToken { + uint32_t start; + uint32_t end; + HighlightTokenType type; +}; + +// code is the UTF-8 code block content without the fence lines; language is +// the fence info string (for example "python"), empty when absent. Returns an +// empty vector whenever highlighting is unavailable (module compiled out, +// unknown language, parse failure). +std::vector highlightCode(const std::string& code, const std::string& language); + +} // namespace Markdown diff --git a/packages/core/cpp/highlight/CodeBlockLanguages.cpp b/packages/core/cpp/highlight/CodeBlockLanguages.cpp new file mode 100644 index 000000000..9faac40f2 --- /dev/null +++ b/packages/core/cpp/highlight/CodeBlockLanguages.cpp @@ -0,0 +1,88 @@ +#include "CodeBlockLanguages.hpp" + +#include +#include +#include +#include + +namespace Markdown { + +namespace { + +struct LanguageName { + const char *key; + const char *name; +}; + +// Sorted by key; displayNameForLanguage binary-searches this table. +constexpr std::array kLanguageNames{{ + {"bash", "Bash"}, + {"c", "C"}, + {"cc", "C++"}, + {"cpp", "C++"}, + {"cs", "C#"}, + {"csharp", "C#"}, + {"css", "CSS"}, + {"cxx", "C++"}, + {"dockerfile", "Dockerfile"}, + {"go", "Go"}, + {"golang", "Go"}, + {"graphql", "GraphQL"}, + {"html", "HTML"}, + {"java", "Java"}, + {"javascript", "JavaScript"}, + {"js", "JavaScript"}, + {"json", "JSON"}, + {"jsx", "JSX"}, + {"kotlin", "Kotlin"}, + {"kt", "Kotlin"}, + {"markdown", "Markdown"}, + {"md", "Markdown"}, + {"objc", "Objective-C"}, + {"objectivec", "Objective-C"}, + {"php", "PHP"}, + {"py", "Python"}, + {"python", "Python"}, + {"rb", "Ruby"}, + {"ruby", "Ruby"}, + {"rs", "Rust"}, + {"rust", "Rust"}, + {"scss", "SCSS"}, + {"sh", "Shell"}, + {"shell", "Shell"}, + {"sql", "SQL"}, + {"swift", "Swift"}, + {"toml", "TOML"}, + {"ts", "TypeScript"}, + {"tsx", "TSX"}, + {"typescript", "TypeScript"}, + {"xml", "XML"}, + {"yaml", "YAML"}, + {"yml", "YAML"}, + {"zsh", "Zsh"}, +}}; + +} // namespace + +std::string displayNameForLanguage(const std::string &language) { + if (language.empty()) { + return ""; + } + + std::string lower = language; + for (char &c : lower) { + c = static_cast(std::tolower(static_cast(c))); + } + + auto it = + std::lower_bound(kLanguageNames.begin(), kLanguageNames.end(), lower.c_str(), + [](const LanguageName &entry, const char *key) { return std::strcmp(entry.key, key) < 0; }); + if (it != kLanguageNames.end() && lower == it->key) { + return it->name; + } + + lower[0] = static_cast(std::toupper(static_cast(lower[0]))); + return lower; +} + +} // namespace Markdown diff --git a/packages/core/cpp/highlight/CodeBlockLanguages.hpp b/packages/core/cpp/highlight/CodeBlockLanguages.hpp new file mode 100644 index 000000000..dbb1ca6e8 --- /dev/null +++ b/packages/core/cpp/highlight/CodeBlockLanguages.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include + +// Display names for code block fence languages, shared by every platform and +// both markdown flavors so a fence like ```python is labeled identically +// everywhere. Unlike the highlighting seam this is always compiled; it does +// not depend on ENRICHED_MARKDOWN_CODE_HIGHLIGHT. + +namespace Markdown { + +// Maps a fence info string (for example "python", "js") to a human readable +// display name ("Python", "JavaScript"). Unknown languages fall back to the +// lowercased input with the first ASCII letter capitalized. Empty input +// returns an empty string. +std::string displayNameForLanguage(const std::string& language); + +} // namespace Markdown diff --git a/packages/react-native-enriched-markdown/ReactNativeEnrichedMarkdown.podspec b/packages/react-native-enriched-markdown/ReactNativeEnrichedMarkdown.podspec index 4d31a7af7..05a2aef98 100644 --- a/packages/react-native-enriched-markdown/ReactNativeEnrichedMarkdown.podspec +++ b/packages/react-native-enriched-markdown/ReactNativeEnrichedMarkdown.podspec @@ -22,7 +22,7 @@ Pod::Spec.new do |s| s.dependency "EnrichedMarkdownCore" else s.private_header_files = "ios/**/*.h", "cpp/**/*.{h,hpp}" - s.source_files = "ios/**/*.{h,m,mm,cpp,swift}", "cpp/md4c/*.{c,h}", "cpp/parser/*.{hpp,cpp}" + s.source_files = "ios/**/*.{h,m,mm,cpp,swift}", "cpp/md4c/*.{c,h}", "cpp/parser/*.{hpp,cpp}", "cpp/highlight/*.{hpp,cpp}" end # To disable LaTeX math rendering (RaTeX, iOS only), add ENV['ENRICHED_MARKDOWN_ENABLE_MATH'] = '0' to your Podfile. @@ -55,7 +55,7 @@ Pod::Spec.new do |s| end pod_xcconfig = { - 'HEADER_SEARCH_PATHS' => "\"#{cpp_root}/md4c\" \"#{cpp_root}/parser\" \"$(PODS_TARGET_SRCROOT)/ios/internals\" \"$(PODS_TARGET_SRCROOT)/ios/input/internals\"", + 'HEADER_SEARCH_PATHS' => "\"#{cpp_root}/md4c\" \"#{cpp_root}/parser\" \"#{cpp_root}/highlight\" \"$(PODS_TARGET_SRCROOT)/ios/internals\" \"$(PODS_TARGET_SRCROOT)/ios/input/internals\"", 'GCC_PREPROCESSOR_DEFINITIONS' => preprocessor_defs, 'CLANG_CXX_LANGUAGE_STANDARD' => 'c++17', 'DEFINES_MODULE' => 'YES' diff --git a/packages/react-native-enriched-markdown/android/src/main/cpp/jni-adapter.cpp b/packages/react-native-enriched-markdown/android/src/main/cpp/jni-adapter.cpp index 56076b241..784a91bdd 100644 --- a/packages/react-native-enriched-markdown/android/src/main/cpp/jni-adapter.cpp +++ b/packages/react-native-enriched-markdown/android/src/main/cpp/jni-adapter.cpp @@ -1,7 +1,10 @@ +#include "CodeBlockHighlighter.hpp" +#include "CodeBlockLanguages.hpp" #include "MD4CParser.hpp" #include #include #include +#include using namespace Markdown; @@ -263,4 +266,69 @@ JNIEXPORT jobject JNICALL Java_com_swmansion_enriched_markdown_parser_Parser_nat } } +JNIEXPORT jstring JNICALL Java_com_swmansion_enriched_markdown_utils_common_CodeBlockNode_nativeDisplayLanguageName( + JNIEnv *env, jobject /* this */, jstring language) { + if (!language) { + return nullptr; + } + const char *languageStr = env->GetStringUTFChars(language, nullptr); + if (!languageStr) { + return nullptr; + } + std::string display = displayNameForLanguage(languageStr); + env->ReleaseStringUTFChars(language, languageStr); + return env->NewStringUTF(display.c_str()); +} + +// Returns highlight tokens as (start, end, type) int triplets with UTF-16 +// offsets, or null when highlighting is unavailable. See +// cpp/highlight/CodeBlockHighlighter.hpp for the seam contract. +JNIEXPORT jintArray JNICALL Java_com_swmansion_enriched_markdown_utils_common_CodeBlockHighlighter_nativeHighlightCode( + JNIEnv *env, jobject /* this */, jstring code, jstring language) { + if (!code) { + return nullptr; + } + + const char *codeStr = env->GetStringUTFChars(code, nullptr); + if (!codeStr) { + return nullptr; + } + const char *languageStr = language ? env->GetStringUTFChars(language, nullptr) : nullptr; + + std::vector tokens; + try { + tokens = highlightCode(codeStr, languageStr ? languageStr : ""); + } catch (const std::exception &e) { + LOGE("Exception during highlighting: %s", e.what()); + tokens.clear(); + } catch (...) { + LOGE("Unknown exception during highlighting"); + tokens.clear(); + } + + env->ReleaseStringUTFChars(code, codeStr); + if (languageStr) { + env->ReleaseStringUTFChars(language, languageStr); + } + + if (tokens.empty()) { + return nullptr; + } + + std::vector flat; + flat.reserve(tokens.size() * 3); + for (const auto &token : tokens) { + flat.push_back(static_cast(token.start)); + flat.push_back(static_cast(token.end)); + flat.push_back(static_cast(token.type)); + } + + jintArray result = env->NewIntArray(static_cast(flat.size())); + if (!result) { + return nullptr; + } + env->SetIntArrayRegion(result, 0, static_cast(flat.size()), flat.data()); + return result; +} + } // extern "C" diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt index 84683253e..ec9cdd157 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt @@ -30,6 +30,7 @@ import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig import com.swmansion.enriched.markdown.utils.text.view.applySelectionColors import com.swmansion.enriched.markdown.views.BlockSegmentView +import com.swmansion.enriched.markdown.views.CodeBlockContainerView import com.swmansion.enriched.markdown.views.TableContainerView import java.util.EnumSet import java.util.concurrent.ExecutorService @@ -267,6 +268,11 @@ class EnrichedMarkdown view.copyAsMarkdownLabel = copyAsMarkdownLabel } + view is CodeBlockContainerView -> { + view.copyLabel = copyLabel + view.copyAsMarkdownLabel = copyAsMarkdownLabel + } + isMathContainerView(view) -> { runCatching { view.javaClass.getMethod("setCopyLabel", String::class.java).invoke(view, copyLabel) @@ -420,6 +426,7 @@ class EnrichedMarkdown is RenderedSegment.Text -> view is EnrichedMarkdownInternalText is RenderedSegment.Table -> view is TableContainerView is RenderedSegment.Math -> isMathContainerView(view) + is RenderedSegment.CodeBlock -> view is CodeBlockContainerView } private fun isMathContainerView(view: View): Boolean = mathContainerClass?.isInstance(view) == true @@ -432,6 +439,7 @@ class EnrichedMarkdown is RenderedSegment.Text -> createTextView(segment) is RenderedSegment.Table -> createTableView(segment, style) is RenderedSegment.Math -> createMathView(segment, style) + is RenderedSegment.CodeBlock -> createCodeBlockView(segment, style) } private fun updateSegmentView( @@ -462,6 +470,10 @@ class EnrichedMarkdown ?.getMethod("applyLatex", String::class.java) ?.invoke(view, segment.latex) } + + is RenderedSegment.CodeBlock -> { + (view as CodeBlockContainerView).applyCodeBlockNode(segment.node) + } } } @@ -472,7 +484,7 @@ class EnrichedMarkdown if (!streamingAnimation) return when (segment) { is RenderedSegment.Text -> animateTextViewTail(view as EnrichedMarkdownInternalText, 0) - is RenderedSegment.Table, is RenderedSegment.Math -> animateBlockViewFadeIn(view) + is RenderedSegment.Table, is RenderedSegment.Math, is RenderedSegment.CodeBlock -> animateBlockViewFadeIn(view) } } @@ -538,6 +550,15 @@ class EnrichedMarkdown applyTableNode(segment.node) } + private fun createCodeBlockView( + segment: RenderedSegment.CodeBlock, + style: StyleConfig, + ) = CodeBlockContainerView(context, style).apply { + copyLabel = this@EnrichedMarkdown.selectionMenuConfig.copyLabel + copyAsMarkdownLabel = this@EnrichedMarkdown.selectionMenuConfig.copyAsMarkdownLabel + applyCodeBlockNode(segment.node) + } + private fun createMathView( segment: RenderedSegment.Math, style: StyleConfig, diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt index 841c8a708..58c18f187 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -31,6 +31,7 @@ import com.swmansion.enriched.markdown.utils.common.getStringOrDefault import com.swmansion.enriched.markdown.utils.common.parseImageRequestHeaders import com.swmansion.enriched.markdown.utils.common.splitASTIntoSegments import com.swmansion.enriched.markdown.utils.text.extensions.replaceMathSpansWithPlaceholders +import com.swmansion.enriched.markdown.views.CodeBlockContainerView import com.swmansion.enriched.markdown.views.TableContainerView import java.util.concurrent.ConcurrentHashMap import kotlin.math.ceil @@ -465,6 +466,15 @@ object MeasurementStore { totalHeightPx += style.mathStyle.marginBottom } } + + is RenderedSegment.CodeBlock -> { + totalHeightPx += style.codeBlockStyle.marginTop + totalHeightPx += CodeBlockContainerView.measureCodeBlockNodeHeight(segment.node, style, context, width) + maxContentWidthPx = width + if (includeBottomMargin) { + totalHeightPx += style.codeBlockStyle.marginBottom + } + } } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt index 08a2c603d..966bf9f6f 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt @@ -12,12 +12,17 @@ import android.text.style.LeadingMarginSpan import android.text.style.LineBackgroundSpan import android.text.style.MetricAffectingSpan import androidx.core.graphics.withSave -import com.swmansion.enriched.markdown.renderer.BlockStyle import com.swmansion.enriched.markdown.renderer.SpanStyleCache import com.swmansion.enriched.markdown.styles.CodeBlockStyle -import com.swmansion.enriched.markdown.utils.text.extensions.applyBlockStyleFont +import com.swmansion.enriched.markdown.utils.text.extensions.applyCodeBlockTextStyle import com.swmansion.enriched.markdown.utils.text.extensions.applyColorPreserving +/** + * Draws the commonmark-flavor code block box as per-line strips. The visual + * conventions (half-stroke border inset, radius reduced by that inset) must + * stay in sync with the whole-rect box the github flavor draws in + * CodeBlockContainerView. + */ class CodeBlockSpan( private val codeBlockStyle: CodeBlockStyle, private val context: Context, @@ -26,14 +31,6 @@ class CodeBlockSpan( ) : MetricAffectingSpan(), LineBackgroundSpan, LeadingMarginSpan { - private val blockStyle = - BlockStyle( - fontSize = codeBlockStyle.fontSize, - fontFamily = codeBlockStyle.fontFamily, - fontWeight = codeBlockStyle.fontWeight, - color = codeBlockStyle.color, - ) - private val path = Path() private val rect = RectF() private val arcRect = RectF() @@ -189,10 +186,8 @@ class CodeBlockSpan( } private fun applyTextStyle(tp: TextPaint) { - tp.textSize = blockStyle.fontSize - - tp.applyBlockStyleFont(blockStyle, context) + tp.applyCodeBlockTextStyle(codeBlockStyle, context) - tp.applyColorPreserving(blockStyle.color, *styleCache.colorsToPreserve) + tp.applyColorPreserving(codeBlockStyle.color, *styleCache.colorsToPreserve) } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/CodeBlockHighlighter.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/CodeBlockHighlighter.kt new file mode 100644 index 000000000..abbf2e448 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/CodeBlockHighlighter.kt @@ -0,0 +1,97 @@ +package com.swmansion.enriched.markdown.utils.common + +import android.graphics.Color +import android.text.Spannable +import android.text.style.ForegroundColorSpan +import android.util.Log +import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE + +/** + * Mirror of HighlightTokenType in cpp/highlight/CodeBlockHighlighter.hpp. + * Values cross the JNI boundary as ordinals, the same way + * MarkdownASTNode.NodeType does; keep the declaration order in sync. + */ +enum class HighlightTokenType { + Keyword, + Operator, + Punctuation, + String, + Number, + Constant, + Comment, + Function, + Type, + Variable, + Property, + Tag, + Attribute, + Embedded, +} + +/** + * Platform adapter over the shared C++ syntax highlighting seam + * (core/cpp/highlight/CodeBlockHighlighter.hpp). + * + * The native call returns semantic tokens as (start, end, type) int triplets + * with UTF-16 offsets into the code string, or null when highlighting is + * unavailable (module compiled out, unknown language, parse failure). The + * adapter applies token colors in place as ForegroundColorSpans onto the + * plain styled code, so highlighting can never change text metrics and the + * measured block height stays valid. When highlighting is unavailable the + * spannable is left untouched and renders as plain text. + */ +object CodeBlockHighlighter { + init { + try { + System.loadLibrary("react_codegen_EnrichedMarkdownTextSpec") + } catch (e: UnsatisfiedLinkError) { + Log.e("CodeBlockHighlighter", "Failed to load native library", e) + } + } + + private external fun nativeHighlightCode( + code: String, + language: String, + ): IntArray? + + fun highlight( + plainCode: Spannable, + code: String, + language: String?, + ) { + val tokens = + try { + nativeHighlightCode(code, language.orEmpty()) + } catch (e: UnsatisfiedLinkError) { + null + } ?: return + + var i = 0 + while (i + 2 < tokens.size) { + val start = tokens[i] + val end = tokens[i + 1] + val color = HighlightTokenType.entries.getOrNull(tokens[i + 2])?.let(::colorForToken) + if (color != null && start >= 0 && end > start && end <= plainCode.length) { + plainCode.setSpan(ForegroundColorSpan(color), start, end, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE) + } + i += 3 + } + } + + // TODO: provisional palette (GitHub light scheme); replace with themable + // per-token colors on CodeBlockStyle when the highlighting module lands. + private fun colorForToken(type: HighlightTokenType): Int? = + when (type) { + HighlightTokenType.Keyword -> Color.parseColor("#CF222E") + HighlightTokenType.String -> Color.parseColor("#0A3069") + HighlightTokenType.Number -> Color.parseColor("#0550AE") + HighlightTokenType.Constant -> Color.parseColor("#0550AE") + HighlightTokenType.Comment -> Color.parseColor("#6E7781") + HighlightTokenType.Function -> Color.parseColor("#8250DF") + HighlightTokenType.Type -> Color.parseColor("#953800") + HighlightTokenType.Property -> Color.parseColor("#0550AE") + HighlightTokenType.Tag -> Color.parseColor("#116329") + HighlightTokenType.Attribute -> Color.parseColor("#0550AE") + else -> null + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/CodeBlockNode.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/CodeBlockNode.kt new file mode 100644 index 000000000..3597ca48b --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/CodeBlockNode.kt @@ -0,0 +1,57 @@ +package com.swmansion.enriched.markdown.utils.common + +import android.util.Log +import com.swmansion.enriched.markdown.parser.MarkdownASTNode + +/** + * Shared code block node semantics, used by both markdown flavors so the + * meaning of a CodeBlock AST node (its text, language, fence) is defined in + * one place. Language display names come from the shared C++ core + * (cpp/highlight/CodeBlockLanguages.hpp) so all platforms label fences + * identically; when the native library is unavailable the raw fence string + * is shown instead. + */ +object CodeBlockNode { + init { + try { + System.loadLibrary("react_codegen_EnrichedMarkdownTextSpec") + } catch (e: UnsatisfiedLinkError) { + Log.e("CodeBlockNode", "Failed to load native library", e) + } + } + + private external fun nativeDisplayLanguageName(language: String): String? + + fun extractCode(node: MarkdownASTNode): String { + val builder = StringBuilder() + + fun append(current: MarkdownASTNode) { + builder.append(current.content) + current.children.forEach { append(it) } + } + append(node) + return builder.toString().trimEnd('\n') + } + + fun language(node: MarkdownASTNode): String? = node.getAttribute("language")?.takeIf { it.isNotEmpty() } + + fun fenceChar(node: MarkdownASTNode): String = node.getAttribute("fenceChar")?.takeIf { it.isNotEmpty() } ?: "`" + + fun fencedMarkdown( + code: String, + language: String?, + fenceChar: String, + ): String { + val fence = fenceChar.repeat(3) + return "$fence${language.orEmpty()}\n$code\n$fence" + } + + fun displayLanguageName(language: String?): String { + if (language.isNullOrEmpty()) return "" + return try { + nativeDisplayLanguageName(language) + } catch (e: UnsatisfiedLinkError) { + null + } ?: language + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownSegment.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownSegment.kt index bc2ac1bdd..9d6af84a5 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownSegment.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownSegment.kt @@ -15,6 +15,10 @@ sealed interface MarkdownSegment { val latex: String, val node: MarkdownASTNode, ) : MarkdownSegment + + data class CodeBlock( + val node: MarkdownASTNode, + ) : MarkdownSegment } fun splitASTIntoSegments(root: MarkdownASTNode): List { @@ -46,6 +50,11 @@ fun splitASTIntoSegments(root: MarkdownASTNode): List { segments.add(MarkdownSegment.Math(latex, child)) } + MarkdownASTNode.NodeType.CodeBlock -> { + flushTextNodes() + segments.add(MarkdownSegment.CodeBlock(child)) + } + else -> { currentTextNodes.add(child) } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/RenderedSegment.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/RenderedSegment.kt index f7cabe601..f5cfb7706 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/RenderedSegment.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/RenderedSegment.kt @@ -27,6 +27,11 @@ sealed interface RenderedSegment { val latex: String, override val signature: Long, ) : RenderedSegment + + data class CodeBlock( + val node: MarkdownASTNode, + override val signature: Long, + ) : RenderedSegment } object MarkdownSegmentRenderer { @@ -53,6 +58,11 @@ object MarkdownSegmentRenderer { signature = SegmentSignature.fnvMixString(signature, segment.latex) RenderedSegment.Math(segment.latex, signature) } + + is MarkdownSegment.CodeBlock -> { + val signature = SegmentSignature.signatureForNode(segment.node) xor SegmentSignature.CODE_BLOCK_KIND_SALT + RenderedSegment.CodeBlock(segment.node, signature) + } } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentSignature.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentSignature.kt index 40911fe3f..5d31ef7d8 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentSignature.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentSignature.kt @@ -11,6 +11,7 @@ object SegmentSignature { internal const val TEXT_KIND_SALT = 0x7465787400000000L // "text" internal const val TABLE_KIND_SALT = 0x7461626C00000000L // "tabl" internal const val MATH_KIND_SALT = 0x6D61746800000000L // "math" + internal const val CODE_BLOCK_KIND_SALT = 0x63626C6B00000000L // "cblk" private fun fnvMixByte( hash: Long, diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/extensions/TextPaintExtensions.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/extensions/TextPaintExtensions.kt index 64eaee4a2..f1b9c3a08 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/extensions/TextPaintExtensions.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/extensions/TextPaintExtensions.kt @@ -7,6 +7,7 @@ import com.facebook.react.common.ReactConstants import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.swmansion.enriched.markdown.renderer.BlockStyle +import com.swmansion.enriched.markdown.styles.CodeBlockStyle fun TextPaint.applyColorPreserving( color: Int, @@ -51,3 +52,25 @@ fun TextPaint.applyBlockStyleFont( typefaceCache[cacheKey] = newTypeface this.typeface = newTypeface } + +/** + * Code block text sizing and font, shared by both flavors (CodeBlockSpan for + * commonmark, CodeBlockContainerView for github) so the code always renders + * and measures with the same paint. Color is applied by the callers: the span + * needs color preserving, the view colors via the text view. + */ +fun TextPaint.applyCodeBlockTextStyle( + style: CodeBlockStyle, + context: Context, +) { + textSize = style.fontSize + applyBlockStyleFont( + BlockStyle( + fontSize = style.fontSize, + fontFamily = style.fontFamily, + fontWeight = style.fontWeight, + color = style.color, + ), + context, + ) +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt new file mode 100644 index 000000000..aa3e16493 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt @@ -0,0 +1,355 @@ +package com.swmansion.enriched.markdown.views + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.RectF +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.text.Layout +import android.text.SpannableString +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils +import android.util.TypedValue +import android.view.View +import android.widget.FrameLayout +import android.widget.HorizontalScrollView +import android.widget.ImageView +import androidx.appcompat.widget.AppCompatImageButton +import androidx.appcompat.widget.AppCompatTextView +import com.swmansion.enriched.markdown.parser.MarkdownASTNode +import com.swmansion.enriched.markdown.spans.LineHeightSpan +import com.swmansion.enriched.markdown.styles.CodeBlockStyle +import com.swmansion.enriched.markdown.styles.StyleConfig +import com.swmansion.enriched.markdown.utils.common.CodeBlockHighlighter +import com.swmansion.enriched.markdown.utils.common.CodeBlockNode +import com.swmansion.enriched.markdown.utils.text.extensions.applyCodeBlockTextStyle +import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE +import kotlin.math.ceil + +/** + * Block segment view for fenced code blocks (see splitASTIntoSegments): + * a fixed header bar (language name, copy button) above a horizontally + * scrolling, non-wrapping code pane. Syntax coloring comes from the + * CodeBlockHighlighter seam and falls back to plain text. The box visuals + * must stay in sync with the commonmark flavor's CodeBlockSpan. + * + * measureCodeBlockNodeHeight must stay in sync with the view: it builds the + * same styled text, paint, and header metrics, so the height reported to + * Yoga matches the laid-out height. Since lines never wrap, the height is + * independent of the available width. + */ +class CodeBlockContainerView( + context: Context, + styleConfig: StyleConfig, +) : FrameLayout(context), + BlockSegmentView { + private val codeBlockStyle: CodeBlockStyle = styleConfig.codeBlockStyle + + private val inset = contentInset(codeBlockStyle) + private val borderW = borderPx(codeBlockStyle) + private val headerH = headerHeight(codeBlockStyle) + + override val segmentMarginTop: Int get() = codeBlockStyle.marginTop.toInt() + override val segmentMarginBottom: Int get() = codeBlockStyle.marginBottom.toInt() + + var copyLabel: String = "" + set(value) { + field = value + copyButton.contentDescription = value + } + var copyAsMarkdownLabel: String = "" + + private var code: String = "" + private var language: String? = null + private var fenceChar: String = "`" + + private val textView = + AppCompatTextView(context).apply { + includeFontPadding = false + setLineSpacing(0f, 1f) + breakStrategy = Layout.BREAK_STRATEGY_SIMPLE + hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE + layoutDirection = View.LAYOUT_DIRECTION_LTR + textDirection = View.TEXT_DIRECTION_LTR + setTextSize(TypedValue.COMPLEX_UNIT_PX, codeBlockStyle.fontSize) + typeface = createCodePaint(codeBlockStyle, context).typeface + setTextColor(codeBlockStyle.color) + val horizontalPad = (inset - borderW).coerceAtLeast(0) + setPadding(horizontalPad, inset, horizontalPad, inset) + setOnLongClickListener { view -> + showContextMenu(view) + true + } + } + + private val scrollView = + HorizontalScrollView(context).apply { + isHorizontalScrollBarEnabled = true + overScrollMode = View.OVER_SCROLL_NEVER + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + horizontalScrollbarThumbDrawable = + GradientDrawable().apply { + setColor(secondaryColor(codeBlockStyle.color)) + cornerRadius = context.resources.displayMetrics.density * 2 + } + } + addView(textView, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + } + + private val languageView = + AppCompatTextView(context).apply { + includeFontPadding = false + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + setTextSize(TypedValue.COMPLEX_UNIT_PX, codeBlockStyle.fontSize * HEADER_LABEL_SCALE) + typeface = headerTypeface + setTextColor(secondaryColor(codeBlockStyle.color)) + } + + private val copyButton = + AppCompatImageButton(context).apply { + background = null + scaleType = ImageView.ScaleType.CENTER + setImageDrawable( + CopyIconDrawable(secondaryColor(codeBlockStyle.color), ceil(codeBlockStyle.fontSize).toInt()), + ) + setOnClickListener { copyCode() } + } + + private val dividerPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = dividerColor(codeBlockStyle.color) + strokeWidth = context.resources.displayMetrics.density + } + + init { + setWillNotDraw(false) + background = + GradientDrawable().apply { + setColor(codeBlockStyle.backgroundColor) + cornerRadius = codeBlockStyle.borderRadius + if (codeBlockStyle.borderWidth > 0f) { + setStroke(ceil(codeBlockStyle.borderWidth).toInt(), codeBlockStyle.borderColor) + } + } + isLongClickable = true + setOnLongClickListener { view -> + showContextMenu(view) + true + } + addView(scrollView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) + addView(languageView, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + addView(copyButton, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + } + + fun applyCodeBlockNode(node: MarkdownASTNode) { + code = CodeBlockNode.extractCode(node) + fenceChar = CodeBlockNode.fenceChar(node) + + val newLanguage = CodeBlockNode.language(node) + if (newLanguage != language) { + language = newLanguage + languageView.text = CodeBlockNode.displayLanguageName(newLanguage) + } + + val plainCode = buildCodeText(code, codeBlockStyle) + CodeBlockHighlighter.highlight(plainCode, code, language) + textView.text = plainCode + } + + override fun onMeasure( + widthSpec: Int, + heightSpec: Int, + ) { + val measuredWidth = MeasureSpec.getSize(widthSpec) + scrollView.measure( + MeasureSpec.makeMeasureSpec((measuredWidth - borderW * 2).coerceAtLeast(0), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), + ) + copyButton.measure( + MeasureSpec.makeMeasureSpec(headerH, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(headerH, MeasureSpec.EXACTLY), + ) + val labelMaxWidth = (measuredWidth - inset * 2 - headerH).coerceAtLeast(0) + languageView.measure( + MeasureSpec.makeMeasureSpec(labelMaxWidth, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), + ) + setMeasuredDimension(measuredWidth, headerH + scrollView.measuredHeight) + } + + override fun onLayout( + changed: Boolean, + left: Int, + top: Int, + right: Int, + bottom: Int, + ) { + val width = right - left + val labelTop = headerH - languageView.measuredHeight + languageView.layout( + inset, + labelTop, + inset + languageView.measuredWidth, + labelTop + languageView.measuredHeight, + ) + + val iconWidth = copyButton.drawable?.intrinsicWidth ?: copyButton.measuredWidth + val iconSlack = ((copyButton.measuredWidth - iconWidth) / 2).coerceAtLeast(0) + val buttonLeft = (width - inset - copyButton.measuredWidth + iconSlack).coerceAtLeast(0) + val buttonTop = (headerH - languageView.measuredHeight / 2 - copyButton.measuredHeight / 2).coerceAtLeast(0) + copyButton.layout( + buttonLeft, + buttonTop, + buttonLeft + copyButton.measuredWidth, + buttonTop + copyButton.measuredHeight, + ) + + scrollView.layout(borderW, headerH, width - borderW, bottom - top - borderW) + } + + // Divider between the header and the code, centered in the gap the code + // text's top inset creates, so it adds no height of its own. + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val borderWidth = borderW.toFloat() + val y = headerH + inset / 2f + canvas.drawLine(borderWidth, y, width - borderWidth, y, dividerPaint) + } + + private fun showContextMenu(anchor: View) { + ContextMenuPopup.show(anchor, this) { + item(ContextMenuPopup.Icon.COPY, copyLabel) { copyCode() } + item(ContextMenuPopup.Icon.DOCUMENT, copyAsMarkdownLabel) { copyFencedMarkdown() } + } + } + + private fun copyCode() { + if (code.isEmpty()) return + copyToClipboard(code) + } + + private fun copyFencedMarkdown() { + if (code.isEmpty()) return + copyToClipboard(CodeBlockNode.fencedMarkdown(code, language, fenceChar)) + } + + private fun copyToClipboard(text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Code", text)) + } + + private class CopyIconDrawable( + color: Int, + private val size: Int, + ) : Drawable() { + private val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = size / 12f + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + this.color = color + } + + override fun draw(canvas: Canvas) { + val u = bounds.width() / 24f + canvas.drawRoundRect(RectF(8 * u, 2 * u, 22 * u, 16 * u), 2 * u, 2 * u, paint) + canvas.drawRoundRect(RectF(2 * u, 8 * u, 16 * u, 22 * u), 2 * u, 2 * u, paint) + } + + override fun getIntrinsicWidth() = size + + override fun getIntrinsicHeight() = size + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + paint.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Java") + override fun getOpacity() = PixelFormat.TRANSLUCENT + } + + companion object { + private const val HEADER_LABEL_SCALE = 0.85f + + private val headerTypeface: Typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + + private fun secondaryColor(color: Int): Int = (color and 0x00FFFFFF) or (0x99 shl 24) + + private fun dividerColor(color: Int): Int = (color and 0x00FFFFFF) or (0x33 shl 24) + + private fun contentInset(style: CodeBlockStyle): Int = ceil(style.padding + style.borderWidth).toInt() + + private fun borderPx(style: CodeBlockStyle): Int = ceil(style.borderWidth).toInt() + + // Header is the top content inset plus one label line; the code text + // view's own top inset then forms the single gap below the label. + private fun headerHeight(style: CodeBlockStyle): Int { + val paint = + TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = style.fontSize * HEADER_LABEL_SCALE + typeface = headerTypeface + } + val metrics = paint.fontMetrics + return contentInset(style) + ceil(metrics.descent - metrics.ascent).toInt() + } + + private fun createCodePaint( + style: CodeBlockStyle, + context: Context, + ): TextPaint = + TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + applyCodeBlockTextStyle(style, context) + } + + private fun buildCodeText( + code: String, + style: CodeBlockStyle, + ): SpannableString = + SpannableString(code).apply { + if (style.lineHeight > 0f && isNotEmpty()) { + setSpan(LineHeightSpan(style.lineHeight), 0, length, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE) + } + } + + // Lines never wrap (the code pane scrolls horizontally), so the layout is + // built at the text's desired width and the result is independent of the + // available width. + fun measureCodeBlockNodeHeight( + node: MarkdownASTNode, + config: StyleConfig, + context: Context, + @Suppress("unused") width: Float, + ): Float { + val style = config.codeBlockStyle + val inset = contentInset(style) + val headerH = headerHeight(style) + val text = buildCodeText(CodeBlockNode.extractCode(node), style) + if (text.isEmpty()) return headerH + inset * 2f + val paint = createCodePaint(style, context) + val contentWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt().coerceAtLeast(1) + val layout = + StaticLayout.Builder + .obtain(text, 0, text.length, paint, contentWidth) + .setIncludePad(false) + .setLineSpacing(0f, 1f) + .setBreakStrategy(Layout.BREAK_STRATEGY_SIMPLE) + .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE) + .build() + return layout.height.toFloat() + inset * 2 + headerH + } + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/jni/CMakeLists.txt b/packages/react-native-enriched-markdown/android/src/main/jni/CMakeLists.txt index b41939902..1142614e9 100644 --- a/packages/react-native-enriched-markdown/android/src/main/jni/CMakeLists.txt +++ b/packages/react-native-enriched-markdown/android/src/main/jni/CMakeLists.txt @@ -27,6 +27,7 @@ set(ANDROID_CPP_DIR "${LIB_ANDROID_DIR}/src/main/cpp") file(GLOB MD4C_SOURCES "${CPP_ROOT}/md4c/*.c") file(GLOB PARSER_SOURCES "${CPP_ROOT}/parser/*.cpp") +file(GLOB HIGHLIGHT_SOURCES "${CPP_ROOT}/highlight/*.cpp") # md4c is C code, build as separate static library with C flags add_library(md4c_lib STATIC ${MD4C_SOURCES}) @@ -46,6 +47,7 @@ add_library( ${LIB_CODEGEN_SRCS} ${LIB_CUSTOM_JNI_SRCS} ${PARSER_SOURCES} + ${HIGHLIGHT_SOURCES} ${ANDROID_CPP_DIR}/jni-adapter.cpp ) @@ -58,6 +60,7 @@ target_include_directories( ${LIB_ANDROID_GENERATED_COMPONENTS_DIR} ${CPP_ROOT}/md4c ${CPP_ROOT}/parser + ${CPP_ROOT}/highlight ) target_compile_definitions(${LIB_TARGET_NAME} PRIVATE MD4C_USE_UTF8=1) diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm index 55eea2593..ce381a31e 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm @@ -17,6 +17,7 @@ #if ENRICHED_MARKDOWN_MATH #import "ENRMMathContainerView.h" #endif +#import "ENRMCodeBlockContainerView.h" #import "ENRMSpoilerCapable.h" #import "ENRMSpoilerOverlayView.h" #import "ENRMSpoilerTapUtils.h" @@ -67,7 +68,6 @@ typedef NS_OPTIONS(NSUInteger, ENRMDirtyFlags) { static char kENRMSegmentFadeAnimatorKey; - @interface EnrichedMarkdown () + (ENRMMd4cFlags *)flagsFromProps:(const EnrichedMarkdownMd4cFlagsStruct &)props; - (void)emitLinkPress:(NSString *)url; @@ -254,6 +254,26 @@ - (void)configureSegmentViewRegistry }]]; #endif + [handlers addObject:[ENRMSegmentViewHandler handlerWithKind:ENRMSegmentKindCodeBlock + matchesView:^BOOL(RCTUIView *view, ENRMRenderedSegment *segment) { + return [view isKindOfClass:[ENRMCodeBlockContainerView class]]; + } + createView:^RCTUIView *(ENRMRenderedSegment *segment) { + EnrichedMarkdown *strongSelf = weakSelf; + if (!strongSelf) { + return [[RCTUIView alloc] init]; + } + + ENRMCodeBlockContainerView *view = + [strongSelf createCodeBlockViewForSegment:segment.codeBlockSegment]; + [strongSelf animateBlockViewIfNeeded:view]; + return view; + } + updateView:^(RCTUIView *view, ENRMRenderedSegment *segment) { + [(ENRMCodeBlockContainerView *)view + applyCodeBlockNode:segment.codeBlockSegment.codeBlockNode]; + }]]; + _segmentViewRegistry = [[ENRMSegmentViewRegistry alloc] initWithHandlers:handlers]; } @@ -315,6 +335,11 @@ - (CGSize)computeSegmentLayoutForWidth:(CGFloat)width applyFrames:(BOOL)applyFra maxContentWidth = width; } #endif + else if ([segment isKindOfClass:[ENRMCodeBlockContainerView class]]) { + yOffset += _config.codeBlockMarginTop; + segmentHeight = [(ENRMCodeBlockContainerView *)segment measureHeight:width]; + maxContentWidth = width; + } if (applyFrames) { CGFloat segmentX = 0; @@ -349,6 +374,9 @@ - (CGSize)computeSegmentLayoutForWidth:(CGFloat)width applyFrames:(BOOL)applyFra yOffset += _config.mathMarginBottom; } #endif + else if ([segment isKindOfClass:[ENRMCodeBlockContainerView class]] && shouldAddBottomMargin) { + yOffset += _config.codeBlockMarginBottom; + } }]; return CGSizeMake(maxContentWidth, yOffset); @@ -472,6 +500,11 @@ - (void)pushSelectionMenuLabelsToSegments mathView.copyAsMarkdownLabel = _selectionMenuLabels.copyAsMarkdownLabel; } #endif + else if ([segment isKindOfClass:[ENRMCodeBlockContainerView class]]) { + ENRMCodeBlockContainerView *codeBlockView = (ENRMCodeBlockContainerView *)segment; + codeBlockView.copyLabel = _selectionMenuLabels.copyLabel; + codeBlockView.copyAsMarkdownLabel = _selectionMenuLabels.copyAsMarkdownLabel; + } } } @@ -753,6 +786,15 @@ - (ENRMMathContainerView *)createMathViewForSegment:(ENRMMathSegment *)mathSegme } #endif +- (ENRMCodeBlockContainerView *)createCodeBlockViewForSegment:(ENRMCodeBlockSegment *)codeBlockSegment +{ + ENRMCodeBlockContainerView *codeBlockView = [[ENRMCodeBlockContainerView alloc] initWithConfig:_config]; + codeBlockView.copyLabel = _selectionMenuLabels.copyLabel; + codeBlockView.copyAsMarkdownLabel = _selectionMenuLabels.copyAsMarkdownLabel; + [codeBlockView applyCodeBlockNode:codeBlockSegment.codeBlockNode]; + return codeBlockView; +} + - (void)animateBlockViewIfNeeded:(RCTUIView *)view { if (!_streamingAnimation) diff --git a/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m index 0e962b428..cb5288dcd 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m @@ -1,5 +1,6 @@ #import "CodeBlockRenderer.h" #import "CodeBlockBackground.h" +#import "ENRMCodeBlockContent.h" #import "LastElementUtils.h" #import "MarkdownASTNode.h" #import "ParagraphStyleUtils.h" @@ -28,7 +29,6 @@ - (void)renderNodeContent:(MarkdownASTNode *)node CGFloat listIndent = context.accumulatedIndent; CGFloat padding = [_config codeBlockPadding]; - CGFloat lineHeight = [_config codeBlockLineHeight]; CGFloat marginTop = [_config codeBlockMarginTop]; CGFloat marginBottom = [_config codeBlockMarginBottom]; @@ -53,23 +53,11 @@ - (void)renderNodeContent:(MarkdownASTNode *)node NSRange contentRange = NSMakeRange(contentStart, contentEnd - contentStart); - UIFont *codeFont = [_config codeBlockFont]; - RCTUIColor *codeColor = [_config codeBlockColor]; - if (codeColor) { - [output addAttributes:@{NSFontAttributeName : codeFont, NSForegroundColorAttributeName : codeColor} - range:contentRange]; - } else { - [output addAttribute:NSFontAttributeName value:codeFont range:contentRange]; - } - - if (lineHeight > 0) { - applyLineHeight(output, contentRange, lineHeight); - } + ENRMApplyCodeBlockTextAttributes(output, contentRange, _config); - // Code is always LTR regardless of app writing direction + // Horizontal padding is paragraph indentation in this flavor; the shared + // helper already forced the LTR left-aligned base style. NSMutableParagraphStyle *baseStyle = [getOrCreateParagraphStyle(output, contentStart) mutableCopy]; - baseStyle.baseWritingDirection = NSWritingDirectionLeftToRight; - baseStyle.alignment = NSTextAlignmentLeft; baseStyle.firstLineHeadIndent = padding + listIndent; baseStyle.headIndent = padding + listIndent; baseStyle.tailIndent = -padding; diff --git a/packages/react-native-enriched-markdown/ios/utils/CodeBlockBackground.m b/packages/react-native-enriched-markdown/ios/utils/CodeBlockBackground.m index 23be20b21..522725868 100644 --- a/packages/react-native-enriched-markdown/ios/utils/CodeBlockBackground.m +++ b/packages/react-native-enriched-markdown/ios/utils/CodeBlockBackground.m @@ -3,6 +3,11 @@ #import "LastElementUtils.h" #import "StyleConfig.h" +// Draws the commonmark-flavor code block box behind the text view's glyph +// runs. The visual conventions (half-stroke border inset, corner radius +// handling) must stay in sync with the whole-rect box the github flavor +// draws in ENRMCodeBlockContainerView's drawRect:. + @implementation CodeBlockBackground { StyleConfig *_config; } diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockContent.h b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockContent.h new file mode 100644 index 000000000..4d6b1812f --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockContent.h @@ -0,0 +1,37 @@ +#pragma once + +#import + +@class MarkdownASTNode; +@class StyleConfig; + +NS_ASSUME_NONNULL_BEGIN + +#ifdef __cplusplus +extern "C" { +#endif + +/// Shared code block node semantics, used by both markdown flavors so the +/// meaning of a CodeBlock AST node (its text, language, fence) is defined in +/// one place. Language display names come from the shared C++ core +/// (cpp/highlight/CodeBlockLanguages.hpp) so all platforms label fences +/// identically. + +NSString *ENRMCodeBlockExtractCode(MarkdownASTNode *node); +NSString *_Nullable ENRMCodeBlockLanguage(MarkdownASTNode *node); +NSString *ENRMCodeBlockFenceChar(MarkdownASTNode *node); +NSString *ENRMCodeBlockFencedMarkdown(NSString *code, NSString *_Nullable language, NSString *fenceChar); +NSString *ENRMCodeBlockDisplayLanguageName(NSString *_Nullable language); + +/// Applies the flavor-independent code block text attributes (code font, +/// color, line height, forced-LTR left-aligned paragraph) to the range. +/// Callers layer their container-specific paragraph adjustments on top (the +/// commonmark span flavor adds padding indents; the github block view has +/// none). +void ENRMApplyCodeBlockTextAttributes(NSMutableAttributedString *string, NSRange range, StyleConfig *config); + +#ifdef __cplusplus +} +#endif + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockContent.mm b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockContent.mm new file mode 100644 index 000000000..585044157 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockContent.mm @@ -0,0 +1,79 @@ +#import "ENRMCodeBlockContent.h" +#include "CodeBlockLanguages.hpp" +#import "ENRMUIKit.h" +#import "MarkdownASTNode.h" +#import "ParagraphStyleUtils.h" +#import "StyleConfig.h" + +static void ENRMAppendNodeContent(MarkdownASTNode *node, NSMutableString *output) +{ + if (node.content.length > 0) { + [output appendString:node.content]; + } + for (MarkdownASTNode *child in node.children) { + ENRMAppendNodeContent(child, output); + } +} + +NSString *ENRMCodeBlockExtractCode(MarkdownASTNode *node) +{ + NSMutableString *code = [NSMutableString string]; + ENRMAppendNodeContent(node, code); + NSUInteger end = code.length; + while (end > 0 && [code characterAtIndex:end - 1] == '\n') { + end--; + } + return [code substringToIndex:end]; +} + +NSString *ENRMCodeBlockLanguage(MarkdownASTNode *node) +{ + NSString *language = node.attributes[@"language"]; + return language.length > 0 ? language : nil; +} + +NSString *ENRMCodeBlockFenceChar(MarkdownASTNode *node) +{ + NSString *fenceChar = node.attributes[@"fenceChar"]; + return fenceChar.length > 0 ? fenceChar : @"`"; +} + +NSString *ENRMCodeBlockFencedMarkdown(NSString *code, NSString *language, NSString *fenceChar) +{ + NSString *fence = [@"" stringByPaddingToLength:3 withString:fenceChar startingAtIndex:0]; + return [NSString stringWithFormat:@"%@%@\n%@\n%@", fence, language ?: @"", code, fence]; +} + +NSString *ENRMCodeBlockDisplayLanguageName(NSString *language) +{ + if (language.length == 0) { + return @""; + } + std::string display = Markdown::displayNameForLanguage(language.UTF8String ?: ""); + return [NSString stringWithUTF8String:display.c_str()] ?: language; +} + +void ENRMApplyCodeBlockTextAttributes(NSMutableAttributedString *string, NSRange range, StyleConfig *config) +{ + if (range.length == 0) { + return; + } + + UIFont *font = [config codeBlockFont]; + RCTUIColor *color = [config codeBlockColor]; + if (color) { + [string addAttributes:@{NSFontAttributeName : font, NSForegroundColorAttributeName : color} range:range]; + } else { + [string addAttribute:NSFontAttributeName value:font range:range]; + } + + CGFloat lineHeight = [config codeBlockLineHeight]; + if (lineHeight > 0) { + applyLineHeight(string, range, lineHeight); + } + + NSMutableParagraphStyle *paragraphStyle = [getOrCreateParagraphStyle(string, range.location) mutableCopy]; + paragraphStyle.baseWritingDirection = NSWritingDirectionLeftToRight; + paragraphStyle.alignment = NSTextAlignmentLeft; + [string addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:range]; +} diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.h b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.h new file mode 100644 index 000000000..93bf2ea67 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.h @@ -0,0 +1,26 @@ +#pragma once + +#import + +NS_ASSUME_NONNULL_BEGIN + +#ifdef __cplusplus +extern "C" { +#endif + +/// Platform adapter over the shared C++ syntax highlighting seam +/// (cpp/highlight/CodeBlockHighlighter.hpp). +/// +/// Applies token colors as foreground-color attributes onto a mutable copy of +/// the plain styled code, so highlighting can never change text metrics and +/// the block height measured from the plain string stays valid. Returns nil +/// when highlighting is unavailable (module compiled out, unknown language, +/// parse failure); callers keep the plain attributed code. +NSAttributedString *_Nullable ENRMHighlightedAttributedCode(NSAttributedString *plainCode, NSString *code, + NSString *_Nullable language); + +#ifdef __cplusplus +} +#endif + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.mm b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.mm new file mode 100644 index 000000000..f5271bff3 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.mm @@ -0,0 +1,71 @@ +#import "ENRMCodeBlockHighlighter.h" +#include "CodeBlockHighlighter.hpp" +#import "ENRMUIKit.h" + +static RCTUIColor *ENRMHexColor(uint32_t rgb) +{ + return [RCTUIColor colorWithRed:((rgb >> 16) & 0xFF) / 255.0 + green:((rgb >> 8) & 0xFF) / 255.0 + blue:(rgb & 0xFF) / 255.0 + alpha:1.0]; +} + +// TODO: provisional palette (GitHub light scheme); replace with themable +// per-token colors on the code block style when the highlighting module lands. +static RCTUIColor *ENRMColorForToken(Markdown::HighlightTokenType type) +{ + switch (type) { + case Markdown::HighlightTokenType::Keyword: + return ENRMHexColor(0xCF222E); + case Markdown::HighlightTokenType::String: + return ENRMHexColor(0x0A3069); + case Markdown::HighlightTokenType::Number: + case Markdown::HighlightTokenType::Constant: + case Markdown::HighlightTokenType::Property: + case Markdown::HighlightTokenType::Attribute: + return ENRMHexColor(0x0550AE); + case Markdown::HighlightTokenType::Comment: + return ENRMHexColor(0x6E7781); + case Markdown::HighlightTokenType::Function: + return ENRMHexColor(0x8250DF); + case Markdown::HighlightTokenType::Type: + return ENRMHexColor(0x953800); + case Markdown::HighlightTokenType::Tag: + return ENRMHexColor(0x116329); + default: + return nil; + } +} + +NSAttributedString *ENRMHighlightedAttributedCode(NSAttributedString *plainCode, NSString *code, + NSString *_Nullable language) +{ + if (code.length == 0) { + return nil; + } + + std::vector tokens; + try { + tokens = Markdown::highlightCode(code.UTF8String ?: "", language.UTF8String ?: ""); + } catch (...) { + return nil; + } + if (tokens.empty()) { + return nil; + } + + NSMutableAttributedString *highlighted = [plainCode mutableCopy]; + NSUInteger length = highlighted.length; + BOOL applied = NO; + for (const auto &token : tokens) { + RCTUIColor *color = ENRMColorForToken(token.type); + if (!color || token.end <= token.start || token.end > length) { + continue; + } + [highlighted addAttribute:NSForegroundColorAttributeName + value:color + range:NSMakeRange(token.start, token.end - token.start)]; + applied = YES; + } + return applied ? highlighted : nil; +} diff --git a/packages/react-native-enriched-markdown/ios/utils/RenderedMarkdownSegment.h b/packages/react-native-enriched-markdown/ios/utils/RenderedMarkdownSegment.h index f36eedcc9..84f474172 100644 --- a/packages/react-native-enriched-markdown/ios/utils/RenderedMarkdownSegment.h +++ b/packages/react-native-enriched-markdown/ios/utils/RenderedMarkdownSegment.h @@ -7,7 +7,12 @@ NS_ASSUME_NONNULL_BEGIN -typedef NS_ENUM(NSInteger, ENRMSegmentKind) { ENRMSegmentKindText, ENRMSegmentKindTable, ENRMSegmentKindMath }; +typedef NS_ENUM(NSInteger, ENRMSegmentKind) { + ENRMSegmentKindText, + ENRMSegmentKindTable, + ENRMSegmentKindMath, + ENRMSegmentKindCodeBlock +}; @interface ENRMTextSegment : NSObject @property (nonatomic, strong) NSArray *nodes; @@ -24,15 +29,22 @@ typedef NS_ENUM(NSInteger, ENRMSegmentKind) { ENRMSegmentKindText, ENRMSegmentKi + (instancetype)segmentWithLatex:(NSString *)latex; @end +@interface ENRMCodeBlockSegment : NSObject +@property (nonatomic, strong) MarkdownASTNode *codeBlockNode; ++ (instancetype)segmentWithCodeBlockNode:(MarkdownASTNode *)node; +@end + @interface ENRMRenderedSegment : NSObject @property (nonatomic, assign) ENRMSegmentKind kind; @property (nonatomic, assign) uint64_t signature; @property (nonatomic, strong, nullable) ENRMRenderResult *textResult; @property (nonatomic, strong, nullable) ENRMTableSegment *tableSegment; @property (nonatomic, strong, nullable) ENRMMathSegment *mathSegment; +@property (nonatomic, strong, nullable) ENRMCodeBlockSegment *codeBlockSegment; + (instancetype)textSegmentWithResult:(ENRMRenderResult *)result signature:(uint64_t)signature; + (instancetype)tableSegmentWithSegment:(ENRMTableSegment *)segment signature:(uint64_t)signature; + (instancetype)mathSegmentWithSegment:(ENRMMathSegment *)segment signature:(uint64_t)signature; ++ (instancetype)codeBlockSegmentWithSegment:(ENRMCodeBlockSegment *)segment signature:(uint64_t)signature; @end #ifdef __cplusplus diff --git a/packages/react-native-enriched-markdown/ios/utils/RenderedMarkdownSegment.m b/packages/react-native-enriched-markdown/ios/utils/RenderedMarkdownSegment.m index e98d79fac..f2a0a5197 100644 --- a/packages/react-native-enriched-markdown/ios/utils/RenderedMarkdownSegment.m +++ b/packages/react-native-enriched-markdown/ios/utils/RenderedMarkdownSegment.m @@ -31,6 +31,16 @@ + (instancetype)segmentWithLatex:(NSString *)latex } @end +@implementation ENRMCodeBlockSegment ++ (instancetype)segmentWithCodeBlockNode:(MarkdownASTNode *)node +{ + NSParameterAssert(node != nil); + ENRMCodeBlockSegment *segment = [[ENRMCodeBlockSegment alloc] init]; + segment.codeBlockNode = node; + return segment; +} +@end + @implementation ENRMRenderedSegment + (instancetype)textSegmentWithResult:(ENRMRenderResult *)result signature:(uint64_t)signature { @@ -61,6 +71,16 @@ + (instancetype)mathSegmentWithSegment:(ENRMMathSegment *)mathSegment signature: segment.signature = signature; return segment; } + ++ (instancetype)codeBlockSegmentWithSegment:(ENRMCodeBlockSegment *)codeBlockSegment signature:(uint64_t)signature +{ + NSParameterAssert(codeBlockSegment != nil); + ENRMRenderedSegment *segment = [[ENRMRenderedSegment alloc] init]; + segment.kind = ENRMSegmentKindCodeBlock; + segment.codeBlockSegment = codeBlockSegment; + segment.signature = signature; + return segment; +} @end // FNV-1a 64-bit hash for segment signatures. Collisions are theoretically diff --git a/packages/react-native-enriched-markdown/ios/utils/SegmentRenderer.m b/packages/react-native-enriched-markdown/ios/utils/SegmentRenderer.m index d84d2e4f5..64defe004 100644 --- a/packages/react-native-enriched-markdown/ios/utils/SegmentRenderer.m +++ b/packages/react-native-enriched-markdown/ios/utils/SegmentRenderer.m @@ -33,7 +33,13 @@ #endif } #endif - else { + else if (child.type == MarkdownNodeTypeCodeBlock) { + if (currentTextNodes.count > 0) { + [segments addObject:[ENRMTextSegment segmentWithNodes:[currentTextNodes copy]]]; + [currentTextNodes removeAllObjects]; + } + [segments addObject:[ENRMCodeBlockSegment segmentWithCodeBlockNode:child]]; + } else { [currentTextNodes addObject:child]; } } @@ -53,9 +59,10 @@ NSArray *segments = ENRMSplitASTIntoSegments(ast); NSMutableArray *renderedSegments = [NSMutableArray array]; - static const uint64_t kTextKindSalt = 0x7465787400000000ULL; // "text" - static const uint64_t kTableKindSalt = 0x7461626C00000000ULL; // "tabl" - static const uint64_t kMathKindSalt = 0x6D61746800000000ULL; // "math" + static const uint64_t kTextKindSalt = 0x7465787400000000ULL; // "text" + static const uint64_t kTableKindSalt = 0x7461626C00000000ULL; // "tabl" + static const uint64_t kMathKindSalt = 0x6D61746800000000ULL; // "math" + static const uint64_t kCodeBlockKindSalt = 0x63626C6B00000000ULL; // "cblk" for (id segment in segments) { if ([segment isKindOfClass:[ENRMTextSegment class]]) { @@ -82,6 +89,12 @@ [renderedSegments addObject:[ENRMRenderedSegment mathSegmentWithSegment:mathSegment signature:signature]]; } #endif + else if ([segment isKindOfClass:[ENRMCodeBlockSegment class]]) { + ENRMCodeBlockSegment *codeBlockSegment = (ENRMCodeBlockSegment *)segment; + uint64_t signature = ENRMSignatureForNode(codeBlockSegment.codeBlockNode) ^ kCodeBlockKindSalt; + [renderedSegments addObject:[ENRMRenderedSegment codeBlockSegmentWithSegment:codeBlockSegment + signature:signature]]; + } } return renderedSegments; diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.h b/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.h new file mode 100644 index 000000000..66c66aba1 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.h @@ -0,0 +1,26 @@ +#pragma once +#import "ENRMUIKit.h" +#import "StyleConfig.h" + +@class MarkdownASTNode; + +NS_ASSUME_NONNULL_BEGIN + +@interface ENRMCodeBlockContainerView : RCTUIView + +- (instancetype)initWithConfig:(StyleConfig *)config; + +- (void)applyCodeBlockNode:(MarkdownASTNode *)node; + +- (CGFloat)measureHeight:(CGFloat)maxWidth; + +@property (nonatomic, strong) StyleConfig *config; + +// Renamed getters avoid the Cocoa `copy` method family (which signals +1 +// retained returns). Property names are unchanged so call sites stay the same. +@property (nonatomic, copy, nullable, getter=menuCopyLabel) NSString *copyLabel; +@property (nonatomic, copy, nullable, getter=menuCopyAsMarkdownLabel) NSString *copyAsMarkdownLabel; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.m b/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.m new file mode 100644 index 000000000..219a3d938 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.m @@ -0,0 +1,482 @@ +#import "ENRMCodeBlockContainerView.h" +#import "ENRMCodeBlockContent.h" +#import "ENRMCodeBlockHighlighter.h" +#import "MarkdownASTNode.h" +#import "PasteboardUtils.h" +#if TARGET_OS_OSX +#import "ENRMMenuAction.h" +#endif + +// Block segment view for fenced code blocks (see SegmentRenderer.m): +// a fixed header bar (language name, copy button) above a horizontally +// scrolling, non-wrapping code pane. The container's drawRect: draws +// background, border, label, and divider; only the copy button and the +// scroll view are subviews. Syntax coloring comes from the +// ENRMCodeBlockHighlighter seam and falls back to plain text. The box +// visuals must stay in sync with the commonmark flavor's CodeBlockBackground. +// +// measureHeight: must stay in sync with the drawn content: it uses the +// unconstrained bounding rect of the same attributed string and the same +// header metrics, so the reported height matches the drawn height. Since +// lines never wrap, the height is independent of the available width. + +static const CGFloat kENRMHeaderLabelScale = 0.85; +static const CGFloat kENRMHeaderSecondaryAlpha = 0.6; +static const CGFloat kENRMHeaderDividerAlpha = 0.2; + +// Used only to pick the scroll indicator style for the code pane, so the +// indicator stays visible on dark code block backgrounds without affecting +// any other scroll view. +static BOOL ENRMColorIsDark(RCTUIColor *color) +{ + if (!color) { + return NO; + } + CGFloat r = 0, g = 0, b = 0, a = 0; +#if !TARGET_OS_OSX + if (![color getRed:&r green:&g blue:&b alpha:&a]) { + CGFloat white = 0; + if (![color getWhite:&white alpha:&a]) { + return NO; + } + r = g = b = white; + } +#else + NSColor *rgb = [color colorUsingColorSpace:[NSColorSpace sRGBColorSpace]]; + if (!rgb) { + return NO; + } + [rgb getRed:&r green:&g blue:&b alpha:&a]; +#endif + return (0.299 * r + 0.587 * g + 0.114 * b) < 0.5; +} + +// Scrollable code pane content. Draws the attributed code unwrapped at a +// fixed origin; the hosting ENRMCodeBlockContainerView draws everything else. +@interface ENRMCodeBlockContentView : RCTUIView +@property (nonatomic, strong, nullable) NSAttributedString *attributedCode; +@property (nonatomic, assign) CGPoint textOrigin; +#if TARGET_OS_OSX +@property (nonatomic, copy, nullable) NSMenu * (^menuProvider)(void); +#endif +@end + +@implementation ENRMCodeBlockContentView + +#if TARGET_OS_OSX +- (BOOL)isFlipped +{ + return YES; +} + +- (NSMenu *)menuForEvent:(NSEvent *)event +{ + return self.menuProvider ? self.menuProvider() : [super menuForEvent:event]; +} +#endif + +- (void)drawRect:(CGRect)rect +{ + if (self.attributedCode.length > 0) { + [self.attributedCode drawAtPoint:self.textOrigin]; + } +} + +@end + +#if !TARGET_OS_OSX +@interface ENRMCodeBlockContainerView () +@end +#endif + +@implementation ENRMCodeBlockContainerView { + NSAttributedString *_attributedCode; + NSString *_cachedCode; + NSString *_cachedLanguage; + NSString *_displayLanguage; + NSString *_fenceChar; + RCTUIScrollView *_scrollView; + ENRMCodeBlockContentView *_codeContentView; + CGSize _codeSize; + CGFloat _headerLabelLineHeight; + NSAttributedString *_languageLabel; + CGSize _languageLabelSize; +#if !TARGET_OS_OSX + UIFont *_headerFont; + UIButton *_copyButton; +#else + NSFont *_headerFont; + NSButton *_copyButton; +#endif +} + +@synthesize copyLabel = _copyLabel; +@synthesize copyAsMarkdownLabel = _copyAsMarkdownLabel; + +- (void)setCopyLabel:(NSString *)copyLabel +{ + _copyLabel = [copyLabel copy]; + _copyButton.accessibilityLabel = _copyLabel; +} + +- (instancetype)initWithConfig:(StyleConfig *)config +{ + self = [super initWithFrame:CGRectZero]; + if (self) { + _config = config; + _cachedCode = @""; + _fenceChar = @"`"; +#if !TARGET_OS_OSX + _headerFont = [UIFont systemFontOfSize:[config codeBlockFont].pointSize * kENRMHeaderLabelScale + weight:UIFontWeightMedium]; + _headerLabelLineHeight = ceil(_headerFont.lineHeight); +#else + _headerFont = [NSFont systemFontOfSize:[config codeBlockFont].pointSize * kENRMHeaderLabelScale + weight:NSFontWeightMedium]; + _headerLabelLineHeight = ceil(_headerFont.ascender - _headerFont.descender); +#endif + self.backgroundColor = [RCTUIColor clearColor]; + [self setupScrollView]; + +#if !TARGET_OS_OSX + self.contentMode = UIViewContentModeRedraw; + self.isAccessibilityElement = YES; + + UIContextMenuInteraction *contextMenu = [[UIContextMenuInteraction alloc] initWithDelegate:self]; + [self addInteraction:contextMenu]; + + _copyButton = [UIButton buttonWithType:UIButtonTypeSystem]; + UIImageSymbolConfiguration *symbolConfig = + [UIImageSymbolConfiguration configurationWithPointSize:[config codeBlockFont].pointSize * kENRMHeaderLabelScale + weight:UIImageSymbolWeightMedium]; + [_copyButton setImage:[UIImage systemImageNamed:@"doc.on.doc" withConfiguration:symbolConfig] + forState:UIControlStateNormal]; + _copyButton.tintColor = [[config codeBlockColor] colorWithAlphaComponent:kENRMHeaderSecondaryAlpha]; + [_copyButton addTarget:self action:@selector(copyCodeToPasteboard) forControlEvents:UIControlEventTouchUpInside]; + [self addSubview:_copyButton]; +#else + NSImage *copyImage = [NSImage imageWithSystemSymbolName:@"doc.on.doc" accessibilityDescription:nil]; + _copyButton = [NSButton buttonWithImage:copyImage target:self action:@selector(copyCodeToPasteboard)]; + _copyButton.bordered = NO; + _copyButton.contentTintColor = [[config codeBlockColor] colorWithAlphaComponent:kENRMHeaderSecondaryAlpha]; + [self addSubview:_copyButton]; +#endif + } + return self; +} + +- (void)setupScrollView +{ + _scrollView = [[RCTUIScrollView alloc] init]; + _scrollView.showsVerticalScrollIndicator = NO; + _scrollView.showsHorizontalScrollIndicator = YES; + _codeContentView = [[ENRMCodeBlockContentView alloc] initWithFrame:CGRectZero]; + BOOL darkBackground = ENRMColorIsDark([_config codeBlockBackgroundColor]); +#if !TARGET_OS_OSX + _scrollView.bounces = YES; + _scrollView.alwaysBounceHorizontal = NO; + _scrollView.backgroundColor = [UIColor clearColor]; + _scrollView.isAccessibilityElement = NO; + _scrollView.accessibilityElementsHidden = YES; + _scrollView.indicatorStyle = darkBackground ? UIScrollViewIndicatorStyleWhite : UIScrollViewIndicatorStyleDefault; + _codeContentView.backgroundColor = [UIColor clearColor]; + _codeContentView.contentMode = UIViewContentModeRedraw; + [_scrollView addSubview:_codeContentView]; + [_codeContentView addInteraction:[[UIContextMenuInteraction alloc] initWithDelegate:self]]; +#else + ((NSScrollView *)_scrollView).drawsBackground = NO; + ((NSScrollView *)_scrollView).scrollerKnobStyle = + darkBackground ? NSScrollerKnobStyleLight : NSScrollerKnobStyleDefault; + __weak ENRMCodeBlockContainerView *weakSelf = self; + NSMenu * (^menuProvider)(void) = ^{ return [weakSelf buildContextMenu]; }; + _codeContentView.menuProvider = menuProvider; + [(NSScrollView *)_scrollView setDocumentView:_codeContentView]; +#endif + [self addSubview:_scrollView]; +} + +- (void)layoutScrollArea +{ + CGFloat borderW = ceil([_config codeBlockBorderWidth]); + CGFloat headerH = [self headerHeight]; + CGFloat inset = [self contentInset]; + CGRect frame = CGRectMake(borderW, headerH, MAX(self.bounds.size.width - borderW * 2, 0), + MAX(self.bounds.size.height - headerH - borderW, 0)); + _scrollView.frame = frame; + + CGFloat horizontalPad = MAX(inset - borderW, 0); + CGFloat contentWidth = MAX(_codeSize.width + horizontalPad * 2, frame.size.width); + _codeContentView.textOrigin = CGPointMake(horizontalPad, inset); + _codeContentView.frame = CGRectMake(0, 0, contentWidth, frame.size.height); +#if !TARGET_OS_OSX + _scrollView.contentSize = CGSizeMake(contentWidth, frame.size.height); + _scrollView.scrollEnabled = contentWidth > frame.size.width; +#endif +} + +// Header is the top content inset plus one label line; the code text's own +// top inset then forms the single gap below the label. +- (CGFloat)headerHeight +{ + return [self contentInset] + _headerLabelLineHeight; +} + +- (void)layoutHeaderButton +{ + CGFloat headerH = [self headerHeight]; + CGFloat iconWidth = _copyButton.intrinsicContentSize.width; + if (iconWidth <= 0 || iconWidth > headerH) { + iconWidth = headerH; + } + CGFloat iconSlack = (headerH - iconWidth) / 2; + CGFloat buttonLeft = MAX(self.bounds.size.width - [self contentInset] - headerH + iconSlack, 0); + CGFloat labelCenterY = headerH - _headerLabelLineHeight / 2; + CGFloat buttonTop = MAX(labelCenterY - headerH / 2, 0); + _copyButton.frame = CGRectMake(buttonLeft, buttonTop, headerH, headerH); +} + +#if !TARGET_OS_OSX +- (void)layoutSubviews +{ + [super layoutSubviews]; + [self layoutHeaderButton]; + [self layoutScrollArea]; +} +#else +- (void)layout +{ + [super layout]; + [self layoutHeaderButton]; + [self layoutScrollArea]; +} +#endif + +#if TARGET_OS_OSX +- (BOOL)isFlipped +{ + return YES; +} + +- (void)setFrameSize:(NSSize)newSize +{ + [super setFrameSize:newSize]; + [self setNeedsDisplay:YES]; +} +#endif + +- (void)applyCodeBlockNode:(MarkdownASTNode *)node +{ + _cachedCode = ENRMCodeBlockExtractCode(node); + _fenceChar = ENRMCodeBlockFenceChar(node); + + NSString *newLanguage = ENRMCodeBlockLanguage(node); + BOOL languageChanged = newLanguage != _cachedLanguage && ![newLanguage isEqualToString:_cachedLanguage]; + if (languageChanged) { + _cachedLanguage = newLanguage; + _displayLanguage = ENRMCodeBlockDisplayLanguageName(newLanguage); + [self rebuildLanguageLabel]; + } + + NSAttributedString *plainCode = [self plainAttributedCode]; + NSAttributedString *highlighted = ENRMHighlightedAttributedCode(plainCode, _cachedCode, _cachedLanguage); + _attributedCode = highlighted ?: plainCode; + + if (_attributedCode.length > 0) { + CGRect codeBounds = [_attributedCode boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX) + options:NSStringDrawingUsesLineFragmentOrigin + context:nil]; + _codeSize = CGSizeMake(ceil(codeBounds.size.width), ceil(codeBounds.size.height)); + } else { + _codeSize = CGSizeZero; + } + _codeContentView.attributedCode = _attributedCode; + +#if !TARGET_OS_OSX + [self setNeedsLayout]; + [self setNeedsDisplay]; + [_codeContentView setNeedsDisplay]; +#else + self.needsLayout = YES; + [self setNeedsDisplay:YES]; + [_codeContentView setNeedsDisplay:YES]; +#endif +} + +- (void)rebuildLanguageLabel +{ + if (_displayLanguage.length == 0) { + _languageLabel = nil; + _languageLabelSize = CGSizeZero; + return; + } + NSMutableDictionary *labelAttributes = [NSMutableDictionary dictionary]; + labelAttributes[NSFontAttributeName] = _headerFont; + RCTUIColor *labelColor = [[_config codeBlockColor] colorWithAlphaComponent:kENRMHeaderSecondaryAlpha]; + if (labelColor) { + labelAttributes[NSForegroundColorAttributeName] = labelColor; + } + _languageLabel = [[NSAttributedString alloc] initWithString:_displayLanguage attributes:labelAttributes]; + _languageLabelSize = _languageLabel.size; +} + +- (NSAttributedString *)plainAttributedCode +{ + if (_cachedCode.length == 0) { + return [[NSAttributedString alloc] initWithString:@""]; + } + + NSMutableAttributedString *attributed = [[NSMutableAttributedString alloc] initWithString:_cachedCode]; + ENRMApplyCodeBlockTextAttributes(attributed, NSMakeRange(0, attributed.length), _config); + return attributed; +} + +- (CGFloat)contentInset +{ + return [_config codeBlockPadding] + [_config codeBlockBorderWidth]; +} + +- (CGFloat)measureHeight:(CGFloat)maxWidth +{ + CGFloat inset = [self contentInset]; + CGFloat headerH = [self headerHeight]; + if (_attributedCode.length == 0) { + return headerH + inset * 2; + } + return _codeSize.height + inset * 2 + headerH; +} + +- (void)drawRect:(CGRect)rect +{ + CGFloat borderWidth = [_config codeBlockBorderWidth]; + CGFloat radius = [_config codeBlockBorderRadius]; + CGRect borderRect = CGRectInset(self.bounds, borderWidth / 2, borderWidth / 2); + +#if !TARGET_OS_OSX + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:borderRect cornerRadius:radius]; +#else + NSBezierPath *path = [NSBezierPath bezierPathWithRoundedRect:borderRect xRadius:radius yRadius:radius]; +#endif + + RCTUIColor *backgroundColor = [_config codeBlockBackgroundColor]; + if (backgroundColor) { + [backgroundColor setFill]; + [path fill]; + } + if (borderWidth > 0) { + RCTUIColor *borderColor = [_config codeBlockBorderColor]; + if (borderColor) { + path.lineWidth = borderWidth; + [borderColor setStroke]; + [path stroke]; + } + } + + CGFloat inset = [self contentInset]; + CGFloat headerH = [self headerHeight]; + + // Divider between the header and the code, centered in the gap the code + // text's top inset creates, so it adds no height of its own. + RCTUIColor *dividerColor = [[_config codeBlockColor] colorWithAlphaComponent:kENRMHeaderDividerAlpha]; + if (dividerColor) { + CGRect dividerRect = CGRectMake(borderWidth, headerH + inset / 2, self.bounds.size.width - borderWidth * 2, 1); + [dividerColor setFill]; +#if !TARGET_OS_OSX + [[UIBezierPath bezierPathWithRect:dividerRect] fill]; +#else + [[NSBezierPath bezierPathWithRect:dividerRect] fill]; +#endif + } + + if (_languageLabel.length > 0) { + [_languageLabel drawAtPoint:CGPointMake(inset, headerH - _languageLabelSize.height)]; + } +} + +- (NSString *)fencedMarkdown +{ + return ENRMCodeBlockFencedMarkdown(_cachedCode, _cachedLanguage, _fenceChar); +} + +- (void)copyCodeToPasteboard +{ + copyStringToPasteboard(_cachedCode); +} + +- (void)copyMarkdownToPasteboard +{ + copyStringToPasteboard([self fencedMarkdown]); +} + +#if !TARGET_OS_OSX +- (UIContextMenuConfiguration *)contextMenuInteraction:(UIContextMenuInteraction *)interaction + configurationForMenuAtLocation:(CGPoint)location +{ + return [UIContextMenuConfiguration + configurationWithIdentifier:nil + previewProvider:nil + actionProvider:^UIMenu *(NSArray *suggestedActions) { + UIAction *copyCode = + [UIAction actionWithTitle:self.copyLabel + image:[RCTUIImage systemImageNamed:@"doc.on.doc"] + identifier:nil + handler:^(__kindof UIAction *action) { [self copyCodeToPasteboard]; }]; + + UIAction *copyMarkdown = + [UIAction actionWithTitle:self.copyAsMarkdownLabel + image:[RCTUIImage systemImageNamed:@"doc.text"] + identifier:nil + handler:^(__kindof UIAction *action) { [self copyMarkdownToPasteboard]; }]; + + return [UIMenu menuWithTitle:@"" children:@[ copyCode, copyMarkdown ]]; + }]; +} +#endif + +#if TARGET_OS_OSX +- (NSMenu *)buildContextMenu +{ + NSMenu *menu = [[NSMenu alloc] initWithTitle:@""]; + [menu addItem:ENRMCreateMenuItem(self.copyLabel, ^{ [self copyCodeToPasteboard]; })]; + [menu addItem:ENRMCreateMenuItem(self.copyAsMarkdownLabel, ^{ [self copyMarkdownToPasteboard]; })]; + return menu; +} + +- (NSMenu *)menuForEvent:(NSEvent *)event +{ + return [self buildContextMenu]; +} +#endif + +- (NSString *)accessibilityLabel +{ + return _cachedCode; +} + +#if !TARGET_OS_OSX +- (UIAccessibilityTraits)accessibilityTraits +{ + return UIAccessibilityTraitStaticText; +} + +// The container is a single accessibility element, which hides the copy +// button subview from VoiceOver; expose the copy action explicitly instead. +- (NSArray *)accessibilityCustomActions +{ + if (self.copyLabel.length == 0) { + return @[]; + } + UIAccessibilityCustomAction *copyAction = + [[UIAccessibilityCustomAction alloc] initWithName:self.copyLabel + target:self + selector:@selector(performCopyAccessibilityAction:)]; + return @[ copyAction ]; +} + +- (BOOL)performCopyAccessibilityAction:(UIAccessibilityCustomAction *)action +{ + [self copyCodeToPasteboard]; + return YES; +} +#endif + +@end