diff --git a/.maestro/enrichedMarkdownText/flows/advanced/inline_elements/text_link_recognition_test.yaml b/.maestro/enrichedMarkdownText/flows/advanced/inline_elements/text_link_recognition_test.yaml new file mode 100644 index 000000000..6509bc6f3 --- /dev/null +++ b/.maestro/enrichedMarkdownText/flows/advanced/inline_elements/text_link_recognition_test.yaml @@ -0,0 +1,65 @@ +appId: swmansion.enriched.markdown.example +tags: + - advanced + - inline + - link + - text +--- +# linkRecognition turns plain-text and whole-inline-code matches into links, leaves +# existing links, fences and partial code matches alone, and keeps the trailing block +# visible (measurement must see the same links the view renders). +- launchApp + +- runFlow: + file: '../../../../subflows/move_to_playground.yaml' + +- runFlow: + file: '../../../subflows/set_enriched_text_value.yaml' + env: + VALUE: | + See ref:one, `ref:two`, [ref:three](https://existing.invalid) and `prefix ref:four`. + + ``` + ref:five stays plain in a fence + ``` + + Last line: ref:six. + +- scrollUntilVisible: + element: + id: recognize-links-button + direction: DOWN + +# Off -> CommonMark -> GitHub +- tapOn: + id: recognize-links-button +- tapOn: + id: recognize-links-button + +- scrollUntilVisible: + element: + id: preview-container + direction: DOWN + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'text_link_recognition_display' + +# Recognition off again on unchanged Markdown: generated links disappear. +- scrollUntilVisible: + element: + id: recognize-links-button + direction: UP +- tapOn: + id: recognize-links-button + +- scrollUntilVisible: + element: + id: preview-container + direction: DOWN + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'text_link_recognition_off_display' diff --git a/.maestro/enrichedMarkdownText/screenshots/android/text_link_recognition_display.png b/.maestro/enrichedMarkdownText/screenshots/android/text_link_recognition_display.png new file mode 100644 index 000000000..b6983ff48 Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/android/text_link_recognition_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/text_link_recognition_off_display.png b/.maestro/enrichedMarkdownText/screenshots/android/text_link_recognition_off_display.png new file mode 100644 index 000000000..e35dd427b Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/android/text_link_recognition_off_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/text_link_recognition_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/text_link_recognition_display.png new file mode 100644 index 000000000..e614b450f Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/ios/text_link_recognition_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/text_link_recognition_off_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/text_link_recognition_off_display.png new file mode 100644 index 000000000..5d3313906 Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/ios/text_link_recognition_off_display.png differ diff --git a/apps/react-native-example/src/screens/playground/PlaygroundScreen.tsx b/apps/react-native-example/src/screens/playground/PlaygroundScreen.tsx index e8d9c84da..71fbbff80 100644 --- a/apps/react-native-example/src/screens/playground/PlaygroundScreen.tsx +++ b/apps/react-native-example/src/screens/playground/PlaygroundScreen.tsx @@ -60,6 +60,7 @@ const INLINE_IMAGE_URI = Image.resolveAssetSource( require('../../assets/logo_icon.png') ).uri; +const REFERENCE_REGEX = /ref:[a-z]+/; const DEFAULT_INPUT_LINE_HEIGHT = undefined; const TALL_INPUT_LINE_HEIGHT = 36; @@ -73,6 +74,7 @@ export default function PlaygroundScreen() { const [underlineEnabled, setUnderlineEnabled] = useState(true); const [setMarkdownModalVisible, setSetMarkdownModalVisible] = useState(false); const [rawInput, setRawInput] = useState(''); + const [recognitionMode, setRecognitionMode] = useState(0); const [inputLineHeight, setInputLineHeight] = useState( DEFAULT_INPUT_LINE_HEIGHT ); @@ -268,12 +270,26 @@ export default function PlaygroundScreen() { Preview + setRecognitionMode((mode) => (mode + 1) % 3)} + testID="recognize-links-button" + > + + Reference links: {['Off', 'CommonMark', 'GitHub'][recognitionMode]} + + {markdown.length > 0 ? ( ``` +### `linkRecognition` + +Opt-in link recognition for text that is not written as a Markdown link, such as `#1234`, `@name`, `$skill` or a file path. Off by default. Works in both flavors on every platform. + +| Key | Type | Matches | +| --- | ---- | ------- | +| `text` | `RegExp` | Every nonempty, non-overlapping match inside a plain-text run | +| `inlineCode` | `RegExp` | An inline-code span whose whole content matches becomes one link | + +A recognized link's URL is the matched text itself, so `linkVariants`, `linkPillContent`, `linkContextMenuItems`, `onLinkPress` and `onLinkLongPress` apply to it like to any other link. Existing links, autolinks, code blocks, images, video and math are never touched. The Markdown source is unchanged: copying, including Copy as Markdown, returns the original text without link syntax. + +Matching happens per parsed text run, after Markdown parsing, so a pattern cannot match across formatting. `__init__.py` as plain text is split by emphasis parsing and will not match, while `` `__init__.py` `` as inline code matches whole. With the `latexMath` flag on (the default), two `$` on one line form a math span, so `$deploy ... $test` is math, not two skills; turn the flag off or escape the `$` in such content. + +Only the regex source and the `i` and `s` flags are used. Patterns must be valid for `NSRegularExpression` and `java.util.regex.Pattern` as well as JavaScript; an invalid pattern turns that recognizer off. This is different from the input's `linkRegex`, which detects URLs and is on by default. Pass the object inline or hoisted as you like: an equal config does not re-send the prop. + +```tsx + openReference(url)} +/> +``` + ### `contextMenuItems` Custom items to add to the text selection context menu. Items appear before the system actions (Copy, etc.). Items with `visible: false` are hidden from the menu. diff --git a/docs/docs/react-native/api-reference/enriched-markdown-text.md b/docs/docs/react-native/api-reference/enriched-markdown-text.md index 58df43ecb..0a84cc9df 100644 --- a/docs/docs/react-native/api-reference/enriched-markdown-text.md +++ b/docs/docs/react-native/api-reference/enriched-markdown-text.md @@ -347,6 +347,30 @@ interface LinkPillContent { iOS and Android only - pills are drawn by the native text stack.} /> +### `linkRecognition` + +Opt-in link recognition for text that is not written as a Markdown link, such as `#1234`, `@name`, `$skill` or a file path. Off by default. Works in both flavors on every platform. + +| Key | Type | Matches | +| --- | ---- | ------- | +| `text` | `RegExp` | Every nonempty, non-overlapping match inside a plain-text run | +| `inlineCode` | `RegExp` | An inline-code span whose whole content matches becomes one link | + +A recognized link's URL is the matched text itself, so `linkVariants`, `linkPillContent`, `linkContextMenuItems`, `onLinkPress` and `onLinkLongPress` apply to it like to any other link. Existing links, autolinks, code blocks, images, video and math are never touched. The Markdown source is unchanged: copying, including Copy as Markdown, returns the original text without link syntax. + +Matching happens per parsed text run, after Markdown parsing, so a pattern cannot match across formatting. `__init__.py` as plain text is split by emphasis parsing and will not match, while `` `__init__.py` `` as inline code matches whole. With the `latexMath` flag on (the default), two `$` on one line form a math span, so `$deploy ... $test` is math, not two skills; turn the flag off or escape the `$` in such content. + +Only the regex source and the `i` and `s` flags are used. Patterns must be valid for `NSRegularExpression` and `java.util.regex.Pattern` as well as JavaScript; an invalid pattern turns that recognizer off. This is different from the input's `linkRegex`, which detects URLs and is on by default. Pass the object inline or hoisted as you like: an equal config does not re-send the prop. + +```tsx + openReference(url)} +/> +``` + ### `contextMenuItems` Custom items to add to the text selection context menu. Items appear before the system actions and are hidden when `visible: false`. On iOS this requires iOS 16+; on earlier versions the prop is ignored. diff --git a/packages/core/cpp/parser/TextLinkRecognizer.cpp b/packages/core/cpp/parser/TextLinkRecognizer.cpp new file mode 100644 index 000000000..6f3d92391 --- /dev/null +++ b/packages/core/cpp/parser/TextLinkRecognizer.cpp @@ -0,0 +1,175 @@ +#include "TextLinkRecognizer.hpp" + +#include +#include +#include +#include + +namespace Markdown { + +namespace { + +using NodePtr = std::shared_ptr; +using NodeList = std::vector; + +bool isOpaque(NodeType type) { + switch (type) { + case NodeType::Link: + case NodeType::CodeBlock: + case NodeType::Image: + case NodeType::Video: + case NodeType::LatexMathInline: + case NodeType::LatexMathDisplay: + return true; + default: + return false; + } +} + +NodePtr makeLink(NodePtr child, const std::string& url) { + auto link = std::make_shared(NodeType::Link); + link->setAttribute("url", url); + link->setAttribute("recognizedLink", "true"); + link->addChild(std::move(child)); + return link; +} + +NodePtr textSlice(const MarkdownASTNode& source, std::string content) { + auto text = std::make_shared(NodeType::Text); + text->content = std::move(content); + text->attributes = source.attributes; + return text; +} + +// Byte offset of each UTF-16 code unit boundary in a UTF-8 string, plus the end. +std::vector utf16BoundariesToBytes(const std::string& text) { + std::vector bytes; + bytes.reserve(text.size() + 1); + size_t i = 0; + while (i < text.size()) { + unsigned char lead = static_cast(text[i]); + size_t length = lead < 0x80 ? 1 : lead < 0xE0 ? 2 : lead < 0xF0 ? 3 : 4; + if (i + length > text.size()) length = text.size() - i; + bytes.push_back(i); + if (length == 4) bytes.push_back(i); // surrogate pair: two units, one start + i += length; + } + bytes.push_back(text.size()); + return bytes; +} + +struct Candidate { + MarkdownASTNode* parent; + size_t index; +}; + +void collect(MarkdownASTNode& node, bool wantText, bool wantCode, std::vector& texts, + std::vector& codes) { + for (size_t i = 0; i < node.children.size(); i++) { + MarkdownASTNode& child = *node.children[i]; + if (isOpaque(child.type)) continue; + if (child.type == NodeType::Text) { + if (wantText && !child.content.empty()) texts.push_back({&node, i}); + } else if (child.type == NodeType::Code) { + if (wantCode) codes.push_back({&node, i}); + } else { + collect(child, wantText, wantCode, texts, codes); + } + } +} + +std::string codeContent(const MarkdownASTNode& code) { + std::string content; + for (const auto& child : code.children) content += child->content; + return content; +} + +using Replacements = std::unordered_map; + +void splitText(const NodePtr& textNode, const std::vector& ranges, Replacements& out) { + const std::string& content = textNode->content; + std::vector bytes = utf16BoundariesToBytes(content); + NodeList pieces; + size_t offset = 0; + for (const TextRange& range : ranges) { + if (range.end <= range.start || range.end >= bytes.size()) continue; + size_t start = bytes[range.start]; + size_t end = bytes[range.end]; + if (start < offset || end <= start) continue; + if (start > offset) pieces.push_back(textSlice(*textNode, content.substr(offset, start - offset))); + std::string matched = content.substr(start, end - start); + pieces.push_back(makeLink(textSlice(*textNode, matched), matched)); + offset = end; + } + if (offset == 0) return; + if (offset < content.size()) pieces.push_back(textSlice(*textNode, content.substr(offset))); + out[textNode.get()] = std::move(pieces); +} + +void applyReplacements(MarkdownASTNode& node, const Replacements& replacements) { + bool changed = false; + for (const auto& child : node.children) { + if (replacements.count(child.get())) { + changed = true; + break; + } + } + if (changed) { + NodeList children; + children.reserve(node.children.size()); + for (const auto& child : node.children) { + auto it = replacements.find(child.get()); + if (it == replacements.end()) { + children.push_back(child); + } else { + children.insert(children.end(), it->second.begin(), it->second.end()); + } + } + node.children = std::move(children); + } + for (const auto& child : node.children) { + if (!isOpaque(child->type)) applyReplacements(*child, replacements); + } +} + +} // namespace + +void recognizeTextLinks(MarkdownASTNode& root, const TextLinkMatchers& matchers) { + const bool wantText = static_cast(matchers.text); + const bool wantCode = static_cast(matchers.inlineCode); + if (!wantText && !wantCode) return; + + std::vector texts; + std::vector codes; + collect(root, wantText, wantCode, texts, codes); + if (texts.empty() && codes.empty()) return; + + Replacements replacements; + + if (!texts.empty()) { + std::vector runs; + runs.reserve(texts.size()); + for (const Candidate& c : texts) runs.emplace_back(c.parent->children[c.index]->content); + std::vector> matches = matchers.text(runs); + for (size_t i = 0; i < texts.size() && i < matches.size(); i++) { + if (!matches[i].empty()) splitText(texts[i].parent->children[texts[i].index], matches[i], replacements); + } + } + + if (!codes.empty()) { + std::vector contents; + contents.reserve(codes.size()); + for (const Candidate& c : codes) contents.push_back(codeContent(*c.parent->children[c.index])); + std::vector spans(contents.begin(), contents.end()); + std::vector matched = matchers.inlineCode(spans); + for (size_t i = 0; i < codes.size() && i < matched.size(); i++) { + if (!matched[i] || contents[i].empty()) continue; + const NodePtr& code = codes[i].parent->children[codes[i].index]; + replacements[code.get()] = NodeList{makeLink(code, contents[i])}; + } + } + + if (!replacements.empty()) applyReplacements(root, replacements); +} + +} // namespace Markdown diff --git a/packages/core/cpp/parser/TextLinkRecognizer.hpp b/packages/core/cpp/parser/TextLinkRecognizer.hpp new file mode 100644 index 000000000..e426affd6 --- /dev/null +++ b/packages/core/cpp/parser/TextLinkRecognizer.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "MarkdownASTNode.hpp" +#include +#include +#include +#include +#include + +namespace Markdown { + +// Offsets are UTF-16 code units, as every host regex engine reports them. +struct TextRange { + size_t start; + size_t end; +}; + +// Host-provided matching, one call per kind per parse. A null function is off. +struct TextLinkMatchers { + // Non-overlapping matches per run, in order. + std::function>(const std::vector& runs)> text; + std::function(const std::vector& spans)> inlineCode; +}; + +// In place. Existing links, code blocks, media and math are not entered. +void recognizeTextLinks(MarkdownASTNode& root, const TextLinkMatchers& matchers); + +} // namespace Markdown diff --git a/packages/react-native-enriched-markdown/__tests__/text-link-regex.test.tsx b/packages/react-native-enriched-markdown/__tests__/text-link-regex.test.tsx new file mode 100644 index 000000000..d9dba1f5e --- /dev/null +++ b/packages/react-native-enriched-markdown/__tests__/text-link-regex.test.tsx @@ -0,0 +1,117 @@ +import { act } from 'react'; +import type { ReactElement } from 'react'; +import { createRoot } from 'test-renderer'; +import type { TestInstance } from 'test-renderer'; +import { EnrichedMarkdownText } from '../src/native/EnrichedMarkdownText'; +import { + isLinkRecognitionEqual, + toNativeLinkRecognition, + toNativeRegexConfig, + toNativeTextLinkRegexConfig, +} from '../src/utils/regexParser'; + +jest.mock('../src/EnrichedMarkdownNativeComponent', () => ({ + __esModule: true, + default: 'EnrichedMarkdownNativeComponent', +})); +jest.mock('../src/EnrichedMarkdownTextNativeComponent', () => ({ + __esModule: true, + default: 'EnrichedMarkdownTextNativeComponent', +})); + +const renderNative = (element: ReactElement): TestInstance => { + const root = createRoot({ + textComponentTypes: [ + 'EnrichedMarkdownNativeComponent', + 'EnrichedMarkdownTextNativeComponent', + ], + }); + act(() => root.render(element)); + const [native] = root.container.queryAll((instance) => + String(instance.type).startsWith('EnrichedMarkdown') + ); + if (!native) throw new Error('Missing native markdown view'); + return native; +}; + +it.each(['commonmark', 'github'] as const)( + 'disables both recognizers by default in %s', + (flavor) => { + const native = renderNative( + + ); + expect(native.props.linkRecognition.text.isDisabled).toBe(true); + expect(native.props.linkRecognition.inlineCode.isDisabled).toBe(true); + } +); + +it.each(['commonmark', 'github'] as const)( + 'normalizes independent regex props without rewriting the source in %s', + (flavor) => { + const markdown = 'ref:one [ref:two](https://example.com) `ref:three`'; + const native = renderNative( + + ); + expect(native.props.markdown).toBe(markdown); + expect(native.props.linkRecognition.text).toEqual({ + pattern: 'ref:\\w+', + caseInsensitive: true, + dotAll: true, + isDisabled: false, + isDefault: false, + }); + expect(native.props.linkRecognition.inlineCode).toEqual({ + pattern: 'ref:[\\w-]+', + caseInsensitive: true, + dotAll: false, + isDisabled: false, + isDefault: false, + }); + } +); + +it('an omitted pattern disables rendering recognition, leaving input defaults intact', () => { + expect(toNativeTextLinkRegexConfig(undefined).isDisabled).toBe(true); + expect(toNativeRegexConfig(undefined).isDefault).toBe(true); +}); + +it('uses the existing variable-width lookbehind rejection without enabling fallback detection', () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(toNativeTextLinkRegexConfig(/(?<=a+)ref:\w+/).isDisabled).toBe(true); + expect(error).toHaveBeenCalled(); + } finally { + error.mockRestore(); + } +}); + +it('ignores JavaScript execution state and only transports supported i/s options', () => { + const regex = /ref:\w+/dgmy; + regex.lastIndex = 14; + expect(toNativeTextLinkRegexConfig(regex)).toEqual({ + pattern: 'ref:\\w+', + caseInsensitive: false, + dotAll: false, + isDisabled: false, + isDefault: false, + }); + expect(regex.lastIndex).toBe(14); +}); + +it('keeps the native prop object when an inline config is equal by value', () => { + const first = toNativeLinkRecognition({ + text: /#\d+/, + inlineCode: undefined, + }); + const second = toNativeLinkRecognition({ text: /#\d+/ }); + expect(isLinkRecognitionEqual(first, second)).toBe(true); + expect( + isLinkRecognitionEqual(first, toNativeLinkRecognition({ text: /#\d+/i })) + ).toBe(false); + expect(isLinkRecognitionEqual(undefined, second)).toBe(false); + expect(toNativeLinkRecognition(undefined).text.isDisabled).toBe(true); +}); diff --git a/packages/react-native-enriched-markdown/__tests__/web-text-link-recognition.test.ts b/packages/react-native-enriched-markdown/__tests__/web-text-link-recognition.test.ts new file mode 100644 index 000000000..e1c45c317 --- /dev/null +++ b/packages/react-native-enriched-markdown/__tests__/web-text-link-recognition.test.ts @@ -0,0 +1,111 @@ +import { recognizeTextLinks } from '../src/web/recognizeTextLinks'; +import type { ASTNode } from '../src/web/types'; + +const text = (content: string): ASTNode => ({ type: 'Text', content }); +const node = (type: ASTNode['type'], ...children: ASTNode[]): ASTNode => ({ + type, + children, +}); +const document = (...children: ASTNode[]) => + node('Document', node('Paragraph', ...children)); +const link = (url: string, child: ASTNode): ASTNode => ({ + type: 'Link', + attributes: { url, recognizedLink: 'true' }, + children: [child], +}); + +function links(root: ASTNode): ASTNode[] { + const own = root.type === 'Link' ? [root] : []; + return own.concat((root.children ?? []).flatMap(links)); +} + +function originalText(root: ASTNode): string { + return ( + (root.content ?? '') + (root.children ?? []).map(originalText).join('') + ); +} + +describe('recognizeTextLinks (web)', () => { + it('returns the same tree when both patterns are absent or invalid', () => { + const ast = document(text('item-12')); + expect(recognizeTextLinks(ast, undefined, undefined)).toBe(ast); + // A pattern that is valid elsewhere but not for this engine arrives as a plain object. + const invalid = { source: '(', ignoreCase: false, dotAll: false } as RegExp; + expect(recognizeTextLinks(ast, invalid, invalid)).toBe(ast); + }); + + it('splits text runs around every nonempty match and keeps the text', () => { + const ast = document(text('Before item-12, item-34 after.')); + const actual = recognizeTextLinks(ast, /item-\d+/, undefined); + expect(actual).toEqual( + document( + text('Before '), + link('item-12', text('item-12')), + text(', '), + link('item-34', text('item-34')), + text(' after.') + ) + ); + expect(originalText(actual)).toBe(originalText(ast)); + }); + + it('ignores empty matches', () => { + const ast = document(text('abc xx def')); + expect( + links(recognizeTextLinks(ast, /x*/, undefined)).map( + (l) => l.attributes?.url + ) + ).toEqual(['xx']); + expect(recognizeTextLinks(ast, /(?=x)/, undefined)).toBe(ast); + }); + + it('leaves links, code blocks, media and math untouched', () => { + const explicit: ASTNode = { + type: 'Link', + attributes: { url: 'https://original.invalid' }, + children: [text('item-12')], + }; + const opaque = ( + ['CodeBlock', 'Image', 'Video', 'LatexMathInline'] as const + ).map((type) => node(type, text('item-12'))); + const ast = node( + 'Document', + node('Paragraph', explicit, text(' item-78 '), ...opaque) + ); + const actual = recognizeTextLinks(ast, /item-\d+/, /item-\d+/); + expect(links(actual).map((l) => l.attributes?.url)).toEqual([ + 'https://original.invalid', + 'item-78', + ]); + }); + + it('recognizes inline code only when the whole span matches', () => { + const whole = node('Code', text('item-'), text('12')); + const partial = node('Code', text('see item-12')); + const actual = recognizeTextLinks( + document(whole, partial), + undefined, + /item-\d+|a/ + ); + expect(actual).toEqual(document(link('item-12', whole), partial)); + expect(links(actual)[0]?.children?.[0]).toBe(whole); + }); + + it('honors only the i and s flags', () => { + const ast = document(text('ITEM-12'), node('Code', text('a\nb'))); + const insensitive = recognizeTextLinks(ast, /item-\d+/gi, /a.b/); + expect(links(insensitive).map((l) => l.attributes?.url)).toEqual([ + 'ITEM-12', + ]); + const dotAll = recognizeTextLinks(ast, undefined, /a.b/s); + expect(links(dotAll).map((l) => l.attributes?.url)).toEqual(['a\nb']); + }); + + it('keeps untouched subtrees by identity', () => { + const untouched = node('Paragraph', text('nothing here')); + const ast = node('Document', untouched, node('Paragraph', text('item-12'))); + const actual = recognizeTextLinks(ast, /item-\d+/, undefined); + expect(actual).not.toBe(ast); + expect(actual.children?.[0]).toBe(untouched); + }); +}); diff --git a/packages/react-native-enriched-markdown/android/consumer-rules.pro b/packages/react-native-enriched-markdown/android/consumer-rules.pro index 1969de35f..c81d9d6bd 100644 --- a/packages/react-native-enriched-markdown/android/consumer-rules.pro +++ b/packages/react-native-enriched-markdown/android/consumer-rules.pro @@ -6,6 +6,8 @@ -keep class com.swmansion.enriched.markdown.parser.MarkdownASTNode { *; } -keep class com.swmansion.enriched.markdown.parser.MarkdownASTNode$NodeType { *; } -keep class com.swmansion.enriched.markdown.parser.Md4cFlags { *; } +-keep class com.swmansion.enriched.markdown.parser.TextLinkMatching { *; } +-keep class com.swmansion.enriched.markdown.utils.common.LinkRegexConfig { *; } # Reflection: Math classes loaded via Class.forName when enableMath=true. -keep class com.swmansion.enriched.markdown.spans.MathInlineSpan { *; } diff --git a/packages/react-native-enriched-markdown/android/src/main/baseline-prof.txt b/packages/react-native-enriched-markdown/android/src/main/baseline-prof.txt index 8a49f91d1..c8b634ee1 100644 --- a/packages/react-native-enriched-markdown/android/src/main/baseline-prof.txt +++ b/packages/react-native-enriched-markdown/android/src/main/baseline-prof.txt @@ -43,7 +43,7 @@ Lcom/swmansion/enriched/markdown/input/autolink/AutoDetectedLinkMarkerSpan; Lcom/swmansion/enriched/markdown/input/autolink/AutoDetectedLinkUnderlineSpan; Lcom/swmansion/enriched/markdown/input/autolink/AutoLinkDetector; Lcom/swmansion/enriched/markdown/input/autolink/AutoLinkDetectorKt; -Lcom/swmansion/enriched/markdown/input/autolink/LinkRegexConfig; +Lcom/swmansion/enriched/markdown/utils/common/LinkRegexConfig; # Input - Detection Lcom/swmansion/enriched/markdown/input/detection/DetectorPipeline; 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 6faeed955..64ad81a80 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,9 +1,11 @@ #include "CodeBlockHighlighter.hpp" #include "CodeBlockLanguages.hpp" #include "MD4CParser.hpp" +#include "TextLinkRecognizer.hpp" #include #include #include +#include #include using namespace Markdown; @@ -192,10 +194,130 @@ static jobject createJavaNode(JNIEnv *env, std::shared_ptr node return javaNode; } +// UTF-16 via NewString, not NewStringUTF: the matcher's offsets must count the same units +// the core maps back to bytes, and modified UTF-8 cannot carry 4-byte sequences. +static std::u16string toUtf16(std::string_view utf8) { + std::u16string out; + out.reserve(utf8.size()); + for (size_t i = 0; i < utf8.size();) { + unsigned char lead = static_cast(utf8[i]); + size_t length = lead < 0x80 ? 1 : lead < 0xE0 ? 2 : lead < 0xF0 ? 3 : 4; + if (i + length > utf8.size()) + break; + uint32_t codePoint = length == 1 ? lead + : length == 2 ? ((lead & 0x1Fu) << 6) | (utf8[i + 1] & 0x3Fu) + : length == 3 ? ((lead & 0x0Fu) << 12) | ((utf8[i + 1] & 0x3Fu) << 6) | (utf8[i + 2] & 0x3Fu) + : ((lead & 0x07u) << 18) | ((utf8[i + 1] & 0x3Fu) << 12) | + ((utf8[i + 2] & 0x3Fu) << 6) | (utf8[i + 3] & 0x3Fu); + if (codePoint >= 0x10000) { + codePoint -= 0x10000; + out.push_back(static_cast(0xD800 + (codePoint >> 10))); + out.push_back(static_cast(0xDC00 + (codePoint & 0x3FF))); + } else { + out.push_back(static_cast(codePoint)); + } + i += length; + } + return out; +} + +static jobjectArray toJavaStrings(JNIEnv *env, const std::vector &strings) { + jclass stringClass = env->FindClass("java/lang/String"); + jobjectArray array = env->NewObjectArray(static_cast(strings.size()), stringClass, nullptr); + for (size_t i = 0; i < strings.size(); i++) { + std::u16string utf16 = toUtf16(strings[i]); + jstring value = env->NewString(reinterpret_cast(utf16.data()), static_cast(utf16.size())); + env->SetObjectArrayElement(array, static_cast(i), value); + env->DeleteLocalRef(value); + } + env->DeleteLocalRef(stringClass); + return array; +} + +// A host exception must not leak into later JNI calls; recognition is dropped for that call. +static bool clearHostException(JNIEnv *env) { + if (!env->ExceptionCheck()) + return false; + env->ExceptionDescribe(); + env->ExceptionClear(); + return true; +} + +// Host matchers for the core recognizer: one JNI round trip per kind per parse, into TextLinkMatching. +// Capturing env is safe because the core invokes these synchronously, inside this native call. +static Markdown::TextLinkMatchers makeTextLinkMatchers(JNIEnv *env, jobject textConfig, jobject codeConfig) { + Markdown::TextLinkMatchers matchers; + if (!textConfig && !codeConfig) + return matchers; + jclass matchingClass = env->FindClass("com/swmansion/enriched/markdown/parser/TextLinkMatching"); + if (!matchingClass) { + clearHostException(env); + return matchers; + } + const char *configSig = "com/swmansion/enriched/markdown/utils/common/LinkRegexConfig"; + + if (textConfig) { + jmethodID method = env->GetStaticMethodID(matchingClass, "matchText", + ("([Ljava/lang/String;L" + std::string(configSig) + ";)[I").c_str()); + if (!method) { + clearHostException(env); + return matchers; + } + matchers.text = [env, matchingClass, method, textConfig](const std::vector &runs) { + std::vector> all(runs.size()); + jobjectArray javaRuns = toJavaStrings(env, runs); + jintArray triples = (jintArray)env->CallStaticObjectMethod(matchingClass, method, javaRuns, textConfig); + env->DeleteLocalRef(javaRuns); + if (clearHostException(env)) + return all; + if (triples) { + jsize count = env->GetArrayLength(triples); + jint *data = env->GetIntArrayElements(triples, nullptr); + for (jsize i = 0; i + 2 < count; i += 3) { + size_t run = static_cast(data[i]); + if (run < all.size()) + all[run].push_back({static_cast(data[i + 1]), static_cast(data[i + 2])}); + } + env->ReleaseIntArrayElements(triples, data, JNI_ABORT); + env->DeleteLocalRef(triples); + } + return all; + }; + } + + if (codeConfig) { + jmethodID method = env->GetStaticMethodID(matchingClass, "matchWholeCode", + ("([Ljava/lang/String;L" + std::string(configSig) + ";)[Z").c_str()); + if (!method) { + clearHostException(env); + return matchers; + } + matchers.inlineCode = [env, matchingClass, method, codeConfig](const std::vector &spans) { + std::vector matched(spans.size(), 0); + jobjectArray javaSpans = toJavaStrings(env, spans); + jbooleanArray flags = (jbooleanArray)env->CallStaticObjectMethod(matchingClass, method, javaSpans, codeConfig); + env->DeleteLocalRef(javaSpans); + if (clearHostException(env)) + return matched; + if (flags) { + jsize count = env->GetArrayLength(flags); + jboolean *data = env->GetBooleanArrayElements(flags, nullptr); + for (jsize i = 0; i < count && static_cast(i) < matched.size(); i++) + matched[i] = data[i] == JNI_TRUE ? 1 : 0; + env->ReleaseBooleanArrayElements(flags, data, JNI_ABORT); + env->DeleteLocalRef(flags); + } + return matched; + }; + } + return matchers; +} + extern "C" { JNIEXPORT jobject JNICALL Java_com_swmansion_enriched_markdown_parser_Parser_nativeParseMarkdown( - JNIEnv *env, jobject /* this */, jstring markdown, jobject flags, jboolean isGFM) { + JNIEnv *env, jobject /* this */, jstring markdown, jobject flags, jboolean isGFM, jobject textLinkRegex, + jobject inlineCodeLinkRegex) { if (!markdown) { LOGE("Markdown string is null"); return nullptr; @@ -263,6 +385,10 @@ JNIEXPORT jobject JNICALL Java_com_swmansion_enriched_markdown_parser_Parser_nat return nullptr; } + if (textLinkRegex || inlineCodeLinkRegex) { + Markdown::recognizeTextLinks(*ast, makeTextLinkMatchers(env, textLinkRegex, inlineCodeLinkRegex)); + } + // Convert C++ AST to Kotlin MarkdownASTNode object jobject javaNode = createJavaNode(env, ast); 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 3d4e06e20..c12937c50 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.styles.LinkPillContent import com.swmansion.enriched.markdown.styles.StyleConfig import com.swmansion.enriched.markdown.utils.common.BreakStrategyUtils import com.swmansion.enriched.markdown.utils.common.CodeBlockStreamingMode +import com.swmansion.enriched.markdown.utils.common.LinkRegexConfig import com.swmansion.enriched.markdown.utils.common.StreamingMarkdownFilter import com.swmansion.enriched.markdown.utils.common.TableStreamingMode import com.swmansion.enriched.markdown.utils.common.isReducedMotionEnabled @@ -80,6 +81,8 @@ class EnrichedMarkdown( var md4cFlags: Md4cFlags = Md4cFlags.DEFAULT private set + private var linkRegex: LinkRegexConfig? = null + private var inlineCodeLinkRegex: LinkRegexConfig? = null private var isGFM: Boolean = true private var allowFontScaling: Boolean = true private var maxFontSizeMultiplier: Float = 0f @@ -201,6 +204,18 @@ class EnrichedMarkdown( } } + fun setLinkRegex(config: LinkRegexConfig?) { + if (linkRegex == config) return + linkRegex = config + renderPending = true + } + + fun setInlineCodeLinkRegex(config: LinkRegexConfig?) { + if (inlineCodeLinkRegex == config) return + inlineCodeLinkRegex = config + renderPending = true + } + fun setMd4cFlags(flags: Md4cFlags) { if (md4cFlags == flags) return md4cFlags = flags @@ -386,6 +401,8 @@ class EnrichedMarkdown( val tableMode = tableStreamingMode val codeBlockMode = codeBlockStreamingMode + val textLinkRegex = linkRegex + val codeLinkRegex = inlineCodeLinkRegex val renderId = ++currentRenderId executor.execute { @@ -405,7 +422,7 @@ class EnrichedMarkdown( } val ast = - parser.parseMarkdown(renderableMarkdown, md4cFlags, isGFM) ?: run { + parser.parseMarkdown(renderableMarkdown, md4cFlags, isGFM, textLinkRegex, codeLinkRegex) ?: run { postToMain(renderId) { applyRenderedSegments(emptyList(), false) } return@execute } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt index cbcfbf11d..5dd58285c 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt @@ -29,6 +29,7 @@ import com.swmansion.enriched.markdown.utils.common.parseAccessibilityLabels import com.swmansion.enriched.markdown.utils.common.parseContextMenuItems import com.swmansion.enriched.markdown.utils.common.parseImageRequestHeaders import com.swmansion.enriched.markdown.utils.common.parseLinkPillContent +import com.swmansion.enriched.markdown.utils.common.parseLinkRecognition import com.swmansion.enriched.markdown.utils.common.parseMd4cFlags import com.swmansion.enriched.markdown.utils.common.parseSelectionMenuConfig import com.swmansion.enriched.markdown.utils.text.interaction.TaskListToggleUtils @@ -153,6 +154,16 @@ class EnrichedMarkdownManager : view?.setSelectionHandleColor(value) } + @ReactProp(name = "linkRecognition") + override fun setLinkRecognition( + view: EnrichedMarkdown?, + value: ReadableMap?, + ) { + val recognition = parseLinkRecognition(value) + view?.setLinkRegex(recognition.text) + view?.setInlineCodeLinkRegex(recognition.inlineCode) + } + @ReactProp(name = "md4cFlags") override fun setMd4cFlags( view: EnrichedMarkdown?, diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt index 4be3b3043..874534916 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt @@ -31,6 +31,7 @@ import com.swmansion.enriched.markdown.styles.LinkPillContent import com.swmansion.enriched.markdown.styles.StyleConfig import com.swmansion.enriched.markdown.utils.common.BreakStrategyUtils import com.swmansion.enriched.markdown.utils.common.EllipsizeUtils +import com.swmansion.enriched.markdown.utils.common.LinkRegexConfig import com.swmansion.enriched.markdown.utils.common.emitCodeBlockPress import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator import com.swmansion.enriched.markdown.utils.text.interaction.CheckboxTouchHelper @@ -103,6 +104,8 @@ class EnrichedMarkdownText var md4cFlags: Md4cFlags = Md4cFlags.DEFAULT private set + private var linkRegex: LinkRegexConfig? = null + private var inlineCodeLinkRegex: LinkRegexConfig? = null private var isGFM: Boolean = false private var lastKnownFontScale: Float = context.resources.configuration.fontScale @@ -195,6 +198,18 @@ class EnrichedMarkdownText } } + fun setLinkRegex(config: LinkRegexConfig?) { + if (linkRegex == config) return + linkRegex = config + scheduleRenderIfNeeded() + } + + fun setInlineCodeLinkRegex(config: LinkRegexConfig?) { + if (inlineCodeLinkRegex == config) return + inlineCodeLinkRegex = config + scheduleRenderIfNeeded() + } + fun setMd4cFlags(flags: Md4cFlags) { if (md4cFlags == flags) return md4cFlags = flags @@ -282,12 +297,14 @@ class EnrichedMarkdownText val markdown = currentMarkdown if (markdown.isEmpty()) return + val textLinkRegex = linkRegex + val codeLinkRegex = inlineCodeLinkRegex val renderId = ++currentRenderId executor.execute { try { val ast = - parser.parseMarkdown(markdown, md4cFlags, isGFM) ?: run { + parser.parseMarkdown(markdown, md4cFlags, isGFM, textLinkRegex, codeLinkRegex) ?: run { mainHandler.post { if (renderId == currentRenderId && isAttachedToWindow) text = "" } return@execute } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt index 54a416b11..f1e334579 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt @@ -25,6 +25,7 @@ import com.swmansion.enriched.markdown.utils.common.parseAccessibilityLabels import com.swmansion.enriched.markdown.utils.common.parseContextMenuItems import com.swmansion.enriched.markdown.utils.common.parseImageRequestHeaders import com.swmansion.enriched.markdown.utils.common.parseLinkPillContent +import com.swmansion.enriched.markdown.utils.common.parseLinkRecognition import com.swmansion.enriched.markdown.utils.common.parseMd4cFlags import com.swmansion.enriched.markdown.utils.common.parseSelectionMenuConfig import com.swmansion.enriched.markdown.utils.text.interaction.TaskListTapUtils @@ -150,6 +151,16 @@ class EnrichedMarkdownTextManager : view?.setSelectionHandleColor(value) } + @ReactProp(name = "linkRecognition") + override fun setLinkRecognition( + view: EnrichedMarkdownText?, + value: ReadableMap?, + ) { + val recognition = parseLinkRecognition(value) + view?.setLinkRegex(recognition.text) + view?.setInlineCodeLinkRegex(recognition.inlineCode) + } + @ReactProp(name = "md4cFlags") override fun setMd4cFlags( view: EnrichedMarkdownText?, 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 ef7c1c714..f4840e420 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 @@ -32,6 +32,7 @@ import com.swmansion.enriched.markdown.utils.common.BreakStrategyUtils import com.swmansion.enriched.markdown.utils.common.CodeBlockStreamingMode import com.swmansion.enriched.markdown.utils.common.EllipsizeUtils import com.swmansion.enriched.markdown.utils.common.FeatureFlags +import com.swmansion.enriched.markdown.utils.common.LinkRegexConfig import com.swmansion.enriched.markdown.utils.common.StreamingMarkdownFilter import com.swmansion.enriched.markdown.utils.common.TableStreamingMode import com.swmansion.enriched.markdown.utils.common.getArrayOrNull @@ -41,6 +42,7 @@ import com.swmansion.enriched.markdown.utils.common.getMapOrNull import com.swmansion.enriched.markdown.utils.common.getStringOrDefault import com.swmansion.enriched.markdown.utils.common.parseImageRequestHeaders import com.swmansion.enriched.markdown.utils.common.parseLinkPillContent +import com.swmansion.enriched.markdown.utils.common.parseLinkRecognition import com.swmansion.enriched.markdown.utils.text.extensions.replaceMathSpansWithPlaceholders import com.swmansion.enriched.markdown.utils.text.span.prepareWidthAwareSpans import java.util.concurrent.ConcurrentHashMap @@ -332,6 +334,7 @@ object MeasurementStore { var result = markdown.hashCode() result = 31 * result + (styleMap?.hashCode() ?: 0) result = 31 * result + (md4cFlagsMap?.hashCode() ?: 0) + result = 31 * result + parseLinkRecognition(props.getMapOrNull("linkRecognition")).hashCode() result = 31 * result + fontScale.toBits() result = 31 * result + allowFontScaling.hashCode() result = 31 * result + maxFontSizeMultiplier.toBits() @@ -400,6 +403,7 @@ object MeasurementStore { // 2. Render & Measure measurePaint.textSize = fontSize val imageRequestHeaders = parseImageRequestHeaders(props.getArrayOrNull("imageRequestHeaders")) + val linkRecognition = parseLinkRecognition(props.getMapOrNull("linkRecognition")) val spannable = tryRenderMarkdown( markdown, @@ -411,6 +415,8 @@ object MeasurementStore { maxFontSizeMultiplier, imageRequestHeaders, parseLinkPillContent(props.getArrayOrNull("linkPillContent")), + linkRecognition.text, + linkRecognition.inlineCode, ) spannable?.replaceMathSpansWithPlaceholders(context) val textToMeasure = spannable ?: markdown @@ -501,9 +507,17 @@ object MeasurementStore { val allowTrailingMargin = props.getBooleanOrDefault("allowTrailingMargin", false) val fontSize = getInitialFontSize(styleMap, context, allowFontScaling, fontScale, maxFontSizeMultiplier) + val linkRecognition = parseLinkRecognition(props.getMapOrNull("linkRecognition")) + return try { val ast = - Parser.shared.parseMarkdown(markdown, md4cFlags, isGFM) + Parser.shared.parseMarkdown( + markdown, + md4cFlags, + isGFM, + linkRecognition.text, + linkRecognition.inlineCode, + ) ?: return YogaMeasureOutput.make(PixelUtil.toDIPFromPixel(width), 0f) val style = StyleConfig(styleMap, context, allowFontScaling, maxFontSizeMultiplier) @@ -654,11 +668,13 @@ object MeasurementStore { maxFontSizeMultiplier: Float, imageRequestHeaders: Map = emptyMap(), linkPillContent: Map = emptyMap(), + linkRegex: LinkRegexConfig? = null, + inlineCodeLinkRegex: LinkRegexConfig? = null, ): Spannable? { if (styleMap == null) return null return try { - val ast = Parser.shared.parseMarkdown(markdown, md4cFlags, isGFM) ?: return null + val ast = Parser.shared.parseMarkdown(markdown, md4cFlags, isGFM, linkRegex, inlineCodeLinkRegex) ?: return null val style = StyleConfig(styleMap, context, allowFontScaling, maxFontSizeMultiplier) style.imageRequestHeaders = imageRequestHeaders style.linkPillContent = linkPillContent diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt index a734c14ba..620878677 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt @@ -14,7 +14,6 @@ import com.facebook.react.uimanager.annotations.ReactProp import com.facebook.react.viewmanagers.EnrichedMarkdownTextInputManagerDelegate import com.facebook.react.viewmanagers.EnrichedMarkdownTextInputManagerInterface import com.facebook.yoga.YogaMeasureMode -import com.swmansion.enriched.markdown.input.autolink.LinkRegexConfig import com.swmansion.enriched.markdown.input.editing.MarkdownShortcutsConfig import com.swmansion.enriched.markdown.input.events.OnCaretRectChangeEvent import com.swmansion.enriched.markdown.input.events.OnChangeMarkdownEvent @@ -35,6 +34,7 @@ import com.swmansion.enriched.markdown.input.layout.InputMeasurementStore import com.swmansion.enriched.markdown.input.model.StyleType import com.swmansion.enriched.markdown.input.toolbar.FormatMenuConfig import com.swmansion.enriched.markdown.input.toolbar.InputSelectionMenuConfig +import com.swmansion.enriched.markdown.utils.common.LinkRegexConfig import com.swmansion.enriched.markdown.utils.input.BorderPropsApplicator import com.swmansion.enriched.markdown.utils.input.MarkdownStyleParser diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt index 632edd07a..d355ec494 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt @@ -27,7 +27,6 @@ import com.facebook.react.uimanager.StateWrapper import com.facebook.react.views.text.ReactTypefaceUtils import com.facebook.react.views.text.TextAttributes import com.swmansion.enriched.markdown.input.autolink.AutoLinkDetector -import com.swmansion.enriched.markdown.input.autolink.LinkRegexConfig import com.swmansion.enriched.markdown.input.detection.DetectorPipeline import com.swmansion.enriched.markdown.input.editing.BlockEditCoordinator import com.swmansion.enriched.markdown.input.editing.ClipboardCoordinator @@ -58,6 +57,7 @@ import com.swmansion.enriched.markdown.input.model.StyleType import com.swmansion.enriched.markdown.input.spans.applyBodyLineHeightSpan import com.swmansion.enriched.markdown.input.spans.bodyLineMinimumFontMetrics import com.swmansion.enriched.markdown.input.toolbar.InputContextMenu +import com.swmansion.enriched.markdown.utils.common.LinkRegexConfig import com.swmansion.enriched.markdown.utils.input.AutoCapitalizeUtils import kotlin.math.ceil diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/autolink/AutoLinkDetector.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/autolink/AutoLinkDetector.kt index a440840aa..1539fa7e5 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/autolink/AutoLinkDetector.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/autolink/AutoLinkDetector.kt @@ -7,6 +7,7 @@ import com.swmansion.enriched.markdown.input.formatting.FormattingStore import com.swmansion.enriched.markdown.input.model.FormattingRange import com.swmansion.enriched.markdown.input.model.InputFormatterStyle import com.swmansion.enriched.markdown.input.model.StyleType +import com.swmansion.enriched.markdown.utils.common.LinkRegexConfig import java.util.regex.Pattern typealias OnLinkDetectedCallback = (text: String, url: String, start: Int, end: Int) -> Unit diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/autolink/LinkRegexConfig.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/autolink/LinkRegexConfig.kt deleted file mode 100644 index da0bb9062..000000000 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/autolink/LinkRegexConfig.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.swmansion.enriched.markdown.input.autolink - -data class LinkRegexConfig( - val pattern: String, - val caseInsensitive: Boolean, - val dotAll: Boolean, - val isDisabled: Boolean, - val isDefault: Boolean, -) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt index 217d46473..0cddbe929 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt @@ -2,6 +2,7 @@ package com.swmansion.enriched.markdown.parser import android.util.Log import com.swmansion.enriched.markdown.utils.common.FeatureFlags +import com.swmansion.enriched.markdown.utils.common.LinkRegexConfig data class Md4cFlags( val underline: Boolean = false, @@ -38,6 +39,8 @@ class Parser { markdown: String, flags: Md4cFlags, isGFM: Boolean, + textLinkRegex: LinkRegexConfig?, + inlineCodeLinkRegex: LinkRegexConfig?, ): MarkdownASTNode? /** @@ -51,13 +54,23 @@ class Parser { markdown: String, flags: Md4cFlags = Md4cFlags.DEFAULT, isGFM: Boolean = true, + linkRegex: LinkRegexConfig? = null, + inlineCodeLinkRegex: LinkRegexConfig? = null, ): MarkdownASTNode? { if (markdown.isBlank()) { return null } try { - val ast = nativeParseMarkdown(markdown, flags, isGFM) + // Only active patterns cross JNI. + val ast = + nativeParseMarkdown( + markdown, + flags, + isGFM, + linkRegex?.takeIf { it.compiled != null }, + inlineCodeLinkRegex?.takeIf { it.compiledWholeSpan != null }, + ) if (ast != null) { return ast diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/TextLinkMatching.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/TextLinkMatching.kt new file mode 100644 index 000000000..a324d67a3 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/TextLinkMatching.kt @@ -0,0 +1,35 @@ +package com.swmansion.enriched.markdown.parser + +import com.swmansion.enriched.markdown.utils.common.LinkRegexConfig + +/** Host matchers the native parser calls back into; offsets are UTF-16 code units, as the core expects. */ +internal object TextLinkMatching { + /** Flat (run index, start, end) triples for every nonempty match, in order. */ + @JvmStatic + fun matchText( + runs: Array, + config: LinkRegexConfig, + ): IntArray { + val pattern = config.compiled ?: return IntArray(0) + val result = ArrayList() + runs.forEachIndexed { index, run -> + val matcher = pattern.matcher(run) + while (matcher.find()) { + if (matcher.end() == matcher.start()) continue + result.add(index) + result.add(matcher.start()) + result.add(matcher.end()) + } + } + return result.toIntArray() + } + + @JvmStatic + fun matchWholeCode( + spans: Array, + config: LinkRegexConfig, + ): BooleanArray { + val pattern = config.compiledWholeSpan ?: return BooleanArray(spans.size) + return BooleanArray(spans.size) { index -> pattern.matcher(spans[index]).find() } + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt index 073a3494c..360a4362f 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt @@ -24,6 +24,7 @@ class LinkRenderer( factory: RendererFactory, ) { val url = node.getAttribute("url") ?: return + val recognizedLink = node.getAttribute("recognizedLink") == "true" factory.renderWithSpan(builder, { factory.renderChildren(node, builder, onLinkPress, onLinkLongPress) }) { start, end, blockStyle -> val variant = factory.styleCache.resolvedVariantForUrl(url) @@ -47,7 +48,16 @@ class LinkRenderer( factory.runAfterRender { text -> pill.resolveLeadingMargin(text, text.getSpanStart(pill), text.getSpanEnd(pill)) } } builder.setSpan( - LinkSpan(url, onLinkPress, onLinkLongPress, factory.styleCache, blockStyle, factory.context), + LinkSpan( + url, + onLinkPress, + onLinkLongPress, + factory.styleCache, + blockStyle, + factory.context, + recognizedLink = recognizedLink, + keepsCodeFont = recognizedLink && node.children.any { it.type == MarkdownASTNode.NodeType.Code }, + ), start, end, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE, diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt index 392c5be90..ff00069cf 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt @@ -17,6 +17,9 @@ class LinkSpan( private val styleCache: SpanStyleCache, private val blockStyle: BlockStyle, private val context: Context, + val recognizedLink: Boolean = false, + // A recognized code span keeps its code font unless a link or variant font is set. + private val keepsCodeFont: Boolean = false, ) : ClickableSpan() { @Volatile private var longPressTriggered = false @@ -43,15 +46,14 @@ class LinkSpan( override fun updateDrawState(textPaint: TextPaint) { super.updateDrawState(textPaint) - textPaint.textSize = blockStyle.fontSize - val variant = styleCache.resolvedVariantForUrl(url) val fontFamily = variant?.fontFamily?.takeIf { it.isNotEmpty() } ?: styleCache.linkFontFamily if (fontFamily.isNotEmpty()) { - val overriddenBlockStyle = blockStyle.copy(fontFamily = fontFamily) - textPaint.applyBlockStyleFont(overriddenBlockStyle, context) - } else { + textPaint.textSize = blockStyle.fontSize + textPaint.applyBlockStyleFont(blockStyle.copy(fontFamily = fontFamily), context) + } else if (!keepsCodeFont) { + textPaint.textSize = blockStyle.fontSize textPaint.applyBlockStyleFont(blockStyle, context) } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/LinkRecognitionProps.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/LinkRecognitionProps.kt new file mode 100644 index 000000000..9affba3b2 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/LinkRecognitionProps.kt @@ -0,0 +1,31 @@ +package com.swmansion.enriched.markdown.utils.common + +import com.facebook.react.bridge.ReadableMap + +data class LinkRecognition( + val text: LinkRegexConfig? = null, + val inlineCode: LinkRegexConfig? = null, +) { + companion object { + val NONE = LinkRecognition() + } +} + +internal fun parseLinkRecognition(map: ReadableMap?): LinkRecognition { + if (map == null) return LinkRecognition.NONE + return LinkRecognition( + text = parseLinkRegexConfig(map.getMapOrNull("text")), + inlineCode = parseLinkRegexConfig(map.getMapOrNull("inlineCode")), + ) +} + +internal fun parseLinkRegexConfig(map: ReadableMap?): LinkRegexConfig? { + if (map == null) return null + return LinkRegexConfig( + pattern = if (map.hasKey("pattern")) map.getString("pattern") ?: "" else "", + caseInsensitive = map.hasKey("caseInsensitive") && map.getBoolean("caseInsensitive"), + dotAll = map.hasKey("dotAll") && map.getBoolean("dotAll"), + isDisabled = !map.hasKey("isDisabled") || map.getBoolean("isDisabled"), + isDefault = map.hasKey("isDefault") && map.getBoolean("isDefault"), + ) +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/LinkRegexConfig.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/LinkRegexConfig.kt new file mode 100644 index 000000000..153f01738 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/LinkRegexConfig.kt @@ -0,0 +1,56 @@ +package com.swmansion.enriched.markdown.utils.common + +import java.util.regex.Pattern +import java.util.regex.PatternSyntaxException + +/** A regex sent from JS. Equal configs share compiled patterns. */ +data class LinkRegexConfig( + val pattern: String, + val caseInsensitive: Boolean, + val dotAll: Boolean, + val isDisabled: Boolean, + val isDefault: Boolean, +) { + private val isActive: Boolean + get() = !isDisabled && !isDefault && pattern.isNotEmpty() + + /** Null when inactive or invalid. */ + val compiled: Pattern? + get() = if (isActive) compiledPattern(pattern, flags) else null + + /** Anchored to the whole string, for inline-code spans. */ + val compiledWholeSpan: Pattern? + get() = if (isActive) compiledPattern("\\A(?:$pattern)\\z", flags) else null + + private val flags: Int + get() = (if (caseInsensitive) Pattern.CASE_INSENSITIVE else 0) or (if (dotAll) Pattern.DOTALL else 0) + + private companion object { + private const val MAX_CACHED_PATTERNS = 64 + private val invalid = Pattern.compile("(?!)") + + // Bounded LRU keyed by flags and source. + private val cache = + object : LinkedHashMap(MAX_CACHED_PATTERNS, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = size > MAX_CACHED_PATTERNS + } + + @Synchronized + fun compiledPattern( + source: String, + flags: Int, + ): Pattern? { + val key = "$flags\u0000$source" + val cached = cache[key] + if (cached != null) return cached.takeUnless { it === invalid } + val compiled = + try { + Pattern.compile(source, flags) + } catch (_: PatternSyntaxException) { + invalid + } + cache[key] = compiled + return compiled.takeUnless { it === invalid } + } + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/serialization/MarkdownASTSerializer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/serialization/MarkdownASTSerializer.kt index 705176702..7e156aa9b 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/serialization/MarkdownASTSerializer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/serialization/MarkdownASTSerializer.kt @@ -298,6 +298,10 @@ object MarkdownASTSerializer { } NodeType.Link -> { + if (node.getAttribute("recognizedLink") == "true") { + appendChildren(node, buffer) + return + } val url = node.getAttribute("url") ?: "" buffer.append("[") appendChildren(node, buffer) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt index 2957f98ce..ec5a0347a 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt @@ -300,7 +300,7 @@ object MarkdownExtractor { val baselineShiftSpans = spannable.getSpans(start, end, BaselineShiftSpan::class.java) val hasSuperscript = baselineShiftSpans.any { it.spanType == BaselineShiftSpan.SpanType.SUPERSCRIPT } val hasSubscript = baselineShiftSpans.any { it.spanType == BaselineShiftSpan.SpanType.SUBSCRIPT } - val linkSpans = spannable.getSpans(start, end, LinkSpan::class.java) + val linkSpans = spannable.getSpans(start, end, LinkSpan::class.java).filterNot { it.recognizedLink } val hasHighlight = spannable.getSpans(start, end, HighlightSpan::class.java).isNotEmpty() var result = text diff --git a/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h b/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h index a56506551..d4961d13c 100644 --- a/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h +++ b/packages/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h @@ -32,6 +32,9 @@ inline folly::dynamic toDynamic(const EnrichedMarkdownTextProps &props) { } serializedProps["linkPillContent"] = std::move(linkPillContent); + // Recognition changes line wrapping, so measurement must parse with the view's patterns. + serializedProps["linkRecognition"] = toDynamic(props.linkRecognition); + return serializedProps; } @@ -58,6 +61,9 @@ inline folly::dynamic toDynamic(const EnrichedMarkdownProps &props) { } serializedProps["linkPillContent"] = std::move(linkPillContent); + // Recognition changes line wrapping, so measurement must parse with the view's patterns. + serializedProps["linkRecognition"] = toDynamic(props.linkRecognition); + return serializedProps; } diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm index d0bd573ae..5a0b7e3e6 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm @@ -6,6 +6,7 @@ #import "ENRMImageAttachment.h" #import "ENRMLatexErrorCoordinator.h" #import "ENRMLinkContextMenus.h" +#import "ENRMLinkRegexProps.h" #import "ENRMMarkdownParser.h" #import "ENRMTailFadeInAnimator.h" #import "ENRMTextInteractionUtils.h" @@ -104,6 +105,8 @@ @implementation EnrichedMarkdown { StyleConfig *_config; ENRMMd4cFlags *_md4cFlags; BOOL _isGFM; + ENRMLinkRegexConfig *_linkRegex; + ENRMLinkRegexConfig *_inlineCodeLinkRegex; NSString *_cachedMarkdown; NSString *_renderedMarkdown; ENRMLatexErrorCoordinator *_latexErrorCoordinator; @@ -182,6 +185,8 @@ - (instancetype)initWithFrame:(CGRect)frame _parser = [[ENRMMarkdownParser alloc] init]; _md4cFlags = [EnrichedMarkdown flagsFromProps:defaultProps->md4cFlags]; _isGFM = defaultProps->isGFM; + _linkRegex = ENRMLinkRegexConfigFromProps(defaultProps->linkRecognition.text); + _inlineCodeLinkRegex = ENRMLinkRegexConfigFromProps(defaultProps->linkRecognition.inlineCode); _segmentViews = [NSMutableArray array]; _segmentSignatures = [NSMutableArray array]; __weak __typeof(self) weakLatexSelf = self; @@ -731,6 +736,8 @@ - (void)renderMarkdownContent:(NSString *)markdownString ENRMMarkdownParser *parser = _parser; ENRMMd4cFlags *md4cFlags = [_md4cFlags copy]; BOOL isGFM = _isGFM; + ENRMLinkRegexConfig *linkRegex = _linkRegex; + ENRMLinkRegexConfig *inlineCodeLinkRegex = _inlineCodeLinkRegex; BOOL allowFontScaling = _fontScaleObserver.allowFontScaling; CGFloat maxFontSizeMultiplier = _maxFontSizeMultiplier; @@ -758,7 +765,11 @@ - (void)renderMarkdownContent:(NSString *)markdownString return YES; } - MarkdownASTNode *ast = [parser parseMarkdown:renderableMarkdown flags:md4cFlags isGFM:isGFM]; + MarkdownASTNode *ast = [parser parseMarkdown:renderableMarkdown + flags:md4cFlags + isGFM:isGFM + linkRegex:linkRegex + inlineCodeLinkRegex:inlineCodeLinkRegex]; if (!ast) return NO; @@ -783,7 +794,11 @@ - (void)renderMarkdownContent:(NSString *)markdownString - (NSArray *)parseAndRenderSegments:(NSString *)markdownString { - MarkdownASTNode *ast = [_parser parseMarkdown:markdownString flags:_md4cFlags isGFM:_isGFM]; + MarkdownASTNode *ast = [_parser parseMarkdown:markdownString + flags:_md4cFlags + isGFM:_isGFM + linkRegex:_linkRegex + inlineCodeLinkRegex:_inlineCodeLinkRegex]; if (!ast) { return nil; } @@ -1158,6 +1173,17 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _dirtyFlags |= ENRMDirtyRecreateSegments | ENRMDirtyForceHeight | ENRMDirtyRender; } + if (!ENRMLinkRegexPropsEqual(oldViewProps.linkRecognition.text, newViewProps.linkRecognition.text)) { + _linkRegex = ENRMLinkRegexConfigFromProps(newViewProps.linkRecognition.text); + _dirtyFlags |= ENRMDirtyRender; + _dirtyFlags |= ENRMDirtyForceHeight; + } + if (!ENRMLinkRegexPropsEqual(oldViewProps.linkRecognition.inlineCode, newViewProps.linkRecognition.inlineCode)) { + _inlineCodeLinkRegex = ENRMLinkRegexConfigFromProps(newViewProps.linkRecognition.inlineCode); + _dirtyFlags |= ENRMDirtyRender; + _dirtyFlags |= ENRMDirtyForceHeight; + } + if (newViewProps.md4cFlags.underline != oldViewProps.md4cFlags.underline || newViewProps.md4cFlags.superscript != oldViewProps.md4cFlags.superscript || newViewProps.md4cFlags.subscript != oldViewProps.md4cFlags.subscript || @@ -1350,6 +1376,8 @@ - (void)prepareForRecycle _config = nil; _md4cFlags = [EnrichedMarkdown flagsFromProps:resetProps->md4cFlags]; _isGFM = resetProps->isGFM; + _linkRegex = ENRMLinkRegexConfigFromProps(resetProps->linkRecognition.text); + _inlineCodeLinkRegex = ENRMLinkRegexConfigFromProps(resetProps->linkRecognition.inlineCode); _maxFontSizeMultiplier = 0; _allowTrailingMargin = NO; _streamingAnimation = NO; diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm b/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm index 4cc1a9694..b7b588b37 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm @@ -8,6 +8,7 @@ #import "ENRMImageAttachment.h" #import "ENRMLatexErrorCoordinator.h" #import "ENRMLinkContextMenus.h" +#import "ENRMLinkRegexProps.h" #import "ENRMMarkdownParser.h" #import "ENRMMarkdownTextView.h" #import "ENRMSpoilerOverlayManager.h" @@ -86,6 +87,8 @@ @implementation EnrichedMarkdownText { StyleConfig *_config; ENRMMd4cFlags *_md4cFlags; BOOL _isGFM; + ENRMLinkRegexConfig *_linkRegex; + ENRMLinkRegexConfig *_inlineCodeLinkRegex; ENRMAsyncRenderCoordinator *_renderCoordinator; @@ -299,6 +302,8 @@ - (instancetype)initWithFrame:(CGRect)frame }; _md4cFlags = [EnrichedMarkdownText flagsFromProps:defaultProps->md4cFlags]; _isGFM = defaultProps->isGFM; + _linkRegex = ENRMLinkRegexConfigFromProps(defaultProps->linkRecognition.text); + _inlineCodeLinkRegex = ENRMLinkRegexConfigFromProps(defaultProps->linkRecognition.inlineCode); _renderCoordinator = [[ENRMAsyncRenderCoordinator alloc] initWithQueueLabel:"com.swmansion.enriched.markdown.render"]; @@ -441,6 +446,8 @@ - (void)renderMarkdownContent:(NSString *)markdownString ENRMMarkdownParser *parser = _parser; ENRMMd4cFlags *md4cFlags = [_md4cFlags copy]; BOOL isGFM = _isGFM; + ENRMLinkRegexConfig *linkRegex = _linkRegex; + ENRMLinkRegexConfig *inlineCodeLinkRegex = _inlineCodeLinkRegex; BOOL allowFontScaling = _fontScaleObserver.allowFontScaling; CGFloat maxFontSizeMultiplier = _maxFontSizeMultiplier; @@ -453,7 +460,11 @@ - (void)renderMarkdownContent:(NSString *)markdownString [_renderCoordinator scheduleRender:^BOOL { - MarkdownASTNode *ast = [parser parseMarkdown:markdownString flags:md4cFlags isGFM:isGFM]; + MarkdownASTNode *ast = [parser parseMarkdown:markdownString + flags:md4cFlags + isGFM:isGFM + linkRegex:linkRegex + inlineCodeLinkRegex:inlineCodeLinkRegex]; if (!ast) return NO; @@ -473,7 +484,11 @@ - (void)renderMarkdownContent:(NSString *)markdownString - (NSMutableAttributedString *)parseAndRenderMarkdown:(NSString *)markdownString { - MarkdownASTNode *ast = [_parser parseMarkdown:markdownString flags:_md4cFlags isGFM:_isGFM]; + MarkdownASTNode *ast = [_parser parseMarkdown:markdownString + flags:_md4cFlags + isGFM:_isGFM + linkRegex:_linkRegex + inlineCodeLinkRegex:_inlineCodeLinkRegex]; if (!ast) { return nil; } @@ -678,6 +693,17 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _dirtyFlags |= ENRMDirtyRender; } + if (!ENRMLinkRegexPropsEqual(oldViewProps.linkRecognition.text, newViewProps.linkRecognition.text)) { + _linkRegex = ENRMLinkRegexConfigFromProps(newViewProps.linkRecognition.text); + _dirtyFlags |= ENRMDirtyRender; + _forceHeightUpdateOnNextRender = YES; + } + if (!ENRMLinkRegexPropsEqual(oldViewProps.linkRecognition.inlineCode, newViewProps.linkRecognition.inlineCode)) { + _inlineCodeLinkRegex = ENRMLinkRegexConfigFromProps(newViewProps.linkRecognition.inlineCode); + _dirtyFlags |= ENRMDirtyRender; + _forceHeightUpdateOnNextRender = YES; + } + if (newViewProps.md4cFlags.underline != oldViewProps.md4cFlags.underline || newViewProps.md4cFlags.superscript != oldViewProps.md4cFlags.superscript || newViewProps.md4cFlags.subscript != oldViewProps.md4cFlags.subscript || @@ -839,6 +865,8 @@ - (void)prepareForRecycle _config = nil; _md4cFlags = [EnrichedMarkdownText flagsFromProps:resetProps->md4cFlags]; _isGFM = resetProps->isGFM; + _linkRegex = ENRMLinkRegexConfigFromProps(resetProps->linkRecognition.text); + _inlineCodeLinkRegex = ENRMLinkRegexConfigFromProps(resetProps->linkRecognition.inlineCode); _maxFontSizeMultiplier = 0; _lastElementMarginBottom = 0; _allowTrailingMargin = NO; diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMLinkRegexConfig.mm b/packages/react-native-enriched-markdown/ios/input/ENRMLinkRegexConfig.mm deleted file mode 100644 index b0a416afa..000000000 --- a/packages/react-native-enriched-markdown/ios/input/ENRMLinkRegexConfig.mm +++ /dev/null @@ -1,60 +0,0 @@ -#import "ENRMLinkRegexConfig.h" - -#import - -@implementation ENRMLinkRegexConfig - -- (instancetype)initWithPattern:(NSString *)pattern - caseInsensitive:(BOOL)caseInsensitive - dotAll:(BOOL)dotAll - isDisabled:(BOOL)isDisabled - isDefault:(BOOL)isDefault -{ - self = [super init]; - if (!self) { - return nil; - } - - _pattern = [pattern copy]; - _caseInsensitive = caseInsensitive; - _dotAll = dotAll; - _isDisabled = isDisabled; - _isDefault = isDefault; - _parsedRegex = nil; - - if (!_isDefault && !_isDisabled && _pattern.length > 0) { - NSRegularExpressionOptions options = 0; - if (_caseInsensitive) { - options |= NSRegularExpressionCaseInsensitive; - } - if (_dotAll) { - options |= NSRegularExpressionDotMatchesLineSeparators; - } - - NSError *error = nil; - NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:_pattern - options:options - error:&error]; - if (error) { - RCTLogWarn( - @"[EnrichedMarkdownTextInput]: Couldn't parse the user-defined link regex '%@', " - "falling back to default regex.", - _pattern); - } else { - _parsedRegex = regex; - } - } - - return self; -} - -- (BOOL)isEqualToConfig:(ENRMLinkRegexConfig *)other -{ - if (other == nil) { - return NO; - } - return [_pattern isEqualToString:other.pattern] && _caseInsensitive == other.caseInsensitive && - _dotAll == other.dotAll && _isDefault == other.isDefault && _isDisabled == other.isDisabled; -} - -@end diff --git a/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h b/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h index 6c8c446f8..885353f4e 100644 --- a/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h +++ b/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h @@ -3,6 +3,7 @@ #import "ENRMBlockquoteContainerView.h" #import "ENRMCodeBlockContainerView.h" #import "ENRMFeatureFlags.h" +#import "ENRMLinkRegexProps.h" #import "ENRMMarkdownParser.h" #import "ENRMTextRenderer.h" #import "ENRMTextViewSetup.h" @@ -181,7 +182,11 @@ static inline CGSize ENRMMeasureMarkdownViewFree(const PropsT &typedProps, CGFlo ENRMMd4cFlags *flags = ENRMMd4cFlagsFromProps(typedProps.md4cFlags); ENRMMarkdownParser *parser = [[ENRMMarkdownParser alloc] init]; - MarkdownASTNode *ast = [parser parseMarkdown:markdown flags:flags isGFM:typedProps.isGFM]; + MarkdownASTNode *ast = [parser parseMarkdown:markdown + flags:flags + isGFM:typedProps.isGFM + linkRegex:ENRMLinkRegexConfigFromProps(typedProps.linkRecognition.text) + inlineCodeLinkRegex:ENRMLinkRegexConfigFromProps(typedProps.linkRecognition.inlineCode)]; if (!ast) { return fallback; } @@ -259,7 +264,11 @@ static inline CGSize ENRMMeasureSegmentedMarkdownViewFree(const PropsT &typedPro ENRMMd4cFlags *flags = ENRMMd4cFlagsFromProps(typedProps.md4cFlags); ENRMMarkdownParser *parser = [[ENRMMarkdownParser alloc] init]; - MarkdownASTNode *ast = [parser parseMarkdown:markdown flags:flags isGFM:typedProps.isGFM]; + MarkdownASTNode *ast = [parser parseMarkdown:markdown + flags:flags + isGFM:typedProps.isGFM + linkRegex:ENRMLinkRegexConfigFromProps(typedProps.linkRecognition.text) + inlineCodeLinkRegex:ENRMLinkRegexConfigFromProps(typedProps.linkRecognition.inlineCode)]; if (!ast) { return fallback; } diff --git a/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h b/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h index 2262f7b8c..5f67471a7 100644 --- a/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h +++ b/packages/react-native-enriched-markdown/ios/internals/MeasurementCache.h @@ -51,6 +51,8 @@ struct MeasurementCacheKey { std::string writingDirection; int numberOfLines; std::string ellipsizeMode; + std::string linkRegexKey; + std::string inlineCodeLinkRegexKey; bool operator==(const MeasurementCacheKey &other) const { @@ -58,13 +60,14 @@ struct MeasurementCacheKey { md4cFlagsUnderline, md4cFlagsSuperscript, md4cFlagsSubscript, md4cFlagsHighlight, md4cFlagsLatexMath, md4cFlagsHardSoftBreaks, md4cFlagsPreserveBlankLines, md4cFlagsAdmonitions, styleFingerprint, linkPillContentFingerprint, fontScale, flavor, lineBreakStrategyIOS, - writingDirection, numberOfLines, ellipsizeMode) == + writingDirection, numberOfLines, ellipsizeMode, linkRegexKey, inlineCodeLinkRegexKey) == std::tie(other.markdown, other.maxWidth, other.allowTrailingMargin, other.allowFontScaling, other.maxFontSizeMultiplier, other.md4cFlagsUnderline, other.md4cFlagsSuperscript, other.md4cFlagsSubscript, other.md4cFlagsHighlight, other.md4cFlagsLatexMath, other.md4cFlagsHardSoftBreaks, other.md4cFlagsPreserveBlankLines, other.md4cFlagsAdmonitions, other.styleFingerprint, other.linkPillContentFingerprint, other.fontScale, other.flavor, - other.lineBreakStrategyIOS, other.writingDirection, other.numberOfLines, other.ellipsizeMode); + other.lineBreakStrategyIOS, other.writingDirection, other.numberOfLines, other.ellipsizeMode, + other.linkRegexKey, other.inlineCodeLinkRegexKey); } }; @@ -93,6 +96,8 @@ struct MeasurementCacheKeyHash { HashUtils::hash_one(h, key.writingDirection); HashUtils::hash_one(h, key.numberOfLines); HashUtils::hash_one(h, key.ellipsizeMode); + HashUtils::hash_one(h, key.linkRegexKey); + HashUtils::hash_one(h, key.inlineCodeLinkRegexKey); return h; } }; @@ -177,6 +182,16 @@ template inline size_t computeLinkPillContentFingerprin return h; } +template inline std::string buildLinkRecognitionCacheKey(const RegexProps &props) +{ + std::string key; + key += props.caseInsensitive ? '1' : '0'; + key += props.dotAll ? '1' : '0'; + key += props.isDisabled ? '1' : '0'; + key += props.isDefault ? '1' : '0'; + return key + props.pattern; +} + template inline MeasurementCacheKey buildMeasurementCacheKey(const PropsType &props, CGFloat maxWidth, CGFloat fontScale, MarkdownFlavor flavor) @@ -203,6 +218,8 @@ inline MeasurementCacheKey buildMeasurementCacheKey(const PropsType &props, CGFl .writingDirection = props.writingDirection, .numberOfLines = props.numberOfLines, .ellipsizeMode = props.ellipsizeMode, + .linkRegexKey = buildLinkRecognitionCacheKey(props.linkRecognition.text), + .inlineCodeLinkRegexKey = buildLinkRecognitionCacheKey(props.linkRecognition.inlineCode), }; } diff --git a/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.h b/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.h index 7bcba9f8c..6ca21812e 100644 --- a/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.h +++ b/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.h @@ -16,10 +16,18 @@ @end +@class ENRMLinkRegexConfig; + @interface ENRMMarkdownParser : NSObject - (MarkdownASTNode *)parseMarkdown:(NSString *)markdown; - (MarkdownASTNode *)parseMarkdown:(NSString *)markdown flags:(ENRMMd4cFlags *)flags; - (MarkdownASTNode *)parseMarkdown:(NSString *)markdown flags:(ENRMMd4cFlags *)flags isGFM:(BOOL)isGFM; +// Recognition runs before returning the AST to renderers and measurement. +- (MarkdownASTNode *)parseMarkdown:(NSString *)markdown + flags:(ENRMMd4cFlags *)flags + isGFM:(BOOL)isGFM + linkRegex:(ENRMLinkRegexConfig *)linkRegex + inlineCodeLinkRegex:(ENRMLinkRegexConfig *)inlineCodeLinkRegex; @end diff --git a/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.mm b/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.mm index a4a0022b7..e8de7419f 100644 --- a/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.mm +++ b/packages/react-native-enriched-markdown/ios/parser/ENRMMarkdownParser.mm @@ -1,7 +1,10 @@ #import "ENRMMarkdownParser.h" +#import "ENRMLinkRegexConfig.h" #import "MarkdownASTNode.h" extern MarkdownASTNode *parseMarkdownWithCppParser(NSString *markdown, ENRMMd4cFlags *flags, BOOL isGFM); +extern MarkdownASTNode *parseMarkdownWithCppParser(NSString *markdown, ENRMMd4cFlags *flags, BOOL isGFM, + ENRMLinkRegexConfig *textRegex, ENRMLinkRegexConfig *codeRegex); @implementation ENRMMd4cFlags @@ -58,4 +61,13 @@ - (MarkdownASTNode *)parseMarkdown:(NSString *)markdown flags:(ENRMMd4cFlags *)f return parseMarkdownWithCppParser(markdown, flags, isGFM); } +- (MarkdownASTNode *)parseMarkdown:(NSString *)markdown + flags:(ENRMMd4cFlags *)flags + isGFM:(BOOL)isGFM + linkRegex:(ENRMLinkRegexConfig *)linkRegex + inlineCodeLinkRegex:(ENRMLinkRegexConfig *)inlineCodeLinkRegex +{ + return parseMarkdownWithCppParser(markdown, flags, isGFM, linkRegex, inlineCodeLinkRegex); +} + @end diff --git a/packages/react-native-enriched-markdown/ios/parser/ENRMTextLinkAttributes.h b/packages/react-native-enriched-markdown/ios/parser/ENRMTextLinkAttributes.h new file mode 100644 index 000000000..9d36943d0 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/parser/ENRMTextLinkAttributes.h @@ -0,0 +1,7 @@ +#pragma once + +#import + +// Rendering metadata only. Copy reconstruction retains the source semantics. +static NSString *const ENRMRecognizedLinkAttributeName = @"ENRMRecognizedLink"; +static NSString *const ENRMRecognizedLinkOriginalUnderlineAttributeName = @"ENRMRecognizedLinkOriginalUnderline"; diff --git a/packages/react-native-enriched-markdown/ios/parser/MarkdownParserBridge.mm b/packages/react-native-enriched-markdown/ios/parser/MarkdownParserBridge.mm index e700fcee0..b65bf830f 100644 --- a/packages/react-native-enriched-markdown/ios/parser/MarkdownParserBridge.mm +++ b/packages/react-native-enriched-markdown/ios/parser/MarkdownParserBridge.mm @@ -1,7 +1,9 @@ +#import "ENRMLinkRegexConfig.h" #import "ENRMMarkdownParser.h" #include "MD4CParser.hpp" #import "MarkdownASTNode.h" #include "MarkdownASTNode.hpp" +#include "TextLinkRecognizer.hpp" #import // Convert C++ AST node to Objective-C AST node @@ -141,8 +143,62 @@ return objcNode; } -// Public function to parse markdown using C++ parser and convert to Objective-C AST +static NSString *ENRMStringFromUTF8(std::string_view utf8) +{ + return [[NSString alloc] initWithBytes:utf8.data() length:utf8.size() encoding:NSUTF8StringEncoding] ?: @""; +} + +// Host matchers for the core recognizer: NSRanges are UTF-16 units, which is what the core expects. +static Markdown::TextLinkMatchers ENRMTextLinkMatchers(ENRMLinkRegexConfig *textRegex, ENRMLinkRegexConfig *codeRegex) +{ + Markdown::TextLinkMatchers matchers; + NSRegularExpression *text = textRegex.parsedRegex; + if (text) { + matchers.text = [text](const std::vector &runs) { + std::vector> all; + all.reserve(runs.size()); + for (std::string_view run : runs) { + NSString *string = ENRMStringFromUTF8(run); + std::vector ranges; + for (NSTextCheckingResult *match in [text matchesInString:string + options:0 + range:NSMakeRange(0, string.length)]) { + if (match.range.length > 0) + ranges.push_back({match.range.location, NSMaxRange(match.range)}); + } + all.push_back(std::move(ranges)); + } + return all; + }; + } + NSRegularExpression *code = codeRegex.parsedWholeSpanRegex; + if (code) { + matchers.inlineCode = [code](const std::vector &spans) { + std::vector matched; + matched.reserve(spans.size()); + for (std::string_view span : spans) { + NSString *string = ENRMStringFromUTF8(span); + NSRange whole = NSMakeRange(0, string.length); + NSTextCheckingResult *match = [code firstMatchInString:string options:0 range:whole]; + matched.push_back(match != nil && NSEqualRanges(match.range, whole)); + } + return matched; + }; + } + return matchers; +} + +MarkdownASTNode *parseMarkdownWithCppParser(NSString *markdown, ENRMMd4cFlags *flags, BOOL isGFM, + ENRMLinkRegexConfig *textRegex, ENRMLinkRegexConfig *codeRegex); + MarkdownASTNode *parseMarkdownWithCppParser(NSString *markdown, ENRMMd4cFlags *flags, BOOL isGFM) +{ + return parseMarkdownWithCppParser(markdown, flags, isGFM, nil, nil); +} + +// Parses with the C++ parser, runs text link recognition in the core, and converts to the Objective-C AST. +MarkdownASTNode *parseMarkdownWithCppParser(NSString *markdown, ENRMMd4cFlags *flags, BOOL isGFM, + ENRMLinkRegexConfig *textRegex, ENRMLinkRegexConfig *codeRegex) { if (markdown.length == 0) { return [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeDocument]; @@ -170,6 +226,9 @@ Markdown::MD4CParser parser; auto cppAST = parser.parse(cppMarkdown, cppFlags, isGFM); + if (cppAST && (textRegex || codeRegex)) { + Markdown::recognizeTextLinks(*cppAST, ENRMTextLinkMatchers(textRegex, codeRegex)); + } // Convert C++ AST to Objective-C AST return convertCppASTToObjC(cppAST); diff --git a/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.m index 4a7b8a7fd..c9fd57d0d 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/LinkRenderer.m @@ -2,6 +2,7 @@ #import "CodeBackground.h" #import "ENRMLinkPillAttachment.h" #import "ENRMSpoilerTapUtils.h" +#import "ENRMTextLinkAttributes.h" #import "FontUtils.h" #import "RenderContext.h" #import "RendererFactory.h" @@ -66,6 +67,7 @@ - (void)renderNodeContent:(MarkdownASTNode *)node into:(NSMutableAttributedString *)output context:(RenderContext *)context { + BOOL recognizedLink = [node.attributes[@"recognizedLink"] isEqualToString:@"true"]; NSUInteger start = output.length; // 1. Render children first to establish base attributes @@ -94,6 +96,11 @@ - (void)renderNodeContent:(MarkdownASTNode *)node options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired usingBlock:^(NSDictionary *attrs, NSRange subrange, BOOL *stop) { NSMutableDictionary *newAttributes = [NSMutableDictionary dictionary]; + if (recognizedLink) { + newAttributes[ENRMRecognizedLinkAttributeName] = @YES; + newAttributes[ENRMRecognizedLinkOriginalUnderlineAttributeName] = + attrs[NSUnderlineStyleAttributeName] ?: @0; + } // Only apply link color if the subrange isn't already colored by the link style if (linkColor && ![attrs[NSForegroundColorAttributeName] isEqual:linkColor]) { diff --git a/packages/react-native-enriched-markdown/ios/renderer/UnderlineRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/UnderlineRenderer.m index e772e4ba5..6a4c20251 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/UnderlineRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/UnderlineRenderer.m @@ -1,4 +1,5 @@ #import "UnderlineRenderer.h" +#import "ENRMTextLinkAttributes.h" #import "MarkdownASTNode.h" #import "RenderContext.h" #import "RendererFactory.h" @@ -20,6 +21,17 @@ - (void)renderNodeContent:(MarkdownASTNode *)node return; [output addAttribute:NSUnderlineStyleAttributeName value:@(NSUnderlineStyleSingle) range:range]; + // Recognized links inside this span were styled before it closed; record the underline for them too. + [output enumerateAttribute:ENRMRecognizedLinkAttributeName + inRange:range + options:0 + usingBlock:^(id value, NSRange linkRange, BOOL *stop) { + if ([value boolValue]) { + [output addAttribute:ENRMRecognizedLinkOriginalUnderlineAttributeName + value:@(NSUnderlineStyleSingle) + range:linkRange]; + } + }]; RCTUIColor *underlineColor = [_config underlineColor]; diff --git a/packages/react-native-enriched-markdown/ios/input/ENRMLinkRegexConfig.h b/packages/react-native-enriched-markdown/ios/utils/ENRMLinkRegexConfig.h similarity index 67% rename from packages/react-native-enriched-markdown/ios/input/ENRMLinkRegexConfig.h rename to packages/react-native-enriched-markdown/ios/utils/ENRMLinkRegexConfig.h index 580a2a042..46a1dbec6 100644 --- a/packages/react-native-enriched-markdown/ios/input/ENRMLinkRegexConfig.h +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMLinkRegexConfig.h @@ -12,6 +12,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign, readonly) BOOL isDisabled; @property (nonatomic, assign, readonly) BOOL isDefault; @property (nonatomic, strong, readonly, nullable) NSRegularExpression *parsedRegex; +@property (nonatomic, strong, readonly, nullable) NSRegularExpression *parsedWholeSpanRegex; - (instancetype)initWithPattern:(NSString *)pattern caseInsensitive:(BOOL)caseInsensitive @@ -19,7 +20,10 @@ NS_ASSUME_NONNULL_BEGIN isDisabled:(BOOL)isDisabled isDefault:(BOOL)isDefault; -- (BOOL)isEqualToConfig:(ENRMLinkRegexConfig *)other; +/// Shared instance per pattern and flags; the renderer's props go through here. Thread-safe. ++ (instancetype)cachedConfigWithPattern:(NSString *)pattern caseInsensitive:(BOOL)caseInsensitive dotAll:(BOOL)dotAll; + +- (BOOL)isEqualToConfig:(nullable ENRMLinkRegexConfig *)other; @end diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMLinkRegexConfig.mm b/packages/react-native-enriched-markdown/ios/utils/ENRMLinkRegexConfig.mm new file mode 100644 index 000000000..d98328898 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMLinkRegexConfig.mm @@ -0,0 +1,104 @@ +#import "ENRMLinkRegexConfig.h" + +#import + +@implementation ENRMLinkRegexConfig { + NSRegularExpression *_parsedWholeSpanRegex; + BOOL _wholeSpanResolved; +} + +- (instancetype)initWithPattern:(NSString *)pattern + caseInsensitive:(BOOL)caseInsensitive + dotAll:(BOOL)dotAll + isDisabled:(BOOL)isDisabled + isDefault:(BOOL)isDefault +{ + self = [super init]; + if (!self) { + return nil; + } + + _pattern = [pattern copy]; + _caseInsensitive = caseInsensitive; + _dotAll = dotAll; + _isDisabled = isDisabled; + _isDefault = isDefault; + _parsedRegex = nil; + + if (!_isDefault && !_isDisabled && _pattern.length > 0) { + NSError *error = nil; + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:_pattern + options:[self options] + error:&error]; + if (error) { + RCTLogWarn(@"[EnrichedMarkdown]: The link regex '%@' is not a valid NSRegularExpression and is ignored.", + _pattern); + } else { + _parsedRegex = regex; + } + } + + return self; +} + ++ (instancetype)cachedConfigWithPattern:(NSString *)pattern caseInsensitive:(BOOL)caseInsensitive dotAll:(BOOL)dotAll +{ + static NSCache *cache; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + cache = [[NSCache alloc] init]; + cache.countLimit = 64; + }); + NSString *key = [NSString stringWithFormat:@"%d%d%@", caseInsensitive, dotAll, pattern]; + ENRMLinkRegexConfig *config = [cache objectForKey:key]; + if (!config) { + config = [[self alloc] initWithPattern:pattern + caseInsensitive:caseInsensitive + dotAll:dotAll + isDisabled:NO + isDefault:NO]; + [cache setObject:config forKey:key]; + } + return config; +} + +- (NSRegularExpressionOptions)options +{ + NSRegularExpressionOptions options = 0; + if (_caseInsensitive) { + options |= NSRegularExpressionCaseInsensitive; + } + if (_dotAll) { + options |= NSRegularExpressionDotMatchesLineSeparators; + } + return options; +} + +- (NSRegularExpression *)parsedWholeSpanRegex +{ + if (_parsedRegex == nil) { + return nil; + } + @synchronized(self) { + if (!_wholeSpanResolved) { + // Anchor the whole alternation so `a|ab` matches "ab" in full. + _parsedWholeSpanRegex = + [NSRegularExpression regularExpressionWithPattern:[NSString stringWithFormat:@"\\A(?:%@)\\z", _pattern] + options:[self options] + error:nil]; + _wholeSpanResolved = YES; + } + return _parsedWholeSpanRegex; + } +} + +- (BOOL)isEqualToConfig:(ENRMLinkRegexConfig *)other +{ + if (other == nil) { + return NO; + } + return [_pattern isEqualToString:other.pattern] && _caseInsensitive == other.caseInsensitive && + _dotAll == other.dotAll && _isDefault == other.isDefault && _isDisabled == other.isDisabled; +} + +@end diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMLinkRegexProps.h b/packages/react-native-enriched-markdown/ios/utils/ENRMLinkRegexProps.h new file mode 100644 index 000000000..b8eb1e783 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMLinkRegexProps.h @@ -0,0 +1,23 @@ +#pragma once + +#import "ENRMLinkRegexConfig.h" + +#ifdef __cplusplus +// Props share EnrichedMarkdownTextInput's native regex transport. +template static inline ENRMLinkRegexConfig *ENRMLinkRegexConfigFromProps(const RegexProps &props) +{ + if (props.isDisabled || props.isDefault || props.pattern.empty()) + return nil; + return [ENRMLinkRegexConfig cachedConfigWithPattern:[NSString stringWithUTF8String:props.pattern.c_str()] + caseInsensitive:props.caseInsensitive + dotAll:props.dotAll]; +} + +template +static inline BOOL ENRMLinkRegexPropsEqual(const RegexProps &oldProps, const RegexProps &newProps) +{ + return oldProps.isDisabled == newProps.isDisabled && oldProps.isDefault == newProps.isDefault && + oldProps.caseInsensitive == newProps.caseInsensitive && oldProps.dotAll == newProps.dotAll && + oldProps.pattern == newProps.pattern; +} +#endif diff --git a/packages/react-native-enriched-markdown/ios/utils/MarkdownASTSerializer.m b/packages/react-native-enriched-markdown/ios/utils/MarkdownASTSerializer.m index 01b9054bc..c6ce7efb4 100644 --- a/packages/react-native-enriched-markdown/ios/utils/MarkdownASTSerializer.m +++ b/packages/react-native-enriched-markdown/ios/utils/MarkdownASTSerializer.m @@ -81,6 +81,10 @@ static void serializeNode(MarkdownASTNode *node, NSMutableString *buffer) break; case MarkdownNodeTypeLink: { + if ([node.attributes[@"recognizedLink"] isEqualToString:@"true"]) { + serializeChildren(node, buffer); + break; + } NSString *url = node.attributes[@"url"] ?: @""; [buffer appendString:@"["]; serializeChildren(node, buffer); diff --git a/packages/react-native-enriched-markdown/ios/utils/MarkdownExtractor.m b/packages/react-native-enriched-markdown/ios/utils/MarkdownExtractor.m index e8f5f7497..90c84501b 100644 --- a/packages/react-native-enriched-markdown/ios/utils/MarkdownExtractor.m +++ b/packages/react-native-enriched-markdown/ios/utils/MarkdownExtractor.m @@ -1,9 +1,11 @@ #import "MarkdownExtractor.h" #import "BaselineShiftTextAttributes.h" #import "BlockquoteBorder.h" +#import "CodeBackground.h" #import "ENRMFeatureFlags.h" #import "ENRMImageAttachment.h" #import "ENRMLinkPillText.h" +#import "ENRMTextLinkAttributes.h" #import "ENRMUIKit.h" #import "HighlightRenderer.h" #include @@ -297,11 +299,16 @@ static void extractFontTraits(NSDictionary *attrs, BOOL *isBold, BOOL *isItalic, // Inline formatting BOOL isBold, isItalic, isMonospace; + BOOL recognizedLink = [attrs[ENRMRecognizedLinkAttributeName] boolValue]; extractFontTraits(attrs, &isBold, &isItalic, &isMonospace); + if (recognizedLink) + isMonospace = [attrs[CodeAttributeName] boolValue]; NSNumber *strikethroughStyle = attrs[NSStrikethroughStyleAttributeName]; BOOL isStrikethrough = (strikethroughStyle != nil && [strikethroughStyle integerValue] != 0); - NSNumber *underlineStyle = attrs[NSUnderlineStyleAttributeName]; + NSNumber *underlineStyle = recognizedLink + ? attrs[ENRMRecognizedLinkOriginalUnderlineAttributeName] + : attrs[NSUnderlineStyleAttributeName]; BOOL isUnderline = (underlineStyle != nil && [underlineStyle integerValue] != 0); // Line-height alignment also shifts baselines. Only semantic script spans // should produce superscript/subscript Markdown on the clipboard. @@ -310,7 +317,7 @@ static void extractFontTraits(NSDictionary *attrs, BOOL *isBold, BOOL *isItalic, BOOL isSubscript = [script isEqualToString:ENRMScriptValueSubscript]; BOOL isHighlight = [attrs[HighlightAttributeName] boolValue]; - NSString *linkURL = attrs[NSLinkAttributeName]; + NSString *linkURL = recognizedLink ? nil : attrs[NSLinkAttributeName]; NSString *segment = applyInlineFormatting(text, isBold, isItalic, isMonospace, isStrikethrough, isUnderline, isSuperscript, isSubscript, isHighlight, linkURL); diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts index 7f157dc7c..32ff76827 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts @@ -447,6 +447,14 @@ interface OnLinkContextMenuItemPressEvent { itemText: string; } +interface TextLinkNativeRegex { + pattern: string; + caseInsensitive: boolean; + dotAll: boolean; + isDisabled: boolean; + isDefault: boolean; +} + export interface NativeProps extends ViewProps { /** * Markdown content to render. @@ -620,6 +628,11 @@ export interface NativeProps extends ViewProps { imageRequestHeaders?: ReadonlyArray>; /** Per-link pill content (label, icon), one entry per exact link URL. */ linkPillContent?: ReadonlyArray>; + /** Opt-in text link recognition; see `EnrichedMarkdownTextProps.linkRecognition`. */ + linkRecognition?: Readonly<{ + text: Readonly; + inlineCode: Readonly; + }>; /** Link long-press menus (iOS 17+): item lists by URL pattern, in matching order. */ linkContextMenuItems?: ReadonlyArray>; onLinkContextMenuItemPress?: CodegenTypes.BubblingEventHandler; diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts index 1aafee29c..6f31ccc1c 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts @@ -448,6 +448,14 @@ interface OnLinkContextMenuItemPressEvent { itemText: string; } +interface TextLinkNativeRegex { + pattern: string; + caseInsensitive: boolean; + dotAll: boolean; + isDisabled: boolean; + isDefault: boolean; +} + export interface NativeProps extends ViewProps { /** * Markdown content to render. @@ -620,6 +628,11 @@ export interface NativeProps extends ViewProps { imageRequestHeaders?: ReadonlyArray>; /** Per-link pill content (label, icon), one entry per exact link URL. */ linkPillContent?: ReadonlyArray>; + /** Opt-in text link recognition; see `EnrichedMarkdownTextProps.linkRecognition`. */ + linkRecognition?: Readonly<{ + text: Readonly; + inlineCode: Readonly; + }>; /** Link long-press menus (iOS 17+): item lists by URL pattern, in matching order. */ linkContextMenuItems?: ReadonlyArray>; onLinkContextMenuItemPress?: CodegenTypes.BubblingEventHandler; diff --git a/packages/react-native-enriched-markdown/src/index.tsx b/packages/react-native-enriched-markdown/src/index.tsx index b7f6c8562..c3f90cd41 100644 --- a/packages/react-native-enriched-markdown/src/index.tsx +++ b/packages/react-native-enriched-markdown/src/index.tsx @@ -1,6 +1,9 @@ export type { LinkVariantStyle, LinkPillStyle } from './types/MarkdownStyle'; export type { LinkPillContent } from './types/MarkdownTextProps'; -export type { LinkContextMenuItem } from './types/MarkdownTextProps'; +export type { + LinkContextMenuItem, + LinkRecognition, +} from './types/MarkdownTextProps'; export { default as EnrichedMarkdownText } from './native/EnrichedMarkdownText'; export type { EnrichedMarkdownTextProps, diff --git a/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx b/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx index 55c099401..ca7157e39 100644 --- a/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx +++ b/packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx @@ -2,6 +2,11 @@ import { useMemo, useCallback, useRef, useEffect } from 'react'; import EnrichedMarkdownTextNativeComponent from '../EnrichedMarkdownTextNativeComponent'; import type { MarkdownStyleInternal } from '../EnrichedMarkdownTextNativeComponent'; import EnrichedMarkdownNativeComponent from '../EnrichedMarkdownNativeComponent'; +import { + isLinkRecognitionEqual, + toNativeLinkRecognition, + type LinkRecognitionNative, +} from '../utils/regexParser'; import { normalizeMarkdownStyle } from '../normalizeMarkdownStyle'; import { isLinkPillContentEqual, @@ -155,6 +160,7 @@ export const EnrichedMarkdownText = ({ linkContextMenuItems, imageRequestHeaders, linkPillContent, + linkRecognition, selectionMenuConfig, accessibilityLabels, selectionColor, @@ -166,6 +172,18 @@ export const EnrichedMarkdownText = ({ ellipsizeMode, ...rest }: EnrichedMarkdownTextProps) => { + // An equal config keeps the previous object, so an inline literal does not + // re-send the prop on every render. + const linkRecognitionRef = useRef( + undefined + ); + const nativeLinkRecognition = useMemo(() => { + const next = toNativeLinkRecognition(linkRecognition); + if (!isLinkRecognitionEqual(linkRecognitionRef.current, next)) { + linkRecognitionRef.current = next; + } + return linkRecognitionRef.current!; + }, [linkRecognition]); const normalizedStyleRef = useRef(null); const normalized = normalizeMarkdownStyle(markdownStyle); // normalizeMarkdownStyle returns cached objects for structurally equal inputs, @@ -394,6 +412,7 @@ export const EnrichedMarkdownText = ({ const sharedProps = { markdown, + linkRecognition: nativeLinkRecognition, markdownStyle: normalizedStyle, onLinkPress: handleLinkPress, onLinkLongPress: handleLinkLongPress, diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts index 46a59bdcf..acf3cfccf 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts @@ -11,6 +11,14 @@ import type { CodeBlockPressEvent, } from './events'; +/** Patterns for `linkRecognition`. An omitted key is off. */ +export interface LinkRecognition { + /** Matched inside plain-text runs. */ + text?: RegExp; + /** Must match an inline-code span's whole content. */ + inlineCode?: RegExp; +} + export interface LinkPillContent { label?: string; iconUri?: string; @@ -412,6 +420,15 @@ export interface EnrichedMarkdownTextProps extends Omit { * @platform ios, android */ linkPillContent?: Record; + /** + * Turns regex matches in plain text (`text`) and whole inline-code spans + * (`inlineCode`) into links. The matched text is the URL; the source is unchanged. + * + * @example + * linkRecognition={{ text: /#\d+/, inlineCode: /[\w./-]+\.tsx?/ }} + * @platform ios, android, web + */ + linkRecognition?: LinkRecognition; /** * Controls the built-in items added to the native text selection menu and * lets you localize their labels. Custom app-provided actions are controlled diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.web.ts b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.web.ts index 51871f585..cac90b45f 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.web.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownTextProps.web.ts @@ -1,3 +1,4 @@ +import type { LinkRecognition } from './MarkdownTextProps'; import type { CSSProperties, HTMLAttributes } from 'react'; import type { MarkdownStyle, Md4cFlags } from './MarkdownStyle'; import type { @@ -38,6 +39,12 @@ export interface EnrichedMarkdownTextProps extends Omit< * @platform ios, android, web */ md4cFlags?: Md4cFlags; + /** + * Turns regex matches in plain text (`text`) and whole inline-code spans + * (`inlineCode`) into links. The matched text is the URL; the source is unchanged. + * @platform ios, android, web + */ + linkRecognition?: LinkRecognition; /** * Callback fired when a link is pressed. * Receives the link URL directly. diff --git a/packages/react-native-enriched-markdown/src/utils/regexParser.ts b/packages/react-native-enriched-markdown/src/utils/regexParser.ts index cee4b2c67..31d15010f 100644 --- a/packages/react-native-enriched-markdown/src/utils/regexParser.ts +++ b/packages/react-native-enriched-markdown/src/utils/regexParser.ts @@ -1,4 +1,5 @@ import type { LinkNativeRegex } from '../EnrichedMarkdownTextInputNativeComponent'; +import type { LinkRecognition } from '../types/MarkdownTextProps'; const DISABLED_REGEX: LinkNativeRegex = { pattern: '', @@ -52,3 +53,41 @@ export const toNativeRegexConfig = ( isDefault: false, }; }; + +/** Renderer recognition is opt-in, including when input normalization falls back. */ +export const toNativeTextLinkRegexConfig = ( + regex: RegExp | undefined +): LinkNativeRegex => { + const config = toNativeRegexConfig(regex ?? null); + return config.isDefault ? DISABLED_REGEX : config; +}; + +export interface LinkRecognitionNative { + text: LinkNativeRegex; + inlineCode: LinkNativeRegex; +} + +export const toNativeLinkRecognition = ( + config: LinkRecognition | undefined +): LinkRecognitionNative => ({ + text: toNativeTextLinkRegexConfig(config?.text), + inlineCode: toNativeTextLinkRegexConfig(config?.inlineCode), +}); + +const isNativeRegexEqual = ( + previous: LinkNativeRegex, + next: LinkNativeRegex +): boolean => + previous.pattern === next.pattern && + previous.caseInsensitive === next.caseInsensitive && + previous.dotAll === next.dotAll && + previous.isDisabled === next.isDisabled && + previous.isDefault === next.isDefault; + +export const isLinkRecognitionEqual = ( + previous: LinkRecognitionNative | undefined, + next: LinkRecognitionNative +): boolean => + previous !== undefined && + isNativeRegexEqual(previous.text, next.text) && + isNativeRegexEqual(previous.inlineCode, next.inlineCode); diff --git a/packages/react-native-enriched-markdown/src/web/EnrichedMarkdownText.tsx b/packages/react-native-enriched-markdown/src/web/EnrichedMarkdownText.tsx index f86778685..08dc7df7e 100644 --- a/packages/react-native-enriched-markdown/src/web/EnrichedMarkdownText.tsx +++ b/packages/react-native-enriched-markdown/src/web/EnrichedMarkdownText.tsx @@ -1,4 +1,10 @@ -import { useState, useEffect, useMemo, type CSSProperties } from 'react'; +import { + useState, + useEffect, + useMemo, + useRef, + type CSSProperties, +} from 'react'; import type { EnrichedMarkdownTextProps } from '../types/MarkdownTextProps.web'; import { normalizeMarkdownStyle } from '../normalizeMarkdownStyle.web'; import { @@ -7,6 +13,7 @@ import { buildStyles, } from './styles'; import { parseMarkdown } from './parseMarkdown'; +import { recognizeTextLinks } from './recognizeTextLinks'; import { RenderNode } from './renderers'; import type { ASTNode, RendererCallbacks, RenderCapabilities } from './types'; import { indexTaskItems, markInlineImages } from './utils'; @@ -19,6 +26,7 @@ export const EnrichedMarkdownText = ({ markdown, markdownStyle = {}, md4cFlags = {}, + linkRecognition, onLinkPress, onLinkLongPress, onImagePress, @@ -53,6 +61,12 @@ export const EnrichedMarkdownText = ({ admonitions = true, } = md4cFlags; + // A regex literal in JSX is a new object every render; re-parse only when its text changes. + const linkRecognitionRef = useRef(linkRecognition); + linkRecognitionRef.current = linkRecognition; + const textLinkKey = linkRecognition?.text?.toString() ?? null; + const inlineCodeLinkKey = linkRecognition?.inlineCode?.toString() ?? null; + useEffect(() => { let cancelled = false; @@ -78,7 +92,13 @@ export const EnrichedMarkdownText = ({ setParseError(false); setKatex(katexInstance); - setAst(result); + setAst( + recognizeTextLinks( + result, + linkRecognitionRef.current?.text, + linkRecognitionRef.current?.inlineCode + ) + ); } }) .catch((error) => { @@ -106,6 +126,8 @@ export const EnrichedMarkdownText = ({ hardSoftBreaks, preserveBlankLines, admonitions, + textLinkKey, + inlineCodeLinkKey, ]); const callbacks = useMemo( diff --git a/packages/react-native-enriched-markdown/src/web/recognizeTextLinks.ts b/packages/react-native-enriched-markdown/src/web/recognizeTextLinks.ts new file mode 100644 index 000000000..d52f45d9d --- /dev/null +++ b/packages/react-native-enriched-markdown/src/web/recognizeTextLinks.ts @@ -0,0 +1,115 @@ +import type { ASTNode } from './types'; + +/** + * Web counterpart of the native TextLinkRecognizer: turns regex matches in plain + * text, and whole inline-code spans, into Link nodes marked `recognizedLink`. + * Existing links, code blocks, media and math stay opaque. + */ + +const OPAQUE: ReadonlySet = new Set([ + 'Link', + 'CodeBlock', + 'Image', + 'Video', + 'LatexMathInline', + 'LatexMathDisplay', +]); + +function flagsOf(regex: RegExp, extra: string): string { + let flags = extra; + if (regex.ignoreCase) flags += 'i'; + if (regex.dotAll) flags += 's'; + return flags; +} + +function compile(regex: RegExp | undefined, wholeSpan: boolean) { + if (!regex || regex.source.length === 0) return null; + try { + return wholeSpan + ? new RegExp(`^(?:${regex.source})$`, flagsOf(regex, '')) + : new RegExp(regex.source, flagsOf(regex, 'g')); + } catch { + return null; + } +} + +function recognizedLink(child: ASTNode, url: string): ASTNode { + return { + type: 'Link', + attributes: { url, recognizedLink: 'true' }, + children: [child], + }; +} + +function transform( + node: ASTNode, + textRegex: RegExp | null, + codeRegex: RegExp | null +): ASTNode[] { + if (OPAQUE.has(node.type)) return [node]; + + if (node.type === 'Code') { + const content = (node.children ?? []).map((c) => c.content ?? '').join(''); + if (content.length > 0 && codeRegex && codeRegex.test(content)) { + return [recognizedLink(node, content)]; + } + return [node]; + } + + if (node.type === 'Text') { + if (!textRegex) return [node]; + const content = node.content ?? ''; + const result: ASTNode[] = []; + let offset = 0; + textRegex.lastIndex = 0; + for (const match of content.matchAll(textRegex)) { + const start = match.index ?? 0; + const matched = match[0]; + if (matched.length === 0) continue; + if (start > offset) { + result.push({ ...node, content: content.slice(offset, start) }); + } + result.push(recognizedLink({ ...node, content: matched }, matched)); + offset = start + matched.length; + } + if (offset === 0) return [node]; + if (offset < content.length) { + result.push({ ...node, content: content.slice(offset) }); + } + return result; + } + + if (!node.children || node.children.length === 0) return [node]; + const children = transformChildren(node.children, textRegex, codeRegex); + return [children === node.children ? node : { ...node, children }]; +} + +// Returns the same array when no child changed, so untouched subtrees are not copied. +function transformChildren( + children: ASTNode[], + textRegex: RegExp | null, + codeRegex: RegExp | null +): ASTNode[] { + let result: ASTNode[] | null = null; + children.forEach((child, index) => { + const transformed = transform(child, textRegex, codeRegex); + const unchanged = transformed.length === 1 && transformed[0] === child; + if (result === null) { + if (unchanged) return; + result = children.slice(0, index); + } + result.push(...transformed); + }); + return result ?? children; +} + +export function recognizeTextLinks( + ast: ASTNode, + textLinkRegex: RegExp | undefined, + inlineCodeLinkRegex: RegExp | undefined +): ASTNode { + const textRegex = compile(textLinkRegex, false); + const codeRegex = compile(inlineCodeLinkRegex, true); + if (!textRegex && !codeRegex) return ast; + return transform(ast, textRegex, codeRegex)[0] ?? ast; +} diff --git a/packages/react-native-enriched-markdown/src/web/types.ts b/packages/react-native-enriched-markdown/src/web/types.ts index 7424ea0a2..781adb157 100644 --- a/packages/react-native-enriched-markdown/src/web/types.ts +++ b/packages/react-native-enriched-markdown/src/web/types.ts @@ -48,6 +48,8 @@ export type NodeType = export interface NodeAttributes { level?: string; url?: string; + /** Stamped by recognizeTextLinks() — not present in the raw WASM output. */ + recognizedLink?: string; title?: string; language?: string; fenceChar?: string;