diff --git a/.maestro/enrichedMarkdownText/flows/advanced/block_elements/blockquote_nested_code_block_combo_test.yaml b/.maestro/enrichedMarkdownText/flows/advanced/block_elements/blockquote_nested_code_block_combo_test.yaml new file mode 100644 index 000000000..43b38d1b1 --- /dev/null +++ b/.maestro/enrichedMarkdownText/flows/advanced/block_elements/blockquote_nested_code_block_combo_test.yaml @@ -0,0 +1,41 @@ +appId: swmansion.enriched.markdown.example +tags: + - block + - advanced + - blockquote + - code_block + - text +--- +# checks a fenced code block nested INSIDE a blockquote, and inside a nested quote - +# the GFM recursive-container behavior from #608 where a quoted code block becomes a +# real code-block container laid out within the quote's box (not inline text). +- launchApp + +- runFlow: + file: '../../../../subflows/move_to_playground.yaml' + +- runFlow: + file: '../../../subflows/set_enriched_text_value.yaml' + env: + VALUE: | + > Install the package: + > + > ```bash + > npm install my-package + > ``` + > + > then import it. + + > Outer quote + > > Nested quote with a code block: + > > + > > ```js + > > import pkg from 'my-package' + > > ``` + > > + > > done. + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'blockquote_nested_code_block_combo_display' diff --git a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_code_block_combo_display.png index 68fe46408..e3738a9d7 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_display.png b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_display.png index 78d36ec6a..d34ec4ce5 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_image_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_image_combo_display.png index 589aa78ca..5227084da 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_image_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_image_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_inner_list_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_inner_list_combo_display.png index 743ed9c5e..e5e80012e 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_inner_list_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_inner_list_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_list_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_list_combo_display.png index 7cbb2773e..dba977242 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_list_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_list_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/blockquote_nested_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_nested_code_block_combo_display.png new file mode 100644 index 000000000..0bf1b73b5 Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/android/blockquote_nested_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/header_blockquote_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/header_blockquote_combo_display.png index 39633a3a4..ec32c723d 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/header_blockquote_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/header_blockquote_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/nested_blockquote_display.png b/.maestro/enrichedMarkdownText/screenshots/android/nested_blockquote_display.png index b3becbba6..bde7722f9 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/nested_blockquote_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/nested_blockquote_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/paragraph_blockquote_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/paragraph_blockquote_combo_display.png index 0f272f700..3c56cbadb 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/paragraph_blockquote_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/paragraph_blockquote_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png index 55a2fb3a2..aa28d9a6f 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_display.png index 6fb8343bb..ff555cae7 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_image_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_image_combo_display.png index 8a46ff5b0..4431411a7 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_image_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_image_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_inner_list_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_inner_list_combo_display.png index 629dea2a6..e69de29bb 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_inner_list_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_inner_list_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_list_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_list_combo_display.png index c45216729..392617eb3 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_list_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_list_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_nested_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_nested_code_block_combo_display.png new file mode 100644 index 000000000..1cba2d0ed Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_nested_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/header_blockquote_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/header_blockquote_combo_display.png index b2fd5d387..abe07bbd3 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/header_blockquote_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/header_blockquote_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/nested_blockquote_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/nested_blockquote_display.png index d2b313f66..0993e7677 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/nested_blockquote_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/nested_blockquote_display.png differ diff --git a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/Blockquote.stories.tsx b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/Blockquote.stories.tsx index 88ffa4356..962312770 100644 --- a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/Blockquote.stories.tsx +++ b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/Blockquote.stories.tsx @@ -5,6 +5,7 @@ import { blockquoteStyledDefaults, fontFamilyControl, fontWeightControl, + githubFlavorArgTypes, type BlockquoteStyleControls, numberControl, } from '../shared/storybookMarkdownStyles'; @@ -22,6 +23,32 @@ const NESTED_MARKDOWN = `> top-level blockquote >> nested blockquote inside the first >>> deeply nested blockquote`; +const BLOCK_ELEMENTS_MARKDOWN = `> Blockquote containing several block elements: +> +> ## A heading inside the quote +> +> A paragraph with **bold**, _italic_ and a [link](https://swmansion.com). +> +> - first bullet +> - second bullet +> +> 1. ordered one +> 2. ordered two +> +> \`\`\`ts +> const answer = 42; +> \`\`\` +> +> | Column A | Column B | +> | -------- | -------- | +> | 1 | 2 | +> +> $$E = mc^2$$ +> +> > a nested blockquote inside +> +> and a trailing paragraph.`; + const argTypes = { fontSize: numberControl('markdownStyle.blockquote.fontSize', { min: 12, @@ -95,9 +122,13 @@ function renderBlockquote( ); } +const flavorArgTypes = githubFlavorArgTypes( + 'commonmark renders quotes inline via spans; github renders each quote as a recursive container view (its own padding/background, nested code blocks become real code-block containers).' +); + const blockquoteStoryBase = { - argTypes, - args: blockquoteStyledDefaults, + argTypes: { ...argTypes, ...flavorArgTypes }, + args: { ...blockquoteStyledDefaults, flavor: 'github' as const }, }; export default storyMeta('Block', 'Blockquote'); @@ -111,7 +142,7 @@ export const Default: TextStory = { render: (args) => renderBlockquote( 'Blockquote', - 'Lines prefixed with >. Use the controls to tune markdownStyle.blockquote.', + 'Lines prefixed with >. Flip the flavor control between commonmark and github to compare the inline (span) and container renderers. Use the controls to tune markdownStyle.blockquote.', args ), }; @@ -125,7 +156,21 @@ export const Nested: TextStory = { render: (args) => renderBlockquote( 'Nested Blockquote', - 'Nest blockquotes with multiple > markers. Use the controls to tune markdownStyle.blockquote.', + 'Nest blockquotes with multiple > markers. Flip flavor to compare commonmark (inline spans) with github (one recursive container box per level). Use the controls to tune markdownStyle.blockquote.', + args + ), +}; + +export const WithBlockElements: TextStory = { + ...blockquoteStoryBase, + args: { + ...blockquoteStoryBase.args, + markdown: BLOCK_ELEMENTS_MARKDOWN, + }, + render: (args) => + renderBlockquote( + 'Blockquote with Block Elements', + 'A blockquote holding many block elements - heading, lists, a fenced code block, a table, display math and a nested quote. With flavor="github" each becomes its own segment view (code block, table, math and nested quote as real containers) nested in the quote; with flavor="commonmark" they render inline. Use the controls to tune markdownStyle.blockquote.', args ), }; diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index bb188c71d..2818eec7c 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -285,7 +285,7 @@ Code blocks are always rendered left-to-right regardless of this prop. Per-parag ### `flavor` -Markdown flavor. Set to `'github'` to enable GitHub Flavored Markdown features: tables and block-style code blocks. +Markdown flavor. Set to `'github'` to enable GitHub Flavored Markdown features: tables, block-style code blocks, and block-style blockquotes. | Type | Default Value | Platform | | --------------------------------- | --------------- | -------- | @@ -293,9 +293,11 @@ Markdown flavor. Set to `'github'` to enable GitHub Flavored Markdown features: > **Note:** > - **`'commonmark'`**: All Markdown content is rendered as a single TextView. Selecting text will select all content in the view. -> - **`'github'`**: The Markdown AST is split into segments. Consecutive text blocks (paragraphs, headings, lists, etc.) are grouped into separate TextView segments, while tables, fenced code blocks, and math blocks are rendered as separate block views. This allows for granular text selection within each segment and enables interactive block features (horizontal table scrolling, context menus, the code block header). Text selection cannot span across segments. +> - **`'github'`**: The Markdown AST is split into segments. Consecutive text blocks (paragraphs, headings, lists, etc.) are grouped into separate TextView segments, while tables, fenced code blocks, math blocks, and blockquotes are rendered as separate block views. This allows for granular text selection within each segment and enables interactive block features (horizontal table scrolling, context menus, the code block header). Text selection cannot span across segments. > > With `'github'`, a fenced code block renders as a dedicated component: a header bar with the language display name (` ```python ` shows "Python") and a copy-code button, a divider, and the code below. Long lines do not wrap — the code pane scrolls horizontally while the header stays fixed. Long-pressing the block opens the Copy / Copy as Markdown menu. With `'commonmark'`, code blocks stay inside the single TextView, styled via spans, and long lines wrap. +> +> With `'github'`, a blockquote also renders as a dedicated component — a recursive container that draws its own box (padding, `backgroundColor`, and accent border with `borderRadius`) and splits its own content into segments. A code block, table, or math block quoted inside therefore becomes a real nested block component, and each nesting level is its own box. With `'commonmark'`, blockquotes stay inside the single TextView, styled via spans. ### `streamingAnimation` diff --git a/docs/ELEMENTS_STRUCTURE.md b/docs/ELEMENTS_STRUCTURE.md index b40c8344e..014adb066 100644 --- a/docs/ELEMENTS_STRUCTURE.md +++ b/docs/ELEMENTS_STRUCTURE.md @@ -12,7 +12,7 @@ Markdown elements in `react-native-enriched-markdown` are organized into block a |---------|--------|----------------|-------------| | Headings | `# H1` to `###### H6` | `h1` - `h6` | Six levels of headings | | Paragraphs | Plain text | `paragraph` | Default text container | -| Blockquotes | `> Quote` | `blockquote` | Quoted content with accent bar, unlimited nesting | +| Blockquotes | `> Quote` | `blockquote` | Quoted content with accent bar, unlimited nesting; with `flavor="github"` rendered as a recursive block container (own padding/background box, and quoted code blocks/tables become real nested block components) | | Code Blocks | ` ``` code ``` ` | `codeBlock` | Multi-line code containers; with `flavor="github"` rendered as a block component with a language header and copy-code button | | Unordered Lists | `- Item`, `* Item`, or `+ Item` | `list` | Bullet lists with unlimited nesting | | Ordered Lists | `1. Item` | `list` | Numbered lists with unlimited nesting | 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 675e10168..8a49f91d1 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 @@ -145,7 +145,6 @@ Lcom/swmansion/enriched/markdown/renderer/BlockquoteRenderer; Lcom/swmansion/enriched/markdown/renderer/CodeBlockRenderer; Lcom/swmansion/enriched/markdown/renderer/CodeBlockRenderer$CodeBlockBoundaryPaddingSpan; Lcom/swmansion/enriched/markdown/renderer/CodeRenderer; -Lcom/swmansion/enriched/markdown/renderer/DocumentRenderer; Lcom/swmansion/enriched/markdown/renderer/EmphasisRenderer; Lcom/swmansion/enriched/markdown/renderer/HeadingRenderer; Lcom/swmansion/enriched/markdown/renderer/HighlightRenderer; @@ -255,22 +254,22 @@ Lcom/swmansion/enriched/markdown/styles/UnderlineStyle; # Utilities - Common Lcom/swmansion/enriched/markdown/utils/common/BreakStrategyUtils; Lcom/swmansion/enriched/markdown/utils/common/FeatureFlags; -Lcom/swmansion/enriched/markdown/utils/common/MarkdownSegment; -Lcom/swmansion/enriched/markdown/utils/common/MarkdownSegment$Math; -Lcom/swmansion/enriched/markdown/utils/common/MarkdownSegment$Table; -Lcom/swmansion/enriched/markdown/utils/common/MarkdownSegment$Text; -Lcom/swmansion/enriched/markdown/utils/common/MarkdownSegmentKt; -Lcom/swmansion/enriched/markdown/utils/common/MarkdownSegmentRenderer; +Lcom/swmansion/enriched/markdown/segments/MarkdownSegment; +Lcom/swmansion/enriched/markdown/segments/MarkdownSegment$Math; +Lcom/swmansion/enriched/markdown/segments/MarkdownSegment$Table; +Lcom/swmansion/enriched/markdown/segments/MarkdownSegment$Text; +Lcom/swmansion/enriched/markdown/segments/MarkdownSegmentKt; +Lcom/swmansion/enriched/markdown/segments/MarkdownSegmentRenderer; Lcom/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtilsKt; Lcom/swmansion/enriched/markdown/utils/common/MotionPreferencesKt; Lcom/swmansion/enriched/markdown/utils/common/ReadableMapExtensionsKt; Lcom/swmansion/enriched/markdown/utils/common/ReconciliationResult; -Lcom/swmansion/enriched/markdown/utils/common/RenderedSegment; -Lcom/swmansion/enriched/markdown/utils/common/RenderedSegment$Math; -Lcom/swmansion/enriched/markdown/utils/common/RenderedSegment$Table; -Lcom/swmansion/enriched/markdown/utils/common/RenderedSegment$Text; -Lcom/swmansion/enriched/markdown/utils/common/SegmentReconciler; -Lcom/swmansion/enriched/markdown/utils/common/SegmentSignature; +Lcom/swmansion/enriched/markdown/segments/RenderedSegment; +Lcom/swmansion/enriched/markdown/segments/RenderedSegment$Math; +Lcom/swmansion/enriched/markdown/segments/RenderedSegment$Table; +Lcom/swmansion/enriched/markdown/segments/RenderedSegment$Text; +Lcom/swmansion/enriched/markdown/segments/SegmentReconciler; +Lcom/swmansion/enriched/markdown/segments/SegmentSignature; Lcom/swmansion/enriched/markdown/utils/common/StreamingMarkdownFilter; Lcom/swmansion/enriched/markdown/utils/common/TableStreamingMode; Lcom/swmansion/enriched/markdown/utils/common/layout/RTLUtilsKt; @@ -309,19 +308,19 @@ Lcom/swmansion/enriched/markdown/utils/text/view/TextSelectionColorsKt; Lcom/swmansion/enriched/markdown/utils/text/view/TextViewSetupKt; # Views -Lcom/swmansion/enriched/markdown/views/BlockSegmentView; +Lcom/swmansion/enriched/markdown/segments/BlockSegmentView; Lcom/swmansion/enriched/markdown/views/ContextMenuPopup; Lcom/swmansion/enriched/markdown/views/ContextMenuPopup$Builder; Lcom/swmansion/enriched/markdown/views/ContextMenuPopup$Icon; Lcom/swmansion/enriched/markdown/views/ContextMenuPopup$MenuItem; -Lcom/swmansion/enriched/markdown/views/MathContainerView; -Lcom/swmansion/enriched/markdown/views/TableContainerView; -Lcom/swmansion/enriched/markdown/views/TableContainerView$CellBackgroundView; -Lcom/swmansion/enriched/markdown/views/TableContainerView$CellTextView; -Lcom/swmansion/enriched/markdown/views/TableContainerView$GridContainerView; -Lcom/swmansion/enriched/markdown/views/TableContainerView$HeaderTypefaceSpan; -Lcom/swmansion/enriched/markdown/views/TableContainerView$TableCellData; +Lcom/swmansion/enriched/markdown/segments/MathContainerView; +Lcom/swmansion/enriched/markdown/segments/TableContainerView; +Lcom/swmansion/enriched/markdown/segments/TableContainerView$CellBackgroundView; +Lcom/swmansion/enriched/markdown/segments/TableContainerView$CellTextView; +Lcom/swmansion/enriched/markdown/segments/TableContainerView$GridContainerView; +Lcom/swmansion/enriched/markdown/segments/TableContainerView$HeaderTypefaceSpan; +Lcom/swmansion/enriched/markdown/segments/TableContainerView$TableCellData; # Math (optional, from src/math source set) Lcom/swmansion/enriched/markdown/spans/MathMeasureHelper; -Lcom/swmansion/enriched/markdown/views/MathContainerView$RaTeXCanvasView; +Lcom/swmansion/enriched/markdown/segments/MathContainerView$RaTeXCanvasView; 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 c67dd9ddb..a74f663a2 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 @@ -5,457 +5,452 @@ import android.content.res.Configuration import android.os.Build import android.os.Handler import android.os.Looper -import android.util.AttributeSet import android.util.Log import android.view.View -import android.widget.FrameLayout import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReadableMap import com.facebook.react.uimanager.StateWrapper import com.swmansion.enriched.markdown.accessibility.AccessibilityLabels import com.swmansion.enriched.markdown.parser.Md4cFlags import com.swmansion.enriched.markdown.parser.Parser +import com.swmansion.enriched.markdown.segments.BlockquoteContainerView +import com.swmansion.enriched.markdown.segments.CodeBlockContainerView +import com.swmansion.enriched.markdown.segments.ContainerNodeView +import com.swmansion.enriched.markdown.segments.MarkdownSegmentRenderer +import com.swmansion.enriched.markdown.segments.RenderedSegment +import com.swmansion.enriched.markdown.segments.SegmentViewConfig +import com.swmansion.enriched.markdown.segments.SegmentViewCreators +import com.swmansion.enriched.markdown.segments.SegmentViewFactory +import com.swmansion.enriched.markdown.segments.TableContainerView +import com.swmansion.enriched.markdown.segments.splitASTIntoSegments import com.swmansion.enriched.markdown.spoiler.SpoilerOverlay 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.FeatureFlags -import com.swmansion.enriched.markdown.utils.common.MarkdownSegmentRenderer -import com.swmansion.enriched.markdown.utils.common.RenderedSegment -import com.swmansion.enriched.markdown.utils.common.SegmentReconciler import com.swmansion.enriched.markdown.utils.common.StreamingMarkdownFilter import com.swmansion.enriched.markdown.utils.common.TableStreamingMode import com.swmansion.enriched.markdown.utils.common.isReducedMotionEnabled -import com.swmansion.enriched.markdown.utils.common.splitASTIntoSegments import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig import com.swmansion.enriched.markdown.utils.text.view.applySelectionColors -import com.swmansion.enriched.markdown.views.BlockSegmentView -import com.swmansion.enriched.markdown.views.CodeBlockContainerView -import com.swmansion.enriched.markdown.views.TableContainerView import java.util.EnumSet import java.util.concurrent.ExecutorService import java.util.concurrent.Executors -import kotlin.math.ceil -import kotlin.math.max - -class EnrichedMarkdown - @JvmOverloads - constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, - ) : FrameLayout(context, attrs, defStyleAttr) { - private enum class DirtyFlag { - RECREATE_SEGMENTS, - FORCE_HEIGHT, - } - private val parser = Parser.shared - private val mainHandler = Handler(Looper.getMainLooper()) - private val executor: ExecutorService = Executors.newSingleThreadExecutor() - private val mathContainerClass: Class<*>? by lazy { - try { - Class.forName("com.swmansion.enriched.markdown.views.MathContainerView") - } catch (_: Exception) { - null - } - } +class EnrichedMarkdown( + context: Context, +) : ContainerNodeView(context) { + private enum class DirtyFlag { + RECREATE_SEGMENTS, + FORCE_HEIGHT, + } - private var currentRenderId = 0L - private val segmentViews = mutableListOf() - private val segmentSignatures = mutableListOf() - private val dirtyFlags = EnumSet.noneOf(DirtyFlag::class.java) - var streamingAnimation: Boolean = false - - // used to force a Yoga re-measure when a block image resolves its box height - var stateWrapper: StateWrapper? = null - private var forceHeightRecalculationCounter = 0 - - var tableStreamingMode: TableStreamingMode = TableStreamingMode.PROGRESSIVE - var codeBlockStreamingMode: CodeBlockStreamingMode = CodeBlockStreamingMode.PROGRESSIVE - private var renderPending: Boolean = false - - var currentMarkdown: String = "" - private set - - var markdownStyle: StyleConfig? = null - private set - - private var markdownStyleMap: ReadableMap? = null - private var lastKnownFontScale: Float = context.resources.configuration.fontScale - - var md4cFlags: Md4cFlags = Md4cFlags.DEFAULT - private set - private var allowFontScaling: Boolean = true - private var maxFontSizeMultiplier: Float = 0f - private var allowTrailingMargin: Boolean = false - private var imageRequestHeaders: Map = emptyMap() - private var selectable: Boolean = true - private var selectionColor: Int? = null - private var selectionHandleColor: Int? = null - private var selectionMenuConfig = SelectionMenuConfig() - private var accessibilityLabels = AccessibilityLabels() - private var textBreakStrategy: String = BreakStrategyUtils.DEFAULT_STRATEGY - - private var onLinkPressCallback: ((String) -> Unit)? = null - private var onLinkLongPressCallback: ((String) -> Unit)? = null - private var onTaskListItemPressCallback: ((Int, Boolean, String) -> Unit)? = null - private var onCopyPressCallback: ((String, String) -> Unit)? = null - private var contextMenuItemTexts: List = emptyList() - var onContextMenuItemPressCallback: ((itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit)? = null - var spoilerOverlay: SpoilerOverlay = SpoilerOverlay.PARTICLES - set(value) { - if (field == value) return - field = value - segmentViews.filterIsInstance().forEach { - it.spoilerOverlay = value - } + private val parser = Parser.shared + private val mainHandler = Handler(Looper.getMainLooper()) + private val executor: ExecutorService = Executors.newSingleThreadExecutor() + private val mathContainerClass: Class<*>? by lazy { SegmentViewCreators.mathContainerClass() } + + private var currentRenderId = 0L + private val dirtyFlags = EnumSet.noneOf(DirtyFlag::class.java) + var streamingAnimation: Boolean = false + + // used to force a Yoga re-measure when a block image resolves its box height + var stateWrapper: StateWrapper? = null + private var forceHeightRecalculationCounter = 0 + + var tableStreamingMode: TableStreamingMode = TableStreamingMode.PROGRESSIVE + var codeBlockStreamingMode: CodeBlockStreamingMode = CodeBlockStreamingMode.PROGRESSIVE + private var renderPending: Boolean = false + + var currentMarkdown: String = "" + private set + + var markdownStyle: StyleConfig? = null + private set + + private var markdownStyleMap: ReadableMap? = null + private var lastKnownFontScale: Float = context.resources.configuration.fontScale + + var md4cFlags: Md4cFlags = Md4cFlags.DEFAULT + private set + private var allowFontScaling: Boolean = true + private var maxFontSizeMultiplier: Float = 0f + private var imageRequestHeaders: Map = emptyMap() + private var selectable: Boolean = true + private var selectionColor: Int? = null + private var selectionHandleColor: Int? = null + private var selectionMenuConfig = SelectionMenuConfig() + private var accessibilityLabels = AccessibilityLabels() + private var textBreakStrategy: String = BreakStrategyUtils.DEFAULT_STRATEGY + + private var onLinkPressCallback: ((String) -> Unit)? = null + private var onLinkLongPressCallback: ((String) -> Unit)? = null + private var onTaskListItemPressCallback: ((Int, Boolean, String) -> Unit)? = null + private var onCopyPressCallback: ((String, String) -> Unit)? = null + private var contextMenuItemTexts: List = emptyList() + var onContextMenuItemPressCallback: ((itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit)? = null + var spoilerOverlay: SpoilerOverlay = SpoilerOverlay.PARTICLES + set(value) { + if (field == value) return + field = value + segmentViews.filterIsInstance().forEach { + it.spoilerOverlay = value } - var enableTaskListItemToggle: Boolean = true - set(value) { - if (field == value) return - field = value - segmentViews.filterIsInstance().forEach { - it.enableTaskListItemToggle = value - } + } + var enableTaskListItemToggle: Boolean = true + set(value) { + if (field == value) return + field = value + segmentViews.filterIsInstance().forEach { + it.enableTaskListItemToggle = value } - - fun setMarkdownContent(markdown: String) { - if (currentMarkdown == markdown) return - currentMarkdown = markdown - renderPending = true } - fun setMarkdownStyle(style: ReadableMap?) { - markdownStyleMap = style - val newConfig = style?.let { StyleConfig(it, context, allowFontScaling, maxFontSizeMultiplier) } - newConfig?.imageRequestHeaders = imageRequestHeaders - if (markdownStyle == newConfig) return - markdownStyle = newConfig - dirtyFlags += DirtyFlag.RECREATE_SEGMENTS - dirtyFlags += DirtyFlag.FORCE_HEIGHT - renderPending = true - } + init { + segmentViewFactory = RootFactory() + } - fun setImageRequestHeaders(headers: Map) { - if (imageRequestHeaders == headers) return - imageRequestHeaders = headers - markdownStyle?.imageRequestHeaders = headers - dirtyFlags += DirtyFlag.RECREATE_SEGMENTS - renderPending = true - } + fun setMarkdownContent(markdown: String) { + if (currentMarkdown == markdown) return + currentMarkdown = markdown + renderPending = true + } - fun commitProps() { - MeasurementStore.updateStreamingTableMode(id, tableStreamingMode) - MeasurementStore.updateStreamingCodeBlockMode(id, codeBlockStreamingMode) - MeasurementStore.updateFontScalingSettings(id, allowFontScaling, maxFontSizeMultiplier) - if (renderPending) { - renderPending = false - scheduleRenderIfNeeded() - } - } + fun setMarkdownStyle(style: ReadableMap?) { + markdownStyleMap = style + val newConfig = style?.let { StyleConfig(it, context, allowFontScaling, maxFontSizeMultiplier) } + newConfig?.imageRequestHeaders = imageRequestHeaders + if (markdownStyle == newConfig) return + markdownStyle = newConfig + dirtyFlags += DirtyFlag.RECREATE_SEGMENTS + dirtyFlags += DirtyFlag.FORCE_HEIGHT + renderPending = true + } - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - if (!allowFontScaling) return - val newFontScale = newConfig.fontScale - if (newFontScale != lastKnownFontScale) { - lastKnownFontScale = newFontScale - recreateStyleConfig() - dirtyFlags += DirtyFlag.RECREATE_SEGMENTS - dirtyFlags += DirtyFlag.FORCE_HEIGHT - scheduleRenderIfNeeded() - } - } + fun setImageRequestHeaders(headers: Map) { + if (imageRequestHeaders == headers) return + imageRequestHeaders = headers + markdownStyle?.imageRequestHeaders = headers + dirtyFlags += DirtyFlag.RECREATE_SEGMENTS + renderPending = true + } - fun setMd4cFlags(flags: Md4cFlags) { - if (md4cFlags == flags) return - md4cFlags = flags - renderPending = true + fun commitProps() { + MeasurementStore.updateStreamingTableMode(id, tableStreamingMode) + MeasurementStore.updateStreamingCodeBlockMode(id, codeBlockStreamingMode) + MeasurementStore.updateFontScalingSettings(id, allowFontScaling, maxFontSizeMultiplier) + if (renderPending) { + renderPending = false + scheduleRenderIfNeeded() } + } - fun setAllowFontScaling(allow: Boolean) { - if (allowFontScaling == allow) return - allowFontScaling = allow - MeasurementStore.updateFontScalingSettings(id, allowFontScaling, maxFontSizeMultiplier) + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + if (!allowFontScaling) return + val newFontScale = newConfig.fontScale + if (newFontScale != lastKnownFontScale) { + lastKnownFontScale = newFontScale recreateStyleConfig() dirtyFlags += DirtyFlag.RECREATE_SEGMENTS dirtyFlags += DirtyFlag.FORCE_HEIGHT - renderPending = true + scheduleRenderIfNeeded() } + } - fun setMaxFontSizeMultiplier(multiplier: Float) { - if (maxFontSizeMultiplier == multiplier) return - maxFontSizeMultiplier = multiplier - MeasurementStore.updateFontScalingSettings(id, allowFontScaling, maxFontSizeMultiplier) - recreateStyleConfig() - dirtyFlags += DirtyFlag.RECREATE_SEGMENTS - dirtyFlags += DirtyFlag.FORCE_HEIGHT - renderPending = true - } + fun setMd4cFlags(flags: Md4cFlags) { + if (md4cFlags == flags) return + md4cFlags = flags + renderPending = true + } - fun setAllowTrailingMargin(allow: Boolean) { - if (allowTrailingMargin == allow) return - allowTrailingMargin = allow - dirtyFlags += DirtyFlag.RECREATE_SEGMENTS - dirtyFlags += DirtyFlag.FORCE_HEIGHT - renderPending = true - } + fun setAllowFontScaling(allow: Boolean) { + if (allowFontScaling == allow) return + allowFontScaling = allow + MeasurementStore.updateFontScalingSettings(id, allowFontScaling, maxFontSizeMultiplier) + recreateStyleConfig() + dirtyFlags += DirtyFlag.RECREATE_SEGMENTS + dirtyFlags += DirtyFlag.FORCE_HEIGHT + renderPending = true + } - fun setIsSelectable(value: Boolean) { - if (selectable == value) return - selectable = value - segmentViews.filterIsInstance().forEach { - it.setIsSelectable(value) - } - } + fun setMaxFontSizeMultiplier(multiplier: Float) { + if (maxFontSizeMultiplier == multiplier) return + maxFontSizeMultiplier = multiplier + MeasurementStore.updateFontScalingSettings(id, allowFontScaling, maxFontSizeMultiplier) + recreateStyleConfig() + dirtyFlags += DirtyFlag.RECREATE_SEGMENTS + dirtyFlags += DirtyFlag.FORCE_HEIGHT + renderPending = true + } - fun setSelectionColor(color: Int?) { - if (selectionColor == color) return - selectionColor = color - applySelectionColorsToSegments() - } + fun setAllowTrailingMargin(allow: Boolean) { + if (trailingMarginEnabled == allow) return + trailingMarginEnabled = allow + dirtyFlags += DirtyFlag.RECREATE_SEGMENTS + dirtyFlags += DirtyFlag.FORCE_HEIGHT + renderPending = true + } - fun setSelectionHandleColor(color: Int?) { - if (selectionHandleColor == color) return - selectionHandleColor = color - applySelectionColorsToSegments() + fun setIsSelectable(value: Boolean) { + if (selectable == value) return + selectable = value + segmentViews.filterIsInstance().forEach { + it.setIsSelectable(value) } + } - fun setTextBreakStrategy(strategy: String) { - if (textBreakStrategy == strategy) return - textBreakStrategy = strategy - MeasurementStore.updateBreakStrategy(id, strategy) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val resolved = BreakStrategyUtils.resolveBreakStrategy(strategy) - segmentViews.filterIsInstance().forEach { - it.breakStrategy = resolved - } - } - dirtyFlags += DirtyFlag.FORCE_HEIGHT - renderPending = true - } + fun setSelectionColor(color: Int?) { + if (selectionColor == color) return + selectionColor = color + applySelectionColorsToSegments() + } + + fun setSelectionHandleColor(color: Int?) { + if (selectionHandleColor == color) return + selectionHandleColor = color + applySelectionColorsToSegments() + } - private fun applySelectionColorsToSegments() { + fun setTextBreakStrategy(strategy: String) { + if (textBreakStrategy == strategy) return + textBreakStrategy = strategy + MeasurementStore.updateBreakStrategy(id, strategy) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val resolved = BreakStrategyUtils.resolveBreakStrategy(strategy) segmentViews.filterIsInstance().forEach { - it.applySelectionColors(selectionColor, selectionHandleColor) + it.breakStrategy = resolved } } + dirtyFlags += DirtyFlag.FORCE_HEIGHT + renderPending = true + } - fun setOnLinkPressCallback(callback: (String) -> Unit) { - onLinkPressCallback = callback + private fun applySelectionColorsToSegments() { + segmentViews.filterIsInstance().forEach { + it.applySelectionColors(selectionColor, selectionHandleColor) } + } - fun setOnLinkLongPressCallback(callback: (String) -> Unit) { - onLinkLongPressCallback = callback - } + fun setOnLinkPressCallback(callback: (String) -> Unit) { + onLinkPressCallback = callback + } - fun setOnTaskListItemPressCallback(callback: ((taskIndex: Int, checked: Boolean, itemText: String) -> Unit)?) { - onTaskListItemPressCallback = callback - } + fun setOnLinkLongPressCallback(callback: (String) -> Unit) { + onLinkLongPressCallback = callback + } - fun setOnCopyPressCallback(callback: ((code: String, language: String) -> Unit)?) { - onCopyPressCallback = callback - } + fun setOnTaskListItemPressCallback(callback: ((taskIndex: Int, checked: Boolean, itemText: String) -> Unit)?) { + onTaskListItemPressCallback = callback + } - fun setContextMenuItems(items: List) { - contextMenuItemTexts = items - segmentViews.filterIsInstance().forEach { - it.setContextMenuItems(items, ::forwardContextMenuItemPress) - } + fun setOnCopyPressCallback(callback: ((code: String, language: String) -> Unit)?) { + onCopyPressCallback = callback + } + + fun setContextMenuItems(items: List) { + contextMenuItemTexts = items + segmentViews.filterIsInstance().forEach { + it.setContextMenuItems(items, ::forwardContextMenuItemPress) } + } - fun setSelectionMenuConfig(config: SelectionMenuConfig) { - if (selectionMenuConfig == config) return - selectionMenuConfig = config - segmentViews.filterIsInstance().forEach { - it.selectionMenuConfig = config - } - // Table and math views cache the copy labels, so re-push them on update - // (e.g. a language change without a remount) to avoid stale labels. - pushCopyLabelsToBlockSegments() + fun setSelectionMenuConfig(config: SelectionMenuConfig) { + if (selectionMenuConfig == config) return + selectionMenuConfig = config + segmentViews.filterIsInstance().forEach { + it.selectionMenuConfig = config } + // Table and math views cache the copy labels, so re-push them on update + // (e.g. a language change without a remount) to avoid stale labels. + pushCopyLabelsToBlockSegments() + } - private fun pushCopyLabelsToBlockSegments() { - val copyLabel = selectionMenuConfig.copyLabel - val copyAsMarkdownLabel = selectionMenuConfig.copyAsMarkdownLabel - segmentViews.forEach { view -> - when { - view is TableContainerView -> { - view.copyLabel = copyLabel - view.copyAsMarkdownLabel = copyAsMarkdownLabel - } + private fun pushCopyLabelsToBlockSegments() { + val copyLabel = selectionMenuConfig.copyLabel + val copyAsMarkdownLabel = selectionMenuConfig.copyAsMarkdownLabel + segmentViews.forEach { view -> + when { + view is TableContainerView -> { + view.copyLabel = copyLabel + view.copyAsMarkdownLabel = copyAsMarkdownLabel + } - view is CodeBlockContainerView -> { - view.copyLabel = copyLabel - view.copyAsMarkdownLabel = copyAsMarkdownLabel - } + view is CodeBlockContainerView -> { + view.copyLabel = copyLabel + view.copyAsMarkdownLabel = copyAsMarkdownLabel + } - isMathContainerView(view) -> { - runCatching { - view.javaClass.getMethod("setCopyLabel", String::class.java).invoke(view, copyLabel) - view.javaClass - .getMethod("setCopyAsMarkdownLabel", String::class.java) - .invoke(view, copyAsMarkdownLabel) - } + isMathContainerView(view) -> { + runCatching { + view.javaClass.getMethod("setCopyLabel", String::class.java).invoke(view, copyLabel) + view.javaClass + .getMethod("setCopyAsMarkdownLabel", String::class.java) + .invoke(view, copyAsMarkdownLabel) } } } } + } - fun setAccessibilityLabels(labels: AccessibilityLabels) { - if (accessibilityLabels == labels) return - accessibilityLabels = labels - segmentViews.filterIsInstance().forEach { - it.accessibilityLabels = labels - } - segmentViews.filterIsInstance().forEach { - it.accessibilityLabels = labels - } - mathContainerClass?.let { mathClass -> - segmentViews.filter { mathClass.isInstance(it) }.forEach { view -> - runCatching { - mathClass - .getMethod("setAccessibilityLabels", AccessibilityLabels::class.java) - .invoke(view, labels) - } + fun setAccessibilityLabels(labels: AccessibilityLabels) { + if (accessibilityLabels == labels) return + accessibilityLabels = labels + segmentViews.filterIsInstance().forEach { + it.accessibilityLabels = labels + } + segmentViews.filterIsInstance().forEach { + it.accessibilityLabels = labels + } + mathContainerClass?.let { mathClass -> + segmentViews.filter { mathClass.isInstance(it) }.forEach { view -> + runCatching { + mathClass + .getMethod("setAccessibilityLabels", AccessibilityLabels::class.java) + .invoke(view, labels) } } } + } - private fun forwardContextMenuItemPress( - itemText: String, - selectedText: String, - selectionStart: Int, - selectionEnd: Int, - ) { - onContextMenuItemPressCallback?.invoke(itemText, selectedText, selectionStart, selectionEnd) + private fun forwardContextMenuItemPress( + itemText: String, + selectedText: String, + selectionStart: Int, + selectionEnd: Int, + ) { + onContextMenuItemPressCallback?.invoke(itemText, selectedText, selectionStart, selectionEnd) + } + + private fun recreateStyleConfig() { + markdownStyleMap?.let { + markdownStyle = + StyleConfig(it, context, allowFontScaling, maxFontSizeMultiplier).also { config -> + config.imageRequestHeaders = imageRequestHeaders + } } + } + + private fun scheduleRenderIfNeeded() { + if (currentMarkdown.isNotEmpty()) scheduleRender() + } - private fun recreateStyleConfig() { - markdownStyleMap?.let { - markdownStyle = - StyleConfig(it, context, allowFontScaling, maxFontSizeMultiplier).also { config -> - config.imageRequestHeaders = imageRequestHeaders + private fun scheduleRender() { + val style = markdownStyle ?: return + val markdown = currentMarkdown.takeIf { it.isNotEmpty() } ?: return + val isStreaming = streamingAnimation + val tableMode = tableStreamingMode + val codeBlockMode = codeBlockStreamingMode + + val renderId = ++currentRenderId + + executor.execute { + try { + val filtered = + if (isStreaming) { + StreamingMarkdownFilter.renderableMarkdownForStreaming(markdown, tableMode, codeBlockMode) + } else { + null } - } - } + val renderableMarkdown = filtered?.markdown ?: markdown + val hasPendingCodeBlock = filtered?.endsInsideOpenCodeFence ?: false - private fun scheduleRenderIfNeeded() { - if (currentMarkdown.isNotEmpty()) scheduleRender() - } + if (renderableMarkdown.isEmpty()) { + postToMain(renderId) { applyRenderedSegments(emptyList(), style, false) } + return@execute + } - private fun scheduleRender() { - val style = markdownStyle ?: return - val markdown = currentMarkdown.takeIf { it.isNotEmpty() } ?: return - val isStreaming = streamingAnimation - val tableMode = tableStreamingMode - val codeBlockMode = codeBlockStreamingMode - - val renderId = ++currentRenderId - - executor.execute { - try { - val filtered = - if (isStreaming) { - StreamingMarkdownFilter.renderableMarkdownForStreaming(markdown, tableMode, codeBlockMode) - } else { - null - } - val renderableMarkdown = filtered?.markdown ?: markdown - val hasPendingCodeBlock = filtered?.endsInsideOpenCodeFence ?: false - - if (renderableMarkdown.isEmpty()) { + val ast = + parser.parseMarkdown(renderableMarkdown, md4cFlags) ?: run { postToMain(renderId) { applyRenderedSegments(emptyList(), style, false) } return@execute } - val ast = - parser.parseMarkdown(renderableMarkdown, md4cFlags) ?: run { - postToMain(renderId) { applyRenderedSegments(emptyList(), style, false) } - return@execute - } - - val segments = splitASTIntoSegments(ast) - val renderedSegments = - MarkdownSegmentRenderer.render( - segments, - style, - context, - onLinkPressCallback, - onLinkLongPressCallback, - ) - - postToMain(renderId) { applyRenderedSegments(renderedSegments, style, hasPendingCodeBlock) } - } catch (e: Exception) { - Log.e(TAG, "Render failed", e) - postToMain(renderId) { applyRenderedSegments(emptyList(), style, false) } - } + val segments = splitASTIntoSegments(ast) + val renderedSegments = + MarkdownSegmentRenderer.render( + segments, + style, + context, + onLinkPressCallback, + onLinkLongPressCallback, + ) + + postToMain(renderId) { applyRenderedSegments(renderedSegments, style, hasPendingCodeBlock) } + } catch (e: Exception) { + Log.e(TAG, "Render failed", e) + postToMain(renderId) { applyRenderedSegments(emptyList(), style, false) } } } + } - // Trailing code block whose closing fence hasn't streamed in yet, if any. - private var pendingCodeBlockSegment: RenderedSegment.CodeBlock? = null + // Trailing code block whose closing fence hasn't streamed in yet, if any. + private var pendingCodeBlockSegment: RenderedSegment.CodeBlock? = null - private fun applyRenderedSegments( - renderedSegments: List, - style: StyleConfig, - hasPendingCodeBlock: Boolean, - ) { - pendingCodeBlockSegment = - if (hasPendingCodeBlock) renderedSegments.lastOrNull() as? RenderedSegment.CodeBlock else null - - val reset = DirtyFlag.RECREATE_SEGMENTS in dirtyFlags - val forceHeight = DirtyFlag.FORCE_HEIGHT in dirtyFlags - dirtyFlags.clear() - - val result = - SegmentReconciler.reconcile( - currentViews = segmentViews.toList(), - currentSignatures = segmentSignatures.toList(), - renderedSegments = renderedSegments, - reset = reset, - matchesKind = ::viewMatchesSegmentKind, - createView = { segment -> - val view = createSegmentView(segment, style) - animateNewView(view, segment) - view - }, - updateView = { view, segment -> updateSegmentView(view, segment) }, - ) - - result.viewsToRemove.forEach { removeView(it) } - result.viewsToAttach.forEach { addView(it) } - - segmentViews.clear() - segmentViews.addAll(result.views) - segmentSignatures.clear() - segmentSignatures.addAll(result.signatures) - - // A just-closed block has unchanged content, so the reconciler reuses it - // without an update; sync pending here to trigger its deferred highlight. - segmentViews.forEachIndexed { index, view -> - if (view is CodeBlockContainerView) { - view.pending = pendingCodeBlockSegment != null && index == segmentViews.size - 1 - } - } + private fun applyRenderedSegments( + renderedSegments: List, + @Suppress("UNUSED_PARAMETER") style: StyleConfig, + hasPendingCodeBlock: Boolean, + ) { + pendingCodeBlockSegment = + if (hasPendingCodeBlock) renderedSegments.lastOrNull() as? RenderedSegment.CodeBlock else null - val topologyChanged = result.viewsToAttach.isNotEmpty() || result.viewsToRemove.isNotEmpty() + val reset = DirtyFlag.RECREATE_SEGMENTS in dirtyFlags + val forceHeight = DirtyFlag.FORCE_HEIGHT in dirtyFlags + dirtyFlags.clear() - if (width > 0) { - val heightBefore = computeSegmentsTotalHeight() - layoutSegments() - val heightAfter = computeSegmentsTotalHeight() + val topologyChanged = applySegments(renderedSegments, reset) - if (forceHeight || topologyChanged || heightBefore != heightAfter) { - MeasurementStore.invalidate(id) - requestLayout() - } + // A just-closed block has unchanged content, so the reconciler reuses it + // without an update; sync pending here to trigger its deferred highlight. + segmentViews.forEachIndexed { index, view -> + if (view is CodeBlockContainerView) { + view.pending = pendingCodeBlockSegment != null && index == segmentViews.size - 1 } } - private fun viewMatchesSegmentKind( + if (width > 0) { + val heightBefore = computeSegmentsTotalHeight() + layoutSegments() + val heightAfter = computeSegmentsTotalHeight() + + if (forceHeight || topologyChanged || heightBefore != heightAfter) { + MeasurementStore.invalidate(id) + requestLayout() + } + } + } + + private fun isMathContainerView(view: View): Boolean = mathContainerClass?.isInstance(view) == true + + private fun segmentViewConfig(): SegmentViewConfig = + SegmentViewConfig( + context = context, + style = markdownStyle!!, + allowFontScaling = allowFontScaling, + maxFontSizeMultiplier = maxFontSizeMultiplier, + accessibilityLabels = accessibilityLabels, + selectionMenuConfig = selectionMenuConfig, + textBreakStrategy = textBreakStrategy, + selectable = selectable, + selectionColor = selectionColor, + selectionHandleColor = selectionHandleColor, + contextMenuItemTexts = contextMenuItemTexts, + onLinkPress = onLinkPressCallback, + onLinkLongPress = onLinkLongPressCallback, + onCopyPress = onCopyPressCallback, + onTaskListItemPress = onTaskListItemPressCallback, + onContextMenuItemPress = ::forwardContextMenuItemPress, + ) + + /** + * Root factory: builds child views through the shared creators, then layers on + * root-only concerns (spoiler overlay mode, task-list toggle, streaming tail / + * fade-in animation, and the pending code block sync for the open trailing + * fence). + */ + private inner class RootFactory : SegmentViewFactory { + override fun matchesKind( view: View, segment: RenderedSegment, ): Boolean = @@ -464,22 +459,40 @@ class EnrichedMarkdown is RenderedSegment.Table -> view is TableContainerView is RenderedSegment.Math -> isMathContainerView(view) is RenderedSegment.CodeBlock -> view is CodeBlockContainerView + is RenderedSegment.Blockquote -> view is BlockquoteContainerView } - private fun isMathContainerView(view: View): Boolean = mathContainerClass?.isInstance(view) == true + override fun createView(segment: RenderedSegment): View { + val config = segmentViewConfig() + return when (segment) { + is RenderedSegment.Text -> { + SegmentViewCreators.createTextView(segment, config).apply { + spoilerOverlay = this@EnrichedMarkdown.spoilerOverlay + enableTaskListItemToggle = this@EnrichedMarkdown.enableTaskListItemToggle + } + } - private fun createSegmentView( - segment: RenderedSegment, - style: StyleConfig, - ): View = - when (segment) { - is RenderedSegment.Text -> createTextView(segment) - is RenderedSegment.Table -> createTableView(segment, style) - is RenderedSegment.Math -> createMathView(segment, style) - is RenderedSegment.CodeBlock -> createCodeBlockView(segment, style) + is RenderedSegment.Table -> { + SegmentViewCreators.createTableView(segment, config) + } + + is RenderedSegment.Math -> { + SegmentViewCreators.createMathView(segment, config) + } + + is RenderedSegment.CodeBlock -> { + SegmentViewCreators.createCodeBlockView(segment, config).apply { + pending = segment === pendingCodeBlockSegment + } + } + + is RenderedSegment.Blockquote -> { + SegmentViewCreators.createBlockquoteView(segment, config) + } } + } - private fun updateSegmentView( + override fun updateView( view: View, segment: RenderedSegment, ) { @@ -487,9 +500,7 @@ class EnrichedMarkdown is RenderedSegment.Text -> { val textView = view as EnrichedMarkdownInternalText val tailStart = textView.text?.length ?: 0 - textView.lastElementMarginBottom = segment.lastElementMarginBottom - textView.applyStyledText(segment.styledText) - segment.imageSpans.forEach { it.registerTextView(textView) } + SegmentViewCreators.updateTextView(textView, segment) animateTextViewTail(textView, tailStart) } @@ -503,9 +514,7 @@ class EnrichedMarkdown } is RenderedSegment.Math -> { - mathContainerClass - ?.getMethod("applyLatex", String::class.java) - ?.invoke(view, segment.latex) + SegmentViewCreators.updateMathView(view, segment) } is RenderedSegment.CodeBlock -> { @@ -514,226 +523,88 @@ class EnrichedMarkdown applyCodeBlockNode(segment.node) } } + + is RenderedSegment.Blockquote -> { + (view as BlockquoteContainerView).applyBlockquoteNode(segment.node) + } } } - private fun animateNewView( + override fun animateNewView( view: View, segment: RenderedSegment, ) { if (!streamingAnimation) return when (segment) { is RenderedSegment.Text -> animateTextViewTail(view as EnrichedMarkdownInternalText, 0) - is RenderedSegment.Table, is RenderedSegment.Math, is RenderedSegment.CodeBlock -> animateBlockViewFadeIn(view) - } - } - - private fun animateTextViewTail( - view: EnrichedMarkdownInternalText, - tailStart: Int, - ) { - if (!streamingAnimation) return - val textLength = view.text?.length ?: 0 - if (textLength <= tailStart) return - TailFadeInAnimator(view).animate(tailStart, textLength) - } - - private fun animateBlockViewFadeIn(view: View) { - if (!streamingAnimation) return - if (isReducedMotionEnabled(context)) return - view.alpha = 0f - view - .animate() - .alpha(1f) - .setDuration(BLOCK_FADE_DURATION_MS) - .start() - } - - private fun createTextView(segment: RenderedSegment.Text) = - EnrichedMarkdownInternalText(context).apply { - spoilerOverlay = this@EnrichedMarkdown.spoilerOverlay - enableTaskListItemToggle = this@EnrichedMarkdown.enableTaskListItemToggle - selectionMenuConfig = this@EnrichedMarkdown.selectionMenuConfig - accessibilityLabels = this@EnrichedMarkdown.accessibilityLabels - setIsSelectable(selectable) - markdownStyle?.paragraphStyle?.fontSize?.let { setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, it) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - breakStrategy = BreakStrategyUtils.resolveBreakStrategy(textBreakStrategy) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && segment.needsJustify) { - justificationMode = android.text.Layout.JUSTIFICATION_MODE_INTER_WORD - } - lastElementMarginBottom = segment.lastElementMarginBottom - applyStyledText(segment.styledText) - segment.imageSpans.forEach { it.registerTextView(this) } - - onTaskListItemPressCallback = { taskIndex, checked, itemText -> - this@EnrichedMarkdown.onTaskListItemPressCallback?.invoke(taskIndex, checked, itemText) - } - if (contextMenuItemTexts.isNotEmpty()) { - setContextMenuItems(contextMenuItemTexts, ::forwardContextMenuItemPress) - } - - applySelectionColors(selectionColor, selectionHandleColor) - } - - private fun createTableView( - segment: RenderedSegment.Table, - style: StyleConfig, - ) = TableContainerView(context, style).apply { - allowFontScaling = this@EnrichedMarkdown.allowFontScaling - maxFontSizeMultiplier = this@EnrichedMarkdown.maxFontSizeMultiplier - accessibilityLabels = this@EnrichedMarkdown.accessibilityLabels - onLinkPress = onLinkPressCallback - onLinkLongPress = onLinkLongPressCallback - copyLabel = this@EnrichedMarkdown.selectionMenuConfig.copyLabel - copyAsMarkdownLabel = this@EnrichedMarkdown.selectionMenuConfig.copyAsMarkdownLabel - applyTableNode(segment.node) - } - - private fun createCodeBlockView( - segment: RenderedSegment.CodeBlock, - style: StyleConfig, - ) = CodeBlockContainerView(context, style).apply { - copyLabel = this@EnrichedMarkdown.selectionMenuConfig.copyLabel - copyAsMarkdownLabel = this@EnrichedMarkdown.selectionMenuConfig.copyAsMarkdownLabel - onCopyPress = { code, language -> - this@EnrichedMarkdown.onCopyPressCallback?.invoke(code, language) - } - pending = segment === pendingCodeBlockSegment - applyCodeBlockNode(segment.node) - } - - private fun createMathView( - segment: RenderedSegment.Math, - style: StyleConfig, - ): View { - val resolvedClass = mathContainerClass - if (!FeatureFlags.IS_MATH_ENABLED || resolvedClass == null) return View(context) - return try { - val view = - resolvedClass - .getConstructor(Context::class.java, StyleConfig::class.java) - .newInstance(context, style) as View - runCatching { - resolvedClass - .getMethod("setAccessibilityLabels", AccessibilityLabels::class.java) - .invoke(view, accessibilityLabels) - } - resolvedClass - .getMethod("setCopyLabel", String::class.java) - .invoke(view, selectionMenuConfig.copyLabel) - resolvedClass - .getMethod("setCopyAsMarkdownLabel", String::class.java) - .invoke(view, selectionMenuConfig.copyAsMarkdownLabel) - resolvedClass.getMethod("applyLatex", String::class.java).invoke(view, segment.latex) - view - } catch (e: Exception) { - Log.e(TAG, "Failed to create math view", e) - View(context) + is RenderedSegment.Table, + is RenderedSegment.Math, + is RenderedSegment.CodeBlock, + is RenderedSegment.Blockquote, + -> animateBlockViewFadeIn(view) } } + } - private fun postToMain( - renderId: Long, - action: () -> Unit, - ) { - mainHandler.post { - if (renderId == currentRenderId) action() - } - } + private fun animateTextViewTail( + view: EnrichedMarkdownInternalText, + tailStart: Int, + ) { + if (!streamingAnimation) return + val textLength = view.text?.length ?: 0 + if (textLength <= tailStart) return + TailFadeInAnimator(view).animate(tailStart, textLength) + } - override fun onLayout( - changed: Boolean, - l: Int, - t: Int, - r: Int, - b: Int, - ) { - layoutSegments() - } + private fun animateBlockViewFadeIn(view: View) { + if (!streamingAnimation) return + if (isReducedMotionEnabled(context)) return + view.alpha = 0f + view + .animate() + .alpha(1f) + .setDuration(BLOCK_FADE_DURATION_MS) + .start() + } - fun onImageLayoutChanged() { - if (width > 0) { - layoutSegments() - requestLayout() - } - MeasurementStore.invalidate(id) - val wrapper = stateWrapper ?: return - val state = Arguments.createMap() - state.putInt("forceHeightRecalculationCounter", ++forceHeightRecalculationCounter) - wrapper.updateState(state) + private fun postToMain( + renderId: Long, + action: () -> Unit, + ) { + mainHandler.post { + if (renderId == currentRenderId) action() } + } - private fun layoutSegments() { - val containerWidth = width - if (containerWidth <= 0) return - - val needsOverhang = - segmentViews.any { view -> - view is TableContainerView && - ceil(view.tableStyle.horizontalOverflow.toDouble()).toInt() > 0 - } - if (clipChildren == needsOverhang) { - clipChildren = !needsOverhang - } - - var currentY = 0 - val lastIndex = segmentViews.lastIndex - val widthSpec = MeasureSpec.makeMeasureSpec(containerWidth, MeasureSpec.EXACTLY) - val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) - - segmentViews.forEachIndexed { index, view -> - val segment = view as? BlockSegmentView - val shouldAddBottomMargin = index != lastIndex || allowTrailingMargin - - currentY += segment?.segmentMarginTop ?: 0 - - val overhang = - if (view is TableContainerView) { - max(ceil(view.tableStyle.horizontalOverflow.toDouble()).toInt(), 0) - } else { - 0 - } - - if (overhang > 0) { - val extendedWidth = containerWidth + overhang * 2 - val extWidthSpec = MeasureSpec.makeMeasureSpec(extendedWidth, MeasureSpec.EXACTLY) - view.measure(extWidthSpec, heightSpec) - view.layout(-overhang, currentY, containerWidth + overhang, currentY + view.measuredHeight) - } else { - view.measure(widthSpec, heightSpec) - view.layout(0, currentY, containerWidth, currentY + view.measuredHeight) - } - currentY += view.measuredHeight - - if (shouldAddBottomMargin) { - currentY += segment?.segmentMarginBottom ?: 0 - } - } - } + override fun onLayout( + changed: Boolean, + l: Int, + t: Int, + r: Int, + b: Int, + ) { + layoutSegments() + } - private fun computeSegmentsTotalHeight(): Int { - var totalHeight = 0 - val lastIndex = segmentViews.lastIndex - segmentViews.forEachIndexed { index, view -> - val segment = view as? BlockSegmentView - totalHeight += segment?.segmentMarginTop ?: 0 - totalHeight += view.measuredHeight - if (index != lastIndex || allowTrailingMargin) { - totalHeight += segment?.segmentMarginBottom ?: 0 - } - } - return totalHeight + fun onImageLayoutChanged() { + if (width > 0) { + layoutSegments() + requestLayout() } + MeasurementStore.invalidate(id) + val wrapper = stateWrapper ?: return + val state = Arguments.createMap() + state.putInt("forceHeightRecalculationCounter", ++forceHeightRecalculationCounter) + wrapper.updateState(state) + } - fun cleanup() { - executor.shutdownNow() - } + fun cleanup() { + executor.shutdownNow() + } - companion object { - private const val TAG = "EnrichedMarkdown" - private const val BLOCK_FADE_DURATION_MS = 200L - } + companion object { + private const val TAG = "EnrichedMarkdown" + private const val BLOCK_FADE_DURATION_MS = 200L } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt index 92c0ad4a8..0c27211fb 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt @@ -8,6 +8,7 @@ import android.util.AttributeSet import android.view.MotionEvent import com.swmansion.enriched.markdown.accessibility.AccessibilityLabels import com.swmansion.enriched.markdown.accessibility.AccessibleMarkdownTextView +import com.swmansion.enriched.markdown.segments.BlockSegmentView import com.swmansion.enriched.markdown.spoiler.SpoilerCapable import com.swmansion.enriched.markdown.spoiler.SpoilerOverlay import com.swmansion.enriched.markdown.spoiler.SpoilerOverlayDrawer @@ -20,7 +21,6 @@ import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForLinkTap import com.swmansion.enriched.markdown.utils.text.view.createSelectionActionModeCallback import com.swmansion.enriched.markdown.utils.text.view.reallowParentInterceptIfLinkReleased import com.swmansion.enriched.markdown.utils.text.view.setupAsMarkdownTextView -import com.swmansion.enriched.markdown.views.BlockSegmentView class EnrichedMarkdownInternalText @JvmOverloads 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 d6f8772e1..5d4a65a39 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 @@ -14,6 +14,12 @@ import com.facebook.yoga.YogaMeasureOutput import com.swmansion.enriched.markdown.parser.Md4cFlags import com.swmansion.enriched.markdown.parser.Parser import com.swmansion.enriched.markdown.renderer.Renderer +import com.swmansion.enriched.markdown.segments.BlockquoteContainerView +import com.swmansion.enriched.markdown.segments.CodeBlockContainerView +import com.swmansion.enriched.markdown.segments.MarkdownSegmentRenderer +import com.swmansion.enriched.markdown.segments.RenderedSegment +import com.swmansion.enriched.markdown.segments.TableContainerView +import com.swmansion.enriched.markdown.segments.splitASTIntoSegments import com.swmansion.enriched.markdown.spans.MathMeasureRequest import com.swmansion.enriched.markdown.spans.MathMetrics import com.swmansion.enriched.markdown.spans.MathRenderMode @@ -21,8 +27,6 @@ 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.FeatureFlags -import com.swmansion.enriched.markdown.utils.common.MarkdownSegmentRenderer -import com.swmansion.enriched.markdown.utils.common.RenderedSegment import com.swmansion.enriched.markdown.utils.common.StreamingMarkdownFilter import com.swmansion.enriched.markdown.utils.common.TableStreamingMode import com.swmansion.enriched.markdown.utils.common.getArrayOrNull @@ -30,10 +34,7 @@ import com.swmansion.enriched.markdown.utils.common.getBooleanOrDefault 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.splitASTIntoSegments import com.swmansion.enriched.markdown.utils.text.extensions.replaceMathSpansWithPlaceholders -import com.swmansion.enriched.markdown.views.CodeBlockContainerView -import com.swmansion.enriched.markdown.views.TableContainerView import java.util.concurrent.ConcurrentHashMap import kotlin.math.ceil @@ -504,6 +505,15 @@ object MeasurementStore { totalHeightPx += style.codeBlockStyle.marginBottom } } + + is RenderedSegment.Blockquote -> { + totalHeightPx += style.blockquoteStyle.marginTop + totalHeightPx += BlockquoteContainerView.measureBlockquoteNodeHeight(segment.node, style, context, width) + maxContentWidthPx = width + if (includeBottomMargin) { + totalHeightPx += style.blockquoteStyle.marginBottom + } + } } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt index 52ef424df..c2b8903fa 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt @@ -103,8 +103,6 @@ class BlockStyleContext { fun setCodeBlockStyle(style: CodeBlockStyle) = pushBlockStyle(BlockType.CODE_BLOCK, style) - fun isInsideBlockElement(): Boolean = blockquoteDepth > 0 || listDepth > 0 - fun incrementListItemNumber() { listItemNumber++ } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt index 1952c9819..f16213e13 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt @@ -8,6 +8,7 @@ import com.swmansion.enriched.markdown.parser.MarkdownASTNode import com.swmansion.enriched.markdown.spans.BlockquoteSpan import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_CONTAINER_BACKGROUND import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE +import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_LINE_HEIGHT_PRIORITY import com.swmansion.enriched.markdown.utils.text.span.applyLineHeightSkippingImages import com.swmansion.enriched.markdown.utils.text.span.applyMarginBottom import com.swmansion.enriched.markdown.utils.text.span.applyMarginTop @@ -164,12 +165,12 @@ class BlockquoteRenderer( var currentPos = start for ((nestedStart, nestedEnd) in nestedRanges) { if (currentPos < nestedStart) { - applyLineHeightSkippingImages(builder, currentPos, nestedStart, lineHeight) + applyLineHeightSkippingImages(builder, currentPos, nestedStart, lineHeight, SPAN_FLAGS_LINE_HEIGHT_PRIORITY) } currentPos = nestedEnd } if (currentPos < end) { - applyLineHeightSkippingImages(builder, currentPos, end, lineHeight) + applyLineHeightSkippingImages(builder, currentPos, end, lineHeight, SPAN_FLAGS_LINE_HEIGHT_PRIORITY) } } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteTextRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteTextRenderer.kt new file mode 100644 index 000000000..5207faee9 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteTextRenderer.kt @@ -0,0 +1,56 @@ +package com.swmansion.enriched.markdown.renderer + +import android.text.SpannableString +import android.text.SpannableStringBuilder +import com.swmansion.enriched.markdown.parser.MarkdownASTNode +import com.swmansion.enriched.markdown.styles.BlockquoteStyle +import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_LINE_HEIGHT_PRIORITY +import com.swmansion.enriched.markdown.utils.text.span.applyLineHeightSkippingImages + +/** + * Renders a GFM blockquote's own prose content - the nodes left after nested + * quotes, code blocks and other block segments have been split out - into a + * standalone SpannableString styled as blockquote text. + * + * It decorates a generic [Renderer.renderContent] pass rather than owning the + * render envelope itself: [push] enters the blockquote block style so text picks + * up the quote's font/color while still emitting block margins between children, + * matching the commonmark BlockquoteRenderer; [postProcess] applies the quote's + * line height once the builder is complete. It draws no border/background/padding + * - the BlockquoteContainerView draws the box. + */ +class BlockquoteTextRenderer( + private val style: BlockquoteStyle, +) { + fun render( + renderer: Renderer, + nodes: List, + onLinkPress: ((String) -> Unit)?, + onLinkLongPress: ((String) -> Unit)?, + ): SpannableString = renderer.renderContent(nodes, onLinkPress, onLinkLongPress, block = this) + + fun push(context: BlockStyleContext) { + context.blockquoteDepth += 1 + context.setBlockquoteStyle(style) + } + + fun pop(context: BlockStyleContext) { + context.popBlockStyle() + context.blockquoteDepth -= 1 + } + + fun postProcess(builder: SpannableStringBuilder) { + if (builder.isNotEmpty()) { + // Priority flag so this block-wide line height iterates before the per-block + // MarginBottomSpans, which then add their gap on top instead of being + // normalized away (see SPAN_FLAGS_LINE_HEIGHT_PRIORITY). + applyLineHeightSkippingImages( + builder, + 0, + builder.length, + style.lineHeight, + SPAN_FLAGS_LINE_HEIGHT_PRIORITY, + ) + } + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/DocumentRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/DocumentRenderer.kt deleted file mode 100644 index f6ad6a24e..000000000 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/DocumentRenderer.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.swmansion.enriched.markdown.renderer - -import android.text.SpannableStringBuilder -import com.swmansion.enriched.markdown.parser.MarkdownASTNode - -class DocumentRenderer : NodeRenderer { - override fun render( - node: MarkdownASTNode, - builder: SpannableStringBuilder, - onLinkPress: ((String) -> Unit)?, - onLinkLongPress: ((String) -> Unit)?, - factory: RendererFactory, - ) { - factory.renderChildren(node, builder, onLinkPress, onLinkLongPress) - } -} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt index d51b7cef5..e028f9fe4 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt @@ -84,7 +84,6 @@ class RendererFactory( private val renderers: Map by lazy { buildMap { - put(MarkdownASTNode.NodeType.Document, DocumentRenderer()) put(MarkdownASTNode.NodeType.Paragraph, ParagraphRenderer(config)) put(MarkdownASTNode.NodeType.Heading, HeadingRenderer(config)) put(MarkdownASTNode.NodeType.Blockquote, BlockquoteRenderer(config)) @@ -144,8 +143,23 @@ class RendererFactory( onLinkPress: ((String) -> Unit)?, onLinkLongPress: ((String) -> Unit)?, ) { - node.children.forEach { child -> - getRenderer(child).render(child, builder, onLinkPress, onLinkLongPress, this) + renderNodes(node.children, builder, onLinkPress, onLinkLongPress) + } + + /** + * Renders a flat list of sibling nodes in order, dispatching each to its + * NodeRenderer. Renders a block's own child content directly - e.g. a GFM + * blockquote's content once its nested block segments have been split out - + * without wrapping it in a synthetic root node. + */ + fun renderNodes( + nodes: List, + builder: SpannableStringBuilder, + onLinkPress: ((String) -> Unit)?, + onLinkLongPress: ((String) -> Unit)?, + ) { + nodes.forEach { node -> + getRenderer(node).render(node, builder, onLinkPress, onLinkLongPress, this) } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ParagraphRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ParagraphRenderer.kt index 88e688211..305acc947 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ParagraphRenderer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ParagraphRenderer.kt @@ -22,8 +22,8 @@ class ParagraphRenderer( ) { val context = factory.blockStyleContext - // If nested (e.g., inside a list or blockquote), render content simply with a newline - if (context.isInsideBlockElement()) { + // Inside a list, render tight with a single newline - lists own their item spacing. + if (context.listDepth > 0) { factory.renderChildren(node, builder, onLinkPress, onLinkLongPress) builder.append("\n") return @@ -32,6 +32,21 @@ class ParagraphRenderer( val start = builder.length val style = config.style.paragraphStyle + // Inside a blockquote, keep the quote's block style (font/color) and line height + // - applied by BlockquoteTextRenderer.postProcess - but still emit block margins so + // spacing between blocks matches the top-level document (a heading defaults to + // marginTop 0, so the gap before it comes from the preceding paragraph's marginBottom). + if (context.blockquoteDepth > 0) { + factory.renderChildren(node, builder, onLinkPress, onLinkLongPress) + if (builder.length > start) { + val marginTop = if (node.containsBlockImage()) config.style.imageStyle.marginTop else style.marginTop + applyMarginTop(builder, start, marginTop) + val marginBottom = if (node.containsBlockImage()) config.style.imageStyle.marginBottom else style.marginBottom + applyMarginBottom(builder, marginBottom) + } + return + } + context.setParagraphStyle(style) try { factory.renderChildren(node, builder, onLinkPress, onLinkLongPress) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/Renderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/Renderer.kt index 459cc64ca..56e61a522 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/Renderer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/Renderer.kt @@ -35,10 +35,29 @@ class Renderer { document: MarkdownASTNode, onLinkPress: ((String) -> Unit)? = null, onLinkLongPress: ((String) -> Unit)? = null, + ): SpannableString = renderContent(document.children, onLinkPress, onLinkLongPress) + + /** + * Renders a flat list of sibling nodes into a standalone SpannableString. This + * is the generic envelope shared by the document and block-segment paths: reset + * the factory, build the spannable, trim the trailing margin, and flush deferred + * spans (e.g. BaselineShiftSpan) after all block-level spans are set - see + * BaselineShiftRenderer for the proper long-term fix. + * + * An optional [block] decorates the pass - it enters a block style before the + * nodes render and post-processes the finished builder - letting a caller render + * content as e.g. blockquote text without this class knowing anything + * block-specific. See [BlockquoteTextRenderer]. + */ + fun renderContent( + nodes: List, + onLinkPress: ((String) -> Unit)? = null, + onLinkLongPress: ((String) -> Unit)? = null, + block: BlockquoteTextRenderer? = null, ): SpannableString { val factory = requireNotNull(cachedFactory) { - "Renderer must be configured with a style before calling renderDocument." + "Renderer must be configured with a style before rendering." } factory.resetForNewRender() @@ -47,13 +66,15 @@ class Renderer { val builder = SpannableStringBuilder() - renderNode(document, builder, onLinkPress, onLinkLongPress, factory) + block?.push(factory.blockStyleContext) + try { + factory.renderNodes(nodes, builder, onLinkPress, onLinkLongPress) + } finally { + block?.pop(factory.blockStyleContext) + } - // Remove trailing margin from last block element removeTrailingMargin(builder) - - // Flush deferred spans (e.g. BaselineShiftSpan) after all block-level spans are set. - // See BaselineShiftRenderer for context and the proper long-term fix. + block?.postProcess(builder) factory.flushDeferredSpans(builder) return SpannableString(builder) @@ -89,16 +110,6 @@ class Renderer { */ fun getLastElementMarginBottom(): Float = lastElementMarginBottom - private fun renderNode( - node: MarkdownASTNode, - builder: SpannableStringBuilder, - onLinkPress: ((String) -> Unit)?, - onLinkLongPress: ((String) -> Unit)?, - factory: RendererFactory, - ) { - factory.getRenderer(node).render(node, builder, onLinkPress, onLinkLongPress, factory) - } - /** * Internal helper used by the Factory's lambda to collect spans. */ diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/BlockSegmentView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/BlockSegmentView.kt similarity index 82% rename from packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/BlockSegmentView.kt rename to packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/BlockSegmentView.kt index e8e96639f..c7cb2ed3d 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/BlockSegmentView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/BlockSegmentView.kt @@ -1,4 +1,4 @@ -package com.swmansion.enriched.markdown.views +package com.swmansion.enriched.markdown.segments /** * Interface for block-level segment views that declare their own margins diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/BlockquoteContainerView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/BlockquoteContainerView.kt new file mode 100644 index 000000000..5cab50789 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/BlockquoteContainerView.kt @@ -0,0 +1,250 @@ +package com.swmansion.enriched.markdown.segments + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.view.View +import androidx.core.graphics.withSave +import com.swmansion.enriched.markdown.EnrichedMarkdownInternalText +import com.swmansion.enriched.markdown.parser.MarkdownASTNode +import com.swmansion.enriched.markdown.styles.BlockquoteStyle +import com.swmansion.enriched.markdown.styles.StyleConfig +import com.swmansion.enriched.markdown.utils.common.BreakStrategyUtils +import kotlin.math.ceil + +/** + * A GFM blockquote rendered as a recursive container: it splits its own AST + * children into segments and lays them out through the shared ContainerNodeView + * machinery, so a nested quote becomes another BlockquoteContainerView, a fenced + * code block becomes a CodeBlockContainerView, and prose collapses into a single + * text view. Nesting depth is automatic: each level is its own view, inset + * further by its padding, so onDraw only ever renders one accent bar plus the + * background for this single level. + * + * The content inset is applied as this view's padding (left = borderWidth + + * gapWidth + padding, other edges = padding), mirroring the geometry + * BlockquoteSpan draws for the in-text (list-nested) path; the base measure and + * layout already honor padding. + * + * Streaming is treated as static inside a quote (no pending-fence handling, no + * tail/fade animation) to keep the recursion tractable; a content change re-signs + * the whole container so the reconciler still reuses the outer quote view. + */ +class BlockquoteContainerView( + context: Context, + private val parentConfig: SegmentViewConfig, +) : ContainerNodeView(context), + BlockSegmentView { + private val blockquoteStyle: BlockquoteStyle = parentConfig.style.blockquoteStyle + + private val borderWidthPx: Float = blockquoteStyle.borderWidth + private val paddingPx: Float = blockquoteStyle.padding + private val leftInset: Int = ceil(borderWidthPx + blockquoteStyle.gapWidth + paddingPx).toInt() + private val verticalInset: Int = ceil(paddingPx).toInt() + private val rightInset: Int = ceil(paddingPx).toInt() + + // Only the outermost quote carries vertical margins; a quote nested directly + // inside another quote is separated by the parent's padding alone (matches the + // commonmark BlockquoteRenderer, which applies margins only at depth 0). + var nested: Boolean = false + + override val segmentMarginTop: Int get() = if (nested) 0 else blockquoteStyle.marginTop.toInt() + override val segmentMarginBottom: Int get() = if (nested) 0 else blockquoteStyle.marginBottom.toInt() + + private val backgroundPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + private val borderPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = blockquoteStyle.borderColor + } + private val rect = RectF() + private val path = Path() + private val radiiArray = FloatArray(8) + + init { + setWillNotDraw(false) + setPadding(leftInset, verticalInset, rightInset, verticalInset) + segmentViewFactory = ChildFactory() + trailingMarginEnabled = false + } + + fun applyBlockquoteNode(node: MarkdownASTNode) { + val segments = splitASTIntoSegments(node) + val rendered = + MarkdownSegmentRenderer.render( + segments, + parentConfig.style, + context, + parentConfig.onLinkPress, + parentConfig.onLinkLongPress, + blockquoteStyle, + ) + applySegments(rendered, reset = false) + requestLayout() + } + + override fun onMeasure( + widthSpec: Int, + heightSpec: Int, + ) { + val measuredWidth = MeasureSpec.getSize(widthSpec) + measureSegmentChildren(measuredWidth) + setMeasuredDimension(measuredWidth, computeSegmentsTotalHeight()) + } + + override fun onLayout( + changed: Boolean, + l: Int, + t: Int, + r: Int, + b: Int, + ) { + layoutSegments() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val radius = blockquoteStyle.borderRadius + val bgColor = blockquoteStyle.backgroundColor?.takeIf { it != Color.TRANSPARENT } + val hasRadius = radius > 0f + + if (hasRadius) { + rect.set(0f, 0f, width.toFloat(), height.toFloat()) + radiiArray.fill(radius) + path.reset() + path.addRoundRect(rect, radiiArray, Path.Direction.CW) + } + + if (bgColor != null) { + backgroundPaint.color = bgColor + if (hasRadius) { + canvas.drawPath(path, backgroundPaint) + } else { + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), backgroundPaint) + } + } + + // Clip the accent bar to the rounded box so its top-left/bottom-left corners + // follow the border radius instead of poking past the background as squares. + if (hasRadius) { + canvas.withSave { + clipPath(path) + drawRect(0f, 0f, borderWidthPx, height.toFloat(), borderPaint) + } + } else { + canvas.drawRect(0f, 0f, borderWidthPx, height.toFloat(), borderPaint) + } + } + + /** + * Factory for this quote's own children. It reuses the shared creators for + * every kind and, for a nested Blockquote, creates another + * BlockquoteContainerView (the recursion). Streaming is static here: no + * animation, no pending-fence sync. + */ + private inner class ChildFactory : SegmentViewFactory { + override fun matchesKind( + view: View, + segment: RenderedSegment, + ): Boolean = + when (segment) { + is RenderedSegment.Text -> view is EnrichedMarkdownInternalText + is RenderedSegment.Table -> view is TableContainerView + is RenderedSegment.Math -> SegmentViewCreators.isMathContainerView(view) + is RenderedSegment.CodeBlock -> view is CodeBlockContainerView + is RenderedSegment.Blockquote -> view is BlockquoteContainerView + } + + override fun createView(segment: RenderedSegment): View = + when (segment) { + is RenderedSegment.Text -> { + SegmentViewCreators.createTextView(segment, parentConfig) + } + + is RenderedSegment.Table -> { + SegmentViewCreators.createTableView(segment, parentConfig) + } + + is RenderedSegment.Math -> { + SegmentViewCreators.createMathView(segment, parentConfig) + } + + is RenderedSegment.CodeBlock -> { + SegmentViewCreators.createCodeBlockView(segment, parentConfig) + } + + is RenderedSegment.Blockquote -> { + SegmentViewCreators.createBlockquoteView(segment, parentConfig).apply { nested = true } + } + } + + override fun updateView( + view: View, + segment: RenderedSegment, + ) { + when (segment) { + is RenderedSegment.Text -> SegmentViewCreators.updateTextView(view as EnrichedMarkdownInternalText, segment) + is RenderedSegment.Table -> (view as TableContainerView).applyTableNode(segment.node) + is RenderedSegment.Math -> SegmentViewCreators.updateMathView(view, segment) + is RenderedSegment.CodeBlock -> (view as CodeBlockContainerView).applyCodeBlockNode(segment.node) + is RenderedSegment.Blockquote -> (view as BlockquoteContainerView).applyBlockquoteNode(segment.node) + } + } + + override fun animateNewView( + view: View, + segment: RenderedSegment, + ) = Unit + } + + companion object { + /** + * View-free height of a blockquote node at the given outer content width. + * The children are summed at the reduced inner width (outer minus horizontal + * inset) and the vertical inset is added; nested quotes recurse through this + * same function via SegmentHeightMeasurer. Math child heights use the shared + * estimate to avoid a main-thread measure from the layout pass. + */ + fun measureBlockquoteNodeHeight( + node: MarkdownASTNode, + config: StyleConfig, + context: Context, + width: Float, + ): Float { + val style = config.blockquoteStyle + val leftInset = ceil(style.borderWidth + style.gapWidth + style.padding).toInt() + val rightInset = ceil(style.padding).toInt() + val verticalInset = ceil(style.padding).toInt() + val innerWidth = (width - leftInset - rightInset).coerceAtLeast(1f) + + val segments = splitASTIntoSegments(node) + val rendered = MarkdownSegmentRenderer.render(segments, config, context, null, null, style) + + val fontSize = style.fontSize + val breakStrategy = BreakStrategyUtils.resolveBreakStrategy(null) + + val childrenHeight = + SegmentHeightMeasurer.measureSegmentsHeight( + segments = rendered, + style = config, + context = context, + contentWidthPx = innerWidth, + fontSizePx = fontSize, + breakStrategy = breakStrategy, + allowTrailingMargin = false, + mathHeightForIndex = { estimateMathHeight(config) }, + ) + + return childrenHeight + verticalInset * 2f + } + + private fun estimateMathHeight(config: StyleConfig): Float { + val estimatedHeight = config.mathStyle.fontSize * 1.4f + return estimatedHeight + config.mathStyle.padding * 2 + } + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerView.kt similarity index 98% rename from packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt rename to packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerView.kt index 380c6be09..1df60a9a4 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/CodeBlockContainerView.kt @@ -1,4 +1,4 @@ -package com.swmansion.enriched.markdown.views +package com.swmansion.enriched.markdown.segments import android.content.ClipData import android.content.ClipboardManager @@ -31,6 +31,8 @@ import com.swmansion.enriched.markdown.utils.common.CodeBlockHighlighter import com.swmansion.enriched.markdown.utils.common.CodeBlockNode import com.swmansion.enriched.markdown.utils.text.extensions.applyCodeBlockTextStyle import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE +import com.swmansion.enriched.markdown.views.ContextMenuPopup +import com.swmansion.enriched.markdown.views.CopyGlyph import kotlin.math.ceil /** diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/ContainerNodeView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/ContainerNodeView.kt new file mode 100644 index 000000000..a2770175d --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/ContainerNodeView.kt @@ -0,0 +1,153 @@ +package com.swmansion.enriched.markdown.segments + +import android.content.Context +import android.view.View +import android.widget.FrameLayout +import kotlin.math.ceil +import kotlin.math.max + +/** + * Reusable view for an AST branch node that holds a vertical stack of block + * children (the document root and every blockquote). It owns the machinery + * shared by both: reconciling a list of RenderedSegment into real child views + * by signature, stacking them vertically with per-segment margins, and + * reporting the total height (including its own vertical padding). + * + * Per-kind view construction is delegated to a SegmentViewFactory the subclass + * supplies, so root-only wiring (link/copy callbacks, selection menu, spoiler, + * accessibility, task-list toggle, pending code block) lives with the root and + * the nested blockquote factory reuses only the shared creators. + * + * Layout honors paddingLeft/Top/Right/Bottom so a subclass can inset its + * children (e.g. a blockquote's border/gap/padding) via padding: children are + * measured at exactly width - paddingLeft - paddingRight and laid out starting + * at (paddingLeft, paddingTop). The root uses zero padding, so its behavior is + * unchanged. + */ +open class ContainerNodeView( + context: Context, +) : FrameLayout(context) { + protected val segmentViews = mutableListOf() + protected val segmentSignatures = mutableListOf() + + protected lateinit var segmentViewFactory: SegmentViewFactory + + protected var trailingMarginEnabled: Boolean = false + + /** + * Reconciles the current child views against renderedSegments, attaching and + * removing views as needed and updating segmentViews/segmentSignatures. + * Returns whether the topology changed (any view attached or removed). + */ + protected fun applySegments( + renderedSegments: List, + reset: Boolean, + ): Boolean { + val result = + SegmentReconciler.reconcile( + currentViews = segmentViews.toList(), + currentSignatures = segmentSignatures.toList(), + renderedSegments = renderedSegments, + reset = reset, + matchesKind = segmentViewFactory::matchesKind, + createView = { segment -> + val view = segmentViewFactory.createView(segment) + segmentViewFactory.animateNewView(view, segment) + view + }, + updateView = { view, segment -> segmentViewFactory.updateView(view, segment) }, + ) + + result.viewsToRemove.forEach { removeView(it) } + result.viewsToAttach.forEach { addView(it) } + + segmentViews.clear() + segmentViews.addAll(result.views) + segmentSignatures.clear() + segmentSignatures.addAll(result.signatures) + + return result.viewsToAttach.isNotEmpty() || result.viewsToRemove.isNotEmpty() + } + + protected fun layoutSegments() { + val containerWidth = width + if (containerWidth <= 0) return + + val contentWidth = (containerWidth - paddingLeft - paddingRight).coerceAtLeast(0) + + val needsOverhang = + segmentViews.any { view -> + view is TableContainerView && + ceil(view.tableStyle.horizontalOverflow.toDouble()).toInt() > 0 + } + if (clipChildren == needsOverhang) { + clipChildren = !needsOverhang + } + + var currentY = paddingTop + val lastIndex = segmentViews.lastIndex + val widthSpec = MeasureSpec.makeMeasureSpec(contentWidth, MeasureSpec.EXACTLY) + val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + + segmentViews.forEachIndexed { index, view -> + val segment = view as? BlockSegmentView + val shouldAddBottomMargin = index != lastIndex || trailingMarginEnabled + + currentY += segment?.segmentMarginTop ?: 0 + + val overhang = + if (view is TableContainerView) { + max(ceil(view.tableStyle.horizontalOverflow.toDouble()).toInt(), 0) + } else { + 0 + } + + if (overhang > 0) { + val extendedWidth = contentWidth + overhang * 2 + val extWidthSpec = MeasureSpec.makeMeasureSpec(extendedWidth, MeasureSpec.EXACTLY) + view.measure(extWidthSpec, heightSpec) + view.layout( + paddingLeft - overhang, + currentY, + paddingLeft + contentWidth + overhang, + currentY + view.measuredHeight, + ) + } else { + view.measure(widthSpec, heightSpec) + view.layout(paddingLeft, currentY, paddingLeft + contentWidth, currentY + view.measuredHeight) + } + currentY += view.measuredHeight + + if (shouldAddBottomMargin) { + currentY += segment?.segmentMarginBottom ?: 0 + } + } + } + + /** + * Measures every child at the inner content width so computeSegmentsTotalHeight + * can read accurate measuredHeights during a subclass's onMeasure, before the + * onLayout pass runs layoutSegments. Without this a container that sizes itself + * from its children (a blockquote) would measure them as zero-height and clip. + */ + protected fun measureSegmentChildren(outerWidth: Int) { + val contentWidth = (outerWidth - paddingLeft - paddingRight).coerceAtLeast(0) + val widthSpec = MeasureSpec.makeMeasureSpec(contentWidth, MeasureSpec.EXACTLY) + val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + segmentViews.forEach { it.measure(widthSpec, heightSpec) } + } + + protected fun computeSegmentsTotalHeight(): Int { + var totalHeight = paddingTop + paddingBottom + val lastIndex = segmentViews.lastIndex + segmentViews.forEachIndexed { index, view -> + val segment = view as? BlockSegmentView + totalHeight += segment?.segmentMarginTop ?: 0 + totalHeight += view.measuredHeight + if (index != lastIndex || trailingMarginEnabled) { + totalHeight += segment?.segmentMarginBottom ?: 0 + } + } + return totalHeight + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownSegment.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/MarkdownSegment.kt similarity index 85% rename from packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownSegment.kt rename to packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/MarkdownSegment.kt index 9d6af84a5..4e8b4d743 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownSegment.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/MarkdownSegment.kt @@ -1,4 +1,4 @@ -package com.swmansion.enriched.markdown.utils.common +package com.swmansion.enriched.markdown.segments import com.swmansion.enriched.markdown.parser.MarkdownASTNode @@ -19,6 +19,10 @@ sealed interface MarkdownSegment { data class CodeBlock( val node: MarkdownASTNode, ) : MarkdownSegment + + data class Blockquote( + val node: MarkdownASTNode, + ) : MarkdownSegment } fun splitASTIntoSegments(root: MarkdownASTNode): List { @@ -55,6 +59,11 @@ fun splitASTIntoSegments(root: MarkdownASTNode): List { segments.add(MarkdownSegment.CodeBlock(child)) } + MarkdownASTNode.NodeType.Blockquote -> { + flushTextNodes() + segments.add(MarkdownSegment.Blockquote(child)) + } + else -> { currentTextNodes.add(child) } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/RenderedSegment.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/RenderedSegment.kt similarity index 74% rename from packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/RenderedSegment.kt rename to packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/RenderedSegment.kt index f5cfb7706..2eb304975 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/RenderedSegment.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/RenderedSegment.kt @@ -1,10 +1,12 @@ -package com.swmansion.enriched.markdown.utils.common +package com.swmansion.enriched.markdown.segments import android.content.Context import android.text.SpannableString import com.swmansion.enriched.markdown.parser.MarkdownASTNode +import com.swmansion.enriched.markdown.renderer.BlockquoteTextRenderer import com.swmansion.enriched.markdown.renderer.Renderer import com.swmansion.enriched.markdown.spans.ImageSpan +import com.swmansion.enriched.markdown.styles.BlockquoteStyle import com.swmansion.enriched.markdown.styles.StyleConfig sealed interface RenderedSegment { @@ -32,6 +34,11 @@ sealed interface RenderedSegment { val node: MarkdownASTNode, override val signature: Long, ) : RenderedSegment + + data class Blockquote( + val node: MarkdownASTNode, + override val signature: Long, + ) : RenderedSegment } object MarkdownSegmentRenderer { @@ -41,11 +48,12 @@ object MarkdownSegmentRenderer { context: Context, onLinkPress: ((String) -> Unit)?, onLinkLongPress: ((String) -> Unit)?, + blockquoteStyle: BlockquoteStyle? = null, ): List = segments.map { segment -> when (segment) { is MarkdownSegment.Text -> { - renderTextSegment(segment.nodes, style, context, onLinkPress, onLinkLongPress) + renderTextSegment(segment.nodes, style, context, onLinkPress, onLinkLongPress, blockquoteStyle) } is MarkdownSegment.Table -> { @@ -63,6 +71,11 @@ object MarkdownSegmentRenderer { val signature = SegmentSignature.signatureForNode(segment.node) xor SegmentSignature.CODE_BLOCK_KIND_SALT RenderedSegment.CodeBlock(segment.node, signature) } + + is MarkdownSegment.Blockquote -> { + val signature = SegmentSignature.signatureForNode(segment.node) xor SegmentSignature.BLOCKQUOTE_KIND_SALT + RenderedSegment.Blockquote(segment.node, signature) + } } } @@ -72,13 +85,20 @@ object MarkdownSegmentRenderer { context: Context, onLinkPress: ((String) -> Unit)?, onLinkLongPress: ((String) -> Unit)?, + blockquoteStyle: BlockquoteStyle?, ): RenderedSegment.Text { - val documentWrapper = MarkdownASTNode(type = MarkdownASTNode.NodeType.Document, children = nodes) val renderer = Renderer().apply { configure(style, context) } val signature = SegmentSignature.signatureForNodes(nodes) xor SegmentSignature.TEXT_KIND_SALT + val styledText = + if (blockquoteStyle != null) { + BlockquoteTextRenderer(blockquoteStyle).render(renderer, nodes, onLinkPress, onLinkLongPress) + } else { + renderer.renderContent(nodes, onLinkPress, onLinkLongPress) + } + return RenderedSegment.Text( - styledText = renderer.renderDocument(documentWrapper, onLinkPress, onLinkLongPress), + styledText = styledText, imageSpans = renderer.getCollectedImageSpans().toList(), needsJustify = style.needsJustify, lastElementMarginBottom = renderer.getLastElementMarginBottom(), diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentHeightMeasurer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentHeightMeasurer.kt new file mode 100644 index 000000000..86c63c72a --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentHeightMeasurer.kt @@ -0,0 +1,124 @@ +package com.swmansion.enriched.markdown.segments + +import android.content.Context +import android.graphics.Paint +import android.os.Build +import android.text.Layout +import android.text.StaticLayout +import android.text.TextPaint +import com.swmansion.enriched.markdown.styles.StyleConfig +import com.swmansion.enriched.markdown.utils.text.extensions.replaceMathSpansWithPlaceholders +import kotlin.math.ceil + +/** + * View-free height summation for a vertical stack of RenderedSegments at a fixed + * content width, single-sourced so the shadow-node measurement pass + * (MeasurementStore) and the blockquote container's own static measurer report + * identical heights. + * + * Math heights must be measured up front on the main thread by the caller and + * supplied through mathHeightForIndex, because the summation runs off the main + * thread. Blockquote children recurse through + * BlockquoteContainerView.measureBlockquoteNodeHeight. + */ +object SegmentHeightMeasurer { + fun measureSegmentsHeight( + segments: List, + style: StyleConfig, + context: Context, + contentWidthPx: Float, + fontSizePx: Float, + breakStrategy: Int, + allowTrailingMargin: Boolean, + mathHeightForIndex: (Int) -> Float, + ): Float { + val widthPx = ceil(contentWidthPx).toInt().coerceAtLeast(1) + val lastIndex = segments.lastIndex + var totalHeightPx = 0f + + for ((index, segment) in segments.withIndex()) { + val isLastSegment = index == lastIndex + val includeBottomMargin = if (isLastSegment) allowTrailingMargin else true + + when (segment) { + is RenderedSegment.Text -> { + segment.styledText.replaceMathSpansWithPlaceholders(context) + val layout = createStaticLayout(segment.styledText, fontSizePx, widthPx, breakStrategy) + totalHeightPx += layout.height + if (includeBottomMargin) { + totalHeightPx += segment.lastElementMarginBottom + } + } + + is RenderedSegment.Table -> { + totalHeightPx += style.tableStyle.marginTop + totalHeightPx += TableContainerView.measureTableNodeHeight(segment.node, style, context) + if (includeBottomMargin) { + totalHeightPx += style.tableStyle.marginBottom + } + } + + is RenderedSegment.Math -> { + totalHeightPx += style.mathStyle.marginTop + totalHeightPx += mathHeightForIndex(index) + if (includeBottomMargin) { + totalHeightPx += style.mathStyle.marginBottom + } + } + + is RenderedSegment.CodeBlock -> { + totalHeightPx += style.codeBlockStyle.marginTop + totalHeightPx += CodeBlockContainerView.measureCodeBlockNodeHeight(segment.node, style, context, contentWidthPx) + if (includeBottomMargin) { + totalHeightPx += style.codeBlockStyle.marginBottom + } + } + + is RenderedSegment.Blockquote -> { + // A quote encountered here is always nested inside another quote, so it + // carries no vertical margin (only the outermost quote does, added by + // MeasurementStore); this mirrors BlockquoteContainerView.nested. + totalHeightPx += BlockquoteContainerView.measureBlockquoteNodeHeight(segment.node, style, context, contentWidthPx) + } + } + } + return totalHeightPx + } + + fun createStaticLayout( + text: CharSequence, + fontSizePx: Float, + widthPx: Int, + breakStrategy: Int, + ): StaticLayout { + val paint = + TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = fontSizePx + } + prepareImageSpansForMeasurement(text, widthPx) + return StaticLayout.Builder + .obtain(text, 0, text.length, paint, widthPx) + .setIncludePad(false) + .setLineSpacing(0f, 1f) + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + @Suppress("WrongConstant") + setBreakStrategy(breakStrategy) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + setUseLineSpacingFromFallbacks(true) + } + }.build() + } + + private fun prepareImageSpansForMeasurement( + text: CharSequence?, + widthPx: Int, + ) { + if (widthPx <= 1) return + val spanned = text as? android.text.Spanned ?: return + spanned + .getSpans(0, spanned.length, com.swmansion.enriched.markdown.spans.ImageSpan::class.java) + .forEach { it.prepareForMeasurement(spanned, widthPx) } + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentReconciler.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentReconciler.kt similarity index 98% rename from packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentReconciler.kt rename to packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentReconciler.kt index 69aaf329c..7ee1752fa 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentReconciler.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentReconciler.kt @@ -1,4 +1,4 @@ -package com.swmansion.enriched.markdown.utils.common +package com.swmansion.enriched.markdown.segments import android.view.View diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentSignature.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentSignature.kt similarity index 94% rename from packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentSignature.kt rename to packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentSignature.kt index 5d31ef7d8..561627c94 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentSignature.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentSignature.kt @@ -1,4 +1,4 @@ -package com.swmansion.enriched.markdown.utils.common +package com.swmansion.enriched.markdown.segments import com.swmansion.enriched.markdown.parser.MarkdownASTNode @@ -12,6 +12,7 @@ object SegmentSignature { internal const val TABLE_KIND_SALT = 0x7461626C00000000L // "tabl" internal const val MATH_KIND_SALT = 0x6D61746800000000L // "math" internal const val CODE_BLOCK_KIND_SALT = 0x63626C6B00000000L // "cblk" + internal const val BLOCKQUOTE_KIND_SALT = 0x6271746500000000L // "bqte" private fun fnvMixByte( hash: Long, diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentViewCreators.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentViewCreators.kt new file mode 100644 index 000000000..20750afd1 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentViewCreators.kt @@ -0,0 +1,182 @@ +package com.swmansion.enriched.markdown.segments + +import android.content.Context +import android.os.Build +import android.text.Layout +import android.util.Log +import android.util.TypedValue +import android.view.View +import com.swmansion.enriched.markdown.EnrichedMarkdownInternalText +import com.swmansion.enriched.markdown.accessibility.AccessibilityLabels +import com.swmansion.enriched.markdown.styles.StyleConfig +import com.swmansion.enriched.markdown.utils.common.BreakStrategyUtils +import com.swmansion.enriched.markdown.utils.common.FeatureFlags +import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig +import com.swmansion.enriched.markdown.utils.text.view.applySelectionColors + +/** + * Configuration shared by every ContainerNodeView's SegmentViewFactory so that + * Text / Table / Math / CodeBlock / Blockquote child views are constructed the + * same way regardless of the host (root document or nested blockquote). + * + * The root supplies the full wiring; a nested blockquote supplies the subset it + * needs (styling, link/copy callbacks, accessibility labels) and treats + * streaming as static. + */ +data class SegmentViewConfig( + val context: Context, + val style: StyleConfig, + val allowFontScaling: Boolean, + val maxFontSizeMultiplier: Float, + val accessibilityLabels: AccessibilityLabels, + val selectionMenuConfig: SelectionMenuConfig, + val textBreakStrategy: String, + val selectable: Boolean, + val selectionColor: Int?, + val selectionHandleColor: Int?, + val contextMenuItemTexts: List, + val onLinkPress: ((String) -> Unit)?, + val onLinkLongPress: ((String) -> Unit)?, + val onCopyPress: ((code: String, language: String) -> Unit)?, + val onTaskListItemPress: ((taskIndex: Int, checked: Boolean, itemText: String) -> Unit)?, + val onContextMenuItemPress: ((itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit)?, +) + +/** + * Pure per-kind child view creators used by both the root and nested factories. + * Kept free of host-only concerns (streaming tail animation, pending fence + * handling, spoiler overlay mode, task-list toggle) so the root can add those on + * top while nested blockquotes reuse the exact same construction. + */ +object SegmentViewCreators { + private const val TAG = "SegmentViewCreators" + + fun createTextView( + segment: RenderedSegment.Text, + config: SegmentViewConfig, + ): EnrichedMarkdownInternalText = + EnrichedMarkdownInternalText(config.context).apply { + selectionMenuConfig = config.selectionMenuConfig + accessibilityLabels = config.accessibilityLabels + setIsSelectable(config.selectable) + setTextSize(TypedValue.COMPLEX_UNIT_PX, config.style.paragraphStyle.fontSize) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + breakStrategy = BreakStrategyUtils.resolveBreakStrategy(config.textBreakStrategy) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && segment.needsJustify) { + justificationMode = Layout.JUSTIFICATION_MODE_INTER_WORD + } + lastElementMarginBottom = segment.lastElementMarginBottom + applyStyledText(segment.styledText) + segment.imageSpans.forEach { it.registerTextView(this) } + + onTaskListItemPressCallback = { taskIndex, checked, itemText -> + config.onTaskListItemPress?.invoke(taskIndex, checked, itemText) + } + + if (config.contextMenuItemTexts.isNotEmpty()) { + val onPress = config.onContextMenuItemPress + if (onPress != null) { + setContextMenuItems(config.contextMenuItemTexts) { itemText, selectedText, start, end -> + onPress(itemText, selectedText, start, end) + } + } + } + + applySelectionColors(config.selectionColor, config.selectionHandleColor) + } + + fun updateTextView( + view: EnrichedMarkdownInternalText, + segment: RenderedSegment.Text, + ) { + view.lastElementMarginBottom = segment.lastElementMarginBottom + view.applyStyledText(segment.styledText) + segment.imageSpans.forEach { it.registerTextView(view) } + } + + fun createTableView( + segment: RenderedSegment.Table, + config: SegmentViewConfig, + ) = TableContainerView(config.context, config.style).apply { + allowFontScaling = config.allowFontScaling + maxFontSizeMultiplier = config.maxFontSizeMultiplier + accessibilityLabels = config.accessibilityLabels + onLinkPress = config.onLinkPress + onLinkLongPress = config.onLinkLongPress + copyLabel = config.selectionMenuConfig.copyLabel + copyAsMarkdownLabel = config.selectionMenuConfig.copyAsMarkdownLabel + applyTableNode(segment.node) + } + + fun createCodeBlockView( + segment: RenderedSegment.CodeBlock, + config: SegmentViewConfig, + ) = CodeBlockContainerView(config.context, config.style).apply { + copyLabel = config.selectionMenuConfig.copyLabel + copyAsMarkdownLabel = config.selectionMenuConfig.copyAsMarkdownLabel + onCopyPress = { code, language -> config.onCopyPress?.invoke(code, language) } + applyCodeBlockNode(segment.node) + } + + // Availability is fixed at build time (the math source set is compiled in or not), so the + // reflective lookup - including the ClassNotFoundException when math is disabled - is resolved + // once instead of on every reconcile via isMathContainerView/matchesKind. + private val cachedMathContainerClass: Class<*>? by lazy { + try { + Class.forName("com.swmansion.enriched.markdown.segments.MathContainerView") + } catch (_: Exception) { + null + } + } + + fun mathContainerClass(): Class<*>? = cachedMathContainerClass + + fun isMathContainerView(view: View): Boolean = cachedMathContainerClass?.isInstance(view) == true + + fun createMathView( + segment: RenderedSegment.Math, + config: SegmentViewConfig, + ): View { + val resolvedClass = mathContainerClass() + if (!FeatureFlags.IS_MATH_ENABLED || resolvedClass == null) return View(config.context) + return try { + val view = + resolvedClass + .getConstructor(Context::class.java, StyleConfig::class.java) + .newInstance(config.context, config.style) as View + runCatching { + resolvedClass + .getMethod("setAccessibilityLabels", AccessibilityLabels::class.java) + .invoke(view, config.accessibilityLabels) + } + resolvedClass + .getMethod("setCopyLabel", String::class.java) + .invoke(view, config.selectionMenuConfig.copyLabel) + resolvedClass + .getMethod("setCopyAsMarkdownLabel", String::class.java) + .invoke(view, config.selectionMenuConfig.copyAsMarkdownLabel) + resolvedClass.getMethod("applyLatex", String::class.java).invoke(view, segment.latex) + view + } catch (e: Exception) { + Log.e(TAG, "Failed to create math view", e) + View(config.context) + } + } + + fun updateMathView( + view: View, + segment: RenderedSegment.Math, + ) { + mathContainerClass() + ?.getMethod("applyLatex", String::class.java) + ?.invoke(view, segment.latex) + } + + fun createBlockquoteView( + segment: RenderedSegment.Blockquote, + config: SegmentViewConfig, + ) = BlockquoteContainerView(config.context, config).apply { + applyBlockquoteNode(segment.node) + } +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentViewFactory.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentViewFactory.kt new file mode 100644 index 000000000..497216543 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/SegmentViewFactory.kt @@ -0,0 +1,29 @@ +package com.swmansion.enriched.markdown.segments + +import android.view.View + +/** + * Per-kind view work seam used by ContainerNodeView to build and reconcile + * child views from RenderedSegment data. The root document and every + * BlockquoteContainerView supply their own implementation so the reconcile / + * layout loop can stay identical while the actual child view construction + * (callbacks, styling, accessibility) varies per host. + */ +interface SegmentViewFactory { + fun matchesKind( + view: View, + segment: RenderedSegment, + ): Boolean + + fun createView(segment: RenderedSegment): View + + fun updateView( + view: View, + segment: RenderedSegment, + ) + + fun animateNewView( + view: View, + segment: RenderedSegment, + ) +} diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/TableContainerView.kt similarity index 98% rename from packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt rename to packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/TableContainerView.kt index 736a0e8d0..dd8964dcb 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/segments/TableContainerView.kt @@ -1,4 +1,4 @@ -package com.swmansion.enriched.markdown.views +package com.swmansion.enriched.markdown.segments import android.content.ClipData import android.content.ClipboardManager @@ -152,11 +152,11 @@ class TableContainerView( isHeader: Boolean, alignment: Layout.Alignment, ): SpannableString { - val root = MarkdownASTNode(NodeType.Document, children = listOf(MarkdownASTNode(NodeType.Paragraph, children = node.children))) + val paragraph = MarkdownASTNode(NodeType.Paragraph, children = node.children) val cellParagraphStyle = styleConfig.tableCellParagraphStyle(tableStyle, isHeader) return styleConfig .withParagraphOverride(cellParagraphStyle) { - Renderer().apply { configure(styleConfig, context) }.renderDocument(root, onLinkPress, onLinkLongPress) + Renderer().apply { configure(styleConfig, context) }.renderContent(listOf(paragraph), onLinkPress, onLinkLongPress) }.apply { if (isNotEmpty()) { if (isHeader) setSpan(HeaderTypefaceSpan(styleConfig.tableHeaderTypeface ?: Typeface.DEFAULT_BOLD), 0, length, 33) @@ -479,7 +479,7 @@ class TableContainerView( config.withParagraphOverride(cellParagraphStyle) { Renderer() .apply { configure(config, context) } - .renderDocument(MarkdownASTNode(NodeType.Document, children = listOf(paragraph)), null, null) + .renderContent(listOf(paragraph), null, null) } styledText.replaceMathSpansWithPlaceholders(context) if (isHeader && styledText.isNotEmpty()) { diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/BlockSpacing.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/BlockSpacing.kt index 6677482b9..296673be6 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/BlockSpacing.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/BlockSpacing.kt @@ -21,6 +21,7 @@ fun applyLineHeightSkippingImages( start: Int, end: Int, lineHeight: Float, + spanFlags: Int = SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE, ) { val blockImageRanges = builder @@ -42,7 +43,7 @@ fun applyLineHeightSkippingImages( createLineHeightSpan(lineHeight), pos, exStart, - SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE, + spanFlags, ) } val skipEnd = if (exEnd < end && builder[exEnd] == '\n') exEnd + 1 else exEnd @@ -53,7 +54,7 @@ fun applyLineHeightSkippingImages( createLineHeightSpan(lineHeight), pos, end, - SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE, + spanFlags, ) } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/SpanFlags.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/SpanFlags.kt index 4066be62f..3cd57c722 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/SpanFlags.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/SpanFlags.kt @@ -15,3 +15,16 @@ const val SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE = SpannableString.SPAN_EXCLUSIVE_EXCLUS */ const val SPAN_FLAGS_CONTAINER_BACKGROUND = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE or Spanned.SPAN_PRIORITY + +/** + * `SPAN_EXCLUSIVE_EXCLUSIVE` with the maximum span priority, for a block-wide + * LineHeightSpan (e.g. a blockquote's uniform line height) that must be iterated + * BEFORE the per-block [MarginBottomSpan]s it overlaps. LineHeightSpans are + * applied in iteration order and higher priority iterates first, so this span + * sets the base line height and the lower-priority margin span then adds its gap + * on top instead of being normalized away. Without it a line-height pass applied + * after the margins (e.g. in BlockquoteTextRenderer.postProcess) would erase them + * and clip the first line's ascent. + */ +const val SPAN_FLAGS_LINE_HEIGHT_PRIORITY = + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE or Spanned.SPAN_PRIORITY diff --git a/packages/react-native-enriched-markdown/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt b/packages/react-native-enriched-markdown/android/src/math/java/com/swmansion/enriched/markdown/segments/MathContainerView.kt similarity index 97% rename from packages/react-native-enriched-markdown/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt rename to packages/react-native-enriched-markdown/android/src/math/java/com/swmansion/enriched/markdown/segments/MathContainerView.kt index 428104279..b4c2bfdce 100644 --- a/packages/react-native-enriched-markdown/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt +++ b/packages/react-native-enriched-markdown/android/src/math/java/com/swmansion/enriched/markdown/segments/MathContainerView.kt @@ -1,4 +1,4 @@ -package com.swmansion.enriched.markdown.views +package com.swmansion.enriched.markdown.segments import android.content.ClipData import android.content.ClipboardManager @@ -17,6 +17,7 @@ import com.swmansion.enriched.markdown.spans.MathMeasureRequest import com.swmansion.enriched.markdown.spans.MathRenderMode import com.swmansion.enriched.markdown.styles.MathStyle import com.swmansion.enriched.markdown.styles.StyleConfig +import com.swmansion.enriched.markdown.views.ContextMenuPopup import io.ratex.RaTeXEngine import io.ratex.RaTeXFontLoader import io.ratex.RaTeXRenderer diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm index ed8b53d0a..dae75b34f 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm @@ -18,6 +18,7 @@ #if ENRICHED_MARKDOWN_MATH #import "ENRMMathContainerView.h" #endif +#import "ENRMBlockquoteContainerView.h" #import "ENRMCodeBlockContainerView.h" #import "ENRMSpoilerCapable.h" #import "ENRMSpoilerOverlayView.h" @@ -288,9 +289,56 @@ - (void)configureSegmentViewRegistry [codeBlockView applyCodeBlockNode:segment.codeBlockSegment.codeBlockNode]; }]]; + [handlers addObject:[ENRMSegmentViewHandler handlerWithKind:ENRMSegmentKindBlockquote + matchesView:^BOOL(RCTUIView *view, ENRMRenderedSegment *segment) { + return [view isKindOfClass:[ENRMBlockquoteContainerView class]]; + } + createView:^RCTUIView *(ENRMRenderedSegment *segment) { + EnrichedMarkdown *strongSelf = weakSelf; + if (!strongSelf) { + return [[RCTUIView alloc] init]; + } + + ENRMBlockquoteContainerView *view = + [strongSelf createBlockquoteViewForSegment:segment.blockquoteSegment]; + [strongSelf animateBlockViewIfNeeded:view]; + return view; + } + updateView:^(RCTUIView *view, ENRMRenderedSegment *segment) { + [(ENRMBlockquoteContainerView *)view + applyBlockquoteNode:segment.blockquoteSegment.blockquoteNode]; + }]]; + _segmentViewRegistry = [[ENRMSegmentViewRegistry alloc] initWithHandlers:handlers]; } +- (ENRMBlockquoteContainerView *)createBlockquoteViewForSegment:(ENRMBlockquoteSegment *)blockquoteSegment +{ + ENRMBlockquoteContainerView *view = [[ENRMBlockquoteContainerView alloc] initWithConfig:_config]; + view.copyLabel = _selectionMenuLabels.copyLabel; + view.copyAsMarkdownLabel = _selectionMenuLabels.copyAsMarkdownLabel; + + __weak EnrichedMarkdown *weakSelf = self; + view.onCopyPress = ^(NSString *code, NSString *language) { + EnrichedMarkdown *strongSelf = weakSelf; + if (strongSelf) + [strongSelf emitCopyPress:code language:language]; + }; + view.onLinkPress = ^(NSString *url) { + EnrichedMarkdown *strongSelf = weakSelf; + if (strongSelf && url) + [strongSelf emitLinkPress:url]; + }; + view.onLinkLongPress = ^(NSString *url) { + EnrichedMarkdown *strongSelf = weakSelf; + if (strongSelf && url) + [strongSelf emitLinkLongPress:url]; + }; + + [view applyBlockquoteNode:blockquoteSegment.blockquoteNode]; + return view; +} + - (CGSize)computeSegmentLayoutForWidth:(CGFloat)width applyFrames:(BOOL)applyFrames { if (_segmentViews.count == 0) @@ -353,6 +401,10 @@ - (CGSize)computeSegmentLayoutForWidth:(CGFloat)width applyFrames:(BOOL)applyFra yOffset += _config.codeBlockMarginTop; segmentHeight = [(ENRMCodeBlockContainerView *)segment measureHeight:width]; maxContentWidth = width; + } else if ([segment isKindOfClass:[ENRMBlockquoteContainerView class]]) { + yOffset += _config.blockquoteMarginTop; + segmentHeight = [(ENRMBlockquoteContainerView *)segment measureHeight:width]; + maxContentWidth = width; } if (applyFrames) { @@ -390,6 +442,8 @@ - (CGSize)computeSegmentLayoutForWidth:(CGFloat)width applyFrames:(BOOL)applyFra #endif else if ([segment isKindOfClass:[ENRMCodeBlockContainerView class]] && shouldAddBottomMargin) { yOffset += _config.codeBlockMarginBottom; + } else if ([segment isKindOfClass:[ENRMBlockquoteContainerView class]] && shouldAddBottomMargin) { + yOffset += _config.blockquoteMarginBottom; } }]; @@ -525,6 +579,11 @@ - (void)pushSelectionMenuLabelsToSegments ENRMCodeBlockContainerView *codeBlockView = (ENRMCodeBlockContainerView *)segment; codeBlockView.copyLabel = _selectionMenuLabels.copyLabel; codeBlockView.copyAsMarkdownLabel = _selectionMenuLabels.copyAsMarkdownLabel; + } else if ([segment isKindOfClass:[ENRMBlockquoteContainerView class]]) { + ENRMBlockquoteContainerView *blockquoteView = (ENRMBlockquoteContainerView *)segment; + blockquoteView.copyLabel = _selectionMenuLabels.copyLabel; + blockquoteView.copyAsMarkdownLabel = _selectionMenuLabels.copyAsMarkdownLabel; + [blockquoteView pushCopyLabelsToChildren]; } } } @@ -595,7 +654,8 @@ - (void)renderMarkdownContent:(NSString *)markdownString return NO; renderedSegments = ENRMRenderSegmentsFromAST(ast, config, allowTrailingMargin, allowFontScaling, - maxFontSizeMultiplier, lineBreakStrategy); + maxFontSizeMultiplier, lineBreakStrategy, + /*blockquoteContent*/ NO); for (ENRMRenderedSegment *segment in renderedSegments) { if (segment.kind == ENRMSegmentKindText && segment.textResult) { ENRMApplyWritingDirectionMode(segment.textResult.attributedText, writingDirectionMode, @@ -621,7 +681,7 @@ - (NSArray *)parseAndRenderSegments:(NSString *)markdownString NSArray *segments = ENRMRenderSegmentsFromAST(ast, _config, _allowTrailingMargin, _fontScaleObserver.allowFontScaling, - _maxFontSizeMultiplier, _lineBreakStrategy); + _maxFontSizeMultiplier, _lineBreakStrategy, /*blockquoteContent*/ NO); for (ENRMRenderedSegment *segment in segments) { if (segment.kind == ENRMSegmentKindText && segment.textResult) { ENRMApplyWritingDirectionMode(segment.textResult.attributedText, _writingDirectionMode, _resolvedLayoutDirection); diff --git a/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h b/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h index 1905d4505..0a58b4e43 100644 --- a/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h +++ b/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h @@ -1,5 +1,6 @@ #pragma once +#import "ENRMBlockquoteContainerView.h" #import "ENRMCodeBlockContainerView.h" #import "ENRMFeatureFlags.h" #import "ENRMMarkdownParser.h" @@ -259,7 +260,7 @@ static inline CGSize ENRMMeasureSegmentedMarkdownViewFree(const PropsT &typedPro NSArray *segments = ENRMRenderSegmentsFromAST(ast, config, typedProps.allowTrailingMargin, typedProps.allowFontScaling, - typedProps.maxFontSizeMultiplier, lineBreakStrategy); + typedProps.maxFontSizeMultiplier, lineBreakStrategy, /*blockquoteContent*/ NO); for (ENRMRenderedSegment *segment in segments) { if (segment.kind == ENRMSegmentKindText && segment.textResult) { ENRMApplyWritingDirectionMode(segment.textResult.attributedText, writingDirectionMode, resolvedLayoutDirection); @@ -305,6 +306,16 @@ static inline CGSize ENRMMeasureSegmentedMarkdownViewFree(const PropsT &typedPro if (shouldAddBottomMargin) { yOffset += config.codeBlockMarginBottom; } + } else if (segment.kind == ENRMSegmentKindBlockquote && segment.blockquoteSegment) { + yOffset += config.blockquoteMarginTop; + yOffset += [ENRMBlockquoteContainerView measureHeightForBlockquoteNode:segment.blockquoteSegment.blockquoteNode + config:config + maxWidth:maxWidth + pointScaleFactor:pointScaleFactor]; + maxContentWidth = maxWidth; + if (shouldAddBottomMargin) { + yOffset += config.blockquoteMarginBottom; + } } #if ENRICHED_MARKDOWN_MATH else if (segment.kind == ENRMSegmentKindMath && segment.mathSegment) { diff --git a/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.h index 7601efb43..246387ea2 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.h +++ b/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.h @@ -1,11 +1,23 @@ #import +@class ENRMBlockquoteTextRenderer; @class MarkdownASTNode; @class RenderContext; +NS_ASSUME_NONNULL_BEGIN + @interface AttributedRenderer : NSObject - (instancetype)initWithConfig:(id)config; -- (NSMutableAttributedString *)renderRoot:(MarkdownASTNode *)root context:(RenderContext *)context; +// Renders a flat list of sibling nodes into an attributed string: sets the baseline block style, +// walks each node, and trims the trailing block margin. Pass a non-nil block to render the nodes as +// a blockquote's own content (blockquote baseline + the quote's line height applied over the run); +// it draws no box - the ENRMBlockquoteContainerView draws the border/background/padding. See +// ENRMBlockquoteTextRenderer. +- (NSMutableAttributedString *)renderNodes:(NSArray *)nodes + context:(RenderContext *)context + block:(nullable ENRMBlockquoteTextRenderer *)block; - (CGFloat)getLastElementMarginBottom; - (void)setAllowTrailingMargin:(BOOL)allow; @end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.m index 83c8b41de..519d37a00 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.m @@ -1,6 +1,7 @@ #import "AttributedRenderer.h" #import "BlockquoteBorder.h" #import "CodeBlockBackground.h" +#import "ENRMBlockquoteTextRenderer.h" #import "LastElementUtils.h" #import "MarkdownASTNode.h" #import "NodeRenderer.h" @@ -29,23 +30,30 @@ - (instancetype)initWithConfig:(StyleConfig *)config } /** - * Entry point for rendering a Markdown AST. - * Sets the baseline global style and initiates the recursive traversal. + * Entry point for rendering a flat list of sibling nodes (e.g. a document's or a block segment's + * children). Sets the baseline block style, runs the recursive traversal, and trims the trailing + * block margin. An optional [block] decorates the pass - it enters a block baseline before the nodes + * render and post-processes the finished string - letting a caller render content as e.g. blockquote + * text without this class knowing anything block-specific. See ENRMBlockquoteTextRenderer. */ -- (NSMutableAttributedString *)renderRoot:(MarkdownASTNode *)root context:(RenderContext *)context +- (NSMutableAttributedString *)renderNodes:(NSArray *)nodes + context:(RenderContext *)context + block:(ENRMBlockquoteTextRenderer *)block { - if (!root) - return [[NSMutableAttributedString alloc] init]; - - // 1. Establish the global baseline style. - // This ensures that leaf nodes (like Text) have valid attributes if they appear at the root. - [context setBlockStyle:BlockTypeParagraph font:_config.paragraphFont color:_config.paragraphColor headingLevel:0]; + // 1. Establish the baseline block style. This ensures leaf nodes (like Text) have valid + // attributes if they appear at the top level. A decorator overrides the default paragraph + // baseline (e.g. the blockquote baseline so paragraphs render tight and pick up the quote's + // font/color). + if (block) { + [block pushOnContext:context]; + } else { + [context setBlockStyle:BlockTypeParagraph font:_config.paragraphFont color:_config.paragraphColor headingLevel:0]; + } NSMutableAttributedString *output = [[NSMutableAttributedString alloc] init]; - // 2. Iterate through root children. - // We skip the 'Root' node itself as it is a container, not a renderable element. - for (MarkdownASTNode *node in root.children) { + // 2. Iterate through the sibling nodes. + for (MarkdownASTNode *node in nodes) { [self renderNodeRecursive:node into:output context:context]; } @@ -54,7 +62,10 @@ - (NSMutableAttributedString *)renderRoot:(MarkdownASTNode *)root context:(Rende _lastElementMarginBottom = 0.0; [self removeTrailingSpacing:output]; - // 4. Cleanup global state to prevent side effects in subsequent renders. + // 4. Let the decorator post-process the finished string (e.g. stamp the quote's line height). + [block postProcess:output]; + + // 5. Cleanup global state to prevent side effects in subsequent renders. [context clearBlockStyle]; return output; diff --git a/packages/react-native-enriched-markdown/ios/renderer/ENRMBlockquoteTextRenderer.h b/packages/react-native-enriched-markdown/ios/renderer/ENRMBlockquoteTextRenderer.h new file mode 100644 index 000000000..f371dd571 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/renderer/ENRMBlockquoteTextRenderer.h @@ -0,0 +1,25 @@ +#pragma once +#import + +@class RenderContext; +@class StyleConfig; + +NS_ASSUME_NONNULL_BEGIN + +/** + * Renders a GFM blockquote's own prose content - the nodes left after nested quotes, code blocks and + * other block segments have been split out - as blockquote text. + * + * It decorates a generic AttributedRenderer pass rather than owning the render envelope itself: + * -pushOnContext: enters the blockquote baseline block style (+depth) so text picks up the quote's + * font/color and paragraphs render tight (currentBlockType == Blockquote), matching the commonmark + * path; -postProcess: applies the quote's line height once the string is complete. It draws no + * border/background/padding - the ENRMBlockquoteContainerView draws the box. + */ +@interface ENRMBlockquoteTextRenderer : NSObject +- (instancetype)initWithConfig:(StyleConfig *)config; +- (void)pushOnContext:(RenderContext *)context; +- (void)postProcess:(NSMutableAttributedString *)text; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/renderer/ENRMBlockquoteTextRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/ENRMBlockquoteTextRenderer.m new file mode 100644 index 000000000..1626d0d0c --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/renderer/ENRMBlockquoteTextRenderer.m @@ -0,0 +1,47 @@ +#import "ENRMBlockquoteTextRenderer.h" +#import "ParagraphStyleUtils.h" +#import "RenderContext.h" +#import "StyleConfig.h" + +@implementation ENRMBlockquoteTextRenderer { + StyleConfig *_config; +} + +- (instancetype)initWithConfig:(StyleConfig *)config +{ + self = [super init]; + if (self) { + _config = config; + } + return self; +} + +- (void)pushOnContext:(RenderContext *)context +{ + // Only set the blockquote baseline block style (tight paragraphs + the quote's font/color). + // Unlike the commonmark inline path we do NOT bump blockquoteDepth: the ENRMBlockquoteContainerView + // already insets its content by the quote's border/gap/padding, so bumping it here would make + // ListItemRenderer add a second blockquoteIndentForDepth on top and over-indent nested lists. + [context setBlockStyle:BlockTypeBlockquote font:_config.blockquoteFont color:_config.blockquoteColor headingLevel:0]; +} + +- (void)postProcess:(NSMutableAttributedString *)text +{ + // Stamp the quote's line height per paragraph. applyLineHeight copies the paragraph style at the + // range start over the whole range, so applying it across the entire run would overwrite every + // paragraph with the first one's style - wiping each list item's firstLineHeadIndent/headIndent + // (the marker draws from a separate attribute, so it would then land on top of un-indented text). + CGFloat lineHeight = [_config blockquoteLineHeight]; + if (text.length == 0 || lineHeight <= 0) { + return; + } + NSString *string = text.string; + NSUInteger location = 0; + while (location < text.length) { + NSRange paragraph = [string paragraphRangeForRange:NSMakeRange(location, 0)]; + applyLineHeight(text, paragraph, lineHeight); + location = NSMaxRange(paragraph); + } +} + +@end diff --git a/packages/react-native-enriched-markdown/ios/renderer/ParagraphRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/ParagraphRenderer.m index cae273432..304d63aa7 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ParagraphRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/ParagraphRenderer.m @@ -31,8 +31,13 @@ - (void)renderNodeContent:(MarkdownASTNode *)node } NSUInteger start = output.length; + // Paragraphs directly inside a blockquote keep their block margins so spacing + // between blocks matches the top-level document (a heading defaults to + // marginTop 0, so the gap before it comes from the preceding paragraph's + // marginBottom). List content stays tight - lists own their item spacing. BOOL shouldApplyMargin = - (context.currentBlockType == BlockTypeNone || context.currentBlockType == BlockTypeParagraph); + (context.currentBlockType == BlockTypeNone || context.currentBlockType == BlockTypeParagraph || + context.currentBlockType == BlockTypeBlockquote); // Detect if the paragraph is a wrapper for a standalone image to use image-specific spacing BOOL isBlockImage = (node.children.count == 1 && ((MarkdownASTNode *)node.children[0]).type == MarkdownNodeTypeImage); diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.h b/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.h new file mode 100644 index 000000000..891a7ce7e --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.h @@ -0,0 +1,78 @@ +#pragma once + +#import "ENRMCodeBlockContainerView.h" +#import "ENRMContainerNodeView.h" +#import "StyleConfig.h" + +@class MarkdownASTNode; + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^ENRMBlockquoteLinkBlock)(NSString *url); + +/** + * A GFM blockquote rendered as a recursive container (issue #608): it splits its + * own AST children into segments and lays them out through the shared + * ENRMContainerNodeView machinery, so a nested quote becomes another + * ENRMBlockquoteContainerView, a fenced code block becomes an + * ENRMCodeBlockContainerView, and prose collapses into a single inner text view. + * Nesting depth is automatic: each level is its own view, inset further by its + * padding, so drawRect: only ever renders one accent bar plus the background for + * this single level. This mirrors Android's BlockquoteContainerView. + * + * The content inset is applied via contentInsets (left = borderWidth + gapWidth + * + padding, other edges = padding), reusing the geometry the commonmark + * BlockquoteBorder draws for the in-text (list-nested) path, which stays + * untouched. The base measure/layout already honor contentInsets. + * + * Streaming is static inside a quote (no pending fence, no animation); a content + * change re-signs the whole subtree so the outer reconciler still reuses the + * quote view. + */ +@interface ENRMBlockquoteContainerView : ENRMContainerNodeView + +- (instancetype)initWithConfig:(StyleConfig *)config; + +// Splits the node's children into segments and applies them to this container. +- (void)applyBlockquoteNode:(MarkdownASTNode *)node; + +// Laid-out height of this quote at the given outer width (the width the parent +// lays it out at, before this view's own inset). +- (CGFloat)measureHeight:(CGFloat)maxWidth; + +// View-free height for the shadow-node measurement pass: the same height an +// instance's measureHeight: reports for the node, without building a view. It +// sums the child segments at the reduced inner width and adds the vertical +// inset, recursing into itself for nested quotes. pointScaleFactor is the layout +// context's scale (the visible instance path uses RCTScreenScale), threaded so +// the per-text-segment pixel rounding matches the laid-out height. ++ (CGFloat)measureHeightForBlockquoteNode:(MarkdownASTNode *)node + config:(StyleConfig *)config + maxWidth:(CGFloat)maxWidth + pointScaleFactor:(CGFloat)pointScaleFactor; + +// YES when this quote is nested directly inside another quote. A nested quote +// carries no vertical margin (only the outermost quote does, applied by the root +// host), matching the commonmark path which applies blockquote margins only at +// depth 0. Set by the parent quote's child registry. +@property (nonatomic, assign) BOOL nested; + +// Bridged up to the JS onLinkPress/onLinkLongPress events; propagated to nested +// quotes so links at any depth fire. +@property (nonatomic, copy, nullable) ENRMBlockquoteLinkBlock onLinkPress; +@property (nonatomic, copy, nullable) ENRMBlockquoteLinkBlock onLinkLongPress; + +// Copy-menu titles and copy callback propagated to block children (code block, +// table, math) inside the quote, recursing into nested quotes. Renamed getters +// avoid the Cocoa `copy` method family, matching ENRMCodeBlockContainerView. +@property (nonatomic, copy, nullable, getter=menuCopyLabel) NSString *copyLabel; +@property (nonatomic, copy, nullable, getter=menuCopyAsMarkdownLabel) NSString *copyAsMarkdownLabel; +@property (nonatomic, copy, nullable) ENRMCodeBlockCopyBlock onCopyPress; + +// Re-applies the current copy labels and onCopyPress to already-created +// children when the labels change without a remount. +- (void)pushCopyLabelsToChildren; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.mm b/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.mm new file mode 100644 index 000000000..d4b9f40f6 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMBlockquoteContainerView.mm @@ -0,0 +1,302 @@ +#import "ENRMBlockquoteContainerView.h" +#import "ENRMCodeBlockContainerView.h" +#import "ENRMFeatureFlags.h" +#import "ENRMSegmentHeightMeasurer.h" +#import "ENRMTextInteractionUtils.h" +#import "ENRMTextRenderer.h" +#import "EnrichedMarkdownInternalText.h" +#import "MarkdownASTNode.h" +#import "SegmentRenderer.h" +#import "TableContainerView.h" +#if ENRICHED_MARKDOWN_MATH +#import "ENRMMathContainerView.h" +#endif + +// Inner content is rendered with trailing margins disabled and the default line +// break strategy; the quote's own vertical inset provides the bottom gap, and +// per-segment margins between children still apply (allowTrailingMargin only +// gates the last child's bottom margin). +static NSArray *ENRMRenderBlockquoteChildren(MarkdownASTNode *node, StyleConfig *config) +{ + return ENRMRenderSegmentsFromAST(node, config, /*allowTrailingMargin*/ NO, /*allowFontScaling*/ YES, + config.maxFontSizeMultiplier, NSLineBreakStrategyNone, /*blockquoteContent*/ YES); +} + +static UIEdgeInsets ENRMBlockquoteContentInsets(StyleConfig *config) +{ + CGFloat padding = config.blockquotePadding; + CGFloat left = ceil(config.blockquoteBorderWidth + config.blockquoteGapWidth + padding); + CGFloat vertical = ceil(padding); + CGFloat right = ceil(padding); + return UIEdgeInsetsMake(vertical, left, vertical, right); +} + +@implementation ENRMBlockquoteContainerView + +- (instancetype)initWithConfig:(StyleConfig *)config +{ + self = [super initWithFrame:CGRectZero]; + if (self) { + self.config = config; + self.contentInsets = ENRMBlockquoteContentInsets(config); + self.allowTrailingMargin = NO; +#if !TARGET_OS_OSX + self.contentMode = UIViewContentModeRedraw; +#endif + self.segmentViewRegistry = [self buildChildRegistry]; + } + return self; +} + +- (void)applyBlockquoteNode:(MarkdownASTNode *)node +{ + NSArray *rendered = ENRMRenderBlockquoteChildren(node, self.config); + [self applySegments:rendered reset:NO]; +} + +- (void)pushCopyLabelsToChildren +{ + for (RCTUIView *child in self.subviews) { + if ([child isKindOfClass:[ENRMCodeBlockContainerView class]]) { + ((ENRMCodeBlockContainerView *)child).copyLabel = self.copyLabel; + ((ENRMCodeBlockContainerView *)child).copyAsMarkdownLabel = self.copyAsMarkdownLabel; + } else if ([child isKindOfClass:[TableContainerView class]]) { + ((TableContainerView *)child).copyLabel = self.copyLabel; + ((TableContainerView *)child).copyAsMarkdownLabel = self.copyAsMarkdownLabel; + } else if ([child isKindOfClass:[ENRMBlockquoteContainerView class]]) { + ENRMBlockquoteContainerView *quote = (ENRMBlockquoteContainerView *)child; + quote.copyLabel = self.copyLabel; + quote.copyAsMarkdownLabel = self.copyAsMarkdownLabel; + [quote pushCopyLabelsToChildren]; + } +#if ENRICHED_MARKDOWN_MATH + else if ([child isKindOfClass:[ENRMMathContainerView class]]) { + ((ENRMMathContainerView *)child).copyLabel = self.copyLabel; + ((ENRMMathContainerView *)child).copyAsMarkdownLabel = self.copyAsMarkdownLabel; + } +#endif + } +} + +// Child registry for this quote's own content. It reuses static creators for +// every kind and, for a nested Blockquote, creates another +// ENRMBlockquoteContainerView (the recursion). Streaming is static: no +// animation, no pending-fence sync. +- (ENRMSegmentViewRegistry *)buildChildRegistry +{ + StyleConfig *config = self.config; + __weak ENRMBlockquoteContainerView *weakSelf = self; + NSMutableArray *handlers = [NSMutableArray array]; + + [handlers + addObject:[ENRMSegmentViewHandler handlerWithKind:ENRMSegmentKindText + matchesView:^BOOL(RCTUIView *view, ENRMRenderedSegment *segment) { + return [view isKindOfClass:[EnrichedMarkdownInternalText class]]; + } + createView:^RCTUIView *(ENRMRenderedSegment *segment) { + ENRMBlockquoteContainerView *strongSelf = weakSelf; + EnrichedMarkdownInternalText *view = [[EnrichedMarkdownInternalText alloc] initWithConfig:config]; + view.lastElementMarginBottom = segment.textResult.lastElementMarginBottom; + view.accessibilityInfo = segment.textResult.accessibilityInfo; + [view applyAttributedText:segment.textResult.attributedText context:segment.textResult.context]; + if (strongSelf) { + [strongSelf attachLinkTapToTextView:view]; + } + return view; + } + updateView:^(RCTUIView *view, ENRMRenderedSegment *segment) { + EnrichedMarkdownInternalText *textView = (EnrichedMarkdownInternalText *)view; + textView.lastElementMarginBottom = segment.textResult.lastElementMarginBottom; + textView.accessibilityInfo = segment.textResult.accessibilityInfo; + [textView applyAttributedText:segment.textResult.attributedText + context:segment.textResult.context]; + }]]; + + [handlers addObject:[ENRMSegmentViewHandler handlerWithKind:ENRMSegmentKindTable + matchesView:^BOOL(RCTUIView *view, ENRMRenderedSegment *segment) { + return [view isKindOfClass:[TableContainerView class]]; + } + createView:^RCTUIView *(ENRMRenderedSegment *segment) { + TableContainerView *view = [[TableContainerView alloc] initWithConfig:config]; + ENRMBlockquoteContainerView *strongSelf = weakSelf; + if (strongSelf) { + view.copyLabel = strongSelf.menuCopyLabel; + view.copyAsMarkdownLabel = strongSelf.menuCopyAsMarkdownLabel; + view.onLinkPress = ^(NSString *url) { + ENRMBlockquoteContainerView *s = weakSelf; + if (s.onLinkPress && url) + s.onLinkPress(url); + }; + view.onLinkLongPress = ^(NSString *url) { + ENRMBlockquoteContainerView *s = weakSelf; + if (s.onLinkLongPress && url) + s.onLinkLongPress(url); + }; + } + [view applyTableNode:segment.tableSegment.tableNode]; + return view; + } + updateView:^(RCTUIView *view, ENRMRenderedSegment *segment) { + [(TableContainerView *)view applyTableNode:segment.tableSegment.tableNode]; + }]]; + + [handlers + addObject:[ENRMSegmentViewHandler handlerWithKind:ENRMSegmentKindCodeBlock + matchesView:^BOOL(RCTUIView *view, ENRMRenderedSegment *segment) { + return [view isKindOfClass:[ENRMCodeBlockContainerView class]]; + } + createView:^RCTUIView *(ENRMRenderedSegment *segment) { + ENRMCodeBlockContainerView *view = [[ENRMCodeBlockContainerView alloc] initWithConfig:config]; + ENRMBlockquoteContainerView *strongSelf = weakSelf; + view.copyLabel = strongSelf.menuCopyLabel; + view.copyAsMarkdownLabel = strongSelf.menuCopyAsMarkdownLabel; + view.onCopyPress = ^(NSString *code, NSString *language) { + ENRMBlockquoteContainerView *s = weakSelf; + if (s.onCopyPress) + s.onCopyPress(code, language); + }; + [view applyCodeBlockNode:segment.codeBlockSegment.codeBlockNode]; + return view; + } + updateView:^(RCTUIView *view, ENRMRenderedSegment *segment) { + [(ENRMCodeBlockContainerView *)view applyCodeBlockNode:segment.codeBlockSegment.codeBlockNode]; + }]]; + + [handlers addObject:[ENRMSegmentViewHandler handlerWithKind:ENRMSegmentKindBlockquote + matchesView:^BOOL(RCTUIView *view, ENRMRenderedSegment *segment) { + return [view isKindOfClass:[ENRMBlockquoteContainerView class]]; + } + createView:^RCTUIView *(ENRMRenderedSegment *segment) { + ENRMBlockquoteContainerView *view = + [[ENRMBlockquoteContainerView alloc] initWithConfig:config]; + view.nested = YES; + ENRMBlockquoteContainerView *strongSelf = weakSelf; + if (strongSelf) { + view.copyLabel = strongSelf.menuCopyLabel; + view.copyAsMarkdownLabel = strongSelf.menuCopyAsMarkdownLabel; + view.onCopyPress = strongSelf.onCopyPress; + view.onLinkPress = ^(NSString *url) { + ENRMBlockquoteContainerView *s = weakSelf; + if (s.onLinkPress && url) + s.onLinkPress(url); + }; + view.onLinkLongPress = ^(NSString *url) { + ENRMBlockquoteContainerView *s = weakSelf; + if (s.onLinkLongPress && url) + s.onLinkLongPress(url); + }; + } + [view applyBlockquoteNode:segment.blockquoteSegment.blockquoteNode]; + return view; + } + updateView:^(RCTUIView *view, ENRMRenderedSegment *segment) { + [(ENRMBlockquoteContainerView *)view + applyBlockquoteNode:segment.blockquoteSegment.blockquoteNode]; + }]]; + +#if ENRICHED_MARKDOWN_MATH +#if !TARGET_OS_OSX + [handlers addObject:[ENRMSegmentViewHandler handlerWithKind:ENRMSegmentKindMath + matchesView:^BOOL(RCTUIView *view, ENRMRenderedSegment *segment) { + return [view isKindOfClass:[ENRMMathContainerView class]]; + } + createView:^RCTUIView *(ENRMRenderedSegment *segment) { + ENRMMathContainerView *view = [[ENRMMathContainerView alloc] initWithConfig:config]; + ENRMBlockquoteContainerView *strongSelf = weakSelf; + view.copyLabel = strongSelf.menuCopyLabel; + view.copyAsMarkdownLabel = strongSelf.menuCopyAsMarkdownLabel; + [view applyLatex:segment.mathSegment.latex]; + return view; + } + updateView:^(RCTUIView *view, ENRMRenderedSegment *segment) { + [(ENRMMathContainerView *)view applyLatex:segment.mathSegment.latex]; + }]]; +#endif +#endif + + return [[ENRMSegmentViewRegistry alloc] initWithHandlers:handlers]; +} + +- (void)attachLinkTapToTextView:(EnrichedMarkdownInternalText *)view +{ + ENRMTapRecognizer *tap = [[ENRMTapRecognizer alloc] initWithTarget:self action:@selector(handleTextTap:)]; + [view.textView addGestureRecognizer:tap]; +} + +- (void)handleTextTap:(ENRMTapRecognizer *)recognizer +{ + ENRMPlatformTextView *textView = (ENRMPlatformTextView *)recognizer.view; + __weak ENRMBlockquoteContainerView *weakSelf = self; + ENRMHandleTapOnTextView(textView, recognizer, ^(NSString *url) { + ENRMBlockquoteContainerView *strongSelf = weakSelf; + if (strongSelf.onLinkPress && url) + strongSelf.onLinkPress(url); + }); +} + +- (CGFloat)measureHeight:(CGFloat)maxWidth +{ + return [self computeContentHeightForWidth:MAX(maxWidth - self.contentInsets.left - self.contentInsets.right, 0)]; +} + ++ (CGFloat)measureHeightForBlockquoteNode:(MarkdownASTNode *)node + config:(StyleConfig *)config + maxWidth:(CGFloat)maxWidth + pointScaleFactor:(CGFloat)pointScaleFactor +{ + UIEdgeInsets insets = ENRMBlockquoteContentInsets(config); + CGFloat innerWidth = MAX(maxWidth - insets.left - insets.right, 1); + + NSArray *rendered = ENRMRenderBlockquoteChildren(node, config); + CGFloat childrenHeight = + ENRMMeasureSegmentsHeightViewFree(rendered, config, innerWidth, /*allowTrailingMargin*/ NO, pointScaleFactor, + ENRMWritingDirectionModeFirstStrong, NSWritingDirectionNatural); + + return childrenHeight + insets.top + insets.bottom; +} + +- (void)drawRect:(CGRect)rect +{ + StyleConfig *config = self.config; + CGFloat borderWidth = config.blockquoteBorderWidth; + CGFloat radius = config.blockquoteBorderRadius; + CGRect bounds = self.bounds; + + // The rounded (or plain) box outline, shared by the background fill and the accent + // bar's clip so the bar's left corners follow the border radius. +#if !TARGET_OS_OSX + UIBezierPath *boxPath = radius > 0 ? [UIBezierPath bezierPathWithRoundedRect:bounds cornerRadius:radius] + : [UIBezierPath bezierPathWithRect:bounds]; +#else + NSBezierPath *boxPath = radius > 0 ? [NSBezierPath bezierPathWithRoundedRect:bounds xRadius:radius yRadius:radius] + : [NSBezierPath bezierPathWithRect:bounds]; +#endif + + RCTUIColor *backgroundColor = config.blockquoteBackgroundColor; + if (backgroundColor && backgroundColor != [RCTUIColor clearColor]) { + [backgroundColor setFill]; + [boxPath fill]; + } + + if (borderWidth > 0) { + RCTUIColor *borderColor = config.blockquoteBorderColor; + if (borderColor) { + CGRect barRect = CGRectMake(0, 0, borderWidth, bounds.size.height); + [borderColor setFill]; +#if !TARGET_OS_OSX + CGContextRef ctx = UIGraphicsGetCurrentContext(); + CGContextSaveGState(ctx); + [boxPath addClip]; + [[UIBezierPath bezierPathWithRect:barRect] fill]; + CGContextRestoreGState(ctx); +#else + [NSGraphicsContext saveGraphicsState]; + [boxPath addClip]; + [[NSBezierPath bezierPathWithRect:barRect] fill]; + [NSGraphicsContext restoreGraphicsState]; +#endif + } + } +} + +@end diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.h b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h similarity index 100% rename from packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.h rename to packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.h diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.m b/packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m similarity index 100% rename from packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.m rename to packages/react-native-enriched-markdown/ios/segments/ENRMCodeBlockContainerView.m diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMContainerNodeView.h b/packages/react-native-enriched-markdown/ios/segments/ENRMContainerNodeView.h new file mode 100644 index 000000000..41e522d05 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMContainerNodeView.h @@ -0,0 +1,52 @@ +#pragma once + +#import "ENRMUIKit.h" +#import "RenderedMarkdownSegment.h" +#import "SegmentViewRegistry.h" +#import "StyleConfig.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * Reusable host view for an AST branch node that holds a vertical stack of block + * children (currently every blockquote; the analogue of Android's + * ContainerNodeView). It owns the machinery shared by such hosts: reconciling a + * list of ENRMRenderedSegment into real child views by signature via + * ENRMSegmentReconciler + a per-kind ENRMSegmentViewRegistry the subclass + * supplies, stacking them vertically with per-segment margins, and reporting the + * total height (including its own content insets). + * + * Layout honors contentInsets so a subclass can inset its children (e.g. a + * blockquote's border/gap/padding): children are laid out at width minus the + * horizontal insets, starting at (insets.left, insets.top). Per-kind margins are + * read from the config exactly as the root view's layout applies them. + * + * Streaming inside a container is treated as static: no fade/tail animation and + * no pending-code-fence sync. A content change re-signs the whole subtree, so + * the outer reconciler still reuses the container view by signature. + */ +@interface ENRMContainerNodeView : RCTUIView + +@property (nonatomic, strong) StyleConfig *config; + +// Set by a subclass before applySegments:reset:. The reconcile/layout loop is +// identical across hosts; only the child view construction varies per registry. +@property (nonatomic, strong) ENRMSegmentViewRegistry *segmentViewRegistry; + +// Inset applied to all children. A plain container leaves this zero. +@property (nonatomic, assign) UIEdgeInsets contentInsets; + +// Whether the last child contributes its trailing bottom margin. +@property (nonatomic, assign) BOOL allowTrailingMargin; + +// Reconciles the current child views against renderedSegments, attaching and +// removing subviews as needed. Requests a re-layout. +- (void)applySegments:(NSArray *)renderedSegments reset:(BOOL)reset; + +// Total laid-out height for the current children at contentWidth (inner width, +// i.e. bounds width minus horizontal insets), including the vertical insets. +- (CGFloat)computeContentHeightForWidth:(CGFloat)contentWidth; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMContainerNodeView.mm b/packages/react-native-enriched-markdown/ios/segments/ENRMContainerNodeView.mm new file mode 100644 index 000000000..53fc6f7d9 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMContainerNodeView.mm @@ -0,0 +1,183 @@ +#import "ENRMContainerNodeView.h" +#import "ENRMBlockquoteContainerView.h" +#import "ENRMCodeBlockContainerView.h" +#import "ENRMFeatureFlags.h" +#import "EnrichedMarkdownInternalText.h" +#import "SegmentReconciler.h" +#import "TableContainerView.h" +#if ENRICHED_MARKDOWN_MATH +#import "ENRMMathContainerView.h" +#endif + +@implementation ENRMContainerNodeView { + NSMutableArray *_segmentViews; + NSMutableArray *_segmentSignatures; +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + _segmentViews = [NSMutableArray array]; + _segmentSignatures = [NSMutableArray array]; + _contentInsets = UIEdgeInsetsZero; + _allowTrailingMargin = NO; + self.backgroundColor = [RCTUIColor clearColor]; +#if !TARGET_OS_OSX + self.contentMode = UIViewContentModeRedraw; +#endif + } + return self; +} + +#if TARGET_OS_OSX +- (BOOL)isFlipped +{ + return YES; +} +#endif + +- (void)applySegments:(NSArray *)renderedSegments reset:(BOOL)reset +{ + ENRMSegmentViewRegistry *registry = _segmentViewRegistry; + ENRMSegmentReconciliationResult *result = [ENRMSegmentReconciler reconcileCurrentViews:_segmentViews + currentSignatures:_segmentSignatures + renderedSegments:renderedSegments + reset:reset + createView:^RCTUIView *(ENRMRenderedSegment *segment) { return [registry createViewForSegment:segment]; } + updateView:^(RCTUIView *view, ENRMRenderedSegment *segment) { [registry updateView:view withSegment:segment]; } + attachView:^(RCTUIView *view) { [self addSubview:view]; } + removeView:^(RCTUIView *view) { [view removeFromSuperview]; } + matchesKind:^BOOL(RCTUIView *view, ENRMRenderedSegment *segment) { + return [registry view:view matchesSegment:segment]; + }]; + + _segmentViews = result.views; + _segmentSignatures = result.signatures; + +#if !TARGET_OS_OSX + [self setNeedsLayout]; +#else + self.needsLayout = YES; +#endif +} + +- (CGFloat)marginTopForView:(RCTUIView *)view +{ + if ([view isKindOfClass:[TableContainerView class]]) { + return _config.tableMarginTop; + } + if ([view isKindOfClass:[ENRMCodeBlockContainerView class]]) { + return _config.codeBlockMarginTop; + } + if ([view isKindOfClass:[ENRMBlockquoteContainerView class]]) { + // A quote laid out inside another quote is nested and carries no margin; the + // outermost quote's margin is applied by the root host instead. + return ((ENRMBlockquoteContainerView *)view).nested ? 0 : _config.blockquoteMarginTop; + } +#if ENRICHED_MARKDOWN_MATH + if ([view isKindOfClass:[ENRMMathContainerView class]]) { + return _config.mathMarginTop; + } +#endif + return 0; +} + +- (CGFloat)marginBottomForView:(RCTUIView *)view +{ + if ([view isKindOfClass:[TableContainerView class]]) { + return _config.tableMarginBottom; + } + if ([view isKindOfClass:[ENRMCodeBlockContainerView class]]) { + return _config.codeBlockMarginBottom; + } + if ([view isKindOfClass:[ENRMBlockquoteContainerView class]]) { + return ((ENRMBlockquoteContainerView *)view).nested ? 0 : _config.blockquoteMarginBottom; + } +#if ENRICHED_MARKDOWN_MATH + if ([view isKindOfClass:[ENRMMathContainerView class]]) { + return _config.mathMarginBottom; + } +#endif + return 0; +} + +- (CGFloat)heightForView:(RCTUIView *)view atWidth:(CGFloat)width shouldAddBottomMargin:(BOOL)shouldAddBottomMargin +{ + if ([view isKindOfClass:[EnrichedMarkdownInternalText class]]) { + EnrichedMarkdownInternalText *textView = (EnrichedMarkdownInternalText *)view; + textView.allowTrailingMargin = shouldAddBottomMargin; + return [textView measureSize:width].height; + } + if ([view isKindOfClass:[TableContainerView class]]) { + return [(TableContainerView *)view measureHeight:width]; + } + if ([view isKindOfClass:[ENRMCodeBlockContainerView class]]) { + return [(ENRMCodeBlockContainerView *)view measureHeight:width]; + } + if ([view isKindOfClass:[ENRMBlockquoteContainerView class]]) { + return [(ENRMBlockquoteContainerView *)view measureHeight:width]; + } +#if ENRICHED_MARKDOWN_MATH + if ([view isKindOfClass:[ENRMMathContainerView class]]) { + return [(ENRMMathContainerView *)view measureHeight:width]; + } +#endif + return 0; +} + +// Mirrors the root's computeSegmentLayoutForWidth: (text via measureSize:, +// block kinds via measureHeight: plus their config margins), but confined to the +// inner content rect defined by contentInsets so a subclass can pad its children. +- (CGSize)layoutChildrenForOuterWidth:(CGFloat)outerWidth applyFrames:(BOOL)applyFrames +{ + CGFloat contentWidth = MAX(outerWidth - _contentInsets.left - _contentInsets.right, 0); + if (_segmentViews.count == 0 || contentWidth <= 0) { + return CGSizeMake(0, _contentInsets.top + _contentInsets.bottom); + } + + CGFloat yOffset = _contentInsets.top; + const NSUInteger lastIndex = _segmentViews.count - 1; + + for (NSUInteger i = 0; i < _segmentViews.count; i++) { + RCTUIView *view = _segmentViews[i]; + const BOOL isLast = (i == lastIndex); + const BOOL shouldAddBottomMargin = (!isLast || _allowTrailingMargin); + + yOffset += [self marginTopForView:view]; + CGFloat height = [self heightForView:view atWidth:contentWidth shouldAddBottomMargin:shouldAddBottomMargin]; + + if (applyFrames) { + view.frame = CGRectMake(_contentInsets.left, yOffset, contentWidth, height); + } + + yOffset += height; + if (shouldAddBottomMargin) { + yOffset += [self marginBottomForView:view]; + } + } + + yOffset += _contentInsets.bottom; + return CGSizeMake(outerWidth, yOffset); +} + +- (CGFloat)computeContentHeightForWidth:(CGFloat)contentWidth +{ + return [self layoutChildrenForOuterWidth:contentWidth + _contentInsets.left + _contentInsets.right applyFrames:NO] + .height; +} + +#if !TARGET_OS_OSX +- (void)layoutSubviews +{ + [super layoutSubviews]; + [self layoutChildrenForOuterWidth:self.bounds.size.width applyFrames:YES]; +} +#else +- (void)layout +{ + [super layout]; + [self layoutChildrenForOuterWidth:self.bounds.size.width applyFrames:YES]; +} +#endif + +@end diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.h b/packages/react-native-enriched-markdown/ios/segments/ENRMMathContainerView.h similarity index 100% rename from packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.h rename to packages/react-native-enriched-markdown/ios/segments/ENRMMathContainerView.h diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.m b/packages/react-native-enriched-markdown/ios/segments/ENRMMathContainerView.m similarity index 100% rename from packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.m rename to packages/react-native-enriched-markdown/ios/segments/ENRMMathContainerView.m diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMSegmentHeightMeasurer.h b/packages/react-native-enriched-markdown/ios/segments/ENRMSegmentHeightMeasurer.h new file mode 100644 index 000000000..3781506e1 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMSegmentHeightMeasurer.h @@ -0,0 +1,32 @@ +#pragma once + +#import "ENRMUIKit.h" +#import "ParagraphStyleUtils.h" +#import "RenderedMarkdownSegment.h" +#import "StyleConfig.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * View-free height summation for a vertical stack of already-rendered + * segments at a fixed content width. This is the iOS counterpart of Android's + * SegmentHeightMeasurer: it single-sources the per-segment height arithmetic so + * the shadow-node measurement pass (ENRMViewFreeMeasurement) and the blockquote + * container's own laid-out/static heights cannot drift. + * + * Each kind is measured with the same helper the visible view uses: text via a + * fresh call-confined TextKit stack (ENRMMeasureAttributedTextViewFree), tables + * via +[TableContainerView measureHeightForTableNode:...], code blocks via + * +[ENRMCodeBlockContainerView measureHeightForCodeBlockNode:...], math via + * +[ENRMMathContainerView measureHeightForLatex:...], and nested blockquotes via + * +[ENRMBlockquoteContainerView measureHeightForBlockquoteNode:...] (recursion). + * Per-kind margins come from the config exactly as computeSegmentLayout applies + * them: marginTop before, marginBottom after unless it is the last segment and + * trailing margins are disabled. + */ +CGFloat ENRMMeasureSegmentsHeightViewFree(NSArray *segments, StyleConfig *config, + CGFloat contentWidth, BOOL allowTrailingMargin, CGFloat pointScaleFactor, + ENRMWritingDirectionMode writingDirectionMode, + NSWritingDirection resolvedLayoutDirection); + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/segments/ENRMSegmentHeightMeasurer.mm b/packages/react-native-enriched-markdown/ios/segments/ENRMSegmentHeightMeasurer.mm new file mode 100644 index 000000000..11910bce6 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/segments/ENRMSegmentHeightMeasurer.mm @@ -0,0 +1,76 @@ +#import "ENRMSegmentHeightMeasurer.h" +#import "ENRMBlockquoteContainerView.h" +#import "ENRMCodeBlockContainerView.h" +#import "ENRMFeatureFlags.h" +#import "ENRMTextRenderer.h" +#import "ENRMViewFreeMeasurement.h" +#import "TableContainerView.h" +#if ENRICHED_MARKDOWN_MATH +#import "ENRMMathContainerView.h" +#endif + +CGFloat ENRMMeasureSegmentsHeightViewFree(NSArray *segments, StyleConfig *config, + CGFloat contentWidth, BOOL allowTrailingMargin, CGFloat pointScaleFactor, + ENRMWritingDirectionMode writingDirectionMode, + NSWritingDirection resolvedLayoutDirection) +{ + if (segments.count == 0) { + return 0; + } + + CGFloat totalHeight = 0; + const NSUInteger lastIndex = segments.count - 1; + + for (NSUInteger i = 0; i < segments.count; i++) { + ENRMRenderedSegment *segment = segments[i]; + const BOOL isLast = (i == lastIndex); + const BOOL shouldAddBottomMargin = (!isLast || allowTrailingMargin); + + if (segment.kind == ENRMSegmentKindText && segment.textResult) { + CGSize textSize = ENRMMeasureAttributedTextViewFree(segment.textResult.attributedText, contentWidth, config, + shouldAddBottomMargin, + segment.textResult.lastElementMarginBottom, pointScaleFactor); + totalHeight += textSize.height; + } else if (segment.kind == ENRMSegmentKindTable && segment.tableSegment) { + totalHeight += config.tableMarginTop; + totalHeight += [TableContainerView measureHeightForTableNode:segment.tableSegment.tableNode + config:config + allowFontScaling:YES + maxFontSizeMultiplier:config.maxFontSizeMultiplier + writingDirectionMode:writingDirectionMode + resolvedLayoutDirection:resolvedLayoutDirection]; + if (shouldAddBottomMargin) { + totalHeight += config.tableMarginBottom; + } + } else if (segment.kind == ENRMSegmentKindCodeBlock && segment.codeBlockSegment) { + totalHeight += config.codeBlockMarginTop; + totalHeight += [ENRMCodeBlockContainerView measureHeightForCodeBlockNode:segment.codeBlockSegment.codeBlockNode + config:config]; + if (shouldAddBottomMargin) { + totalHeight += config.codeBlockMarginBottom; + } + } else if (segment.kind == ENRMSegmentKindBlockquote && segment.blockquoteSegment) { + // This helper only ever sums a quote's own children, so any quote here is + // nested and carries no vertical margin (only the outermost quote does, + // added by the root measurement pass); mirrors ENRMBlockquoteContainerView.nested. + totalHeight += + [ENRMBlockquoteContainerView measureHeightForBlockquoteNode:segment.blockquoteSegment.blockquoteNode + config:config + maxWidth:contentWidth + pointScaleFactor:pointScaleFactor]; + } +#if ENRICHED_MARKDOWN_MATH + else if (segment.kind == ENRMSegmentKindMath && segment.mathSegment) { + totalHeight += config.mathMarginTop; + totalHeight += [ENRMMathContainerView measureHeightForLatex:segment.mathSegment.latex + config:config + maxWidth:contentWidth]; + if (shouldAddBottomMargin) { + totalHeight += config.mathMarginBottom; + } + } +#endif + } + + return totalHeight; +} diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMTableGridView.h b/packages/react-native-enriched-markdown/ios/segments/ENRMTableGridView.h similarity index 100% rename from packages/react-native-enriched-markdown/ios/views/ENRMTableGridView.h rename to packages/react-native-enriched-markdown/ios/segments/ENRMTableGridView.h diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMTableGridView.m b/packages/react-native-enriched-markdown/ios/segments/ENRMTableGridView.m similarity index 100% rename from packages/react-native-enriched-markdown/ios/views/ENRMTableGridView.m rename to packages/react-native-enriched-markdown/ios/segments/ENRMTableGridView.m diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMTableIOSGridView.h b/packages/react-native-enriched-markdown/ios/segments/ENRMTableIOSGridView.h similarity index 100% rename from packages/react-native-enriched-markdown/ios/views/ENRMTableIOSGridView.h rename to packages/react-native-enriched-markdown/ios/segments/ENRMTableIOSGridView.h diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMTableIOSGridView.m b/packages/react-native-enriched-markdown/ios/segments/ENRMTableIOSGridView.m similarity index 100% rename from packages/react-native-enriched-markdown/ios/views/ENRMTableIOSGridView.m rename to packages/react-native-enriched-markdown/ios/segments/ENRMTableIOSGridView.m diff --git a/packages/react-native-enriched-markdown/ios/utils/RenderedMarkdownSegment.h b/packages/react-native-enriched-markdown/ios/segments/RenderedMarkdownSegment.h similarity index 82% rename from packages/react-native-enriched-markdown/ios/utils/RenderedMarkdownSegment.h rename to packages/react-native-enriched-markdown/ios/segments/RenderedMarkdownSegment.h index 84f474172..3193566ac 100644 --- a/packages/react-native-enriched-markdown/ios/utils/RenderedMarkdownSegment.h +++ b/packages/react-native-enriched-markdown/ios/segments/RenderedMarkdownSegment.h @@ -11,7 +11,8 @@ typedef NS_ENUM(NSInteger, ENRMSegmentKind) { ENRMSegmentKindText, ENRMSegmentKindTable, ENRMSegmentKindMath, - ENRMSegmentKindCodeBlock + ENRMSegmentKindCodeBlock, + ENRMSegmentKindBlockquote }; @interface ENRMTextSegment : NSObject @@ -34,6 +35,11 @@ typedef NS_ENUM(NSInteger, ENRMSegmentKind) { + (instancetype)segmentWithCodeBlockNode:(MarkdownASTNode *)node; @end +@interface ENRMBlockquoteSegment : NSObject +@property (nonatomic, strong) MarkdownASTNode *blockquoteNode; ++ (instancetype)segmentWithBlockquoteNode:(MarkdownASTNode *)node; +@end + @interface ENRMRenderedSegment : NSObject @property (nonatomic, assign) ENRMSegmentKind kind; @property (nonatomic, assign) uint64_t signature; @@ -41,10 +47,12 @@ typedef NS_ENUM(NSInteger, ENRMSegmentKind) { @property (nonatomic, strong, nullable) ENRMTableSegment *tableSegment; @property (nonatomic, strong, nullable) ENRMMathSegment *mathSegment; @property (nonatomic, strong, nullable) ENRMCodeBlockSegment *codeBlockSegment; +@property (nonatomic, strong, nullable) ENRMBlockquoteSegment *blockquoteSegment; + (instancetype)textSegmentWithResult:(ENRMRenderResult *)result signature:(uint64_t)signature; + (instancetype)tableSegmentWithSegment:(ENRMTableSegment *)segment signature:(uint64_t)signature; + (instancetype)mathSegmentWithSegment:(ENRMMathSegment *)segment signature:(uint64_t)signature; + (instancetype)codeBlockSegmentWithSegment:(ENRMCodeBlockSegment *)segment signature:(uint64_t)signature; ++ (instancetype)blockquoteSegmentWithSegment:(ENRMBlockquoteSegment *)segment signature:(uint64_t)signature; @end #ifdef __cplusplus diff --git a/packages/react-native-enriched-markdown/ios/utils/RenderedMarkdownSegment.m b/packages/react-native-enriched-markdown/ios/segments/RenderedMarkdownSegment.m similarity index 86% rename from packages/react-native-enriched-markdown/ios/utils/RenderedMarkdownSegment.m rename to packages/react-native-enriched-markdown/ios/segments/RenderedMarkdownSegment.m index f2a0a5197..27663c08b 100644 --- a/packages/react-native-enriched-markdown/ios/utils/RenderedMarkdownSegment.m +++ b/packages/react-native-enriched-markdown/ios/segments/RenderedMarkdownSegment.m @@ -41,6 +41,16 @@ + (instancetype)segmentWithCodeBlockNode:(MarkdownASTNode *)node } @end +@implementation ENRMBlockquoteSegment ++ (instancetype)segmentWithBlockquoteNode:(MarkdownASTNode *)node +{ + NSParameterAssert(node != nil); + ENRMBlockquoteSegment *segment = [[ENRMBlockquoteSegment alloc] init]; + segment.blockquoteNode = node; + return segment; +} +@end + @implementation ENRMRenderedSegment + (instancetype)textSegmentWithResult:(ENRMRenderResult *)result signature:(uint64_t)signature { @@ -81,6 +91,16 @@ + (instancetype)codeBlockSegmentWithSegment:(ENRMCodeBlockSegment *)codeBlockSeg segment.signature = signature; return segment; } + ++ (instancetype)blockquoteSegmentWithSegment:(ENRMBlockquoteSegment *)blockquoteSegment signature:(uint64_t)signature +{ + NSParameterAssert(blockquoteSegment != nil); + ENRMRenderedSegment *segment = [[ENRMRenderedSegment alloc] init]; + segment.kind = ENRMSegmentKindBlockquote; + segment.blockquoteSegment = blockquoteSegment; + segment.signature = signature; + return segment; +} @end // FNV-1a 64-bit hash for segment signatures. Collisions are theoretically diff --git a/packages/react-native-enriched-markdown/ios/utils/SegmentReconciler.h b/packages/react-native-enriched-markdown/ios/segments/SegmentReconciler.h similarity index 100% rename from packages/react-native-enriched-markdown/ios/utils/SegmentReconciler.h rename to packages/react-native-enriched-markdown/ios/segments/SegmentReconciler.h diff --git a/packages/react-native-enriched-markdown/ios/utils/SegmentReconciler.m b/packages/react-native-enriched-markdown/ios/segments/SegmentReconciler.m similarity index 100% rename from packages/react-native-enriched-markdown/ios/utils/SegmentReconciler.m rename to packages/react-native-enriched-markdown/ios/segments/SegmentReconciler.m diff --git a/packages/react-native-enriched-markdown/ios/segments/SegmentRenderer.h b/packages/react-native-enriched-markdown/ios/segments/SegmentRenderer.h new file mode 100644 index 000000000..15831911d --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/segments/SegmentRenderer.h @@ -0,0 +1,26 @@ +#pragma once + +#import + +@class ENRMRenderedSegment; +@class MarkdownASTNode; +@class StyleConfig; + +NS_ASSUME_NONNULL_BEGIN + +#ifdef __cplusplus +extern "C" { +#endif + +// blockquoteContent = YES when splitting a blockquote's own children (called by +// ENRMBlockquoteContainerView): text segments are then rendered with blockquote styling +// (tight paragraphs, quote font/color/line height) instead of paragraph styling. +NSArray * +ENRMRenderSegmentsFromAST(MarkdownASTNode *ast, StyleConfig *config, BOOL allowTrailingMargin, BOOL allowFontScaling, + CGFloat maxFontSizeMultiplier, NSLineBreakStrategy lineBreakStrategy, BOOL blockquoteContent); + +#ifdef __cplusplus +} +#endif + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/utils/SegmentRenderer.m b/packages/react-native-enriched-markdown/ios/segments/SegmentRenderer.m similarity index 74% rename from packages/react-native-enriched-markdown/ios/utils/SegmentRenderer.m rename to packages/react-native-enriched-markdown/ios/segments/SegmentRenderer.m index 64defe004..06f100e8d 100644 --- a/packages/react-native-enriched-markdown/ios/utils/SegmentRenderer.m +++ b/packages/react-native-enriched-markdown/ios/segments/SegmentRenderer.m @@ -39,6 +39,12 @@ [currentTextNodes removeAllObjects]; } [segments addObject:[ENRMCodeBlockSegment segmentWithCodeBlockNode:child]]; + } else if (child.type == MarkdownNodeTypeBlockquote) { + if (currentTextNodes.count > 0) { + [segments addObject:[ENRMTextSegment segmentWithNodes:[currentTextNodes copy]]]; + [currentTextNodes removeAllObjects]; + } + [segments addObject:[ENRMBlockquoteSegment segmentWithBlockquoteNode:child]]; } else { [currentTextNodes addObject:child]; } @@ -54,21 +60,25 @@ NSArray *ENRMRenderSegmentsFromAST(MarkdownASTNode *ast, StyleConfig *config, BOOL allowTrailingMargin, BOOL allowFontScaling, CGFloat maxFontSizeMultiplier, - NSLineBreakStrategy lineBreakStrategy) + NSLineBreakStrategy lineBreakStrategy, BOOL blockquoteContent) { NSArray *segments = ENRMSplitASTIntoSegments(ast); NSMutableArray *renderedSegments = [NSMutableArray array]; - static const uint64_t kTextKindSalt = 0x7465787400000000ULL; // "text" - static const uint64_t kTableKindSalt = 0x7461626C00000000ULL; // "tabl" - static const uint64_t kMathKindSalt = 0x6D61746800000000ULL; // "math" - static const uint64_t kCodeBlockKindSalt = 0x63626C6B00000000ULL; // "cblk" + static const uint64_t kTextKindSalt = 0x7465787400000000ULL; // "text" + static const uint64_t kTableKindSalt = 0x7461626C00000000ULL; // "tabl" + static const uint64_t kMathKindSalt = 0x6D61746800000000ULL; // "math" + static const uint64_t kCodeBlockKindSalt = 0x63626C6B00000000ULL; // "cblk" + static const uint64_t kBlockquoteKindSalt = 0x6271746500000000ULL; // "bqte" for (id segment in segments) { if ([segment isKindOfClass:[ENRMTextSegment class]]) { ENRMTextSegment *textSegment = (ENRMTextSegment *)segment; - ENRMRenderResult *rendered = ENRMRenderASTNodes(textSegment.nodes, config, allowTrailingMargin, allowFontScaling, - maxFontSizeMultiplier, lineBreakStrategy); + ENRMRenderResult *rendered = blockquoteContent + ? ENRMRenderBlockquoteContentNodes(textSegment.nodes, config, allowFontScaling, + maxFontSizeMultiplier, lineBreakStrategy) + : ENRMRenderASTNodes(textSegment.nodes, config, allowTrailingMargin, + allowFontScaling, maxFontSizeMultiplier, lineBreakStrategy); uint64_t signature = ENRMSignatureForNodes(textSegment.nodes) ^ kTextKindSalt; [renderedSegments addObject:[ENRMRenderedSegment textSegmentWithResult:rendered signature:signature]]; } else if ([segment isKindOfClass:[ENRMTableSegment class]]) { @@ -94,6 +104,11 @@ uint64_t signature = ENRMSignatureForNode(codeBlockSegment.codeBlockNode) ^ kCodeBlockKindSalt; [renderedSegments addObject:[ENRMRenderedSegment codeBlockSegmentWithSegment:codeBlockSegment signature:signature]]; + } else if ([segment isKindOfClass:[ENRMBlockquoteSegment class]]) { + ENRMBlockquoteSegment *blockquoteSegment = (ENRMBlockquoteSegment *)segment; + uint64_t signature = ENRMSignatureForNode(blockquoteSegment.blockquoteNode) ^ kBlockquoteKindSalt; + [renderedSegments addObject:[ENRMRenderedSegment blockquoteSegmentWithSegment:blockquoteSegment + signature:signature]]; } } diff --git a/packages/react-native-enriched-markdown/ios/utils/SegmentViewRegistry.h b/packages/react-native-enriched-markdown/ios/segments/SegmentViewRegistry.h similarity index 100% rename from packages/react-native-enriched-markdown/ios/utils/SegmentViewRegistry.h rename to packages/react-native-enriched-markdown/ios/segments/SegmentViewRegistry.h diff --git a/packages/react-native-enriched-markdown/ios/utils/SegmentViewRegistry.m b/packages/react-native-enriched-markdown/ios/segments/SegmentViewRegistry.m similarity index 100% rename from packages/react-native-enriched-markdown/ios/utils/SegmentViewRegistry.m rename to packages/react-native-enriched-markdown/ios/segments/SegmentViewRegistry.m diff --git a/packages/react-native-enriched-markdown/ios/views/TableContainerView.h b/packages/react-native-enriched-markdown/ios/segments/TableContainerView.h similarity index 100% rename from packages/react-native-enriched-markdown/ios/views/TableContainerView.h rename to packages/react-native-enriched-markdown/ios/segments/TableContainerView.h diff --git a/packages/react-native-enriched-markdown/ios/views/TableContainerView.m b/packages/react-native-enriched-markdown/ios/segments/TableContainerView.m similarity index 98% rename from packages/react-native-enriched-markdown/ios/views/TableContainerView.m rename to packages/react-native-enriched-markdown/ios/segments/TableContainerView.m index b76936d86..326e240b8 100644 --- a/packages/react-native-enriched-markdown/ios/views/TableContainerView.m +++ b/packages/react-native-enriched-markdown/ios/segments/TableContainerView.m @@ -77,17 +77,12 @@ static NSTextAlignment ENRMTableTextAlignmentFromString(NSString *align) ENRMWritingDirectionMode writingDirectionMode, NSWritingDirection resolvedLayoutDirection) { - MarkdownASTNode *temporaryRoot = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeDocument]; - for (MarkdownASTNode *child in cellNode.children) { - [temporaryRoot addChild:child]; - } - AttributedRenderer *renderer = [[AttributedRenderer alloc] initWithConfig:cellConfig]; RenderContext *context = [RenderContext new]; context.allowFontScaling = allowFontScaling; context.maxFontSizeMultiplier = maxFontSizeMultiplier; - NSMutableAttributedString *attributedText = [renderer renderRoot:temporaryRoot context:context]; + NSMutableAttributedString *attributedText = [renderer renderNodes:cellNode.children context:context block:nil]; [context applyLinkAttributesToString:attributedText]; diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMTextRenderer.h b/packages/react-native-enriched-markdown/ios/utils/ENRMTextRenderer.h index 092876f89..6b426af76 100644 --- a/packages/react-native-enriched-markdown/ios/utils/ENRMTextRenderer.h +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMTextRenderer.h @@ -23,6 +23,14 @@ ENRMRenderResult *ENRMRenderASTNodes(NSArray *nodes, StyleCon BOOL allowFontScaling, CGFloat maxFontSizeMultiplier, NSLineBreakStrategy lineBreakStrategy); +// Renders a blockquote's own text content: like ENRMRenderASTNodes but with a blockquote baseline +// (tight paragraphs, quote font/color) and the quote's line height applied over the result. Draws +// no box - the ENRMBlockquoteContainerView draws the border/background/padding. Mirrors Android's +// BlockquoteTextRenderer. +ENRMRenderResult *ENRMRenderBlockquoteContentNodes(NSArray *nodes, StyleConfig *config, + BOOL allowFontScaling, CGFloat maxFontSizeMultiplier, + NSLineBreakStrategy lineBreakStrategy); + #ifdef __cplusplus } #endif diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMTextRenderer.m b/packages/react-native-enriched-markdown/ios/utils/ENRMTextRenderer.m index 8e7a26866..9ab10cf02 100644 --- a/packages/react-native-enriched-markdown/ios/utils/ENRMTextRenderer.m +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMTextRenderer.m @@ -1,7 +1,7 @@ #import "ENRMTextRenderer.h" #import "AccessibilityInfo.h" #import "AttributedRenderer.h" -#import "MarkdownASTNode.h" +#import "ENRMBlockquoteTextRenderer.h" #import "ParagraphStyleUtils.h" #import "RenderContext.h" #import "StyleConfig.h" @@ -9,15 +9,11 @@ @implementation ENRMRenderResult @end -ENRMRenderResult *ENRMRenderASTNodes(NSArray *nodes, StyleConfig *config, BOOL allowTrailingMargin, - BOOL allowFontScaling, CGFloat maxFontSizeMultiplier, - NSLineBreakStrategy lineBreakStrategy) +static ENRMRenderResult *ENRMRenderASTNodesCore(NSArray *nodes, StyleConfig *config, + BOOL allowTrailingMargin, BOOL allowFontScaling, + CGFloat maxFontSizeMultiplier, NSLineBreakStrategy lineBreakStrategy, + ENRMBlockquoteTextRenderer *block) { - MarkdownASTNode *root = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeDocument]; - for (MarkdownASTNode *node in nodes) { - [root addChild:node]; - } - AttributedRenderer *renderer = [[AttributedRenderer alloc] initWithConfig:config]; [renderer setAllowTrailingMargin:allowTrailingMargin]; @@ -25,7 +21,8 @@ @implementation ENRMRenderResult context.allowFontScaling = allowFontScaling; context.maxFontSizeMultiplier = maxFontSizeMultiplier; - NSMutableAttributedString *attributedText = [renderer renderRoot:root context:context]; + NSMutableAttributedString *attributedText = [renderer renderNodes:nodes context:context block:block]; + [context applyLinkAttributesToString:attributedText]; ENRMApplyLineBreakStrategyToParagraphStyles(attributedText, lineBreakStrategy); @@ -36,3 +33,20 @@ @implementation ENRMRenderResult result.lastElementMarginBottom = [renderer getLastElementMarginBottom]; return result; } + +ENRMRenderResult *ENRMRenderASTNodes(NSArray *nodes, StyleConfig *config, BOOL allowTrailingMargin, + BOOL allowFontScaling, CGFloat maxFontSizeMultiplier, + NSLineBreakStrategy lineBreakStrategy) +{ + return ENRMRenderASTNodesCore(nodes, config, allowTrailingMargin, allowFontScaling, maxFontSizeMultiplier, + lineBreakStrategy, /*block*/ nil); +} + +ENRMRenderResult *ENRMRenderBlockquoteContentNodes(NSArray *nodes, StyleConfig *config, + BOOL allowFontScaling, CGFloat maxFontSizeMultiplier, + NSLineBreakStrategy lineBreakStrategy) +{ + ENRMBlockquoteTextRenderer *block = [[ENRMBlockquoteTextRenderer alloc] initWithConfig:config]; + return ENRMRenderASTNodesCore(nodes, config, /*allowTrailingMargin*/ NO, allowFontScaling, maxFontSizeMultiplier, + lineBreakStrategy, block); +} diff --git a/packages/react-native-enriched-markdown/ios/utils/SegmentRenderer.h b/packages/react-native-enriched-markdown/ios/utils/SegmentRenderer.h deleted file mode 100644 index d598ff2bf..000000000 --- a/packages/react-native-enriched-markdown/ios/utils/SegmentRenderer.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#import - -@class ENRMRenderedSegment; -@class MarkdownASTNode; -@class StyleConfig; - -NS_ASSUME_NONNULL_BEGIN - -#ifdef __cplusplus -extern "C" { -#endif - -NSArray *ENRMRenderSegmentsFromAST(MarkdownASTNode *ast, StyleConfig *config, - BOOL allowTrailingMargin, BOOL allowFontScaling, - CGFloat maxFontSizeMultiplier, - NSLineBreakStrategy lineBreakStrategy); - -#ifdef __cplusplus -} -#endif - -NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/src/web/globalStyles.ts b/packages/react-native-enriched-markdown/src/web/globalStyles.ts index 53acfa4c3..909626b7a 100644 --- a/packages/react-native-enriched-markdown/src/web/globalStyles.ts +++ b/packages/react-native-enriched-markdown/src/web/globalStyles.ts @@ -8,6 +8,14 @@ const RULES: ReadonlyArray = [ 'enrm-selection-style', `.${ENRM_TEXT_CLASS} ::selection { background-color: var(${ENRM_SELECTION_BG_VAR}); }`, ], + // Trim the trailing margin of a quote's last block so it doesn't add space + // above the box's bottom padding, mirroring native. !important overrides the + // per-element inline margin; the quote is a flex-item BFC so the margin can't + // collapse out on its own. + [ + 'enrm-blockquote-trailing-margin', + `.${ENRM_TEXT_CLASS} blockquote > *:last-child { margin-bottom: 0 !important; }`, + ], ]; for (const [id, css] of RULES) { diff --git a/packages/react-native-enriched-markdown/src/web/styles.ts b/packages/react-native-enriched-markdown/src/web/styles.ts index 8f9e11cc8..a93d89399 100644 --- a/packages/react-native-enriched-markdown/src/web/styles.ts +++ b/packages/react-native-enriched-markdown/src/web/styles.ts @@ -103,7 +103,12 @@ function paragraphStyle(style: MarkdownStyleInternal): CSSProperties { function paragraphInBlockquoteStyle( style: MarkdownStyleInternal ): CSSProperties { - return { ...baseBlock(style.paragraph), marginTop: 0, marginBottom: 0 }; + // Keep the paragraph's block margins so spacing between blocks inside a quote + // matches the top-level document (e.g. the gap before a heading). The last + // child's trailing margin is zeroed by a global blockquote :last-child rule + // (see globalStyles), mirroring native's trailing-margin trim - the quote is a + // flex-item BFC, so that margin would otherwise not collapse out of the box. + return baseBlock(style.paragraph); } function headingStyle( @@ -125,7 +130,7 @@ function blockquoteStyle(style: MarkdownStyleInternal): CSSProperties { marginBottom: blockquote.marginBottom, marginInlineStart: 0, // reset UA default (40px in LTR, auto in RTL) marginInlineEnd: 0, - paddingInlineStart: blockquote.gapWidth, + paddingInlineStart: blockquote.gapWidth + blockquote.padding, paddingInlineEnd: blockquote.padding, paddingTop: blockquote.padding, paddingBottom: blockquote.padding,