react-native-jet-markdown 0.2.2 → 0.2.3-beta.1
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/JetMarkdown.podspec +1 -1
- package/README.md +28 -27
- package/android/src/main/java/com/jetmarkdown/JetMarkdownView.kt +11 -11
- package/android/src/main/java/com/jetmarkdown/views/BlockStackView.kt +49 -22
- package/android/src/main/java/com/jetmarkdown/views/BlockTextView.kt +2 -0
- package/android/src/main/java/com/jetmarkdown/views/TableBlockView.kt +11 -3
- package/cpp/core/InlineExtensions.cpp +2 -2
- package/cpp/core/Parser.cpp +7 -1
- package/cpp/md4c/md4c.c +14 -1
- package/ios/JetMarkdownView.mm +9 -16
- package/ios/measure/JMDMarkdownMeasurer.mm +1 -1
- package/ios/render/JMDBlock.h +7 -0
- package/ios/render/JMDBlock.m +2 -0
- package/ios/render/JMDBlockRenderer.mm +27 -1
- package/ios/render/JMDRenderedContent.mm +36 -11
- package/ios/views/JMDBlockStackView.m +36 -14
- package/ios/views/JMDBlockTextView.h +6 -1
- package/ios/views/JMDBlockTextView.m +90 -127
- package/ios/views/JMDBoxViews.m +14 -11
- package/ios/views/JMDTableView.m +15 -8
- package/package.json +2 -1
- package/cpp/tests/parser_tests.cpp +0 -260
- package/cpp/tests/run_tests.sh +0 -22
package/JetMarkdown.podspec
CHANGED
|
@@ -11,7 +11,7 @@ Pod::Spec.new do |s|
|
|
|
11
11
|
s.authors = package["author"]
|
|
12
12
|
|
|
13
13
|
s.platforms = { :ios => min_ios_version_supported }
|
|
14
|
-
s.source = { :git => "https://github.com/alizahid/react-native-jet-markdown.git", :tag => "#{s.version}" }
|
|
14
|
+
s.source = { :git => "https://github.com/alizahid/react-native-jet-markdown.git", :tag => "v#{s.version}" }
|
|
15
15
|
|
|
16
16
|
# Headers are deliberately NOT in source_files. Xcode's project-wide
|
|
17
17
|
# header maps index every pod target's headers BY BASENAME, so adding
|
package/README.md
CHANGED
|
@@ -33,6 +33,8 @@ cd ios && pod install
|
|
|
33
33
|
|
|
34
34
|
Requires React Native 0.86+ with the New Architecture (Fabric).
|
|
35
35
|
|
|
36
|
+
> **Android support is experimental.** The Android renderer implements the same feature set as iOS, but it has had far less real-world use. Expect rough edges and please [report](https://github.com/alizahid/react-native-jet-markdown/issues) anything that renders differently from iOS.
|
|
37
|
+
|
|
36
38
|
## Usage
|
|
37
39
|
|
|
38
40
|
```tsx
|
|
@@ -71,20 +73,29 @@ const styles: MarkdownStyles = mergeStyles({
|
|
|
71
73
|
| Prop | Type | Description |
|
|
72
74
|
| --- | --- | --- |
|
|
73
75
|
| `markdown` | `string` | The markdown source. |
|
|
74
|
-
| `style` | `MarkdownContainerStyle` |
|
|
75
|
-
| `styles` | `MarkdownStyles` | Per-element styles
|
|
76
|
-
| `images` | `{ url, width, height }[]` | Pre-sizing data. Listed images lay out at their final size immediately
|
|
76
|
+
| `style` | `MarkdownContainerStyle` | Container box (`backgroundColor`, `padding*`, `gap`) plus base text styles that cascade into every element. See [Container style](#container-style). |
|
|
77
|
+
| `styles` | `MarkdownStyles` | Per-element styles. See [Styling](#styling). Hoist to module scope or memoize. |
|
|
78
|
+
| `images` | `{ url, width, height }[]` | Pre-sizing data. Listed images lay out at their final size immediately (zero layout shift); unlisted ones render a full-width, 200pt-tall placeholder and snap to their real aspect once loaded. |
|
|
79
|
+
| `allowFontScaling` | `boolean` | Scale all text, including `lineHeight`, with the system font size setting. Default `true`. |
|
|
77
80
|
| `onLinkPress` | `({ url }) => void` | Link or mention tapped. Mentions arrive with their scheme (e.g. `users://ali`). |
|
|
78
81
|
| `onLinkLongPress` | `({ url }) => void` | Link long-pressed. |
|
|
79
82
|
| `onImagePress` | `({ url, x, y, width, height }) => void` | Image tapped. `width`/`height` are the rendered size and `x`/`y` the position relative to the screen (dp) — everything a lightbox needs for a zoom-from-thumbnail transition. |
|
|
80
83
|
|
|
81
84
|
## Styling
|
|
82
85
|
|
|
83
|
-
The viewer ships **unstyled by default
|
|
86
|
+
The viewer ships **unstyled by default**: with no `styles` prop the output is plain text (only bold/italic runs, monospace code, list markers, and the spoiler cover survive). Two exports cover the common cases:
|
|
84
87
|
|
|
85
88
|
- `defaultStyles` — a plain `MarkdownStyles` object with the classic markdown look (heading scale, blue links, code boxes, quote bar, table separators). It's just data: spread it, fork it, or use it as a reference.
|
|
86
89
|
- `mergeStyles(overrides)` — deep-merges your overrides into `defaultStyles` (element sections merge key-by-key, heading levels individually).
|
|
87
90
|
|
|
91
|
+
### Container style
|
|
92
|
+
|
|
93
|
+
The `style` prop styles the container itself: `backgroundColor`, `padding` (+ `paddingHorizontal`/`paddingVertical` and per-side), and `gap` (spacing between blocks; wins over `styles.gap`). Text keys on it (`fontSize`, `fontWeight`, `fontFamily`, `color`, `lineHeight`, `fontVariant`, `textDecoration*`) are the base of the cascade: every element inherits them unless its own section overrides. Element builtins survive the cascade — heading sizes and weights stay unless `headings.hN` overrides, and code keeps its monospace font unless `codeBlock` overrides.
|
|
94
|
+
|
|
95
|
+
All of these are measured natively. For outer layout (margin, width, flex), wrap the viewer in a `View`.
|
|
96
|
+
|
|
97
|
+
### Element styles
|
|
98
|
+
|
|
88
99
|
Two shared shapes compose every element style:
|
|
89
100
|
|
|
90
101
|
**Text** — `fontSize`, `fontWeight`, `fontFamily`, `color`, `backgroundColor` (run highlight), `fontVariant`, `lineHeight`, `textDecorationColor`, `textDecorationLine`, `textDecorationStyle`
|
|
@@ -125,6 +136,10 @@ Each column gets its natural (unwrapped) width, clamped to `[minColumnWidth, max
|
|
|
125
136
|
|
|
126
137
|
Mentions are plain markdown links with custom schemes — `[@ali](users://ali)`, `[#general](channels://general)` — classified by the `mention.variants` regexes and styled accordingly. Presses arrive through `onLinkPress`; branch on the URL scheme.
|
|
127
138
|
|
|
139
|
+
### Images
|
|
140
|
+
|
|
141
|
+
A paragraph containing only an image renders as a block image, aspect-fit to the container width. Pass `images` to pre-size known images and avoid layout shift. Loading runs on SDWebImage (iOS) and Glide (Android) — the same cores expo-image uses — with memory + disk caches, request dedupe, and animated GIF playback (plus APNG on iOS).
|
|
142
|
+
|
|
128
143
|
## Using in lists
|
|
129
144
|
|
|
130
145
|
Rendering hundreds of viewers in FlatList / FlashList / LegendList is a first-class use case:
|
|
@@ -161,37 +176,23 @@ You may notice styles ship natively as one `stylesJson` string instead of a stru
|
|
|
161
176
|
## Platform notes & limitations
|
|
162
177
|
|
|
163
178
|
- New Architecture (Fabric) only; React Native 0.86+.
|
|
179
|
+
- Android support is experimental; iOS is the reference implementation.
|
|
164
180
|
- `textDecorationStyle`/`textDecorationColor` render fully on iOS; Android draws plain underline/strikethrough.
|
|
165
181
|
- `borderCurve: 'continuous'` is iOS-only.
|
|
166
182
|
- `fontVariant` supports `tabular-nums`, `proportional-nums`, `oldstyle-nums`, `lining-nums`, `small-caps` (font support required).
|
|
167
|
-
- `allowFontScaling` (default `true`) scales all text — including `lineHeight` — with the system font size setting.
|
|
168
183
|
- Spoilers and inline formatting inside link labels stay literal; spoilers inside table cells are unsupported (the `|` delimiter conflicts).
|
|
169
184
|
- Code blocks render plain monospace (no syntax highlighting).
|
|
185
|
+
- The viewer fills the width it is given and sizes its own height. Under an unconstrained width (a horizontal `ScrollView`, `alignSelf: 'flex-start'`) it has nothing to wrap against and renders empty — give it a definite width.
|
|
170
186
|
|
|
171
187
|
## Sponsors
|
|
172
188
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
<b>Duet</b>
|
|
181
|
-
</a>
|
|
182
|
-
<br>
|
|
183
|
-
<sub>Your AI coworker that runs your business 24/7</sub>
|
|
184
|
-
</td>
|
|
185
|
-
<td align="center" width="200">
|
|
186
|
-
<a href="https://acorn.blue">
|
|
187
|
-
<img alt="Acorn" src="https://github.com/alizahid/react-native-jet-markdown/blob/main/.github/acorn.png?raw=true" width="96"><br>
|
|
188
|
-
<b>Acorn</b>
|
|
189
|
-
</a>
|
|
190
|
-
<br>
|
|
191
|
-
<sub>Reddit for mobile</sub>
|
|
192
|
-
</td>
|
|
193
|
-
</tr>
|
|
194
|
-
</table>
|
|
189
|
+
<p align="center">
|
|
190
|
+
<a href="https://acorn.blue"><img alt="Acorn" src="https://acorn.blue/images/acorn.png" width="96"></a>
|
|
191
|
+
</p>
|
|
192
|
+
|
|
193
|
+
<p align="center">
|
|
194
|
+
Built for and sponsored by <a href="https://acorn.blue">Acorn</a>, a Reddit client for iOS.
|
|
195
|
+
</p>
|
|
195
196
|
|
|
196
197
|
## License
|
|
197
198
|
|
|
@@ -10,6 +10,7 @@ import com.facebook.react.uimanager.StateWrapper
|
|
|
10
10
|
import com.facebook.react.uimanager.UIManagerHelper
|
|
11
11
|
import com.facebook.react.uimanager.events.Event
|
|
12
12
|
import com.jetmarkdown.render.ContentCache
|
|
13
|
+
import com.jetmarkdown.render.RenderedContent
|
|
13
14
|
import com.jetmarkdown.style.StyleConfig
|
|
14
15
|
import com.jetmarkdown.views.BlockStackView
|
|
15
16
|
import com.jetmarkdown.views.MarkdownHost
|
|
@@ -27,12 +28,14 @@ class JetMarkdownView(context: Context) : ViewGroup(context), MarkdownHost {
|
|
|
27
28
|
set(value) {
|
|
28
29
|
if (field != value) {
|
|
29
30
|
field = value
|
|
30
|
-
|
|
31
|
+
boundLayout = null
|
|
31
32
|
requestLayout()
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
// Layouts are cached per (content, width, image sizes), and content per
|
|
36
|
+
// (markdown, styles, font scale, appearance): identity says whether the
|
|
37
|
+
// bound view tree is still current.
|
|
38
|
+
private var boundLayout: RenderedContent.WidthLayout? = null
|
|
36
39
|
private val stack = BlockStackView(context)
|
|
37
40
|
|
|
38
41
|
/** url -> [w, h] dp: from the images prop (wins) and loaded bitmaps. */
|
|
@@ -53,8 +56,7 @@ class JetMarkdownView(context: Context) : ViewGroup(context), MarkdownHost {
|
|
|
53
56
|
stylesJson = ""
|
|
54
57
|
allowFontScaling = true
|
|
55
58
|
stateWrapper = null
|
|
56
|
-
|
|
57
|
-
boundWidth = 0
|
|
59
|
+
boundLayout = null
|
|
58
60
|
propImageSizes.clear()
|
|
59
61
|
loadedImageSizes.clear()
|
|
60
62
|
revealedSpoilers.clear()
|
|
@@ -200,9 +202,9 @@ class JetMarkdownView(context: Context) : ViewGroup(context), MarkdownHost {
|
|
|
200
202
|
val paddingRightPx = (styles.paddingRight * density).toInt()
|
|
201
203
|
val paddingTopPx = (styles.paddingTop * density).toInt()
|
|
202
204
|
val contentWidthPx = (r - l) - paddingLeftPx - paddingRightPx
|
|
203
|
-
if (contentWidthPx <= 0
|
|
205
|
+
if (contentWidthPx <= 0) {
|
|
204
206
|
stack.setBlocks(emptyList(), 0f)
|
|
205
|
-
|
|
207
|
+
boundLayout = null
|
|
206
208
|
return
|
|
207
209
|
}
|
|
208
210
|
|
|
@@ -210,10 +212,8 @@ class JetMarkdownView(context: Context) : ViewGroup(context), MarkdownHost {
|
|
|
210
212
|
val imageSizes = mergedImageSizes()
|
|
211
213
|
val layout = content.layoutFor(contentWidthPx, imageSizes)
|
|
212
214
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
boundKey = key
|
|
216
|
-
boundWidth = contentWidthPx
|
|
215
|
+
if (layout !== boundLayout) {
|
|
216
|
+
boundLayout = layout
|
|
217
217
|
stack.setBlocks(layout.measured, content.gap)
|
|
218
218
|
}
|
|
219
219
|
|
|
@@ -17,45 +17,63 @@ class BlockStackView(context: Context) : ViewGroup(context) {
|
|
|
17
17
|
|
|
18
18
|
var host: MarkdownHost? = null
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
// Discard hook for views dropped for good: stop in-flight image requests
|
|
21
|
+
// from delivering into a recycled host.
|
|
22
|
+
private fun unbindImages(view: View) {
|
|
23
|
+
when (view) {
|
|
24
|
+
is MarkdownImageView -> view.unbind()
|
|
25
|
+
is ViewGroup -> for (index in 0 until view.childCount) unbindImages(view.getChildAt(index))
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Rebinds in place: a child of the right kind at the same index is reused,
|
|
31
|
+
* anything else is replaced. List recycling rebinds whole trees, so the
|
|
32
|
+
* common case (same block kinds, different content) allocates nothing.
|
|
33
|
+
*/
|
|
29
34
|
fun setBlocks(blocks: List<MeasuredBlock>, gap: Float) {
|
|
30
|
-
unbindImages(this)
|
|
31
35
|
measured = blocks
|
|
32
36
|
gapPx = gap
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
blocks.forEachIndexed { index, block ->
|
|
38
|
+
val old = getChildAt(index)
|
|
39
|
+
val view = bindView(old, block)
|
|
40
|
+
if (view !== old) {
|
|
41
|
+
if (old != null) {
|
|
42
|
+
unbindImages(old)
|
|
43
|
+
removeViewAt(index)
|
|
44
|
+
}
|
|
45
|
+
addView(view, index)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
while (childCount > blocks.size) {
|
|
49
|
+
val extra = getChildAt(childCount - 1)
|
|
50
|
+
unbindImages(extra)
|
|
51
|
+
removeViewAt(childCount - 1)
|
|
36
52
|
}
|
|
37
53
|
requestLayout()
|
|
38
54
|
}
|
|
39
55
|
|
|
40
|
-
private fun
|
|
56
|
+
private fun bindView(old: View?, measuredBlock: MeasuredBlock): View {
|
|
41
57
|
return when (val block = measuredBlock.block) {
|
|
42
|
-
is Block.Text -> BlockTextView(context).apply {
|
|
58
|
+
is Block.Text -> (old as? BlockTextView ?: BlockTextView(context)).apply {
|
|
43
59
|
this.host = this@BlockStackView.host
|
|
44
60
|
setBlock(block)
|
|
45
61
|
measuredBlock.textLayout?.let(::setTextLayout)
|
|
46
62
|
}
|
|
47
|
-
is Block.Code -> CodeBlockView(context).apply {
|
|
48
|
-
|
|
63
|
+
is Block.Code -> (old as? CodeBlockView ?: CodeBlockView(context)).apply {
|
|
64
|
+
bind(measuredBlock, block)
|
|
65
|
+
}
|
|
66
|
+
is Block.Quote -> (old as? QuoteView ?: QuoteView(context)).apply {
|
|
49
67
|
bind(measuredBlock, block, gapPx, this@BlockStackView.host)
|
|
50
68
|
}
|
|
51
|
-
is Block.ListBlock -> ListBlockView(context).apply {
|
|
69
|
+
is Block.ListBlock -> (old as? ListBlockView ?: ListBlockView(context)).apply {
|
|
52
70
|
bind(measuredBlock, block, gapPx, this@BlockStackView.host)
|
|
53
71
|
}
|
|
54
|
-
is Block.Divider -> DividerView(context).apply { color = block.color }
|
|
55
|
-
is Block.Table -> TableBlockView(context).apply {
|
|
72
|
+
is Block.Divider -> (old as? DividerView ?: DividerView(context)).apply { color = block.color }
|
|
73
|
+
is Block.Table -> (old as? TableBlockView ?: TableBlockView(context)).apply {
|
|
56
74
|
bind(measuredBlock, block, this@BlockStackView.host)
|
|
57
75
|
}
|
|
58
|
-
is Block.Image -> MarkdownImageView(context).apply {
|
|
76
|
+
is Block.Image -> (old as? MarkdownImageView ?: MarkdownImageView(context)).apply {
|
|
59
77
|
this.host = this@BlockStackView.host
|
|
60
78
|
bind(block)
|
|
61
79
|
}
|
|
@@ -228,13 +246,22 @@ class ListBlockView(context: Context) : ViewGroup(context) {
|
|
|
228
246
|
measured = measuredBlock
|
|
229
247
|
block = list
|
|
230
248
|
gapPx = gap
|
|
231
|
-
|
|
249
|
+
// Children are (marker, content) pairs; grow/shrink the pair list, then
|
|
250
|
+
// rebind every pair in place.
|
|
251
|
+
val needed = measuredBlock.markerLayouts.size * 2
|
|
252
|
+
while (childCount > needed) {
|
|
253
|
+
removeViewAt(childCount - 1)
|
|
254
|
+
}
|
|
255
|
+
while (childCount < needed) {
|
|
256
|
+
addView(BlockTextView(context))
|
|
257
|
+
addView(BlockStackView(context))
|
|
258
|
+
}
|
|
232
259
|
measuredBlock.markerLayouts.forEachIndexed { index, marker ->
|
|
233
|
-
|
|
234
|
-
|
|
260
|
+
(getChildAt(index * 2) as BlockTextView).setTextLayout(marker)
|
|
261
|
+
(getChildAt(index * 2 + 1) as BlockStackView).apply {
|
|
235
262
|
this.host = host
|
|
236
263
|
setBlocks(measuredBlock.rowContents[index], gap)
|
|
237
|
-
}
|
|
264
|
+
}
|
|
238
265
|
}
|
|
239
266
|
requestLayout()
|
|
240
267
|
}
|
|
@@ -54,6 +54,8 @@ class BlockTextView(context: Context) : View(context) {
|
|
|
54
54
|
|
|
55
55
|
fun setBlock(block: Block.Text) {
|
|
56
56
|
textBlock = block
|
|
57
|
+
// Spoiler styling may change without the layout changing.
|
|
58
|
+
invalidate()
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
|
@@ -74,13 +74,21 @@ class TableGridView(context: Context) : ViewGroup(context) {
|
|
|
74
74
|
fun bind(measuredBlock: MeasuredBlock, table: Block.Table, host: MarkdownHost? = null) {
|
|
75
75
|
measured = measuredBlock
|
|
76
76
|
block = table
|
|
77
|
-
|
|
77
|
+
// One text view per cell, row-major; grow/shrink then rebind in place.
|
|
78
|
+
val needed = measuredBlock.cellLayouts.sumOf { it.size }
|
|
79
|
+
while (childCount > needed) {
|
|
80
|
+
removeViewAt(childCount - 1)
|
|
81
|
+
}
|
|
82
|
+
while (childCount < needed) {
|
|
83
|
+
addView(BlockTextView(context))
|
|
84
|
+
}
|
|
85
|
+
var index = 0
|
|
78
86
|
for (row in measuredBlock.cellLayouts) {
|
|
79
87
|
for (cell in row) {
|
|
80
|
-
|
|
88
|
+
(getChildAt(index++) as BlockTextView).apply {
|
|
81
89
|
this.host = host
|
|
82
90
|
setTextLayout(cell)
|
|
83
|
-
}
|
|
91
|
+
}
|
|
84
92
|
}
|
|
85
93
|
}
|
|
86
94
|
invalidate()
|
|
@@ -278,8 +278,8 @@ void superscriptPass(Node* parent, AstArena& arena) {
|
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
void process(Node* node, AstArena& arena) {
|
|
281
|
-
if (node->type == NodeType::
|
|
282
|
-
node->type == NodeType::
|
|
281
|
+
if (node->type == NodeType::CodeBlock || node->type == NodeType::InlineCode ||
|
|
282
|
+
node->type == NodeType::Image) {
|
|
283
283
|
return;
|
|
284
284
|
}
|
|
285
285
|
if (!node->children.empty()) {
|
package/cpp/core/Parser.cpp
CHANGED
|
@@ -319,6 +319,11 @@ int onEnterSpan(MD_SPANTYPE type, void* detail, void* userdata) {
|
|
|
319
319
|
auto* d = static_cast<MD_SPAN_A_DETAIL*>(detail);
|
|
320
320
|
Node* node = state->push(NodeType::Link);
|
|
321
321
|
node->url = attributeToString(d->href);
|
|
322
|
+
// md4c (patched) lets Reddit-style spaces through in destinations;
|
|
323
|
+
// encode them so the URL is openable as-is.
|
|
324
|
+
for (size_t i = 0; (i = node->url.find(' ', i)) != std::string::npos; i += 3) {
|
|
325
|
+
node->url.replace(i, 1, "%20");
|
|
326
|
+
}
|
|
322
327
|
break;
|
|
323
328
|
}
|
|
324
329
|
case MD_SPAN_IMG: {
|
|
@@ -443,7 +448,8 @@ std::unique_ptr<MarkdownDocument> parseMarkdown(const std::string& markdown) {
|
|
|
443
448
|
|
|
444
449
|
MD_PARSER parser = {};
|
|
445
450
|
parser.abi_version = 0;
|
|
446
|
-
parser.flags = MD_FLAG_TABLES | MD_FLAG_PERMISSIVEAUTOLINKS | MD_FLAG_NOHTML
|
|
451
|
+
parser.flags = MD_FLAG_TABLES | MD_FLAG_PERMISSIVEAUTOLINKS | MD_FLAG_NOHTML |
|
|
452
|
+
MD_FLAG_PERMISSIVEATXHEADERS; // Reddit accepts "###Heading"
|
|
447
453
|
parser.enter_block = onEnterBlock;
|
|
448
454
|
parser.leave_block = onLeaveBlock;
|
|
449
455
|
parser.enter_span = onEnterSpan;
|
package/cpp/md4c/md4c.c
CHANGED
|
@@ -2055,9 +2055,22 @@ md_is_link_destination_B(MD_CTX* ctx, OFF beg, OFF max_end, OFF* p_end,
|
|
|
2055
2055
|
continue;
|
|
2056
2056
|
}
|
|
2057
2057
|
|
|
2058
|
-
if(
|
|
2058
|
+
if(ISCNTRL(off))
|
|
2059
2059
|
break;
|
|
2060
2060
|
|
|
2061
|
+
/* JETMARKDOWN PATCH: Reddit (snudown) allows spaces inside link
|
|
2062
|
+
* destinations. Whitespace ends the destination only when what
|
|
2063
|
+
* follows is a title opener, the closing ')' or the end. */
|
|
2064
|
+
if(ISWHITESPACE(off)) {
|
|
2065
|
+
OFF tmp = off;
|
|
2066
|
+
while(tmp < max_end && ISWHITESPACE(tmp))
|
|
2067
|
+
tmp++;
|
|
2068
|
+
if(tmp >= max_end || ISANYOF(tmp, _T("\"')")))
|
|
2069
|
+
break;
|
|
2070
|
+
off = tmp;
|
|
2071
|
+
continue;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2061
2074
|
/* Link destination may include balanced pairs of unescaped '(' ')'.
|
|
2062
2075
|
* Note we limit the maximal nesting level by 32 to protect us from
|
|
2063
2076
|
* https://github.com/jgm/cmark/issues/214 */
|
package/ios/JetMarkdownView.mm
CHANGED
|
@@ -41,8 +41,9 @@ static BOOL JMDIsScrollInProgress(UIView *view);
|
|
|
41
41
|
NSString *_stylesJson;
|
|
42
42
|
BOOL _allowFontScaling;
|
|
43
43
|
JMDBlockStackView *_stack;
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
// Layout objects are cached per (content, width, image sizes, font
|
|
45
|
+
// scale), so identity says whether the bound view tree is still current.
|
|
46
|
+
JMDWidthLayout *_boundLayout;
|
|
46
47
|
// url -> @[w, h] points: from the images prop (wins) and loaded bitmaps.
|
|
47
48
|
NSMutableDictionary<NSString *, NSArray<NSNumber *> *> *_propImageSizes;
|
|
48
49
|
NSMutableDictionary<NSString *, NSArray<NSNumber *> *> *_loadedImageSizes;
|
|
@@ -346,7 +347,7 @@ static void JMDSetNeedsDisplayDeep(UIView *view) {
|
|
|
346
347
|
// Dynamic (platform) colors resolve at draw time for text, but border
|
|
347
348
|
// and background colors snapshot into CGColors when blocks bind; rebind
|
|
348
349
|
// so they re-resolve under the new appearance.
|
|
349
|
-
|
|
350
|
+
_boundLayout = nil;
|
|
350
351
|
[self setNeedsLayout];
|
|
351
352
|
JMDSetNeedsDisplayDeep(self);
|
|
352
353
|
}
|
|
@@ -449,7 +450,7 @@ static void JMDSetNeedsDisplayDeep(UIView *view) {
|
|
|
449
450
|
[super prepareForRecycle];
|
|
450
451
|
_markdown = @"";
|
|
451
452
|
_stylesJson = @"";
|
|
452
|
-
|
|
453
|
+
_boundLayout = nil;
|
|
453
454
|
_state = nullptr;
|
|
454
455
|
[_propImageSizes removeAllObjects];
|
|
455
456
|
[_loadedImageSizes removeAllObjects];
|
|
@@ -463,9 +464,9 @@ static void JMDSetNeedsDisplayDeep(UIView *view) {
|
|
|
463
464
|
|
|
464
465
|
const CGFloat contentWidth =
|
|
465
466
|
self.bounds.size.width - styles.paddingLeft - styles.paddingRight;
|
|
466
|
-
if (contentWidth <= 0
|
|
467
|
+
if (contentWidth <= 0) {
|
|
467
468
|
[_stack setBlocks:@[] gap:0];
|
|
468
|
-
|
|
469
|
+
_boundLayout = nil;
|
|
469
470
|
return;
|
|
470
471
|
}
|
|
471
472
|
|
|
@@ -477,16 +478,8 @@ static void JMDSetNeedsDisplayDeep(UIView *view) {
|
|
|
477
478
|
NSDictionary *imageSizes = [self mergedImageSizes];
|
|
478
479
|
JMDWidthLayout *layout = [content layoutForWidth:contentWidth imageSizes:imageSizes];
|
|
479
480
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
NSString *key = [NSString stringWithFormat:@"%@\x1f%@\x1f%@\x1f%.3f",
|
|
483
|
-
_markdown,
|
|
484
|
-
_stylesJson,
|
|
485
|
-
[JMDRenderedContent keyForImageSizes:imageSizes],
|
|
486
|
-
fontScale];
|
|
487
|
-
if (![key isEqualToString:_boundKey] || _boundWidth != contentWidth) {
|
|
488
|
-
_boundKey = key;
|
|
489
|
-
_boundWidth = contentWidth;
|
|
481
|
+
if (layout != _boundLayout) {
|
|
482
|
+
_boundLayout = layout;
|
|
490
483
|
[_stack setBlocks:layout.measured gap:content.gap];
|
|
491
484
|
}
|
|
492
485
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
fontScale:(CGFloat)fontScale {
|
|
13
13
|
JMDStyleConfig *styles = [JMDStyleConfig configWithJson:stylesJson];
|
|
14
14
|
const CGFloat contentWidth = maxWidth - styles.paddingLeft - styles.paddingRight;
|
|
15
|
-
if (contentWidth <= 0
|
|
15
|
+
if (contentWidth <= 0) {
|
|
16
16
|
return 0;
|
|
17
17
|
}
|
|
18
18
|
|
package/ios/render/JMDBlock.h
CHANGED
|
@@ -92,6 +92,13 @@ FOUNDATION_EXPORT NSAttributedStringKey const JMDRunBackgroundAttributeName;
|
|
|
92
92
|
/// Tables: resolved column widths and per-row heights.
|
|
93
93
|
@property (nonatomic, strong) NSArray<NSNumber *> *columnWidths;
|
|
94
94
|
@property (nonatomic, strong) NSArray<NSNumber *> *rowHeights;
|
|
95
|
+
/// TextKit stacks built at measure time (storage owns the layout manager
|
|
96
|
+
/// and container); the views draw with these, so measure and draw never
|
|
97
|
+
/// disagree and text is laid out once. Text/Code: textStorage. Lists:
|
|
98
|
+
/// one per row. Tables: row-major cells.
|
|
99
|
+
@property (nonatomic, strong, nullable) NSTextStorage *textStorage;
|
|
100
|
+
@property (nonatomic, strong) NSArray<NSTextStorage *> *markerStorages;
|
|
101
|
+
@property (nonatomic, strong) NSArray<NSArray<NSTextStorage *> *> *cellStorages;
|
|
95
102
|
@end
|
|
96
103
|
|
|
97
104
|
NS_ASSUME_NONNULL_END
|
package/ios/render/JMDBlock.m
CHANGED
|
@@ -186,7 +186,7 @@ NSArray<NSDictionary *> *featureSettings(NSArray<NSString *> *variants) {
|
|
|
186
186
|
return features;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
UIFont *
|
|
189
|
+
UIFont *buildFontUncached(const ResolvedAttrs &attrs) {
|
|
190
190
|
UIFont *base;
|
|
191
191
|
if (attrs.family.length > 0) {
|
|
192
192
|
UIFontDescriptor *descriptor = [UIFontDescriptor fontDescriptorWithFontAttributes:@{
|
|
@@ -228,6 +228,32 @@ UIFont *buildFont(const ResolvedAttrs &attrs) {
|
|
|
228
228
|
return font ?: base;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// Descriptor matching (family + weight trait, especially for custom
|
|
232
|
+
// families) costs real time and runs once per text run otherwise. UIFont is
|
|
233
|
+
// immutable, NSCache is thread-safe: the layout thread and main thread share
|
|
234
|
+
// one cache.
|
|
235
|
+
UIFont *buildFont(const ResolvedAttrs &attrs) {
|
|
236
|
+
static NSCache<NSString *, UIFont *> *cache;
|
|
237
|
+
static dispatch_once_t onceToken;
|
|
238
|
+
dispatch_once(&onceToken, ^{
|
|
239
|
+
cache = [NSCache new];
|
|
240
|
+
cache.countLimit = 256;
|
|
241
|
+
});
|
|
242
|
+
NSString *key = [NSString stringWithFormat:@"%@\x1f%.2f\x1f%ld\x1f%d\x1f%@",
|
|
243
|
+
attrs.family ?: @"",
|
|
244
|
+
attrs.fontSize,
|
|
245
|
+
(long)attrs.weight,
|
|
246
|
+
attrs.italic ? 1 : 0,
|
|
247
|
+
[attrs.variants componentsJoinedByString:@","] ?: @""];
|
|
248
|
+
UIFont *cached = [cache objectForKey:key];
|
|
249
|
+
if (cached != nil) {
|
|
250
|
+
return cached;
|
|
251
|
+
}
|
|
252
|
+
UIFont *font = buildFontUncached(attrs);
|
|
253
|
+
[cache setObject:font forKey:key];
|
|
254
|
+
return font;
|
|
255
|
+
}
|
|
256
|
+
|
|
231
257
|
NSUnderlineStyle decorationMask(NSString *style) {
|
|
232
258
|
if ([style isEqualToString:@"double"]) {
|
|
233
259
|
return NSUnderlineStyleDouble;
|
|
@@ -84,12 +84,13 @@
|
|
|
84
84
|
return layout;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
// Lays text out with TextKit, not NSStringDrawing: boundingRect stops at
|
|
88
|
+
// the last line's drawn descent, dropping the bottom of the final line box
|
|
89
|
+
// under a custom lineHeight. The full box keeps overlays (spoiler covers,
|
|
90
|
+
// run background chips) from being clipped at the view's bottom edge and
|
|
91
|
+
// matches how RN's <Text> and the Android renderer measure. The returned
|
|
92
|
+
// storage owns the layout manager; views draw with the same stack.
|
|
93
|
+
- (NSTextStorage *)layoutText:(NSAttributedString *)text width:(CGFloat)width {
|
|
93
94
|
NSTextStorage *storage = [[NSTextStorage alloc] initWithAttributedString:text];
|
|
94
95
|
NSLayoutManager *layoutManager = [NSLayoutManager new];
|
|
95
96
|
NSTextContainer *container = [[NSTextContainer alloc]
|
|
@@ -98,10 +99,20 @@
|
|
|
98
99
|
[layoutManager addTextContainer:container];
|
|
99
100
|
[storage addLayoutManager:layoutManager];
|
|
100
101
|
[layoutManager ensureLayoutForTextContainer:container];
|
|
101
|
-
|
|
102
|
+
return storage;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static CGSize JMDUsedSize(NSTextStorage *storage) {
|
|
106
|
+
NSLayoutManager *layoutManager = storage.layoutManagers.firstObject;
|
|
107
|
+
const CGRect used =
|
|
108
|
+
[layoutManager usedRectForTextContainer:layoutManager.textContainers.firstObject];
|
|
102
109
|
return CGSizeMake(ceil(used.size.width), ceil(used.size.height));
|
|
103
110
|
}
|
|
104
111
|
|
|
112
|
+
- (CGSize)textSize:(NSAttributedString *)text width:(CGFloat)width {
|
|
113
|
+
return JMDUsedSize([self layoutText:text width:width]);
|
|
114
|
+
}
|
|
115
|
+
|
|
105
116
|
- (JMDMeasuredBlock *)measureBlock:(JMDBlock *)block
|
|
106
117
|
width:(CGFloat)width
|
|
107
118
|
imageSizes:(nullable NSDictionary<NSString *, NSArray<NSNumber *> *> *)imageSizes {
|
|
@@ -111,13 +122,17 @@
|
|
|
111
122
|
|
|
112
123
|
switch (block.kind) {
|
|
113
124
|
case JMDBlockKindText: {
|
|
114
|
-
|
|
125
|
+
NSTextStorage *storage = [self layoutText:block.attributedText width:width];
|
|
126
|
+
const CGSize size = JMDUsedSize(storage);
|
|
127
|
+
measured.textStorage = storage;
|
|
115
128
|
measured.height = size.height;
|
|
116
129
|
measured.textHeight = size.height;
|
|
117
130
|
break;
|
|
118
131
|
}
|
|
119
132
|
case JMDBlockKindCode: {
|
|
120
|
-
|
|
133
|
+
NSTextStorage *storage = [self layoutText:block.attributedText width:CGFLOAT_MAX];
|
|
134
|
+
const CGSize size = JMDUsedSize(storage);
|
|
135
|
+
measured.textStorage = storage;
|
|
121
136
|
measured.contentWidth = size.width;
|
|
122
137
|
measured.textHeight = size.height;
|
|
123
138
|
measured.height =
|
|
@@ -142,11 +157,14 @@
|
|
|
142
157
|
const CGFloat contentWidth = MAX(width - contentX, 1);
|
|
143
158
|
measured.contentWidth = contentWidth;
|
|
144
159
|
NSMutableArray<NSNumber *> *markerHeights = [NSMutableArray new];
|
|
160
|
+
NSMutableArray<NSTextStorage *> *markerStorages = [NSMutableArray new];
|
|
145
161
|
NSMutableArray<NSArray<JMDMeasuredBlock *> *> *rowContents = [NSMutableArray new];
|
|
146
162
|
CGFloat height = 0;
|
|
147
163
|
for (NSUInteger i = 0; i < block.rows.count; i++) {
|
|
148
164
|
JMDListRow *row = block.rows[i];
|
|
149
|
-
|
|
165
|
+
NSTextStorage *markerStorage = [self layoutText:row.marker width:block.markerWidth];
|
|
166
|
+
[markerStorages addObject:markerStorage];
|
|
167
|
+
const CGSize markerSize = JMDUsedSize(markerStorage);
|
|
150
168
|
NSMutableArray<JMDMeasuredBlock *> *content = [NSMutableArray new];
|
|
151
169
|
for (JMDBlock *child in row.content) {
|
|
152
170
|
[content addObject:[self measureBlock:child width:contentWidth imageSizes:imageSizes]];
|
|
@@ -159,6 +177,7 @@
|
|
|
159
177
|
}
|
|
160
178
|
}
|
|
161
179
|
measured.markerHeights = markerHeights;
|
|
180
|
+
measured.markerStorages = markerStorages;
|
|
162
181
|
measured.rowContents = rowContents;
|
|
163
182
|
measured.height = height;
|
|
164
183
|
break;
|
|
@@ -209,6 +228,7 @@
|
|
|
209
228
|
}
|
|
210
229
|
|
|
211
230
|
NSMutableArray<NSNumber *> *rowHeights = [NSMutableArray new];
|
|
231
|
+
NSMutableArray<NSArray<NSTextStorage *> *> *cellStorages = [NSMutableArray new];
|
|
212
232
|
CGFloat contentHeight = 0;
|
|
213
233
|
for (JMDTableRow *row in block.tableRows) {
|
|
214
234
|
JMDLayoutStyle *rowStyle = row.isHeader ? block.headerRowStyle : block.bodyRowStyle;
|
|
@@ -217,10 +237,14 @@
|
|
|
217
237
|
const CGFloat cellPadV = pad.top + pad.bottom;
|
|
218
238
|
const CGFloat rowExtra = rowStyle.borderTopWidth + rowStyle.borderBottomWidth;
|
|
219
239
|
CGFloat rowHeight = 0;
|
|
240
|
+
NSMutableArray<NSTextStorage *> *rowStorages = [NSMutableArray new];
|
|
220
241
|
for (NSUInteger column = 0; column < row.cells.count; column++) {
|
|
221
242
|
const CGFloat cellWidth = MAX(columnWidths[column] - cellPadH, 1);
|
|
222
|
-
|
|
243
|
+
NSTextStorage *storage = [self layoutText:row.cells[column] width:cellWidth];
|
|
244
|
+
[rowStorages addObject:storage];
|
|
245
|
+
rowHeight = MAX(rowHeight, JMDUsedSize(storage).height);
|
|
223
246
|
}
|
|
247
|
+
[cellStorages addObject:rowStorages];
|
|
224
248
|
rowHeight += cellPadV + rowExtra;
|
|
225
249
|
[rowHeights addObject:@(rowHeight)];
|
|
226
250
|
contentHeight += rowHeight;
|
|
@@ -235,6 +259,7 @@
|
|
|
235
259
|
|
|
236
260
|
measured.columnWidths = widths;
|
|
237
261
|
measured.rowHeights = rowHeights;
|
|
262
|
+
measured.cellStorages = cellStorages;
|
|
238
263
|
measured.contentWidth = contentWidth;
|
|
239
264
|
measured.height = contentHeight + block.layoutStyle.verticalInset;
|
|
240
265
|
break;
|