react-native-jet-markdown 0.2.1 → 0.2.3-beta.0

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.
Files changed (66) hide show
  1. package/JetMarkdown.podspec +1 -1
  2. package/README.md +31 -82
  3. package/android/src/main/cpp/JniBindings.cpp +0 -106
  4. package/android/src/main/java/com/jetmarkdown/JetMarkdownNative.kt +0 -79
  5. package/android/src/main/java/com/jetmarkdown/JetMarkdownPackage.kt +1 -2
  6. package/android/src/main/java/com/jetmarkdown/JetMarkdownView.kt +11 -11
  7. package/android/src/main/java/com/jetmarkdown/render/spans/MarkdownLineHeightSpan.kt +2 -13
  8. package/android/src/main/java/com/jetmarkdown/views/BlockStackView.kt +49 -22
  9. package/android/src/main/java/com/jetmarkdown/views/BlockTextView.kt +2 -0
  10. package/android/src/main/java/com/jetmarkdown/views/TableBlockView.kt +11 -3
  11. package/cpp/react/override/react/renderer/components/JetMarkdownViewSpec/ComponentDescriptors.h +0 -4
  12. package/ios/JetMarkdownView.mm +9 -16
  13. package/ios/measure/JMDMarkdownMeasurer.mm +1 -1
  14. package/ios/render/JMDBlock.h +7 -0
  15. package/ios/render/JMDBlock.m +2 -0
  16. package/ios/render/JMDBlockRenderer.mm +27 -1
  17. package/ios/render/JMDRenderedContent.mm +36 -11
  18. package/ios/views/JMDBlockStackView.m +36 -14
  19. package/ios/views/JMDBlockTextView.h +6 -1
  20. package/ios/views/JMDBlockTextView.m +90 -127
  21. package/ios/views/JMDBoxViews.m +14 -11
  22. package/ios/views/JMDTableView.m +15 -8
  23. package/lib/module/index.js +0 -2
  24. package/lib/module/index.js.map +1 -1
  25. package/lib/typescript/src/index.d.ts +1 -3
  26. package/lib/typescript/src/index.d.ts.map +1 -1
  27. package/lib/typescript/src/types.d.ts +0 -127
  28. package/lib/typescript/src/types.d.ts.map +1 -1
  29. package/package.json +3 -5
  30. package/src/index.tsx +0 -9
  31. package/src/types.ts +0 -134
  32. package/android/src/main/java/com/jetmarkdown/editor/EditorSpans.kt +0 -217
  33. package/android/src/main/java/com/jetmarkdown/editor/JetMarkdownEditorManager.kt +0 -224
  34. package/android/src/main/java/com/jetmarkdown/editor/JetMarkdownEditorView.kt +0 -1736
  35. package/cpp/core/AstToMarkdown.cpp +0 -549
  36. package/cpp/core/AstToMarkdown.h +0 -16
  37. package/cpp/core/EditorRuns.cpp +0 -640
  38. package/cpp/core/EditorRuns.h +0 -94
  39. package/cpp/core/EditorText.cpp +0 -15
  40. package/cpp/core/EditorText.h +0 -18
  41. package/cpp/react/JetMarkdownEditorShadowNode.cpp +0 -54
  42. package/cpp/react/JetMarkdownEditorShadowNode.h +0 -55
  43. package/cpp/react/JetMarkdownEditorState.h +0 -35
  44. package/cpp/tests/parser_tests.cpp +0 -662
  45. package/cpp/tests/run_tests.sh +0 -25
  46. package/ios/editor/JetMarkdownEditor.h +0 -12
  47. package/ios/editor/JetMarkdownEditor.mm +0 -2002
  48. package/lib/module/JetMarkdownEditor.js +0 -85
  49. package/lib/module/JetMarkdownEditor.js.map +0 -1
  50. package/lib/module/JetMarkdownEditor.native.js +0 -244
  51. package/lib/module/JetMarkdownEditor.native.js.map +0 -1
  52. package/lib/module/JetMarkdownEditorNativeComponent.ts +0 -173
  53. package/lib/module/useJetMarkdownEditor.js +0 -41
  54. package/lib/module/useJetMarkdownEditor.js.map +0 -1
  55. package/lib/typescript/src/JetMarkdownEditor.d.ts +0 -6
  56. package/lib/typescript/src/JetMarkdownEditor.d.ts.map +0 -1
  57. package/lib/typescript/src/JetMarkdownEditor.native.d.ts +0 -6
  58. package/lib/typescript/src/JetMarkdownEditor.native.d.ts.map +0 -1
  59. package/lib/typescript/src/JetMarkdownEditorNativeComponent.d.ts +0 -105
  60. package/lib/typescript/src/JetMarkdownEditorNativeComponent.d.ts.map +0 -1
  61. package/lib/typescript/src/useJetMarkdownEditor.d.ts +0 -36
  62. package/lib/typescript/src/useJetMarkdownEditor.d.ts.map +0 -1
  63. package/src/JetMarkdownEditor.native.tsx +0 -301
  64. package/src/JetMarkdownEditor.tsx +0 -90
  65. package/src/JetMarkdownEditorNativeComponent.ts +0 -173
  66. package/src/useJetMarkdownEditor.ts +0 -50
@@ -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
@@ -1,9 +1,9 @@
1
1
  <p align="center">
2
- <img alt="react-native-jet-markdown — native markdown viewer and WYSIWYG editor for React Native" src="https://raw.githubusercontent.com/alizahid/react-native-jet-markdown/main/docs/hero.svg" width="900">
2
+ <img alt="react-native-jet-markdown — native markdown viewer for React Native" src="https://raw.githubusercontent.com/alizahid/react-native-jet-markdown/main/docs/hero.svg" width="900">
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- Fast, fully native Markdown viewer &amp; WYSIWYG editor for React Native
6
+ Fast, fully native Markdown viewer for React Native
7
7
  </p>
8
8
 
9
9
  <p align="center">
@@ -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` | Main container style: `backgroundColor`, `padding`/`padding{Left,Right,Top,Bottom}`, `gap` (spacing between blocks), plus base text styles (`fontSize`, `fontWeight`, `fontFamily`, `color`, `fontVariant`, `textDecoration*`) that cascade into every text element unless overridden per-element via `styles`. Element builtins survive the cascade: heading sizes/weight stay unless `headings.hN` overrides, and code blocks keep their monospace font unless `codeBlock` overrides. For outer layout (margin, width, flex), wrap the viewer in a `View`. |
75
- | `styles` | `MarkdownStyles` | Per-element styles (below). Omitted = fully plain output (no colors, boxes, heading sizes — only bold/italic runs, monospace code, list markers, and the spoiler cover). Pass the exported `defaultStyles` for the classic look, or `mergeStyles(overrides)` for defaults + your changes. Hoist to module scope or memoize. |
76
- | `images` | `{ url, width, height }[]` | Pre-sizing data. Listed images lay out at their final size immediately zero layout shift. Unlisted images render a styled full-width, 200pt-tall placeholder, then snap to their real aspect once loaded. 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). |
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**. Two exports cover the common cases:
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:
@@ -150,100 +165,34 @@ Numbers from the shared C++ core (Apple M-series, `-O2`; the same code runs on d
150
165
  | --- | --- | --- |
151
166
  | Parse a typical feed post | 1.5 KB mixed markdown | **35 µs** |
152
167
  | Parse a large document | 160 KB | **4.1 ms** |
153
- | Open a document in the editor (`editorFromMarkdown`) | 160 KB, ~6,400 style runs | **4.7 ms** |
154
- | Serialize the editor back to markdown | 160 KB | **5.8 ms** |
155
168
  | Serialize the full `defaultStyles` object in JS | 980 B JSON | **3 µs**, memoized |
156
169
 
157
- Beyond raw parsing: measurement runs on the Fabric layout thread (never on main), parse + layout results are cached and shared between measure and mount, editor keystrokes only rebuild the derived styling of the lines they touch, and markdown serialization is coalesced to once per frame. The 500-item Feed screen in the example app scrolls at 60 fps on both platforms.
170
+ Beyond raw parsing: measurement runs on the Fabric layout thread (never on main), and parse + layout results are cached and shared between measure and mount. The 500-item Feed screen in the example app scrolls at 60 fps on both platforms.
158
171
 
159
172
  ### Why styles cross as JSON
160
173
 
161
174
  You may notice styles ship natively as one `stylesJson` string instead of a structured object. This is deliberate, not legacy: Fabric props cross via JSI either way (there is no old-bridge serialization in either design), codegen cannot express the open-keyed `mention.variants` map, and the string doubles as the cache key shared by the C++ measurer, iOS, and Android — a structured prop would need deep equality or native re-serialization to get the same caching. The cost is ~3 µs once per distinct styles object.
162
175
 
163
- ## Editor
164
-
165
- > [!WARNING]
166
- > The editor is not ready for production use yet. The viewer (`JetMarkdownView`) is stable; `JetMarkdownEditor` still has known issues and its API may change. Track progress in the issue tracker before shipping it.
167
-
168
- `JetMarkdownEditor` is a WYSIWYG editor with markdown as the interchange format: no visible syntax while editing, `onChangeMarkdown` fires with serialized markdown on every edit, and `setValue`/`defaultValue`/paste parse markdown into styled content.
169
-
170
- ```tsx
171
- import { JetMarkdownEditor, useJetMarkdownEditor } from "react-native-jet-markdown";
172
-
173
- function Compose() {
174
- const editor = useJetMarkdownEditor();
175
-
176
- return (
177
- <>
178
- <JetMarkdownEditor
179
- mentionTriggers={["@"]}
180
- onChangeMarkdown={(markdown) => save(markdown)}
181
- onMentionChange={({ query }) => search(query)}
182
- placeholder="Write something..."
183
- ref={editor.ref}
184
- />
185
- <Button onPress={editor.toggleBold} title="B" />
186
- </>
187
- );
188
- }
189
- ```
190
-
191
- ### Formatting
192
-
193
- Inline marks — bold, italic, strikethrough, inline code, spoiler, superscript, subscript — toggle over the selection, or arm for the text typed next at a collapsed cursor (`toggleBold()`, `toggleItalic()`, `toggleStrikethrough()`, `toggleCode()`, `toggleSpoiler()`, `toggleSuperscript()`, `toggleSubscript()`).
194
-
195
- Blocks apply per line: `toggleHeading(1-6)`, `toggleBlockQuote()`, `toggleCodeBlock()`, `toggleUnorderedList()`, `toggleOrderedList()`. Enter continues a list, Enter on an empty item exits it, Enter after a heading returns to paragraph, and backspace at the start of a formatted line clears its block first.
196
-
197
- `onChangeState` reports the formatting at the cursor or selection (`isBold`, `headingLevel`, `isUnorderedList`, ...) for toolbar highlighting.
198
-
199
- ### Links & mentions
200
-
201
- `insertLink(url, label?)` links the selection (or inserts a linked label), `removeLink()` strips it. Typing a bare `http(s)://` URL followed by a space emits `onLinkDetected` — call `insertLink` if you want it linkified.
202
-
203
- Mentions start when a `mentionTriggers` character is typed at a word start: `onMentionStart` → `onMentionChange` (with the growing query) → `onMentionEnd`. Call `insertMention(trigger, label, url)` — e.g. `("@", "ali", "users://ali")` — to replace the query with an atomic token that deletes as one unit.
204
-
205
- ### Paste
206
-
207
- Paste never inserts directly. `onPaste` receives `{ text?, images?, preventDefault() }` — images are reported (never auto-inserted) with `url`, `width`, `height`. Unless you call `preventDefault()` synchronously, the pasted text is parsed as markdown and inserted styled.
208
-
209
- ### Everything else
210
-
211
- `getMarkdown()` resolves the current markdown; `focus()`, `blur()`, `setSelection(start, end)`, `setValue(markdown)`, and `insertMarkdown(markdown)` do what they say. Hardware keyboards get Cmd+B / Cmd+I (iOS) and Ctrl+B / Ctrl+I (Android). The editor shares the viewer's `style` / `styles` contract — the `base`/`paragraph` cascade drives the root text and `link.color` drives links. The viewer's `gap` is not applied while editing (a text field flows line to line); drive the editing feel with the text styles themselves.
212
-
213
176
  ## Platform notes & limitations
214
177
 
215
178
  - New Architecture (Fabric) only; React Native 0.86+.
179
+ - Android support is experimental; iOS is the reference implementation.
216
180
  - `textDecorationStyle`/`textDecorationColor` render fully on iOS; Android draws plain underline/strikethrough.
217
181
  - `borderCurve: 'continuous'` is iOS-only.
218
182
  - `fontVariant` supports `tabular-nums`, `proportional-nums`, `oldstyle-nums`, `lining-nums`, `small-caps` (font support required).
219
- - `allowFontScaling` (default `true`) scales all text — including `lineHeight` — with the system font size setting on both components.
220
183
  - Spoilers and inline formatting inside link labels stay literal; spoilers inside table cells are unsupported (the `|` delimiter conflicts).
221
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.
222
186
 
223
187
  ## Sponsors
224
188
 
225
- Development of react-native-jet-markdown is supported by:
226
-
227
- <table>
228
- <tr>
229
- <td align="center" width="200">
230
- <a href="https://duet.so">
231
- <img alt="Duet" src="https://github.com/alizahid/react-native-jet-markdown/blob/main/.github/duet.png?raw=true" width="96"><br>
232
- <b>Duet</b>
233
- </a>
234
- <br>
235
- <sub>Your AI coworker that runs your business 24/7</sub>
236
- </td>
237
- <td align="center" width="200">
238
- <a href="https://acorn.blue">
239
- <img alt="Acorn" src="https://github.com/alizahid/react-native-jet-markdown/blob/main/.github/acorn.png?raw=true" width="96"><br>
240
- <b>Acorn</b>
241
- </a>
242
- <br>
243
- <sub>Reddit for mobile</sub>
244
- </td>
245
- </tr>
246
- </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>
247
196
 
248
197
  ## License
249
198
 
@@ -12,7 +12,6 @@
12
12
  #include <vector>
13
13
 
14
14
  #include "core/AstSerializer.h"
15
- #include "core/EditorRuns.h"
16
15
  #include "core/Parser.h"
17
16
  #include "react/JetMarkdownMeasurer.h"
18
17
 
@@ -87,111 +86,6 @@ Java_com_jetmarkdown_JetMarkdownNative_parse(JNIEnv* env, jclass, jbyteArray mar
87
86
  return toByteArray(env, bytes.data(), bytes.size());
88
87
  }
89
88
 
90
- extern "C" JNIEXPORT jbyteArray JNICALL
91
- Java_com_jetmarkdown_JetMarkdownNative_markdownFromEditorContent(
92
- JNIEnv* env,
93
- jclass,
94
- jbyteArray text,
95
- jintArray runs,
96
- jintArray lineBlocks,
97
- jintArray linkRanges,
98
- jbyteArray linkUrls) {
99
- std::vector<jetmarkdown::StyledRun> styledRuns;
100
- if (runs != nullptr) {
101
- const jsize length = env->GetArrayLength(runs);
102
- std::vector<jint> values(static_cast<size_t>(length));
103
- if (length > 0) {
104
- env->GetIntArrayRegion(runs, 0, length, values.data());
105
- }
106
- for (jsize i = 0; i + 2 < length; i += 3) {
107
- styledRuns.push_back(
108
- {static_cast<uint32_t>(values[i]),
109
- static_cast<uint32_t>(values[i + 1]),
110
- static_cast<uint32_t>(values[i + 2])});
111
- }
112
- }
113
- std::vector<jetmarkdown::EditorLine> lines;
114
- if (lineBlocks != nullptr) {
115
- const jsize length = env->GetArrayLength(lineBlocks);
116
- std::vector<jint> values(static_cast<size_t>(length));
117
- if (length > 0) {
118
- env->GetIntArrayRegion(lineBlocks, 0, length, values.data());
119
- }
120
- for (jsize i = 0; i + 1 < length; i += 2) {
121
- lines.push_back(
122
- {static_cast<jetmarkdown::EditorBlockType>(values[i]),
123
- static_cast<uint8_t>(values[i + 1])});
124
- }
125
- }
126
- // Link URLs cross as one newline-joined blob (URLs cannot contain '\n').
127
- std::vector<jetmarkdown::LinkRun> links;
128
- if (linkRanges != nullptr) {
129
- const jsize length = env->GetArrayLength(linkRanges);
130
- std::vector<jint> values(static_cast<size_t>(length));
131
- if (length > 0) {
132
- env->GetIntArrayRegion(linkRanges, 0, length, values.data());
133
- }
134
- const std::string urls = toStdString(env, linkUrls);
135
- size_t urlStart = 0;
136
- for (jsize i = 0; i + 1 < length; i += 2) {
137
- const size_t urlEnd = urls.find('\n', urlStart);
138
- links.push_back(
139
- {static_cast<uint32_t>(values[i]),
140
- static_cast<uint32_t>(values[i + 1]),
141
- urls.substr(urlStart, urlEnd == std::string::npos ? std::string::npos
142
- : urlEnd - urlStart)});
143
- urlStart = urlEnd == std::string::npos ? urls.size() : urlEnd + 1;
144
- }
145
- }
146
- const std::string result = jetmarkdown::markdownFromEditor(
147
- toStdString(env, text), styledRuns, lines, links);
148
- return toByteArray(
149
- env, reinterpret_cast<const uint8_t*>(result.data()), result.size());
150
- }
151
-
152
- extern "C" JNIEXPORT jbyteArray JNICALL
153
- Java_com_jetmarkdown_JetMarkdownNative_editorFromMarkdownContent(
154
- JNIEnv* env,
155
- jclass,
156
- jbyteArray markdown) {
157
- const jetmarkdown::EditorDocument document =
158
- jetmarkdown::editorFromMarkdown(toStdString(env, markdown));
159
- // [int32 runCount][runCount x (start, end, flags)][int32 lineCount]
160
- // [lineCount x (type, level)][utf8 text], little-endian.
161
- std::vector<uint8_t> bytes;
162
- bytes.reserve(
163
- 8 + document.runs.size() * 12 + document.lines.size() * 8 +
164
- document.text.size());
165
- const auto push32 = [&bytes](uint32_t value) {
166
- bytes.push_back(static_cast<uint8_t>(value & 0xFF));
167
- bytes.push_back(static_cast<uint8_t>((value >> 8) & 0xFF));
168
- bytes.push_back(static_cast<uint8_t>((value >> 16) & 0xFF));
169
- bytes.push_back(static_cast<uint8_t>((value >> 24) & 0xFF));
170
- };
171
- push32(static_cast<uint32_t>(document.runs.size()));
172
- for (const jetmarkdown::StyledRun& run : document.runs) {
173
- push32(run.start);
174
- push32(run.end);
175
- push32(run.flags);
176
- }
177
- push32(static_cast<uint32_t>(document.lines.size()));
178
- for (const jetmarkdown::EditorLine& line : document.lines) {
179
- push32(static_cast<uint32_t>(line.type));
180
- push32(line.level);
181
- }
182
- push32(static_cast<uint32_t>(document.links.size()));
183
- for (const jetmarkdown::LinkRun& link : document.links) {
184
- push32(link.start);
185
- push32(link.end);
186
- push32(static_cast<uint32_t>(link.url.size()));
187
- }
188
- for (const jetmarkdown::LinkRun& link : document.links) {
189
- bytes.insert(bytes.end(), link.url.begin(), link.url.end());
190
- }
191
- bytes.insert(bytes.end(), document.text.begin(), document.text.end());
192
- return toByteArray(env, bytes.data(), bytes.size());
193
- }
194
-
195
89
  extern "C" JNIEXPORT void JNICALL
196
90
  Java_com_jetmarkdown_JetMarkdownNative_installMeasurer(
197
91
  JNIEnv* env,
@@ -29,86 +29,7 @@ object JetMarkdownNative {
29
29
  }
30
30
 
31
31
 
32
- /** Decoded editor content: text, inline-mark runs, line blocks, links. */
33
- class EditorContent(
34
- val text: String,
35
- /** Flat `[start, end, flags]` triples in UTF-16 offsets. */
36
- val runs: IntArray,
37
- /** Flat `[type, level]` pairs, one per text line. */
38
- val lineBlocks: IntArray,
39
- /** Flat `[start, end]` pairs, aligned with [linkUrls]. */
40
- val linkRanges: IntArray,
41
- val linkUrls: List<String>,
42
- )
43
-
44
- /**
45
- * Editor: markdown from text + inline-mark runs + per-line blocks +
46
- * links. Runs are flat `[start, end, flags]` triples in UTF-16 offsets
47
- * (Spannable indices); lineBlocks are `[type, level]` pairs per line;
48
- * links are `[start, end]` pairs with URLs joined by '\n'.
49
- */
50
- fun markdownFromEditor(
51
- text: String,
52
- runs: IntArray,
53
- lineBlocks: IntArray,
54
- linkRanges: IntArray = IntArray(0),
55
- linkUrls: List<String> = emptyList(),
56
- ): String {
57
- return markdownFromEditorContent(
58
- text.toByteArray(Charsets.UTF_8),
59
- runs,
60
- lineBlocks,
61
- linkRanges,
62
- linkUrls.joinToString("\n").toByteArray(Charsets.UTF_8),
63
- ).toString(Charsets.UTF_8)
64
- }
65
-
66
- /**
67
- * Editor: markdown parsed into editor content. The native payload is
68
- * `[int32 runCount][triples][int32 lineCount][pairs][int32 linkCount]
69
- * [(start, end, urlLen) triples][url bytes][utf8 text]`, little-endian.
70
- */
71
- fun editorFromMarkdown(markdown: String): EditorContent {
72
- val bytes = editorFromMarkdownContent(markdown.toByteArray(Charsets.UTF_8))
73
- val buffer = java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN)
74
- val runs = IntArray(buffer.int * 3)
75
- for (i in runs.indices) {
76
- runs[i] = buffer.int
77
- }
78
- val lineBlocks = IntArray(buffer.int * 2)
79
- for (i in lineBlocks.indices) {
80
- lineBlocks[i] = buffer.int
81
- }
82
- val linkCount = buffer.int
83
- val linkRanges = IntArray(linkCount * 2)
84
- val urlLengths = IntArray(linkCount)
85
- for (i in 0 until linkCount) {
86
- linkRanges[i * 2] = buffer.int
87
- linkRanges[i * 2 + 1] = buffer.int
88
- urlLengths[i] = buffer.int
89
- }
90
- val linkUrls = ArrayList<String>(linkCount)
91
- for (i in 0 until linkCount) {
92
- val urlBytes = ByteArray(urlLengths[i])
93
- buffer.get(urlBytes)
94
- linkUrls.add(urlBytes.toString(Charsets.UTF_8))
95
- }
96
- val text = ByteArray(buffer.remaining())
97
- buffer.get(text)
98
- return EditorContent(text.toString(Charsets.UTF_8), runs, lineBlocks, linkRanges, linkUrls)
99
- }
100
-
101
32
  @JvmStatic private external fun parse(markdown: ByteArray): ByteArray
102
33
 
103
- @JvmStatic private external fun markdownFromEditorContent(
104
- text: ByteArray,
105
- runs: IntArray,
106
- lineBlocks: IntArray,
107
- linkRanges: IntArray,
108
- linkUrls: ByteArray,
109
- ): ByteArray
110
-
111
- @JvmStatic private external fun editorFromMarkdownContent(markdown: ByteArray): ByteArray
112
-
113
34
  @JvmStatic private external fun installMeasurer(measurer: MarkdownMeasurer)
114
35
  }
@@ -5,7 +5,6 @@ import com.facebook.react.bridge.NativeModule
5
5
  import com.facebook.react.bridge.ReactApplicationContext
6
6
  import com.facebook.react.module.model.ReactModuleInfoProvider
7
7
  import com.facebook.react.uimanager.ViewManager
8
- import com.jetmarkdown.editor.JetMarkdownEditorManager
9
8
 
10
9
  class JetMarkdownViewPackage : BaseReactPackage() {
11
10
  override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
@@ -13,7 +12,7 @@ class JetMarkdownViewPackage : BaseReactPackage() {
13
12
  // font/color resolution must work from the very first measure. The view
14
13
  // managers re-install with the themed context on view creation.
15
14
  com.jetmarkdown.style.PlatformColorResolver.install(reactContext)
16
- return listOf(JetMarkdownViewManager(), JetMarkdownEditorManager())
15
+ return listOf(JetMarkdownViewManager())
17
16
  }
18
17
 
19
18
  override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = null
@@ -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
- boundKey = null
31
+ boundLayout = null
31
32
  requestLayout()
32
33
  }
33
34
  }
34
- private var boundKey: List<Any>? = null
35
- private var boundWidth: Int = 0
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
- boundKey = null
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 || markdown.isEmpty()) {
205
+ if (contentWidthPx <= 0) {
204
206
  stack.setBlocks(emptyList(), 0f)
205
- boundKey = null
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
- val key = listOf(markdown, stylesJson, imageSizes.hashCode(), fontScale)
214
- if (boundKey != key || boundWidth != contentWidthPx) {
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
 
@@ -1,26 +1,15 @@
1
1
  package com.jetmarkdown.render.spans
2
2
 
3
3
  import android.graphics.Paint
4
- import android.text.TextPaint
5
4
  import android.text.style.LineHeightSpan
6
- import android.text.style.MetricAffectingSpan
7
5
  import kotlin.math.ceil
8
6
  import kotlin.math.floor
9
7
 
10
8
  /**
11
9
  * React Native's lineHeight semantics: the line box is exactly [heightPx]
12
- * tall with glyphs centered (mirrors RN's CustomLineHeightSpan). Extends
13
- * MetricAffectingSpan (a no-op there) so DynamicLayout — the editor —
14
- * reflows when the span is attached; plain LineHeightSpan is not an
15
- * UpdateLayout span.
10
+ * tall with glyphs centered (mirrors RN's CustomLineHeightSpan).
16
11
  */
17
- open class MarkdownLineHeightSpan(private val heightPx: Int) :
18
- MetricAffectingSpan(),
19
- LineHeightSpan {
20
- override fun updateMeasureState(paint: TextPaint) = Unit
21
-
22
- override fun updateDrawState(paint: TextPaint) = Unit
23
-
12
+ class MarkdownLineHeightSpan(private val heightPx: Int) : LineHeightSpan {
24
13
  override fun chooseHeight(
25
14
  text: CharSequence,
26
15
  start: Int,
@@ -17,45 +17,63 @@ class BlockStackView(context: Context) : ViewGroup(context) {
17
17
 
18
18
  var host: MarkdownHost? = null
19
19
 
20
- private fun unbindImages(group: android.view.ViewGroup) {
21
- for (index in 0 until group.childCount) {
22
- when (val child = group.getChildAt(index)) {
23
- is MarkdownImageView -> child.unbind()
24
- is android.view.ViewGroup -> unbindImages(child)
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
- removeAllViews()
34
- for (block in blocks) {
35
- addView(createView(block))
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 createView(measuredBlock: MeasuredBlock): View {
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 { bind(measuredBlock, block) }
48
- is Block.Quote -> QuoteView(context).apply {
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
- removeAllViews()
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
- addView(BlockTextView(context).apply { setTextLayout(marker) })
234
- addView(BlockStackView(context).apply {
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
- removeAllViews()
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
- addView(BlockTextView(context).apply {
88
+ (getChildAt(index++) as BlockTextView).apply {
81
89
  this.host = host
82
90
  setTextLayout(cell)
83
- })
91
+ }
84
92
  }
85
93
  }
86
94
  invalidate()