@mathers101/react-native-render-rich-text 0.1.10 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -20
- package/README.md +140 -140
- package/ReactNativeRenderRichText.podspec +23 -23
- package/android/build.gradle +86 -86
- package/android/consumer-rules.pro +3 -3
- package/android/src/main/AndroidManifest.xml +2 -2
- package/android/src/main/java/com/renderrichtext/EnrichedTiptapTextLayoutManager.kt +24 -24
- package/android/src/main/java/com/renderrichtext/EnrichedTiptapTextPackage.kt +13 -13
- package/android/src/main/java/com/renderrichtext/MeasurementStore.kt +644 -644
- package/android/src/main/java/com/renderrichtext/accessibility/AccessibilityLabels.kt +17 -17
- package/android/src/main/java/com/renderrichtext/accessibility/AccessibleTiptapTextView.kt +112 -112
- package/android/src/main/java/com/renderrichtext/accessibility/TiptapAccessibilityHelper.kt +576 -576
- package/android/src/main/java/com/renderrichtext/events/CodeBlockPressEvent.kt +24 -24
- package/android/src/main/java/com/renderrichtext/events/ContextMenuItemPressEvent.kt +28 -28
- package/android/src/main/java/com/renderrichtext/events/CopyPressEvent.kt +24 -24
- package/android/src/main/java/com/renderrichtext/events/ImagePressEvent.kt +25 -25
- package/android/src/main/java/com/renderrichtext/events/LatexErrorEvent.kt +29 -29
- package/android/src/main/java/com/renderrichtext/events/LinkLongPressEvent.kt +23 -23
- package/android/src/main/java/com/renderrichtext/events/LinkPressEvent.kt +23 -23
- package/android/src/main/java/com/renderrichtext/events/MentionLongPressEvent.kt +25 -25
- package/android/src/main/java/com/renderrichtext/events/MentionPressEvent.kt +25 -25
- package/android/src/main/java/com/renderrichtext/events/TaskListItemPressEvent.kt +26 -26
- package/android/src/main/java/com/renderrichtext/math/LatexErrorReporter.kt +9 -9
- package/android/src/main/java/com/renderrichtext/parser/Parser.kt +59 -59
- package/android/src/main/java/com/renderrichtext/parser/TextLinkMatching.kt +35 -35
- package/android/src/main/java/com/renderrichtext/renderer/BaselineShiftRenderer.kt +45 -45
- package/android/src/main/java/com/renderrichtext/renderer/BlankLineRenderer.kt +39 -39
- package/android/src/main/java/com/renderrichtext/renderer/BlockquoteRenderer.kt +243 -243
- package/android/src/main/java/com/renderrichtext/renderer/BlockquoteTextRenderer.kt +56 -56
- package/android/src/main/java/com/renderrichtext/renderer/CodeBlockRenderer.kt +135 -135
- package/android/src/main/java/com/renderrichtext/renderer/CodeRenderer.kt +37 -37
- package/android/src/main/java/com/renderrichtext/renderer/EmphasisRenderer.kt +28 -28
- package/android/src/main/java/com/renderrichtext/renderer/HeadingRenderer.kt +81 -81
- package/android/src/main/java/com/renderrichtext/renderer/HighlightRenderer.kt +26 -26
- package/android/src/main/java/com/renderrichtext/renderer/LineBreakRenderer.kt +17 -17
- package/android/src/main/java/com/renderrichtext/renderer/LinkRenderer.kt +90 -90
- package/android/src/main/java/com/renderrichtext/renderer/ListContextManager.kt +109 -109
- package/android/src/main/java/com/renderrichtext/renderer/ListItemRenderer.kt +211 -211
- package/android/src/main/java/com/renderrichtext/renderer/ListRenderer.kt +133 -133
- package/android/src/main/java/com/renderrichtext/renderer/MentionRenderer.kt +85 -85
- package/android/src/main/java/com/renderrichtext/renderer/ParagraphRenderer.kt +91 -91
- package/android/src/main/java/com/renderrichtext/renderer/Renderer.kt +148 -148
- package/android/src/main/java/com/renderrichtext/renderer/SoftBreakRenderer.kt +17 -17
- package/android/src/main/java/com/renderrichtext/renderer/SpanStyleCache.kt +143 -143
- package/android/src/main/java/com/renderrichtext/renderer/StrikethroughRenderer.kt +28 -28
- package/android/src/main/java/com/renderrichtext/renderer/StrongRenderer.kt +28 -28
- package/android/src/main/java/com/renderrichtext/renderer/TextRenderer.kt +31 -31
- package/android/src/main/java/com/renderrichtext/renderer/ThematicBreakRenderer.kt +46 -46
- package/android/src/main/java/com/renderrichtext/renderer/UnderlineRenderer.kt +28 -28
- package/android/src/main/java/com/renderrichtext/spans/BaseListSpan.kt +164 -164
- package/android/src/main/java/com/renderrichtext/spans/BaselineShiftSpan.kt +27 -27
- package/android/src/main/java/com/renderrichtext/spans/BlockquoteSpan.kt +302 -302
- package/android/src/main/java/com/renderrichtext/spans/CodeBackgroundSpan.kt +205 -205
- package/android/src/main/java/com/renderrichtext/spans/CodeBlockSpan.kt +216 -216
- package/android/src/main/java/com/renderrichtext/spans/CodeSpan.kt +32 -32
- package/android/src/main/java/com/renderrichtext/spans/EmphasisSpan.kt +42 -42
- package/android/src/main/java/com/renderrichtext/spans/FadeInSpan.kt +29 -29
- package/android/src/main/java/com/renderrichtext/spans/HeadingSpan.kt +38 -38
- package/android/src/main/java/com/renderrichtext/spans/HighlightSpan.kt +80 -80
- package/android/src/main/java/com/renderrichtext/spans/ImageSpan.kt +481 -481
- package/android/src/main/java/com/renderrichtext/spans/InlineBackgroundGeometry.kt +42 -42
- package/android/src/main/java/com/renderrichtext/spans/LineHeightSpan.kt +39 -39
- package/android/src/main/java/com/renderrichtext/spans/LinkPillIconCache.kt +154 -154
- package/android/src/main/java/com/renderrichtext/spans/LinkPillSpan.kt +381 -381
- package/android/src/main/java/com/renderrichtext/spans/LinkSpan.kt +68 -68
- package/android/src/main/java/com/renderrichtext/spans/ListItemSpacingSpan.kt +33 -33
- package/android/src/main/java/com/renderrichtext/spans/ListMarkerAnchorSpan.kt +38 -38
- package/android/src/main/java/com/renderrichtext/spans/MarginBottomSpan.kt +76 -76
- package/android/src/main/java/com/renderrichtext/spans/MentionChipSpan.kt +132 -132
- package/android/src/main/java/com/renderrichtext/spans/MentionSpan.kt +46 -46
- package/android/src/main/java/com/renderrichtext/spans/OrderedListSpan.kt +127 -127
- package/android/src/main/java/com/renderrichtext/spans/SpoilerSpan.kt +33 -33
- package/android/src/main/java/com/renderrichtext/spans/StrikethroughSpan.kt +12 -12
- package/android/src/main/java/com/renderrichtext/spans/StrongSpan.kt +39 -39
- package/android/src/main/java/com/renderrichtext/spans/TaskListSpan.kt +143 -143
- package/android/src/main/java/com/renderrichtext/spans/TextSpan.kt +26 -26
- package/android/src/main/java/com/renderrichtext/spans/ThematicBreakSpan.kt +69 -69
- package/android/src/main/java/com/renderrichtext/spans/UnorderedListSpan.kt +96 -96
- package/android/src/main/java/com/renderrichtext/spoiler/SpoilerStubs.kt +46 -46
- package/android/src/main/java/com/renderrichtext/styles/BaseBlockStyle.kt +11 -11
- package/android/src/main/java/com/renderrichtext/styles/BlockquoteStyle.kt +83 -83
- package/android/src/main/java/com/renderrichtext/styles/CodeBlockStyle.kt +80 -80
- package/android/src/main/java/com/renderrichtext/styles/CodeStyle.kt +26 -26
- package/android/src/main/java/com/renderrichtext/styles/EmphasisStyle.kt +21 -21
- package/android/src/main/java/com/renderrichtext/styles/FactoidStyle.kt +21 -21
- package/android/src/main/java/com/renderrichtext/styles/HeadingStyle.kt +33 -33
- package/android/src/main/java/com/renderrichtext/styles/HighlightStyle.kt +19 -19
- package/android/src/main/java/com/renderrichtext/styles/ImageStyle.kt +29 -29
- package/android/src/main/java/com/renderrichtext/styles/InlineImageStyle.kt +17 -17
- package/android/src/main/java/com/renderrichtext/styles/InlineMathStyle.kt +17 -17
- package/android/src/main/java/com/renderrichtext/styles/LinkPillContent.kt +36 -36
- package/android/src/main/java/com/renderrichtext/styles/LinkPillStyle.kt +44 -44
- package/android/src/main/java/com/renderrichtext/styles/LinkStyle.kt +23 -23
- package/android/src/main/java/com/renderrichtext/styles/LinkVariantEntry.kt +34 -34
- package/android/src/main/java/com/renderrichtext/styles/ListStyle.kt +65 -65
- package/android/src/main/java/com/renderrichtext/styles/MathStyle.kt +29 -29
- package/android/src/main/java/com/renderrichtext/styles/MentionStyle.kt +19 -19
- package/android/src/main/java/com/renderrichtext/styles/ParagraphStyle.kt +33 -33
- package/android/src/main/java/com/renderrichtext/styles/SpoilerStyle.kt +27 -27
- package/android/src/main/java/com/renderrichtext/styles/StrikethroughStyle.kt +17 -17
- package/android/src/main/java/com/renderrichtext/styles/StrongStyle.kt +21 -21
- package/android/src/main/java/com/renderrichtext/styles/StyleConfig.kt +411 -411
- package/android/src/main/java/com/renderrichtext/styles/StyleParser.kt +92 -92
- package/android/src/main/java/com/renderrichtext/styles/SubscriptStyle.kt +16 -16
- package/android/src/main/java/com/renderrichtext/styles/SuperscriptStyle.kt +16 -16
- package/android/src/main/java/com/renderrichtext/styles/TableStyle.kt +75 -75
- package/android/src/main/java/com/renderrichtext/styles/TaskListStyle.kt +29 -29
- package/android/src/main/java/com/renderrichtext/styles/TextAlignment.kt +32 -32
- package/android/src/main/java/com/renderrichtext/styles/ThematicBreakStyle.kt +23 -23
- package/android/src/main/java/com/renderrichtext/styles/UnderlineStyle.kt +17 -17
- package/android/src/main/java/com/renderrichtext/styles/VideoStyle.kt +31 -31
- package/android/src/main/java/com/renderrichtext/utils/common/BorderStyleUtils.kt +86 -86
- package/android/src/main/java/com/renderrichtext/utils/common/BreakStrategyUtils.kt +32 -32
- package/android/src/main/java/com/renderrichtext/utils/common/CodeBlockHighlighter.kt +36 -36
- package/android/src/main/java/com/renderrichtext/utils/common/CodeBlockNode.kt +42 -42
- package/android/src/main/java/com/renderrichtext/utils/common/EllipsizeUtils.kt +27 -27
- package/android/src/main/java/com/renderrichtext/utils/common/EnrichedTiptapAncestorUtils.kt +10 -10
- package/android/src/main/java/com/renderrichtext/utils/common/FeatureFlags.kt +9 -9
- package/android/src/main/java/com/renderrichtext/utils/common/LinkRecognitionProps.kt +31 -31
- package/android/src/main/java/com/renderrichtext/utils/common/LinkRegexConfig.kt +56 -56
- package/android/src/main/java/com/renderrichtext/utils/common/MotionPreferences.kt +18 -18
- package/android/src/main/java/com/renderrichtext/utils/common/ReadableMapExtensions.kt +28 -28
- package/android/src/main/java/com/renderrichtext/utils/common/layout/RTLUtils.kt +6 -6
- package/android/src/main/java/com/renderrichtext/utils/common/serialization/TiptapASTSerializer.kt +368 -368
- package/android/src/main/java/com/renderrichtext/utils/text/ImageCache.kt +88 -88
- package/android/src/main/java/com/renderrichtext/utils/text/ImageDownloader.kt +203 -203
- package/android/src/main/java/com/renderrichtext/utils/text/LocalImageLoader.kt +167 -167
- package/android/src/main/java/com/renderrichtext/utils/text/TailFadeInAnimator.kt +84 -84
- package/android/src/main/java/com/renderrichtext/utils/text/conversion/HTMLGenerator.kt +1105 -1105
- package/android/src/main/java/com/renderrichtext/utils/text/conversion/TiptapExtractor.kt +423 -423
- package/android/src/main/java/com/renderrichtext/utils/text/extensions/ParagraphUtils.kt +9 -9
- package/android/src/main/java/com/renderrichtext/utils/text/extensions/SpannableExtensions.kt +9 -9
- package/android/src/main/java/com/renderrichtext/utils/text/extensions/TextPaintExtensions.kt +76 -76
- package/android/src/main/java/com/renderrichtext/utils/text/interaction/CheckboxTouchHelper.kt +61 -61
- package/android/src/main/java/com/renderrichtext/utils/text/interaction/TaskListTapUtils.kt +286 -286
- package/android/src/main/java/com/renderrichtext/utils/text/span/ApplyLineHeight.kt +39 -39
- package/android/src/main/java/com/renderrichtext/utils/text/span/BlockSpacing.kt +142 -142
- package/android/src/main/java/com/renderrichtext/utils/text/span/SpanFlags.kt +43 -43
- package/android/src/main/java/com/renderrichtext/utils/text/span/WidthAwareSpans.kt +30 -30
- package/android/src/main/java/com/renderrichtext/utils/text/view/CodeBlockPressHost.kt +15 -15
- package/android/src/main/java/com/renderrichtext/utils/text/view/ImagePressHost.kt +19 -19
- package/android/src/main/java/com/renderrichtext/utils/text/view/SelectionActionMode.kt +235 -235
- package/android/src/main/java/com/renderrichtext/utils/text/view/TextSelectionColors.kt +48 -48
- package/android/src/main/java/com/renderrichtext/utils/text/view/TextViewSetup.kt +119 -119
- package/android/src/main/java/com/renderrichtext/views/ContextMenuPopup.kt +217 -217
- package/android/src/main/java/com/renderrichtext/views/CopyGlyph.kt +101 -101
- package/android/src/main/java/com/renderrichtext/views/WrapGlyph.kt +40 -40
- package/android/src/main/jni/CMakeLists.txt +52 -52
- package/android/src/main/jni/react/renderer/components/RichTextSpec/ComponentDescriptors.h +30 -30
- package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextComponentDescriptor.h +29 -29
- package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextMeasurementManager.cpp +45 -45
- package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextMeasurementManager.h +21 -21
- package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextShadowNode.cpp +38 -38
- package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextShadowNode.h +53 -53
- package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextState.cpp +9 -9
- package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextState.h +24 -24
- package/android/src/main/jni/react/renderer/components/RichTextSpec/conversions.h +40 -40
- package/cpp/parser/TiptapParser.hpp +13 -13
- package/cpp/vendor/nlohmann/json.hpp +24765 -24765
- package/ios/EnrichedTiptapText.h +27 -27
- package/ios/attachments/ENRMImageAttachment.h +51 -51
- package/ios/attachments/ENRMImageAttachment.m +398 -398
- package/ios/attachments/ENRMLinkPillAttachment.h +47 -47
- package/ios/attachments/ENRMLinkPillAttachment.m +324 -324
- package/ios/attachments/ENRMLinkPillIconCache.h +28 -28
- package/ios/attachments/ENRMLinkPillIconCache.m +181 -181
- package/ios/attachments/ENRMLinkPillText.h +27 -27
- package/ios/attachments/ENRMLinkPillText.m +118 -118
- package/ios/attachments/ENRMMathInlineAttachment.h +7 -7
- package/ios/attachments/ENRMMathInlineAttachment.m +4 -4
- package/ios/attachments/ThematicBreakAttachment.h +15 -15
- package/ios/attachments/ThematicBreakAttachment.m +33 -33
- package/ios/internals/ENRMViewFreeMeasurement.h +208 -208
- package/ios/internals/EnrichedTiptapTextComponentDescriptor.h +19 -19
- package/ios/internals/EnrichedTiptapTextShadowNode.h +43 -43
- package/ios/internals/EnrichedTiptapTextShadowNode.mm +69 -69
- package/ios/internals/EnrichedTiptapTextState.h +24 -24
- package/ios/internals/MeasurementCache.h +308 -308
- package/ios/internals/ShadowMeasurementUtils.h +145 -145
- package/ios/parser/ENRMTextLinkAttributes.h +7 -7
- package/ios/parser/ENRMTiptapParser.h +33 -33
- package/ios/parser/ENRMTiptapParser.mm +73 -73
- package/ios/parser/TiptapASTNode.m +52 -52
- package/ios/renderer/AttributedRenderer.h +23 -23
- package/ios/renderer/AttributedRenderer.m +229 -229
- package/ios/renderer/BaseRenderer.h +33 -33
- package/ios/renderer/BaseRenderer.m +46 -46
- package/ios/renderer/BaselineShiftTextAttributes.h +10 -10
- package/ios/renderer/BaselineShiftTextAttributes.m +26 -26
- package/ios/renderer/BlankLineRenderer.h +4 -4
- package/ios/renderer/BlankLineRenderer.m +51 -51
- package/ios/renderer/BlockquoteRenderer.h +6 -6
- package/ios/renderer/BlockquoteRenderer.m +258 -258
- package/ios/renderer/CodeBlockRenderer.h +9 -9
- package/ios/renderer/CodeBlockRenderer.m +99 -99
- package/ios/renderer/CodeRenderer.h +9 -9
- package/ios/renderer/CodeRenderer.m +69 -69
- package/ios/renderer/ENRMBlockquoteTextRenderer.h +25 -25
- package/ios/renderer/ENRMBlockquoteTextRenderer.m +48 -48
- package/ios/renderer/EmphasisRenderer.h +5 -5
- package/ios/renderer/EmphasisRenderer.m +99 -99
- package/ios/renderer/HeadingRenderer.h +6 -6
- package/ios/renderer/HeadingRenderer.m +102 -102
- package/ios/renderer/HighlightRenderer.h +7 -7
- package/ios/renderer/HighlightRenderer.m +33 -33
- package/ios/renderer/LinkRenderer.h +6 -6
- package/ios/renderer/LinkRenderer.m +157 -157
- package/ios/renderer/ListItemRenderer.h +24 -24
- package/ios/renderer/ListItemRenderer.m +283 -283
- package/ios/renderer/ListRenderer.h +13 -13
- package/ios/renderer/ListRenderer.m +126 -126
- package/ios/renderer/MentionRenderer.h +9 -9
- package/ios/renderer/MentionRenderer.m +96 -96
- package/ios/renderer/NodeRenderer.h +8 -8
- package/ios/renderer/ParagraphRenderer.h +6 -6
- package/ios/renderer/ParagraphRenderer.m +85 -85
- package/ios/renderer/RenderContext.h +145 -145
- package/ios/renderer/RenderContext.m +402 -402
- package/ios/renderer/RendererFactory.h +12 -12
- package/ios/renderer/StrikethroughRenderer.h +5 -5
- package/ios/renderer/StrikethroughRenderer.m +30 -30
- package/ios/renderer/StrongRenderer.h +5 -5
- package/ios/renderer/StrongRenderer.m +87 -87
- package/ios/renderer/TextRenderer.h +6 -6
- package/ios/renderer/TextRenderer.m +18 -18
- package/ios/renderer/ThematicBreakRenderer.h +4 -4
- package/ios/renderer/ThematicBreakRenderer.m +41 -41
- package/ios/renderer/UnderlineRenderer.h +4 -4
- package/ios/renderer/UnderlineRenderer.m +41 -41
- package/ios/styles/ENRMFontSlot.h +12 -12
- package/ios/styles/ENRMFontSlot.m +24 -24
- package/ios/styles/StyleConfig.h +488 -488
- package/ios/styles/StyleConfig.mm +2820 -2820
- package/ios/utils/AccessibilityInfo.h +35 -35
- package/ios/utils/AccessibilityInfo.m +24 -24
- package/ios/utils/BlockquoteBorder.h +26 -26
- package/ios/utils/BlockquoteBorder.m +294 -294
- package/ios/utils/CodeBackground.h +19 -19
- package/ios/utils/CodeBackground.m +196 -196
- package/ios/utils/CodeBlockBackground.h +17 -17
- package/ios/utils/CodeBlockBackground.m +102 -102
- package/ios/utils/ContextMenuUtils.h +76 -76
- package/ios/utils/ContextMenuUtils.mm +75 -75
- package/ios/utils/ENRMAccessibilityLabels.h +40 -40
- package/ios/utils/ENRMAccessibilityLabels.m +5 -5
- package/ios/utils/ENRMAsyncRenderCoordinator.h +24 -24
- package/ios/utils/ENRMAsyncRenderCoordinator.m +37 -37
- package/ios/utils/ENRMAtomicSize.h +65 -65
- package/ios/utils/ENRMCodeBlockContent.h +37 -37
- package/ios/utils/ENRMCodeBlockContent.mm +89 -89
- package/ios/utils/ENRMFeatureFlags.h +25 -25
- package/ios/utils/ENRMImageDownloader.h +30 -30
- package/ios/utils/ENRMImageDownloader.m +168 -168
- package/ios/utils/ENRMLatexErrorCoordinator.h +27 -27
- package/ios/utils/ENRMLatexErrorCoordinator.mm +75 -75
- package/ios/utils/ENRMLatexErrorReporting.h +13 -13
- package/ios/utils/ENRMLinkContextMenus.h +93 -93
- package/ios/utils/ENRMLinkContextMenus.m +178 -178
- package/ios/utils/ENRMLinkRegexConfig.h +30 -30
- package/ios/utils/ENRMLinkRegexConfig.mm +104 -104
- package/ios/utils/ENRMLinkRegexProps.h +23 -23
- package/ios/utils/ENRMLocalImageLoader.h +35 -35
- package/ios/utils/ENRMLocalImageLoader.m +111 -111
- package/ios/utils/ENRMMenuAction.h +27 -27
- package/ios/utils/ENRMMenuAction.m +33 -33
- package/ios/utils/ENRMSpoilerOverlayManager.h +15 -15
- package/ios/utils/ENRMSpoilerOverlayManager.m +19 -19
- package/ios/utils/ENRMSpoilerTapUtils.h +6 -6
- package/ios/utils/ENRMSpoilerTapUtils.m +11 -11
- package/ios/utils/ENRMTailFadeInAnimator.h +16 -16
- package/ios/utils/ENRMTailFadeInAnimator.m +161 -161
- package/ios/utils/ENRMTextHitTest.h +23 -23
- package/ios/utils/ENRMTextHitTest.m +20 -20
- package/ios/utils/ENRMTextInteractionUtils.h +20 -20
- package/ios/utils/ENRMTextInteractionUtils.m +15 -15
- package/ios/utils/ENRMTextRenderer.h +38 -38
- package/ios/utils/ENRMTextRenderer.m +54 -54
- package/ios/utils/ENRMTextViewSetup.h +132 -132
- package/ios/utils/ENRMUIKit.h +429 -429
- package/ios/utils/EditMenuUtils.h +105 -105
- package/ios/utils/EditMenuUtils.m +143 -143
- package/ios/utils/FontScaleObserver.h +10 -10
- package/ios/utils/FontScaleObserver.m +55 -55
- package/ios/utils/FontUtils.h +31 -31
- package/ios/utils/FontUtils.m +63 -63
- package/ios/utils/HTMLGenerator.h +25 -25
- package/ios/utils/HTMLGenerator.m +1041 -1041
- package/ios/utils/HeightUpdateUtils.h +25 -25
- package/ios/utils/ImageRequestHeaderUtils.h +35 -35
- package/ios/utils/LastElementUtils.h +97 -97
- package/ios/utils/LinkContextMenuUtils.h +55 -55
- package/ios/utils/LinkPillContentUtils.h +46 -46
- package/ios/utils/ListMarkerDrawer.h +15 -15
- package/ios/utils/ListMarkerDrawer.m +239 -239
- package/ios/utils/MentionBackground.h +26 -26
- package/ios/utils/MentionBackground.m +86 -86
- package/ios/utils/ParagraphStyleUtils.h +87 -87
- package/ios/utils/ParagraphStyleUtils.m +484 -484
- package/ios/utils/PasteboardUtils.h +48 -48
- package/ios/utils/PasteboardUtils.m +166 -166
- package/ios/utils/RTFExportUtils.h +24 -24
- package/ios/utils/RTFExportUtils.m +299 -299
- package/ios/utils/SelectionColorUtils.h +14 -14
- package/ios/utils/StylePropsUtils.h +1340 -1340
- package/ios/utils/TaskListTapUtils.h +6 -6
- package/ios/utils/TaskListTapUtils.m +14 -14
- package/ios/utils/TextViewLayoutManager.h +14 -14
- package/ios/utils/TiptapAccessibilityElementBuilder.h +43 -43
- package/ios/utils/TiptapAccessibilityElementBuilder.m +432 -432
- package/ios/utils/TiptapExtractor.h +17 -17
- package/ios/utils/TiptapExtractor.m +367 -367
- package/ios/views/ENRMTiptapTextView.h +25 -25
- package/ios/views/ENRMTiptapTextView.m +247 -247
- package/package.json +65 -70
- package/react-native.config.js +10 -10
- package/src/admonitionDefaults.ts +58 -58
- package/src/darkModeTextStyle.ts +99 -99
- package/src/headingDefaults.ts +21 -21
- package/src/index.tsx +4 -4
- package/src/linkVariantUtils.ts +120 -120
- package/src/normalizeTiptapStyle.ts +397 -397
- package/src/rn-globals.d.ts +1 -1
- package/src/styleUtils.ts +107 -107
- package/src/types/TiptapStyle.ts +532 -532
- package/src/types/TiptapStyleInternal.ts +299 -299
- package/src/types/linkPill.ts +9 -9
- package/src/utils/isDev.ts +4 -4
|
@@ -1,576 +1,576 @@
|
|
|
1
|
-
package com.renderrichtext.accessibility
|
|
2
|
-
|
|
3
|
-
import android.graphics.Rect
|
|
4
|
-
import android.os.Bundle
|
|
5
|
-
import android.text.Layout
|
|
6
|
-
import android.text.Spanned
|
|
7
|
-
import android.view.View
|
|
8
|
-
import android.view.ViewTreeObserver
|
|
9
|
-
import android.widget.TextView
|
|
10
|
-
import androidx.core.view.ViewCompat
|
|
11
|
-
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
|
|
12
|
-
import androidx.customview.widget.ExploreByTouchHelper
|
|
13
|
-
import com.renderrichtext.spans.BaseListSpan
|
|
14
|
-
import com.renderrichtext.spans.BlockquoteSpan
|
|
15
|
-
import com.renderrichtext.spans.HeadingSpan
|
|
16
|
-
import com.renderrichtext.spans.ImageSpan
|
|
17
|
-
import com.renderrichtext.spans.LinkPillSpan
|
|
18
|
-
import com.renderrichtext.spans.LinkSpan
|
|
19
|
-
import com.renderrichtext.spans.OrderedListSpan
|
|
20
|
-
|
|
21
|
-
class TiptapAccessibilityHelper(
|
|
22
|
-
private val textView: TextView,
|
|
23
|
-
) : ExploreByTouchHelper(textView) {
|
|
24
|
-
private var items: List<AccessibilityItem> = emptyList()
|
|
25
|
-
private var needsRebuild = false
|
|
26
|
-
private var lastLayoutHashCode = 0
|
|
27
|
-
private var pendingLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
|
|
28
|
-
|
|
29
|
-
var labels: AccessibilityLabels = AccessibilityLabels()
|
|
30
|
-
set(value) {
|
|
31
|
-
if (field == value) return
|
|
32
|
-
field = value
|
|
33
|
-
invalidateAccessibilityItems()
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
data class AccessibilityItem(
|
|
37
|
-
val id: Int,
|
|
38
|
-
val text: String,
|
|
39
|
-
/** Full character range — used for hit-testing so there are no gaps between items. */
|
|
40
|
-
val start: Int,
|
|
41
|
-
val end: Int,
|
|
42
|
-
/** Trimmed character range — used for bounds calculation to avoid whitespace offsets. */
|
|
43
|
-
val visibleStart: Int = start,
|
|
44
|
-
val visibleEnd: Int = end,
|
|
45
|
-
val headingLevel: Int = 0,
|
|
46
|
-
val linkUrl: String? = null,
|
|
47
|
-
val listInfo: ListItemInfo? = null,
|
|
48
|
-
val imageAltText: String? = null,
|
|
49
|
-
) {
|
|
50
|
-
val isHeading get() = headingLevel > 0
|
|
51
|
-
val isLink get() = linkUrl != null
|
|
52
|
-
val isListItem get() = listInfo != null
|
|
53
|
-
val isImage get() = imageAltText != null
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
data class ListItemInfo(
|
|
57
|
-
val isOrdered: Boolean,
|
|
58
|
-
val itemNumber: Int,
|
|
59
|
-
val depth: Int,
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
private data class SpanRange(
|
|
63
|
-
val start: Int,
|
|
64
|
-
val end: Int,
|
|
65
|
-
val headingLevel: Int = 0,
|
|
66
|
-
val linkUrl: String? = null,
|
|
67
|
-
val imageAltText: String? = null,
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
fun invalidateAccessibilityItems() {
|
|
71
|
-
needsRebuild = true
|
|
72
|
-
if (textView.layout != null) {
|
|
73
|
-
rebuildIfNeeded()
|
|
74
|
-
invalidateRoot()
|
|
75
|
-
} else {
|
|
76
|
-
schedulePostLayoutRebuild()
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
private fun schedulePostLayoutRebuild() {
|
|
81
|
-
if (pendingLayoutListener != null) return
|
|
82
|
-
val observer = textView.viewTreeObserver
|
|
83
|
-
if (!observer.isAlive) return
|
|
84
|
-
val listener =
|
|
85
|
-
ViewTreeObserver.OnGlobalLayoutListener {
|
|
86
|
-
removePendingLayoutListener()
|
|
87
|
-
if (needsRebuild) {
|
|
88
|
-
rebuildIfNeeded()
|
|
89
|
-
invalidateRoot()
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
pendingLayoutListener = listener
|
|
93
|
-
observer.addOnGlobalLayoutListener(listener)
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Drops the pending global-layout listener, if any is still registered.
|
|
98
|
-
*
|
|
99
|
-
* Must run while the view is attached: `textView.viewTreeObserver` returns
|
|
100
|
-
* the window's observer only until detach, and the listener was merged into
|
|
101
|
-
* it from the view's floating observer when the view attached.
|
|
102
|
-
*/
|
|
103
|
-
fun cleanup() {
|
|
104
|
-
removePendingLayoutListener()
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
private fun removePendingLayoutListener() {
|
|
108
|
-
val listener = pendingLayoutListener ?: return
|
|
109
|
-
pendingLayoutListener = null
|
|
110
|
-
val observer = textView.viewTreeObserver
|
|
111
|
-
if (observer.isAlive) {
|
|
112
|
-
observer.removeOnGlobalLayoutListener(listener)
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* When virtual children exist, prevent the host TextView from appearing as a
|
|
118
|
-
* standalone focusable element in TalkBack's swipe order. Without this,
|
|
119
|
-
* `setTextIsSelectable(true)` keeps the view focusable and TalkBack reads
|
|
120
|
-
* the entire text as one element before entering the virtual hierarchy.
|
|
121
|
-
*/
|
|
122
|
-
private fun updateHostFocusability() {
|
|
123
|
-
val hasVirtualChildren = items.isNotEmpty()
|
|
124
|
-
ViewCompat.setScreenReaderFocusable(textView, !hasVirtualChildren)
|
|
125
|
-
textView.importantForAccessibility =
|
|
126
|
-
if (hasVirtualChildren) View.IMPORTANT_FOR_ACCESSIBILITY_YES else View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
private fun rebuildIfNeeded() {
|
|
130
|
-
val layout = textView.layout ?: return
|
|
131
|
-
if (needsRebuild || lastLayoutHashCode != layout.hashCode()) {
|
|
132
|
-
items = buildItems()
|
|
133
|
-
needsRebuild = false
|
|
134
|
-
lastLayoutHashCode = layout.hashCode()
|
|
135
|
-
updateHostFocusability()
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
private fun buildItems(): List<AccessibilityItem> {
|
|
140
|
-
val spanned = textView.text as? Spanned ?: return emptyList()
|
|
141
|
-
if (spanned.isEmpty()) return emptyList()
|
|
142
|
-
|
|
143
|
-
val text = spanned.toString()
|
|
144
|
-
val endLimit = visibleTextLength(text.length)
|
|
145
|
-
val result = mutableListOf<AccessibilityItem>()
|
|
146
|
-
var nextId = 0
|
|
147
|
-
val semanticSpans = collectSemanticSpans(spanned)
|
|
148
|
-
|
|
149
|
-
var paraStart = 0
|
|
150
|
-
while (paraStart < endLimit) {
|
|
151
|
-
val newlineIdx = text.indexOf('\n', paraStart)
|
|
152
|
-
val paraEnd = minOf(if (newlineIdx == -1) text.length else newlineIdx + 1, endLimit)
|
|
153
|
-
val trimmed = text.substring(paraStart, paraEnd).trim()
|
|
154
|
-
|
|
155
|
-
if (trimmed.isNotEmpty()) {
|
|
156
|
-
val spansInParagraph =
|
|
157
|
-
semanticSpans
|
|
158
|
-
.filter { it.start < paraEnd && it.end > paraStart }
|
|
159
|
-
.map { it.copy(end = minOf(it.end, endLimit)) }
|
|
160
|
-
|
|
161
|
-
if (spansInParagraph.isEmpty()) {
|
|
162
|
-
result.add(
|
|
163
|
-
createTextItem(nextId++, trimmed, paraStart, paraEnd, text, spanned),
|
|
164
|
-
)
|
|
165
|
-
} else {
|
|
166
|
-
nextId = addSegmentedItems(result, spanned, text, paraStart, paraEnd, spansInParagraph, nextId)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
paraStart = paraEnd
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return result.ifEmpty {
|
|
173
|
-
val visibleText = text.substring(0, endLimit).trim()
|
|
174
|
-
if (visibleText.isEmpty()) emptyList() else listOf(AccessibilityItem(0, visibleText, 0, endLimit))
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Last line that is visible; less than lineCount - 1 when height-capped (e.g. `clip`). */
|
|
179
|
-
private fun lastVisibleLineIndex(layout: Layout): Int {
|
|
180
|
-
val maxLines = textView.maxLines.coerceAtLeast(1)
|
|
181
|
-
return (minOf(layout.lineCount, maxLines) - 1).coerceIn(0, layout.lineCount - 1)
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Ellipsis-aware end offset for a visible line. */
|
|
185
|
-
private fun visibleLineEnd(
|
|
186
|
-
layout: Layout,
|
|
187
|
-
line: Int,
|
|
188
|
-
): Int {
|
|
189
|
-
val lineEnd = layout.getLineEnd(line)
|
|
190
|
-
val count = layout.getEllipsisCount(line)
|
|
191
|
-
if (count == 0) return lineEnd
|
|
192
|
-
// Only a tail ellipsis hides text up to the end of the line. Head/middle hide a prefix or an
|
|
193
|
-
// inner range, so the line end is still laid out and must stay reachable.
|
|
194
|
-
val hiddenStart = layout.getLineStart(line) + layout.getEllipsisStart(line)
|
|
195
|
-
return if (hiddenStart + count >= lineEnd) hiddenStart else lineEnd
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** End offset of the laid-out (visible) text; less than the full length when truncated. */
|
|
199
|
-
private fun visibleTextLength(fullLength: Int): Int {
|
|
200
|
-
val layout = textView.layout ?: return fullLength
|
|
201
|
-
if (layout.lineCount == 0) return fullLength
|
|
202
|
-
return minOf(fullLength, maxOf(0, visibleLineEnd(layout, lastVisibleLineIndex(layout))))
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
private fun collectSemanticSpans(spanned: Spanned): List<SpanRange> =
|
|
206
|
-
(
|
|
207
|
-
spanned.getSpans(0, spanned.length, HeadingSpan::class.java).map {
|
|
208
|
-
SpanRange(spanned.getSpanStart(it), spanned.getSpanEnd(it), headingLevel = it.level)
|
|
209
|
-
} +
|
|
210
|
-
spanned.getSpans(0, spanned.length, LinkSpan::class.java).map {
|
|
211
|
-
SpanRange(spanned.getSpanStart(it), spanned.getSpanEnd(it), linkUrl = it.url)
|
|
212
|
-
} +
|
|
213
|
-
spanned.getSpans(0, spanned.length, ImageSpan::class.java).map {
|
|
214
|
-
SpanRange(spanned.getSpanStart(it), spanned.getSpanEnd(it), imageAltText = it.altText)
|
|
215
|
-
}
|
|
216
|
-
).sortedBy { it.start }
|
|
217
|
-
|
|
218
|
-
private fun addSegmentedItems(
|
|
219
|
-
items: MutableList<AccessibilityItem>,
|
|
220
|
-
spanned: Spanned,
|
|
221
|
-
text: String,
|
|
222
|
-
paraStart: Int,
|
|
223
|
-
paraEnd: Int,
|
|
224
|
-
spans: List<SpanRange>,
|
|
225
|
-
startId: Int,
|
|
226
|
-
): Int {
|
|
227
|
-
var nextId = startId
|
|
228
|
-
var segmentPos = paraStart
|
|
229
|
-
|
|
230
|
-
for (span in spans) {
|
|
231
|
-
if (span.start < segmentPos) continue
|
|
232
|
-
|
|
233
|
-
// Text before the span
|
|
234
|
-
if (segmentPos < span.start) {
|
|
235
|
-
val beforeText = text.substring(segmentPos, span.start).trim()
|
|
236
|
-
if (beforeText.isNotEmpty() && beforeText.any { it.isLetterOrDigit() }) {
|
|
237
|
-
items.add(createTextItem(nextId++, beforeText, segmentPos, span.start, text, spanned))
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// The semantic span itself
|
|
242
|
-
val pill =
|
|
243
|
-
if (span.linkUrl != null) {
|
|
244
|
-
spanned.getSpans(span.start, span.end, LinkPillSpan::class.java).firstOrNull {
|
|
245
|
-
spanned.getSpanStart(it) == span.start && spanned.getSpanEnd(it) == span.end
|
|
246
|
-
}
|
|
247
|
-
} else {
|
|
248
|
-
null
|
|
249
|
-
}
|
|
250
|
-
val content = span.imageAltText ?: pill?.accessibilityText ?: spanned.substring(span.start, span.end).trim()
|
|
251
|
-
if (content.isNotEmpty()) {
|
|
252
|
-
items.add(createSpanItem(nextId++, content, span, spanned))
|
|
253
|
-
}
|
|
254
|
-
segmentPos = span.end
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Text after the last span
|
|
258
|
-
if (segmentPos < paraEnd) {
|
|
259
|
-
val afterText = text.substring(segmentPos, paraEnd).trim()
|
|
260
|
-
if (afterText.isNotEmpty() && afterText.any { it.isLetterOrDigit() }) {
|
|
261
|
-
items.add(createTextItem(nextId++, afterText, segmentPos, paraEnd, text, spanned))
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
return nextId
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
private fun createTextItem(
|
|
269
|
-
id: Int,
|
|
270
|
-
label: String,
|
|
271
|
-
start: Int,
|
|
272
|
-
end: Int,
|
|
273
|
-
text: String,
|
|
274
|
-
spanned: Spanned,
|
|
275
|
-
) = AccessibilityItem(
|
|
276
|
-
id = id,
|
|
277
|
-
text = label,
|
|
278
|
-
start = start,
|
|
279
|
-
end = end,
|
|
280
|
-
visibleStart = text.findFirstNonWhitespace(start, end),
|
|
281
|
-
visibleEnd = text.findLastNonWhitespace(start, end),
|
|
282
|
-
listInfo = getListInfoAt(spanned, start, requireStart = true),
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
private fun createSpanItem(
|
|
286
|
-
id: Int,
|
|
287
|
-
content: String,
|
|
288
|
-
span: SpanRange,
|
|
289
|
-
spanned: Spanned,
|
|
290
|
-
): AccessibilityItem {
|
|
291
|
-
val listContext =
|
|
292
|
-
if (span.headingLevel > 0 || span.imageAltText != null) {
|
|
293
|
-
null
|
|
294
|
-
} else {
|
|
295
|
-
getListInfoAt(spanned, span.start, requireStart = span.linkUrl == null)
|
|
296
|
-
}
|
|
297
|
-
return AccessibilityItem(
|
|
298
|
-
id = id,
|
|
299
|
-
text = content,
|
|
300
|
-
start = span.start,
|
|
301
|
-
end = span.end,
|
|
302
|
-
headingLevel = span.headingLevel,
|
|
303
|
-
linkUrl = span.linkUrl,
|
|
304
|
-
listInfo = listContext,
|
|
305
|
-
imageAltText = span.imageAltText,
|
|
306
|
-
)
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
override fun getVirtualViewAt(
|
|
310
|
-
x: Float,
|
|
311
|
-
y: Float,
|
|
312
|
-
): Int {
|
|
313
|
-
rebuildIfNeeded()
|
|
314
|
-
if (items.isEmpty()) return HOST_ID
|
|
315
|
-
|
|
316
|
-
val offset = getCharOffsetAt(x, y)
|
|
317
|
-
|
|
318
|
-
val exact =
|
|
319
|
-
items
|
|
320
|
-
.filter { offset in it.start until it.end }
|
|
321
|
-
.minByOrNull { it.hitTestPriority }
|
|
322
|
-
if (exact != null) return exact.id
|
|
323
|
-
|
|
324
|
-
return items.minByOrNull { it.distanceTo(offset) }?.id ?: HOST_ID
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
override fun getVisibleVirtualViews(ids: MutableList<Int>) {
|
|
328
|
-
rebuildIfNeeded()
|
|
329
|
-
items.forEach { ids.add(it.id) }
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
override fun onPopulateNodeForHost(host: AccessibilityNodeInfoCompat) {
|
|
333
|
-
super.onPopulateNodeForHost(host)
|
|
334
|
-
rebuildIfNeeded()
|
|
335
|
-
if (items.isNotEmpty()) {
|
|
336
|
-
host.isScreenReaderFocusable = false
|
|
337
|
-
host.isFocusable = false
|
|
338
|
-
// Prevent TalkBack from reading the full text when swiping onto the host.
|
|
339
|
-
// Without this, the host node retains the TextView's text and TalkBack
|
|
340
|
-
// announces it as a single element before entering the virtual children.
|
|
341
|
-
host.text = null
|
|
342
|
-
host.contentDescription = null
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
override fun onPopulateNodeForVirtualView(
|
|
347
|
-
id: Int,
|
|
348
|
-
node: AccessibilityNodeInfoCompat,
|
|
349
|
-
) {
|
|
350
|
-
val item = items.getOrNull(id)
|
|
351
|
-
if (item == null) {
|
|
352
|
-
node.contentDescription = ""
|
|
353
|
-
node.setBoundsInParent(Rect())
|
|
354
|
-
return
|
|
355
|
-
}
|
|
356
|
-
node.apply {
|
|
357
|
-
text = item.text
|
|
358
|
-
contentDescription = item.text
|
|
359
|
-
isFocusable = true
|
|
360
|
-
isScreenReaderFocusable = true
|
|
361
|
-
setBoundsInParent(boundsForItem(item))
|
|
362
|
-
applySemantics(item)
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
override fun onPerformActionForVirtualView(
|
|
367
|
-
id: Int,
|
|
368
|
-
action: Int,
|
|
369
|
-
args: Bundle?,
|
|
370
|
-
): Boolean {
|
|
371
|
-
val item = items.getOrNull(id) ?: return false
|
|
372
|
-
if (action == AccessibilityNodeInfoCompat.ACTION_CLICK && item.isLink) {
|
|
373
|
-
val spanned = textView.text as? Spanned ?: return false
|
|
374
|
-
val linkSpan = spanned.getSpans(item.start, item.end, LinkSpan::class.java).firstOrNull() ?: return false
|
|
375
|
-
linkSpan.onClick(textView)
|
|
376
|
-
return true
|
|
377
|
-
}
|
|
378
|
-
return false
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
private fun AccessibilityNodeInfoCompat.applySemantics(item: AccessibilityItem) {
|
|
382
|
-
item.listInfo?.let { info ->
|
|
383
|
-
setCollectionItemInfo(
|
|
384
|
-
AccessibilityNodeInfoCompat.CollectionItemInfoCompat.obtain(info.itemNumber - 1, 1, 0, 1, false, false),
|
|
385
|
-
)
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
val blockquoteAnnouncement = blockquoteAnnouncementFor(item)
|
|
389
|
-
when {
|
|
390
|
-
item.isHeading -> {
|
|
391
|
-
isHeading = true
|
|
392
|
-
if (blockquoteAnnouncement != null) {
|
|
393
|
-
roleDescription = blockquoteAnnouncement
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
item.isImage -> {
|
|
398
|
-
roleDescription = appendBlockquote("image", blockquoteAnnouncement)
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
item.isLink -> {
|
|
402
|
-
isClickable = true
|
|
403
|
-
addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK)
|
|
404
|
-
val base = item.listInfo?.let { "link, ${it.listAnnouncement}" } ?: "link"
|
|
405
|
-
roleDescription = appendBlockquote(base, blockquoteAnnouncement)
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
item.isListItem -> {
|
|
409
|
-
roleDescription = appendBlockquote(item.listInfo!!.listAnnouncement, blockquoteAnnouncement)
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
blockquoteAnnouncement != null -> {
|
|
413
|
-
roleDescription = blockquoteAnnouncement
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
private fun appendBlockquote(
|
|
419
|
-
base: String,
|
|
420
|
-
blockquoteAnnouncement: String?,
|
|
421
|
-
): String = if (blockquoteAnnouncement != null) "$base, $blockquoteAnnouncement" else base
|
|
422
|
-
|
|
423
|
-
private fun blockquoteAnnouncementFor(item: AccessibilityItem): String? {
|
|
424
|
-
val spanned = textView.text as? Spanned ?: return null
|
|
425
|
-
if (spanned.isEmpty()) return null
|
|
426
|
-
val pos = item.start.coerceIn(0, spanned.length - 1).coerceAtLeast(0)
|
|
427
|
-
if (pos >= spanned.length) return null
|
|
428
|
-
val spans = spanned.getSpans(pos, pos + 1, BlockquoteSpan::class.java)
|
|
429
|
-
if (spans.isEmpty()) return null
|
|
430
|
-
val maxDepth = spans.maxOf { it.depth }
|
|
431
|
-
return if (maxDepth >= 1) labels.nestedBlockquote else labels.blockquote
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
private val ListItemInfo.listAnnouncement: String
|
|
435
|
-
get() {
|
|
436
|
-
val nested = depth > 0
|
|
437
|
-
return if (isOrdered) {
|
|
438
|
-
val template = if (nested) labels.nestedOrderedItem else labels.orderedItem
|
|
439
|
-
template.replace("{n}", itemNumber.toString())
|
|
440
|
-
} else {
|
|
441
|
-
if (nested) labels.nestedBulletPoint else labels.bulletPoint
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
private fun boundsForItem(item: AccessibilityItem): Rect {
|
|
446
|
-
val layout = textView.layout ?: return Rect()
|
|
447
|
-
if (layout.lineCount == 0) return Rect()
|
|
448
|
-
val maxOffset = visibleLineEnd(layout, lastVisibleLineIndex(layout))
|
|
449
|
-
val vs = item.visibleStart.coerceIn(0, maxOffset)
|
|
450
|
-
val ve = item.visibleEnd.coerceIn(vs, maxOffset)
|
|
451
|
-
|
|
452
|
-
val startLine = layout.getLineForOffset(vs)
|
|
453
|
-
val endLine = layout.getLineForOffset(maxOf(vs, ve - 1))
|
|
454
|
-
|
|
455
|
-
if (startLine == endLine) {
|
|
456
|
-
return singleLineBounds(vs, ve, startLine)
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
return multiLineBounds(vs, startLine, endLine)
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
private fun singleLineBounds(
|
|
463
|
-
start: Int,
|
|
464
|
-
end: Int,
|
|
465
|
-
line: Int,
|
|
466
|
-
): Rect {
|
|
467
|
-
val layout = textView.layout
|
|
468
|
-
val left = layout.getPrimaryHorizontal(start).toInt() + textView.paddingLeft
|
|
469
|
-
val rawRight = layout.getPrimaryHorizontal(end).toInt() + textView.paddingLeft
|
|
470
|
-
val right = if (rawRight <= left) layout.getLineRight(line).toInt() + textView.paddingLeft else rawRight
|
|
471
|
-
|
|
472
|
-
return Rect(
|
|
473
|
-
left,
|
|
474
|
-
layout.getLineTop(line) + textView.paddingTop,
|
|
475
|
-
right,
|
|
476
|
-
layout.getLineBottom(line) + textView.paddingTop,
|
|
477
|
-
)
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
private fun multiLineBounds(
|
|
481
|
-
visibleStart: Int,
|
|
482
|
-
startLine: Int,
|
|
483
|
-
endLine: Int,
|
|
484
|
-
): Rect {
|
|
485
|
-
val layout = textView.layout
|
|
486
|
-
val padLeft = textView.paddingLeft
|
|
487
|
-
|
|
488
|
-
val firstLineLeft = layout.getPrimaryHorizontal(visibleStart).toInt() + padLeft
|
|
489
|
-
val firstLineRight = layout.getLineRight(startLine).toInt() + padLeft
|
|
490
|
-
|
|
491
|
-
var minLeft = firstLineLeft
|
|
492
|
-
var maxRight = firstLineRight
|
|
493
|
-
for (line in (startLine + 1)..endLine) {
|
|
494
|
-
minLeft = minOf(minLeft, layout.getLineLeft(line).toInt() + padLeft)
|
|
495
|
-
maxRight = maxOf(maxRight, layout.getLineRight(line).toInt() + padLeft)
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
return Rect(
|
|
499
|
-
minOf(firstLineLeft, minLeft),
|
|
500
|
-
layout.getLineTop(startLine) + textView.paddingTop,
|
|
501
|
-
maxRight,
|
|
502
|
-
layout.getLineBottom(endLine) + textView.paddingTop,
|
|
503
|
-
)
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
private fun getCharOffsetAt(
|
|
507
|
-
x: Float,
|
|
508
|
-
y: Float,
|
|
509
|
-
): Int {
|
|
510
|
-
val layout = textView.layout ?: return 0
|
|
511
|
-
val line = layout.getLineForVertical(y.toInt()).coerceIn(0, layout.lineCount - 1)
|
|
512
|
-
return layout.getOffsetForHorizontal(line, x)
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
private val AccessibilityItem.hitTestPriority: Int
|
|
516
|
-
get() =
|
|
517
|
-
when {
|
|
518
|
-
isLink -> 0
|
|
519
|
-
isImage -> 1
|
|
520
|
-
isHeading -> 2
|
|
521
|
-
isListItem -> 3
|
|
522
|
-
else -> 4
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
private fun AccessibilityItem.distanceTo(offset: Int): Int =
|
|
526
|
-
when {
|
|
527
|
-
offset < start -> start - offset
|
|
528
|
-
offset >= end -> offset - end + 1
|
|
529
|
-
else -> 0
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
private fun getListInfoAt(
|
|
533
|
-
spanned: Spanned,
|
|
534
|
-
position: Int,
|
|
535
|
-
requireStart: Boolean,
|
|
536
|
-
): ListItemInfo? {
|
|
537
|
-
val deepest =
|
|
538
|
-
spanned
|
|
539
|
-
.getSpans(position, position + 1, BaseListSpan::class.java)
|
|
540
|
-
.maxByOrNull { it.depth } ?: return null
|
|
541
|
-
|
|
542
|
-
if (requireStart) {
|
|
543
|
-
val spanStart = spanned.getSpanStart(deepest)
|
|
544
|
-
val firstChar =
|
|
545
|
-
(spanStart until minOf(spanStart + 10, spanned.length))
|
|
546
|
-
.firstOrNull { !spanned[it].isWhitespace() } ?: spanStart
|
|
547
|
-
if (position > firstChar + 1) return null
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
return ListItemInfo(
|
|
551
|
-
isOrdered = deepest is OrderedListSpan,
|
|
552
|
-
itemNumber = (deepest as? OrderedListSpan)?.itemNumber ?: 0,
|
|
553
|
-
depth = deepest.depth,
|
|
554
|
-
)
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
private fun String.findFirstNonWhitespace(
|
|
559
|
-
from: Int,
|
|
560
|
-
to: Int,
|
|
561
|
-
): Int {
|
|
562
|
-
for (i in from until to) {
|
|
563
|
-
if (!this[i].isWhitespace()) return i
|
|
564
|
-
}
|
|
565
|
-
return from
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
private fun String.findLastNonWhitespace(
|
|
569
|
-
from: Int,
|
|
570
|
-
to: Int,
|
|
571
|
-
): Int {
|
|
572
|
-
for (i in (to - 1) downTo from) {
|
|
573
|
-
if (!this[i].isWhitespace()) return i + 1
|
|
574
|
-
}
|
|
575
|
-
return to
|
|
576
|
-
}
|
|
1
|
+
package com.renderrichtext.accessibility
|
|
2
|
+
|
|
3
|
+
import android.graphics.Rect
|
|
4
|
+
import android.os.Bundle
|
|
5
|
+
import android.text.Layout
|
|
6
|
+
import android.text.Spanned
|
|
7
|
+
import android.view.View
|
|
8
|
+
import android.view.ViewTreeObserver
|
|
9
|
+
import android.widget.TextView
|
|
10
|
+
import androidx.core.view.ViewCompat
|
|
11
|
+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
|
|
12
|
+
import androidx.customview.widget.ExploreByTouchHelper
|
|
13
|
+
import com.renderrichtext.spans.BaseListSpan
|
|
14
|
+
import com.renderrichtext.spans.BlockquoteSpan
|
|
15
|
+
import com.renderrichtext.spans.HeadingSpan
|
|
16
|
+
import com.renderrichtext.spans.ImageSpan
|
|
17
|
+
import com.renderrichtext.spans.LinkPillSpan
|
|
18
|
+
import com.renderrichtext.spans.LinkSpan
|
|
19
|
+
import com.renderrichtext.spans.OrderedListSpan
|
|
20
|
+
|
|
21
|
+
class TiptapAccessibilityHelper(
|
|
22
|
+
private val textView: TextView,
|
|
23
|
+
) : ExploreByTouchHelper(textView) {
|
|
24
|
+
private var items: List<AccessibilityItem> = emptyList()
|
|
25
|
+
private var needsRebuild = false
|
|
26
|
+
private var lastLayoutHashCode = 0
|
|
27
|
+
private var pendingLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
|
|
28
|
+
|
|
29
|
+
var labels: AccessibilityLabels = AccessibilityLabels()
|
|
30
|
+
set(value) {
|
|
31
|
+
if (field == value) return
|
|
32
|
+
field = value
|
|
33
|
+
invalidateAccessibilityItems()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
data class AccessibilityItem(
|
|
37
|
+
val id: Int,
|
|
38
|
+
val text: String,
|
|
39
|
+
/** Full character range — used for hit-testing so there are no gaps between items. */
|
|
40
|
+
val start: Int,
|
|
41
|
+
val end: Int,
|
|
42
|
+
/** Trimmed character range — used for bounds calculation to avoid whitespace offsets. */
|
|
43
|
+
val visibleStart: Int = start,
|
|
44
|
+
val visibleEnd: Int = end,
|
|
45
|
+
val headingLevel: Int = 0,
|
|
46
|
+
val linkUrl: String? = null,
|
|
47
|
+
val listInfo: ListItemInfo? = null,
|
|
48
|
+
val imageAltText: String? = null,
|
|
49
|
+
) {
|
|
50
|
+
val isHeading get() = headingLevel > 0
|
|
51
|
+
val isLink get() = linkUrl != null
|
|
52
|
+
val isListItem get() = listInfo != null
|
|
53
|
+
val isImage get() = imageAltText != null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
data class ListItemInfo(
|
|
57
|
+
val isOrdered: Boolean,
|
|
58
|
+
val itemNumber: Int,
|
|
59
|
+
val depth: Int,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
private data class SpanRange(
|
|
63
|
+
val start: Int,
|
|
64
|
+
val end: Int,
|
|
65
|
+
val headingLevel: Int = 0,
|
|
66
|
+
val linkUrl: String? = null,
|
|
67
|
+
val imageAltText: String? = null,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
fun invalidateAccessibilityItems() {
|
|
71
|
+
needsRebuild = true
|
|
72
|
+
if (textView.layout != null) {
|
|
73
|
+
rebuildIfNeeded()
|
|
74
|
+
invalidateRoot()
|
|
75
|
+
} else {
|
|
76
|
+
schedulePostLayoutRebuild()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private fun schedulePostLayoutRebuild() {
|
|
81
|
+
if (pendingLayoutListener != null) return
|
|
82
|
+
val observer = textView.viewTreeObserver
|
|
83
|
+
if (!observer.isAlive) return
|
|
84
|
+
val listener =
|
|
85
|
+
ViewTreeObserver.OnGlobalLayoutListener {
|
|
86
|
+
removePendingLayoutListener()
|
|
87
|
+
if (needsRebuild) {
|
|
88
|
+
rebuildIfNeeded()
|
|
89
|
+
invalidateRoot()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
pendingLayoutListener = listener
|
|
93
|
+
observer.addOnGlobalLayoutListener(listener)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Drops the pending global-layout listener, if any is still registered.
|
|
98
|
+
*
|
|
99
|
+
* Must run while the view is attached: `textView.viewTreeObserver` returns
|
|
100
|
+
* the window's observer only until detach, and the listener was merged into
|
|
101
|
+
* it from the view's floating observer when the view attached.
|
|
102
|
+
*/
|
|
103
|
+
fun cleanup() {
|
|
104
|
+
removePendingLayoutListener()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private fun removePendingLayoutListener() {
|
|
108
|
+
val listener = pendingLayoutListener ?: return
|
|
109
|
+
pendingLayoutListener = null
|
|
110
|
+
val observer = textView.viewTreeObserver
|
|
111
|
+
if (observer.isAlive) {
|
|
112
|
+
observer.removeOnGlobalLayoutListener(listener)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* When virtual children exist, prevent the host TextView from appearing as a
|
|
118
|
+
* standalone focusable element in TalkBack's swipe order. Without this,
|
|
119
|
+
* `setTextIsSelectable(true)` keeps the view focusable and TalkBack reads
|
|
120
|
+
* the entire text as one element before entering the virtual hierarchy.
|
|
121
|
+
*/
|
|
122
|
+
private fun updateHostFocusability() {
|
|
123
|
+
val hasVirtualChildren = items.isNotEmpty()
|
|
124
|
+
ViewCompat.setScreenReaderFocusable(textView, !hasVirtualChildren)
|
|
125
|
+
textView.importantForAccessibility =
|
|
126
|
+
if (hasVirtualChildren) View.IMPORTANT_FOR_ACCESSIBILITY_YES else View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private fun rebuildIfNeeded() {
|
|
130
|
+
val layout = textView.layout ?: return
|
|
131
|
+
if (needsRebuild || lastLayoutHashCode != layout.hashCode()) {
|
|
132
|
+
items = buildItems()
|
|
133
|
+
needsRebuild = false
|
|
134
|
+
lastLayoutHashCode = layout.hashCode()
|
|
135
|
+
updateHostFocusability()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private fun buildItems(): List<AccessibilityItem> {
|
|
140
|
+
val spanned = textView.text as? Spanned ?: return emptyList()
|
|
141
|
+
if (spanned.isEmpty()) return emptyList()
|
|
142
|
+
|
|
143
|
+
val text = spanned.toString()
|
|
144
|
+
val endLimit = visibleTextLength(text.length)
|
|
145
|
+
val result = mutableListOf<AccessibilityItem>()
|
|
146
|
+
var nextId = 0
|
|
147
|
+
val semanticSpans = collectSemanticSpans(spanned)
|
|
148
|
+
|
|
149
|
+
var paraStart = 0
|
|
150
|
+
while (paraStart < endLimit) {
|
|
151
|
+
val newlineIdx = text.indexOf('\n', paraStart)
|
|
152
|
+
val paraEnd = minOf(if (newlineIdx == -1) text.length else newlineIdx + 1, endLimit)
|
|
153
|
+
val trimmed = text.substring(paraStart, paraEnd).trim()
|
|
154
|
+
|
|
155
|
+
if (trimmed.isNotEmpty()) {
|
|
156
|
+
val spansInParagraph =
|
|
157
|
+
semanticSpans
|
|
158
|
+
.filter { it.start < paraEnd && it.end > paraStart }
|
|
159
|
+
.map { it.copy(end = minOf(it.end, endLimit)) }
|
|
160
|
+
|
|
161
|
+
if (spansInParagraph.isEmpty()) {
|
|
162
|
+
result.add(
|
|
163
|
+
createTextItem(nextId++, trimmed, paraStart, paraEnd, text, spanned),
|
|
164
|
+
)
|
|
165
|
+
} else {
|
|
166
|
+
nextId = addSegmentedItems(result, spanned, text, paraStart, paraEnd, spansInParagraph, nextId)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
paraStart = paraEnd
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return result.ifEmpty {
|
|
173
|
+
val visibleText = text.substring(0, endLimit).trim()
|
|
174
|
+
if (visibleText.isEmpty()) emptyList() else listOf(AccessibilityItem(0, visibleText, 0, endLimit))
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Last line that is visible; less than lineCount - 1 when height-capped (e.g. `clip`). */
|
|
179
|
+
private fun lastVisibleLineIndex(layout: Layout): Int {
|
|
180
|
+
val maxLines = textView.maxLines.coerceAtLeast(1)
|
|
181
|
+
return (minOf(layout.lineCount, maxLines) - 1).coerceIn(0, layout.lineCount - 1)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Ellipsis-aware end offset for a visible line. */
|
|
185
|
+
private fun visibleLineEnd(
|
|
186
|
+
layout: Layout,
|
|
187
|
+
line: Int,
|
|
188
|
+
): Int {
|
|
189
|
+
val lineEnd = layout.getLineEnd(line)
|
|
190
|
+
val count = layout.getEllipsisCount(line)
|
|
191
|
+
if (count == 0) return lineEnd
|
|
192
|
+
// Only a tail ellipsis hides text up to the end of the line. Head/middle hide a prefix or an
|
|
193
|
+
// inner range, so the line end is still laid out and must stay reachable.
|
|
194
|
+
val hiddenStart = layout.getLineStart(line) + layout.getEllipsisStart(line)
|
|
195
|
+
return if (hiddenStart + count >= lineEnd) hiddenStart else lineEnd
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** End offset of the laid-out (visible) text; less than the full length when truncated. */
|
|
199
|
+
private fun visibleTextLength(fullLength: Int): Int {
|
|
200
|
+
val layout = textView.layout ?: return fullLength
|
|
201
|
+
if (layout.lineCount == 0) return fullLength
|
|
202
|
+
return minOf(fullLength, maxOf(0, visibleLineEnd(layout, lastVisibleLineIndex(layout))))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private fun collectSemanticSpans(spanned: Spanned): List<SpanRange> =
|
|
206
|
+
(
|
|
207
|
+
spanned.getSpans(0, spanned.length, HeadingSpan::class.java).map {
|
|
208
|
+
SpanRange(spanned.getSpanStart(it), spanned.getSpanEnd(it), headingLevel = it.level)
|
|
209
|
+
} +
|
|
210
|
+
spanned.getSpans(0, spanned.length, LinkSpan::class.java).map {
|
|
211
|
+
SpanRange(spanned.getSpanStart(it), spanned.getSpanEnd(it), linkUrl = it.url)
|
|
212
|
+
} +
|
|
213
|
+
spanned.getSpans(0, spanned.length, ImageSpan::class.java).map {
|
|
214
|
+
SpanRange(spanned.getSpanStart(it), spanned.getSpanEnd(it), imageAltText = it.altText)
|
|
215
|
+
}
|
|
216
|
+
).sortedBy { it.start }
|
|
217
|
+
|
|
218
|
+
private fun addSegmentedItems(
|
|
219
|
+
items: MutableList<AccessibilityItem>,
|
|
220
|
+
spanned: Spanned,
|
|
221
|
+
text: String,
|
|
222
|
+
paraStart: Int,
|
|
223
|
+
paraEnd: Int,
|
|
224
|
+
spans: List<SpanRange>,
|
|
225
|
+
startId: Int,
|
|
226
|
+
): Int {
|
|
227
|
+
var nextId = startId
|
|
228
|
+
var segmentPos = paraStart
|
|
229
|
+
|
|
230
|
+
for (span in spans) {
|
|
231
|
+
if (span.start < segmentPos) continue
|
|
232
|
+
|
|
233
|
+
// Text before the span
|
|
234
|
+
if (segmentPos < span.start) {
|
|
235
|
+
val beforeText = text.substring(segmentPos, span.start).trim()
|
|
236
|
+
if (beforeText.isNotEmpty() && beforeText.any { it.isLetterOrDigit() }) {
|
|
237
|
+
items.add(createTextItem(nextId++, beforeText, segmentPos, span.start, text, spanned))
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The semantic span itself
|
|
242
|
+
val pill =
|
|
243
|
+
if (span.linkUrl != null) {
|
|
244
|
+
spanned.getSpans(span.start, span.end, LinkPillSpan::class.java).firstOrNull {
|
|
245
|
+
spanned.getSpanStart(it) == span.start && spanned.getSpanEnd(it) == span.end
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
null
|
|
249
|
+
}
|
|
250
|
+
val content = span.imageAltText ?: pill?.accessibilityText ?: spanned.substring(span.start, span.end).trim()
|
|
251
|
+
if (content.isNotEmpty()) {
|
|
252
|
+
items.add(createSpanItem(nextId++, content, span, spanned))
|
|
253
|
+
}
|
|
254
|
+
segmentPos = span.end
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Text after the last span
|
|
258
|
+
if (segmentPos < paraEnd) {
|
|
259
|
+
val afterText = text.substring(segmentPos, paraEnd).trim()
|
|
260
|
+
if (afterText.isNotEmpty() && afterText.any { it.isLetterOrDigit() }) {
|
|
261
|
+
items.add(createTextItem(nextId++, afterText, segmentPos, paraEnd, text, spanned))
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return nextId
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private fun createTextItem(
|
|
269
|
+
id: Int,
|
|
270
|
+
label: String,
|
|
271
|
+
start: Int,
|
|
272
|
+
end: Int,
|
|
273
|
+
text: String,
|
|
274
|
+
spanned: Spanned,
|
|
275
|
+
) = AccessibilityItem(
|
|
276
|
+
id = id,
|
|
277
|
+
text = label,
|
|
278
|
+
start = start,
|
|
279
|
+
end = end,
|
|
280
|
+
visibleStart = text.findFirstNonWhitespace(start, end),
|
|
281
|
+
visibleEnd = text.findLastNonWhitespace(start, end),
|
|
282
|
+
listInfo = getListInfoAt(spanned, start, requireStart = true),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
private fun createSpanItem(
|
|
286
|
+
id: Int,
|
|
287
|
+
content: String,
|
|
288
|
+
span: SpanRange,
|
|
289
|
+
spanned: Spanned,
|
|
290
|
+
): AccessibilityItem {
|
|
291
|
+
val listContext =
|
|
292
|
+
if (span.headingLevel > 0 || span.imageAltText != null) {
|
|
293
|
+
null
|
|
294
|
+
} else {
|
|
295
|
+
getListInfoAt(spanned, span.start, requireStart = span.linkUrl == null)
|
|
296
|
+
}
|
|
297
|
+
return AccessibilityItem(
|
|
298
|
+
id = id,
|
|
299
|
+
text = content,
|
|
300
|
+
start = span.start,
|
|
301
|
+
end = span.end,
|
|
302
|
+
headingLevel = span.headingLevel,
|
|
303
|
+
linkUrl = span.linkUrl,
|
|
304
|
+
listInfo = listContext,
|
|
305
|
+
imageAltText = span.imageAltText,
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
override fun getVirtualViewAt(
|
|
310
|
+
x: Float,
|
|
311
|
+
y: Float,
|
|
312
|
+
): Int {
|
|
313
|
+
rebuildIfNeeded()
|
|
314
|
+
if (items.isEmpty()) return HOST_ID
|
|
315
|
+
|
|
316
|
+
val offset = getCharOffsetAt(x, y)
|
|
317
|
+
|
|
318
|
+
val exact =
|
|
319
|
+
items
|
|
320
|
+
.filter { offset in it.start until it.end }
|
|
321
|
+
.minByOrNull { it.hitTestPriority }
|
|
322
|
+
if (exact != null) return exact.id
|
|
323
|
+
|
|
324
|
+
return items.minByOrNull { it.distanceTo(offset) }?.id ?: HOST_ID
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
override fun getVisibleVirtualViews(ids: MutableList<Int>) {
|
|
328
|
+
rebuildIfNeeded()
|
|
329
|
+
items.forEach { ids.add(it.id) }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
override fun onPopulateNodeForHost(host: AccessibilityNodeInfoCompat) {
|
|
333
|
+
super.onPopulateNodeForHost(host)
|
|
334
|
+
rebuildIfNeeded()
|
|
335
|
+
if (items.isNotEmpty()) {
|
|
336
|
+
host.isScreenReaderFocusable = false
|
|
337
|
+
host.isFocusable = false
|
|
338
|
+
// Prevent TalkBack from reading the full text when swiping onto the host.
|
|
339
|
+
// Without this, the host node retains the TextView's text and TalkBack
|
|
340
|
+
// announces it as a single element before entering the virtual children.
|
|
341
|
+
host.text = null
|
|
342
|
+
host.contentDescription = null
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
override fun onPopulateNodeForVirtualView(
|
|
347
|
+
id: Int,
|
|
348
|
+
node: AccessibilityNodeInfoCompat,
|
|
349
|
+
) {
|
|
350
|
+
val item = items.getOrNull(id)
|
|
351
|
+
if (item == null) {
|
|
352
|
+
node.contentDescription = ""
|
|
353
|
+
node.setBoundsInParent(Rect())
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
node.apply {
|
|
357
|
+
text = item.text
|
|
358
|
+
contentDescription = item.text
|
|
359
|
+
isFocusable = true
|
|
360
|
+
isScreenReaderFocusable = true
|
|
361
|
+
setBoundsInParent(boundsForItem(item))
|
|
362
|
+
applySemantics(item)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
override fun onPerformActionForVirtualView(
|
|
367
|
+
id: Int,
|
|
368
|
+
action: Int,
|
|
369
|
+
args: Bundle?,
|
|
370
|
+
): Boolean {
|
|
371
|
+
val item = items.getOrNull(id) ?: return false
|
|
372
|
+
if (action == AccessibilityNodeInfoCompat.ACTION_CLICK && item.isLink) {
|
|
373
|
+
val spanned = textView.text as? Spanned ?: return false
|
|
374
|
+
val linkSpan = spanned.getSpans(item.start, item.end, LinkSpan::class.java).firstOrNull() ?: return false
|
|
375
|
+
linkSpan.onClick(textView)
|
|
376
|
+
return true
|
|
377
|
+
}
|
|
378
|
+
return false
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private fun AccessibilityNodeInfoCompat.applySemantics(item: AccessibilityItem) {
|
|
382
|
+
item.listInfo?.let { info ->
|
|
383
|
+
setCollectionItemInfo(
|
|
384
|
+
AccessibilityNodeInfoCompat.CollectionItemInfoCompat.obtain(info.itemNumber - 1, 1, 0, 1, false, false),
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
val blockquoteAnnouncement = blockquoteAnnouncementFor(item)
|
|
389
|
+
when {
|
|
390
|
+
item.isHeading -> {
|
|
391
|
+
isHeading = true
|
|
392
|
+
if (blockquoteAnnouncement != null) {
|
|
393
|
+
roleDescription = blockquoteAnnouncement
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
item.isImage -> {
|
|
398
|
+
roleDescription = appendBlockquote("image", blockquoteAnnouncement)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
item.isLink -> {
|
|
402
|
+
isClickable = true
|
|
403
|
+
addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK)
|
|
404
|
+
val base = item.listInfo?.let { "link, ${it.listAnnouncement}" } ?: "link"
|
|
405
|
+
roleDescription = appendBlockquote(base, blockquoteAnnouncement)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
item.isListItem -> {
|
|
409
|
+
roleDescription = appendBlockquote(item.listInfo!!.listAnnouncement, blockquoteAnnouncement)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
blockquoteAnnouncement != null -> {
|
|
413
|
+
roleDescription = blockquoteAnnouncement
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private fun appendBlockquote(
|
|
419
|
+
base: String,
|
|
420
|
+
blockquoteAnnouncement: String?,
|
|
421
|
+
): String = if (blockquoteAnnouncement != null) "$base, $blockquoteAnnouncement" else base
|
|
422
|
+
|
|
423
|
+
private fun blockquoteAnnouncementFor(item: AccessibilityItem): String? {
|
|
424
|
+
val spanned = textView.text as? Spanned ?: return null
|
|
425
|
+
if (spanned.isEmpty()) return null
|
|
426
|
+
val pos = item.start.coerceIn(0, spanned.length - 1).coerceAtLeast(0)
|
|
427
|
+
if (pos >= spanned.length) return null
|
|
428
|
+
val spans = spanned.getSpans(pos, pos + 1, BlockquoteSpan::class.java)
|
|
429
|
+
if (spans.isEmpty()) return null
|
|
430
|
+
val maxDepth = spans.maxOf { it.depth }
|
|
431
|
+
return if (maxDepth >= 1) labels.nestedBlockquote else labels.blockquote
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private val ListItemInfo.listAnnouncement: String
|
|
435
|
+
get() {
|
|
436
|
+
val nested = depth > 0
|
|
437
|
+
return if (isOrdered) {
|
|
438
|
+
val template = if (nested) labels.nestedOrderedItem else labels.orderedItem
|
|
439
|
+
template.replace("{n}", itemNumber.toString())
|
|
440
|
+
} else {
|
|
441
|
+
if (nested) labels.nestedBulletPoint else labels.bulletPoint
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private fun boundsForItem(item: AccessibilityItem): Rect {
|
|
446
|
+
val layout = textView.layout ?: return Rect()
|
|
447
|
+
if (layout.lineCount == 0) return Rect()
|
|
448
|
+
val maxOffset = visibleLineEnd(layout, lastVisibleLineIndex(layout))
|
|
449
|
+
val vs = item.visibleStart.coerceIn(0, maxOffset)
|
|
450
|
+
val ve = item.visibleEnd.coerceIn(vs, maxOffset)
|
|
451
|
+
|
|
452
|
+
val startLine = layout.getLineForOffset(vs)
|
|
453
|
+
val endLine = layout.getLineForOffset(maxOf(vs, ve - 1))
|
|
454
|
+
|
|
455
|
+
if (startLine == endLine) {
|
|
456
|
+
return singleLineBounds(vs, ve, startLine)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return multiLineBounds(vs, startLine, endLine)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private fun singleLineBounds(
|
|
463
|
+
start: Int,
|
|
464
|
+
end: Int,
|
|
465
|
+
line: Int,
|
|
466
|
+
): Rect {
|
|
467
|
+
val layout = textView.layout
|
|
468
|
+
val left = layout.getPrimaryHorizontal(start).toInt() + textView.paddingLeft
|
|
469
|
+
val rawRight = layout.getPrimaryHorizontal(end).toInt() + textView.paddingLeft
|
|
470
|
+
val right = if (rawRight <= left) layout.getLineRight(line).toInt() + textView.paddingLeft else rawRight
|
|
471
|
+
|
|
472
|
+
return Rect(
|
|
473
|
+
left,
|
|
474
|
+
layout.getLineTop(line) + textView.paddingTop,
|
|
475
|
+
right,
|
|
476
|
+
layout.getLineBottom(line) + textView.paddingTop,
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private fun multiLineBounds(
|
|
481
|
+
visibleStart: Int,
|
|
482
|
+
startLine: Int,
|
|
483
|
+
endLine: Int,
|
|
484
|
+
): Rect {
|
|
485
|
+
val layout = textView.layout
|
|
486
|
+
val padLeft = textView.paddingLeft
|
|
487
|
+
|
|
488
|
+
val firstLineLeft = layout.getPrimaryHorizontal(visibleStart).toInt() + padLeft
|
|
489
|
+
val firstLineRight = layout.getLineRight(startLine).toInt() + padLeft
|
|
490
|
+
|
|
491
|
+
var minLeft = firstLineLeft
|
|
492
|
+
var maxRight = firstLineRight
|
|
493
|
+
for (line in (startLine + 1)..endLine) {
|
|
494
|
+
minLeft = minOf(minLeft, layout.getLineLeft(line).toInt() + padLeft)
|
|
495
|
+
maxRight = maxOf(maxRight, layout.getLineRight(line).toInt() + padLeft)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return Rect(
|
|
499
|
+
minOf(firstLineLeft, minLeft),
|
|
500
|
+
layout.getLineTop(startLine) + textView.paddingTop,
|
|
501
|
+
maxRight,
|
|
502
|
+
layout.getLineBottom(endLine) + textView.paddingTop,
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private fun getCharOffsetAt(
|
|
507
|
+
x: Float,
|
|
508
|
+
y: Float,
|
|
509
|
+
): Int {
|
|
510
|
+
val layout = textView.layout ?: return 0
|
|
511
|
+
val line = layout.getLineForVertical(y.toInt()).coerceIn(0, layout.lineCount - 1)
|
|
512
|
+
return layout.getOffsetForHorizontal(line, x)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private val AccessibilityItem.hitTestPriority: Int
|
|
516
|
+
get() =
|
|
517
|
+
when {
|
|
518
|
+
isLink -> 0
|
|
519
|
+
isImage -> 1
|
|
520
|
+
isHeading -> 2
|
|
521
|
+
isListItem -> 3
|
|
522
|
+
else -> 4
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private fun AccessibilityItem.distanceTo(offset: Int): Int =
|
|
526
|
+
when {
|
|
527
|
+
offset < start -> start - offset
|
|
528
|
+
offset >= end -> offset - end + 1
|
|
529
|
+
else -> 0
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private fun getListInfoAt(
|
|
533
|
+
spanned: Spanned,
|
|
534
|
+
position: Int,
|
|
535
|
+
requireStart: Boolean,
|
|
536
|
+
): ListItemInfo? {
|
|
537
|
+
val deepest =
|
|
538
|
+
spanned
|
|
539
|
+
.getSpans(position, position + 1, BaseListSpan::class.java)
|
|
540
|
+
.maxByOrNull { it.depth } ?: return null
|
|
541
|
+
|
|
542
|
+
if (requireStart) {
|
|
543
|
+
val spanStart = spanned.getSpanStart(deepest)
|
|
544
|
+
val firstChar =
|
|
545
|
+
(spanStart until minOf(spanStart + 10, spanned.length))
|
|
546
|
+
.firstOrNull { !spanned[it].isWhitespace() } ?: spanStart
|
|
547
|
+
if (position > firstChar + 1) return null
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return ListItemInfo(
|
|
551
|
+
isOrdered = deepest is OrderedListSpan,
|
|
552
|
+
itemNumber = (deepest as? OrderedListSpan)?.itemNumber ?: 0,
|
|
553
|
+
depth = deepest.depth,
|
|
554
|
+
)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private fun String.findFirstNonWhitespace(
|
|
559
|
+
from: Int,
|
|
560
|
+
to: Int,
|
|
561
|
+
): Int {
|
|
562
|
+
for (i in from until to) {
|
|
563
|
+
if (!this[i].isWhitespace()) return i
|
|
564
|
+
}
|
|
565
|
+
return from
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private fun String.findLastNonWhitespace(
|
|
569
|
+
from: Int,
|
|
570
|
+
to: Int,
|
|
571
|
+
): Int {
|
|
572
|
+
for (i in (to - 1) downTo from) {
|
|
573
|
+
if (!this[i].isWhitespace()) return i + 1
|
|
574
|
+
}
|
|
575
|
+
return to
|
|
576
|
+
}
|