@cellgit/markdown-render 0.1.0 → 1.0.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 (57) hide show
  1. package/README.md +436 -178
  2. package/README.zh-CN.md +530 -0
  3. package/THIRD-PARTY-NOTICES.md +332 -0
  4. package/dist/markdown-render.css +3 -3
  5. package/dist/markdown-render.esm.css +3 -3
  6. package/dist/markdown-render.esm.js +1 -91452
  7. package/dist/markdown-render.html +20 -75
  8. package/dist/markdown-render.js +1 -91457
  9. package/dist/scripts/bridge.js +119 -0
  10. package/dist/scripts/chat-renderer.js +2770 -0
  11. package/dist/scripts/copy.js +155 -0
  12. package/dist/scripts/height-sync.js +205 -0
  13. package/dist/scripts/renderer.js +398 -0
  14. package/ios-example/MarkdownViewController.swift +123 -33
  15. package/ios-example/README.md +2 -0
  16. package/ios-example/SwiftUIMarkdownExample.swift +111 -0
  17. package/package.json +17 -9
  18. package/dist/fonts/KaTeX_AMS-Regular.ttf +0 -0
  19. package/dist/fonts/KaTeX_AMS-Regular.woff +0 -0
  20. package/dist/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  21. package/dist/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  22. package/dist/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  23. package/dist/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  24. package/dist/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  25. package/dist/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  26. package/dist/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  27. package/dist/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  28. package/dist/fonts/KaTeX_Main-Bold.ttf +0 -0
  29. package/dist/fonts/KaTeX_Main-Bold.woff +0 -0
  30. package/dist/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  31. package/dist/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  32. package/dist/fonts/KaTeX_Main-Italic.ttf +0 -0
  33. package/dist/fonts/KaTeX_Main-Italic.woff +0 -0
  34. package/dist/fonts/KaTeX_Main-Regular.ttf +0 -0
  35. package/dist/fonts/KaTeX_Main-Regular.woff +0 -0
  36. package/dist/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  37. package/dist/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  38. package/dist/fonts/KaTeX_Math-Italic.ttf +0 -0
  39. package/dist/fonts/KaTeX_Math-Italic.woff +0 -0
  40. package/dist/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  41. package/dist/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  42. package/dist/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  43. package/dist/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  44. package/dist/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  45. package/dist/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  46. package/dist/fonts/KaTeX_Script-Regular.ttf +0 -0
  47. package/dist/fonts/KaTeX_Script-Regular.woff +0 -0
  48. package/dist/fonts/KaTeX_Size1-Regular.ttf +0 -0
  49. package/dist/fonts/KaTeX_Size1-Regular.woff +0 -0
  50. package/dist/fonts/KaTeX_Size2-Regular.ttf +0 -0
  51. package/dist/fonts/KaTeX_Size2-Regular.woff +0 -0
  52. package/dist/fonts/KaTeX_Size3-Regular.ttf +0 -0
  53. package/dist/fonts/KaTeX_Size3-Regular.woff +0 -0
  54. package/dist/fonts/KaTeX_Size4-Regular.ttf +0 -0
  55. package/dist/fonts/KaTeX_Size4-Regular.woff +0 -0
  56. package/dist/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  57. package/dist/fonts/KaTeX_Typewriter-Regular.woff +0 -0
@@ -0,0 +1,398 @@
1
+ (function (global) {
2
+ 'use strict';
3
+
4
+ const bridge = global.MarkdownBridge;
5
+ const documentRef = global.document;
6
+ const container = documentRef ? documentRef.getElementById('markdown-content') : null;
7
+
8
+ if (!container) {
9
+ if (bridge && typeof bridge.notifyError === 'function') {
10
+ bridge.notifyError(new Error('Markdown container not found'));
11
+ }
12
+ return;
13
+ }
14
+ if (!global.MarkdownRender) {
15
+ console.error('MarkdownRender library is missing. Make sure markdown-render.js is loaded before renderer.js');
16
+ if (bridge && typeof bridge.notifyError === 'function') {
17
+ bridge.notifyError(new Error('MarkdownRender library missing'));
18
+ }
19
+ return;
20
+ }
21
+ if (!global.MarkdownHeightSync || typeof global.MarkdownHeightSync.createHeightSync !== 'function') {
22
+ console.error('Height sync module is missing. Ensure height-sync.js is loaded.');
23
+ if (bridge && typeof bridge.notifyError === 'function') {
24
+ bridge.notifyError(new Error('Height sync module missing'));
25
+ }
26
+ return;
27
+ }
28
+
29
+ const renderLib = global.MarkdownRender;
30
+ const renderCompleteCallbacks = [];
31
+
32
+ const defaultConfig = {
33
+ theme: { mode: 'auto' },
34
+ layout: {},
35
+ i18n: {},
36
+ markdown: {},
37
+ streaming: { incremental: true }
38
+ };
39
+ const runtimeConfig = Object.assign({}, defaultConfig, global.MarkdownWebViewConfig || {});
40
+
41
+ // ---------------------------------------------------------------------
42
+ // Theme + layout setup
43
+ // ---------------------------------------------------------------------
44
+
45
+ function applyTheme(theme) {
46
+ const config = theme || {};
47
+
48
+ // Preferred path: the render core exposes the token-based theme engine,
49
+ // which honors `mode`, a named `preset`, and per-token `tokens` overrides.
50
+ if (typeof renderLib.applyTheme === 'function') {
51
+ try {
52
+ renderLib.applyTheme({
53
+ mode: config.mode,
54
+ preset: config.preset,
55
+ tokens: config.tokens
56
+ });
57
+ return;
58
+ } catch (err) {
59
+ console.warn('Failed to apply theme config:', err);
60
+ }
61
+ }
62
+
63
+ // Fallback for an older core without the theme engine: honor mode only.
64
+ const root = documentRef ? documentRef.documentElement : null;
65
+ if (!root) return;
66
+ if (config.mode === 'light' || config.mode === 'dark') {
67
+ root.setAttribute('data-theme', config.mode);
68
+ } else {
69
+ root.removeAttribute('data-theme');
70
+ }
71
+ }
72
+
73
+ function applyLayout(layout) {
74
+ if (!layout) return;
75
+ try {
76
+ if (layout.padding !== undefined && renderLib.setPadding) renderLib.setPadding(layout.padding);
77
+ if (layout.bottomGap !== undefined && renderLib.setBottomGap) renderLib.setBottomGap(layout.bottomGap);
78
+ if (layout.background && renderLib.setBackground) renderLib.setBackground(layout.background);
79
+ if (layout.fontSize !== undefined && renderLib.setFontSize) renderLib.setFontSize(layout.fontSize);
80
+ } catch (err) {
81
+ console.warn('Failed to apply layout config:', err);
82
+ }
83
+ }
84
+
85
+ function applyExtensionStyles(extensions) {
86
+ if (!Array.isArray(extensions) || extensions.length === 0) return;
87
+ if (typeof renderLib.buildExtensionCSS !== 'function' || !documentRef) return;
88
+ try {
89
+ const css = renderLib.buildExtensionCSS(extensions);
90
+ if (!css) return;
91
+ let style = documentRef.getElementById('md-ext-vars');
92
+ if (!style) {
93
+ style = documentRef.createElement('style');
94
+ style.id = 'md-ext-vars';
95
+ (documentRef.head || documentRef.documentElement).appendChild(style);
96
+ }
97
+ style.textContent = css;
98
+ } catch (err) {
99
+ console.warn('Failed to apply extension styles:', err);
100
+ }
101
+ }
102
+
103
+ applyTheme(runtimeConfig.theme);
104
+ applyLayout(runtimeConfig.layout);
105
+ applyExtensionStyles(runtimeConfig.extensions);
106
+
107
+ // ---------------------------------------------------------------------
108
+ // Streaming state. The scanner walks each new tail at most once across the
109
+ // entire stream, so total scanning cost is O(buffer length).
110
+ // ---------------------------------------------------------------------
111
+
112
+ const scanState = newScanState();
113
+ let stableText = '';
114
+ let stableHtml = '';
115
+ let streamingBuffer = '';
116
+ let pendingFrame = null;
117
+ let pendingReason = 'render';
118
+ let renderingInProgress = false;
119
+ let finalFlush = false;
120
+
121
+ function newScanState() {
122
+ return {
123
+ cursor: 0,
124
+ lineStart: 0,
125
+ inFence: false,
126
+ fenceMark: '',
127
+ inMath: false,
128
+ lastSafe: 0,
129
+ prevNonBlankLine: null
130
+ };
131
+ }
132
+
133
+ // Lines starting with these markers are list / quote / table rows. We refuse
134
+ // to advance the stable cutoff when the previous non-blank line matches —
135
+ // re-rendering a partial list/table in isolation would shatter the layout.
136
+ const LIST_LIKE = /^(?:[-*+]\s|\d+\.\s|>|\|)/;
137
+
138
+ function advanceScan(text) {
139
+ let i = scanState.cursor;
140
+ const len = text.length;
141
+ while (i < len) {
142
+ const ch = text.charCodeAt(i);
143
+ if (ch === 0x0a /* \n */) {
144
+ const line = text.slice(scanState.lineStart, i);
145
+ consumeLine(line, i);
146
+ scanState.lineStart = i + 1;
147
+ }
148
+ i += 1;
149
+ }
150
+ scanState.cursor = i;
151
+ }
152
+
153
+ function consumeLine(line, newlineOffset) {
154
+ const trimmed = line.trim();
155
+
156
+ if (!scanState.inMath) {
157
+ if (!scanState.inFence) {
158
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
159
+ if (fenceMatch) {
160
+ scanState.inFence = true;
161
+ scanState.fenceMark = fenceMatch[1];
162
+ }
163
+ } else if (trimmed.startsWith(scanState.fenceMark)) {
164
+ scanState.inFence = false;
165
+ scanState.fenceMark = '';
166
+ }
167
+ }
168
+
169
+ if (!scanState.inFence) {
170
+ if (trimmed === '$$') {
171
+ scanState.inMath = !scanState.inMath;
172
+ }
173
+ }
174
+
175
+ if (!scanState.inFence && !scanState.inMath && trimmed === '') {
176
+ if (scanState.prevNonBlankLine !== null && !LIST_LIKE.test(scanState.prevNonBlankLine)) {
177
+ scanState.lastSafe = newlineOffset + 1;
178
+ }
179
+ }
180
+
181
+ if (trimmed !== '') {
182
+ scanState.prevNonBlankLine = trimmed;
183
+ }
184
+ }
185
+
186
+ function resetScan() {
187
+ Object.assign(scanState, newScanState());
188
+ stableText = '';
189
+ stableHtml = '';
190
+ }
191
+
192
+ // ---------------------------------------------------------------------
193
+ // Render helpers
194
+ // ---------------------------------------------------------------------
195
+
196
+ function renderSegment(text, options) {
197
+ if (!text) return '';
198
+ try {
199
+ return renderLib.renderMarkdown(text, options);
200
+ } catch (err) {
201
+ console.error('Markdown segment render error:', err);
202
+ if (bridge && typeof bridge.notifyError === 'function') {
203
+ bridge.notifyError(err, 'render');
204
+ }
205
+ return '';
206
+ }
207
+ }
208
+
209
+ function commit(html) {
210
+ // Prefer DOM diffing when the renderer exports applyStreamingHtml — it
211
+ // preserves code-block scroll positions across stream flushes.
212
+ if (typeof renderLib.applyStreamingHtml === 'function') {
213
+ renderLib.applyStreamingHtml(container, html, { preserveCodeBlockScroll: true });
214
+ } else {
215
+ container.innerHTML = html;
216
+ }
217
+ }
218
+
219
+ function fullRerender(options) {
220
+ commit(renderSegment(streamingBuffer, options));
221
+ }
222
+
223
+ function incrementalRerender(options) {
224
+ advanceScan(streamingBuffer);
225
+ const cutoff = scanState.lastSafe;
226
+ if (cutoff > stableText.length) {
227
+ const newStable = streamingBuffer.slice(stableText.length, cutoff);
228
+ if (newStable.length > 0) {
229
+ stableHtml += renderSegment(newStable, options);
230
+ stableText = streamingBuffer.slice(0, cutoff);
231
+ }
232
+ }
233
+ const unstable = streamingBuffer.slice(stableText.length);
234
+ const unstableHtml = renderSegment(unstable, options);
235
+ commit(stableHtml + unstableHtml);
236
+ }
237
+
238
+ function flush() {
239
+ pendingFrame = null;
240
+ if (renderingInProgress) {
241
+ pendingFrame = raf(flush);
242
+ return;
243
+ }
244
+ renderingInProgress = true;
245
+ try {
246
+ const options = Object.assign({}, runtimeConfig.markdown || {});
247
+ if (runtimeConfig.extensions) {
248
+ options.extensions = runtimeConfig.extensions;
249
+ }
250
+ // The copy button is built inside the render pipeline, so the strings
251
+ // have to travel with the render options rather than only reaching the
252
+ // click handler.
253
+ options.i18n = runtimeConfig.i18n || {};
254
+ if (runtimeConfig.streaming && runtimeConfig.streaming.incremental !== false) {
255
+ incrementalRerender(options);
256
+ } else {
257
+ fullRerender(options);
258
+ }
259
+ // Event contract: `renderComplete` fires once when a content cycle first
260
+ // paints (initial render), and once more on the final flush of a stream.
261
+ // Every in-between streaming flush reports through the (cheaper,
262
+ // host-debounced) height-change channel instead of re-announcing
263
+ // "render complete" on every animation frame.
264
+ if (!heightSync.hasPostedInitial() || finalFlush) {
265
+ heightSync.scheduleInitial(pendingReason);
266
+ } else {
267
+ heightSync.scheduleHeight(pendingReason);
268
+ }
269
+ finalFlush = false;
270
+ } finally {
271
+ renderingInProgress = false;
272
+ }
273
+ }
274
+
275
+ function raf(fn) {
276
+ if (typeof global.requestAnimationFrame === 'function') {
277
+ return global.requestAnimationFrame(fn);
278
+ }
279
+ return setTimeout(fn, 16);
280
+ }
281
+
282
+ function cancelFrame(handle) {
283
+ if (handle === null) return;
284
+ if (typeof global.cancelAnimationFrame === 'function') {
285
+ global.cancelAnimationFrame(handle);
286
+ } else {
287
+ clearTimeout(handle);
288
+ }
289
+ }
290
+
291
+ function scheduleFlush(reason) {
292
+ pendingReason = reason || pendingReason;
293
+ if (pendingFrame !== null) return;
294
+ pendingFrame = raf(flush);
295
+ }
296
+
297
+ // ---------------------------------------------------------------------
298
+ // Public API used by the SDK bridge
299
+ // ---------------------------------------------------------------------
300
+
301
+ function renderMarkdown(markdownText) {
302
+ if (typeof markdownText !== 'string') {
303
+ if (bridge && typeof bridge.notifyError === 'function') {
304
+ bridge.notifyError(new Error('Invalid markdown: expected string'), 'render');
305
+ }
306
+ return false;
307
+ }
308
+ resetScan();
309
+ streamingBuffer = markdownText;
310
+ heightSync.beginRenderCycle();
311
+ scheduleFlush('render');
312
+ return true;
313
+ }
314
+
315
+ function renderMarkdownStream(markdownText) {
316
+ return renderMarkdown(markdownText);
317
+ }
318
+
319
+ function appendMarkdownChunk(chunk, opts) {
320
+ const options = opts || {};
321
+ streamingBuffer += typeof chunk === 'string' ? chunk : '';
322
+ scheduleFlush('append');
323
+ if (options.isLast) {
324
+ cancelFrame(pendingFrame);
325
+ pendingFrame = null;
326
+ finalFlush = true;
327
+ flush();
328
+ }
329
+ return true;
330
+ }
331
+
332
+ function getContentHeight() {
333
+ return heightSync.measure();
334
+ }
335
+
336
+ function clearContent() {
337
+ cancelFrame(pendingFrame);
338
+ pendingFrame = null;
339
+ streamingBuffer = '';
340
+ resetScan();
341
+ container.innerHTML = '';
342
+ heightSync.beginRenderCycle();
343
+ heightSync.scheduleHeight('clear', { force: true });
344
+ }
345
+
346
+ function onRenderComplete(callback) {
347
+ if (typeof callback === 'function') renderCompleteCallbacks.push(callback);
348
+ }
349
+
350
+ function emitRenderComplete(info) {
351
+ renderCompleteCallbacks.forEach((cb) => {
352
+ try { cb(info); } catch (err) { console.warn('onRenderComplete callback failed', err); }
353
+ });
354
+ }
355
+
356
+ const heightSync = global.MarkdownHeightSync.createHeightSync({
357
+ container,
358
+ bridge,
359
+ onInitialRender(height) {
360
+ emitRenderComplete({ height, reason: pendingReason });
361
+ }
362
+ });
363
+
364
+ if (typeof runtimeConfig.onRenderComplete === 'function') {
365
+ onRenderComplete(runtimeConfig.onRenderComplete);
366
+ }
367
+
368
+ let detachCopy = () => {};
369
+ if (global.MarkdownCopy && typeof global.MarkdownCopy.attachCopyHandler === 'function') {
370
+ detachCopy = global.MarkdownCopy.attachCopyHandler({
371
+ bridge,
372
+ strings: runtimeConfig.i18n
373
+ });
374
+ }
375
+
376
+ global.renderMarkdown = renderMarkdown;
377
+ global.renderMarkdownStream = renderMarkdownStream;
378
+ global.appendMarkdownChunk = appendMarkdownChunk;
379
+ global.getContentHeight = getContentHeight;
380
+ global.clearContent = clearContent;
381
+ global.MarkdownRenderer = {
382
+ onRenderComplete,
383
+ version: bridge && bridge.VERSION ? bridge.VERSION : '1.0.0'
384
+ };
385
+
386
+ if (bridge && typeof bridge.notifyPageReady === 'function') {
387
+ bridge.notifyPageReady();
388
+ }
389
+
390
+ if (typeof global.addEventListener === 'function') {
391
+ global.addEventListener('pagehide', () => {
392
+ if (typeof detachCopy === 'function') detachCopy();
393
+ heightSync.disconnect();
394
+ cancelFrame(pendingFrame);
395
+ pendingFrame = null;
396
+ });
397
+ }
398
+ })(window);
@@ -8,6 +8,21 @@ class MarkdownViewController: UIViewController {
8
8
 
9
9
  private var webView: WKWebView!
10
10
  private var isHTMLLoaded = false
11
+ private let maxRenderRetries = 20
12
+ private let renderRetryDelay: TimeInterval = 0.1
13
+ private var renderRetryCount = 0
14
+ var onContentHeightChange: ((CGFloat) -> Void)?
15
+
16
+ private enum ScriptHandler: String, CaseIterable {
17
+ case renderComplete
18
+ case pageReady
19
+ case contentHeightChanged
20
+ case iOSHandler
21
+ }
22
+
23
+ private var scriptMessageNames: [String] {
24
+ return ScriptHandler.allCases.map { $0.rawValue }
25
+ }
11
26
 
12
27
  // MARK: - Lifecycle
13
28
 
@@ -16,8 +31,7 @@ class MarkdownViewController: UIViewController {
16
31
 
17
32
  // Configure message handlers for iOS communication
18
33
  let contentController = WKUserContentController()
19
- contentController.add(self, name: "renderComplete")
20
- contentController.add(self, name: "pageReady")
34
+ scriptMessageNames.forEach { contentController.add(self, name: $0) }
21
35
  config.userContentController = contentController
22
36
 
23
37
  // Create and configure webView
@@ -38,29 +52,42 @@ class MarkdownViewController: UIViewController {
38
52
  loadHTMLFile()
39
53
  }
40
54
 
55
+ deinit {
56
+ removeScriptMessageHandlers()
57
+ }
58
+
41
59
  // MARK: - Public Methods
42
60
 
43
61
  /// Render markdown content
44
62
  /// - Parameter markdown: The markdown string to render
45
63
  func renderMarkdown(_ markdown: String) {
46
64
  guard isHTMLLoaded else {
47
- // Queue the render request until HTML is loaded
48
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
65
+ // Queue the render request until HTML is loaded, with a bounded retry count
66
+ guard renderRetryCount < maxRenderRetries else {
67
+ print("❌ HTML not ready after \(maxRenderRetries) retries; aborting render.")
68
+ return
69
+ }
70
+ renderRetryCount += 1
71
+ DispatchQueue.main.asyncAfter(deadline: .now() + renderRetryDelay) { [weak self] in
49
72
  self?.renderMarkdown(markdown)
50
73
  }
51
74
  return
52
75
  }
53
76
 
54
- // Escape the markdown string for JavaScript
55
- let escapedMarkdown = markdown
56
- .replacingOccurrences(of: "\\", with: "\\\\")
57
- .replacingOccurrences(of: "`", with: "\\`")
58
- .replacingOccurrences(of: "$", with: "\\$")
59
- .replacingOccurrences(of: "\n", with: "\\n")
60
- .replacingOccurrences(of: "\r", with: "\\r")
61
- .replacingOccurrences(of: "\"", with: "\\\"")
77
+ // Reset retry counter once HTML is ready
78
+ renderRetryCount = 0
79
+
80
+ // Serialize markdown safely for JS (avoids manual escaping and script-breaking sequences)
81
+ let jsonString: String
82
+ if let data = try? JSONSerialization.data(withJSONObject: markdown, options: []),
83
+ let serialized = String(data: data, encoding: .utf8) {
84
+ jsonString = serialized
85
+ } else {
86
+ print("❌ Failed to serialize markdown to JSON; aborting render.")
87
+ return
88
+ }
62
89
 
63
- let javascript = "window.renderMarkdown(`\(escapedMarkdown)`)"
90
+ let javascript = "window.renderMarkdown(\(jsonString))"
64
91
 
65
92
  webView.evaluateJavaScript(javascript) { result, error in
66
93
  if let error = error {
@@ -75,13 +102,7 @@ class MarkdownViewController: UIViewController {
75
102
  /// - Parameter completion: Callback with the content height in points
76
103
  func getContentHeight(completion: @escaping (CGFloat) -> Void) {
77
104
  webView.evaluateJavaScript("window.getContentHeight()") { result, error in
78
- if let height = result as? CGFloat {
79
- completion(height)
80
- } else if let height = result as? Int {
81
- completion(CGFloat(height))
82
- } else {
83
- completion(0)
84
- }
105
+ completion(self.parseHeight(from: result))
85
106
  }
86
107
  }
87
108
 
@@ -117,6 +138,42 @@ class MarkdownViewController: UIViewController {
117
138
  print("❌ Error loading HTML: \(error.localizedDescription)")
118
139
  }
119
140
  }
141
+
142
+ private func removeScriptMessageHandlers() {
143
+ guard let contentController = webView?.configuration.userContentController else { return }
144
+ // Avoid WKUserContentController retaining self after the controller is gone.
145
+ scriptMessageNames.forEach { contentController.removeScriptMessageHandler(forName: $0) }
146
+ }
147
+
148
+ private func parseHeight(from value: Any?) -> CGFloat {
149
+ switch value {
150
+ case let number as NSNumber:
151
+ return CGFloat(truncating: number)
152
+ default:
153
+ return 0
154
+ }
155
+ }
156
+
157
+ private struct BridgeMessage {
158
+ let action: String
159
+ let payload: [String: Any]
160
+ let version: String?
161
+ }
162
+
163
+ private func decodeBridgeMessage(_ message: WKScriptMessage) -> BridgeMessage {
164
+ let dictionary = message.body as? [String: Any] ?? [:]
165
+ let action = dictionary["action"] as? String ?? message.name
166
+ let payload = dictionary["payload"] as? [String: Any] ?? dictionary
167
+ let version = dictionary["version"] as? String
168
+ return BridgeMessage(action: action, payload: payload, version: version)
169
+ }
170
+
171
+ private func extractHeight(from payload: [String: Any]) -> CGFloat? {
172
+ if let value = payload["height"] ?? payload["contentHeight"] ?? payload["value"] {
173
+ return parseHeight(from: value)
174
+ }
175
+ return nil
176
+ }
120
177
  }
121
178
 
122
179
  // MARK: - WKScriptMessageHandler
@@ -126,22 +183,55 @@ extension MarkdownViewController: WKScriptMessageHandler {
126
183
  _ userContentController: WKUserContentController,
127
184
  didReceive message: WKScriptMessage
128
185
  ) {
129
- if message.name == "pageReady" {
130
- print("✅ WebView page is ready")
186
+ let bridgeMessage = decodeBridgeMessage(message)
187
+ let payload = bridgeMessage.payload
188
+ let versionLabel = bridgeMessage.version.map { " [v\($0)]" } ?? ""
189
+
190
+ switch bridgeMessage.action {
191
+ case "pageReady":
192
+ print("✅ WebView page is ready\(versionLabel)")
131
193
  isHTMLLoaded = true
132
- } else if message.name == "renderComplete" {
133
- if let body = message.body as? [String: Any] {
134
- if let success = body["success"] as? Bool {
135
- if success {
136
- print("✅ Render completed successfully")
137
- if let height = body["height"] as? CGFloat {
138
- print("📏 Content height: \(height)")
139
- }
140
- } else if let error = body["error"] as? String {
141
- print("❌ Render failed: \(error)")
142
- }
194
+
195
+ case "renderComplete":
196
+ let success = payload["success"] as? Bool ?? true
197
+ if success {
198
+ if let height = extractHeight(from: payload) {
199
+ print("✅ Render completed successfully\(versionLabel), height: \(height)")
200
+ notifyHeightChange(height)
201
+ } else {
202
+ print("✅ Render completed successfully\(versionLabel)")
143
203
  }
204
+ } else {
205
+ let errorMessage = payload["error"] as? String ?? "Unknown render error"
206
+ print("❌ Render failed\(versionLabel): \(errorMessage)")
207
+ }
208
+
209
+ case "contentHeight", "contentHeightChanged":
210
+ if let height = extractHeight(from: payload) {
211
+ print("↔️ Content height updated\(versionLabel): \(height)")
212
+ notifyHeightChange(height)
144
213
  }
214
+
215
+ case "copyMarkdownText":
216
+ if let info = payload["dict"] as? [String: Any] {
217
+ print("📋 Copy event received\(versionLabel): \(info)")
218
+ } else {
219
+ print("📋 Copy event received\(versionLabel)")
220
+ }
221
+
222
+ case "renderError":
223
+ let errorMessage = payload["error"] as? String ?? "Unknown render error"
224
+ print("❌ Render error\(versionLabel): \(errorMessage)")
225
+
226
+ default:
227
+ print("ℹ️ Received iOS handler action\(versionLabel): \(bridgeMessage.action)")
228
+ }
229
+ }
230
+
231
+ private func notifyHeightChange(_ height: CGFloat) {
232
+ guard height >= 0 else { return }
233
+ DispatchQueue.main.async { [weak self] in
234
+ self?.onContentHeightChange?(height)
145
235
  }
146
236
  }
147
237
  }
@@ -79,6 +79,8 @@ struct ContentView: View {
79
79
  }
80
80
  ```
81
81
 
82
+ > ✅ 完整示例参考 `SwiftUIMarkdownExample.swift`,其中演示了如何通过 `onContentHeightChange` 绑定高度实现自适应布局。
83
+
82
84
  ## API 参考
83
85
 
84
86
  ### renderMarkdown(_ markdown: String)