@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.
- package/README.md +436 -178
- package/README.zh-CN.md +530 -0
- package/THIRD-PARTY-NOTICES.md +332 -0
- package/dist/markdown-render.css +3 -3
- package/dist/markdown-render.esm.css +3 -3
- package/dist/markdown-render.esm.js +1 -91452
- package/dist/markdown-render.html +20 -75
- package/dist/markdown-render.js +1 -91457
- package/dist/scripts/bridge.js +119 -0
- package/dist/scripts/chat-renderer.js +2770 -0
- package/dist/scripts/copy.js +155 -0
- package/dist/scripts/height-sync.js +205 -0
- package/dist/scripts/renderer.js +398 -0
- package/ios-example/MarkdownViewController.swift +123 -33
- package/ios-example/README.md +2 -0
- package/ios-example/SwiftUIMarkdownExample.swift +111 -0
- package/package.json +17 -9
- package/dist/fonts/KaTeX_AMS-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_AMS-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/dist/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- package/dist/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- package/dist/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- package/dist/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Main-Bold.ttf +0 -0
- package/dist/fonts/KaTeX_Main-Bold.woff +0 -0
- package/dist/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- package/dist/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- package/dist/fonts/KaTeX_Main-Italic.ttf +0 -0
- package/dist/fonts/KaTeX_Main-Italic.woff +0 -0
- package/dist/fonts/KaTeX_Main-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Main-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- package/dist/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- package/dist/fonts/KaTeX_Math-Italic.ttf +0 -0
- package/dist/fonts/KaTeX_Math-Italic.woff +0 -0
- package/dist/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- package/dist/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- package/dist/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- package/dist/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- package/dist/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Script-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Script-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Size1-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Size1-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Size2-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Size2-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Size3-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Size3-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Size4-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Size4-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- 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:
|
|
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
|
-
|
|
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
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
}
|