@difizen/libro-rendermime 0.3.37 → 0.3.38

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.
@@ -3,8 +3,10 @@ import React from 'react';
3
3
  import './index.less';
4
4
  export declare const RawTextRender: React.FC<{
5
5
  model: BaseOutputView;
6
+ refreshKey?: string;
6
7
  }>;
7
8
  export declare const TextRender: React.NamedExoticComponent<{
8
9
  model: BaseOutputView;
10
+ refreshKey?: string | undefined;
9
11
  }>;
10
12
  //# sourceMappingURL=text-render.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"text-render.d.ts","sourceRoot":"","sources":["../../src/components/text-render.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAG1D,OAAO,KAAsC,MAAM,OAAO,CAAC;AAC3D,OAAO,cAAc,CAAC;AAWtB,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC;IAAE,KAAK,EAAE,cAAc,CAAA;CAAE,CAqJ7D,CAAC;AAEF,eAAO,MAAM,UAAU;WAvJuB,cAAc;EAuJT,CAAC"}
1
+ {"version":3,"file":"text-render.d.ts","sourceRoot":"","sources":["../../src/components/text-render.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAG1D,OAAO,KAAsC,MAAM,OAAO,CAAC;AAC3D,OAAO,cAAc,CAAC;AAWtB,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC;IACnC,KAAK,EAAE,cAAc,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CA2IA,CAAC;AAEF,eAAO,MAAM,UAAU;WA/Id,cAAc;;EA+I4B,CAAC"}
@@ -23,7 +23,8 @@ function getLastThreeAfterFirstTwo(arr) {
23
23
  return arr.slice(startIndex);
24
24
  }
25
25
  export var RawTextRender = function RawTextRender(props) {
26
- var model = props.model;
26
+ var model = props.model,
27
+ refreshKey = props.refreshKey;
27
28
  var renderTextRef = useRef(null);
28
29
  var renderTextContainerRef = useRef(null);
29
30
  var defaultRenderMime = useInject(RenderMimeRegistry);
@@ -58,18 +59,11 @@ export var RawTextRender = function RawTextRender(props) {
58
59
  }
59
60
  }
60
61
  // eslint-disable-next-line react-hooks/exhaustive-deps
61
- }, [mimeType, dataContent]);
62
+ }, [mimeType, dataContent, refreshKey]);
62
63
  var content = null;
63
64
  if (isLargeOutputDisplay) {
64
- content = /*#__PURE__*/_jsxs(_Fragment, {
65
- children: [/*#__PURE__*/_jsx("div", {
66
- className: "libro-text-render",
67
- ref: renderTextRef,
68
- style: {
69
- overflowY: 'auto',
70
- maxHeight: 'unset'
71
- }
72
- }), model.raw['display_text'] && /*#__PURE__*/_jsxs("div", {
65
+ content = /*#__PURE__*/_jsx(_Fragment, {
66
+ children: model.raw['display_text'] && /*#__PURE__*/_jsxs("div", {
73
67
  className: "libro-text-display-action-container",
74
68
  children: [/*#__PURE__*/_jsx("span", {
75
69
  children: "\u8F93\u51FA\u5DF2\u88AB\u622A\u65AD\uFF0C\u70B9\u51FB\u53EF\u5728\u6EDA\u52A8\u5BB9\u5668\u5185"
@@ -99,18 +93,11 @@ export var RawTextRender = function RawTextRender(props) {
99
93
  children: "\u6587\u672C\u7F16\u8F91\u5668"
100
94
  }), "\u4E2D\u6253\u5F00"]
101
95
  })]
102
- })]
96
+ })
103
97
  });
104
98
  } else {
105
- content = /*#__PURE__*/_jsxs(_Fragment, {
106
- children: [/*#__PURE__*/_jsx("div", {
107
- className: "libro-text-render",
108
- ref: renderTextRef,
109
- style: {
110
- overflowY: 'auto',
111
- maxHeight: '420px'
112
- }
113
- }), model.raw['display_text'] && /*#__PURE__*/_jsxs("div", {
99
+ content = /*#__PURE__*/_jsx(_Fragment, {
100
+ children: model.raw['display_text'] && /*#__PURE__*/_jsxs("div", {
114
101
  className: "libro-text-display-action-container",
115
102
  children: [/*#__PURE__*/_jsx("span", {
116
103
  children: "\u5F53\u524D\u5904\u4E8E\u6EDA\u52A8\u67E5\u770B\uFF0C\u70B9\u51FB\u53EF"
@@ -122,13 +109,20 @@ export var RawTextRender = function RawTextRender(props) {
122
109
  className: "libro-text-display-action",
123
110
  children: "\u622A\u65AD\u67E5\u770B"
124
111
  })]
125
- })]
112
+ })
126
113
  });
127
114
  }
128
- return /*#__PURE__*/_jsx("div", {
115
+ return /*#__PURE__*/_jsxs("div", {
129
116
  className: "libro-text-render-container",
130
117
  ref: renderTextContainerRef,
131
- children: content
118
+ children: [/*#__PURE__*/_jsx("div", {
119
+ className: "libro-text-render",
120
+ ref: renderTextRef,
121
+ style: {
122
+ overflowY: 'auto',
123
+ maxHeight: isLargeOutputDisplay ? 'unset' : '420px'
124
+ }
125
+ }), content]
132
126
  });
133
127
  };
134
128
  export var TextRender = /*#__PURE__*/React.memo(RawTextRender);
package/es/renderers.d.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import type { IRenderHTMLOptions, IRenderImageOptions, IRenderMarkdownOptions, IRenderSVGOptions, IRenderTextOptions } from './rendermime-protocol.js';
2
+ export declare function disposeHost(host: HTMLElement): void;
2
3
  /**
3
4
  * renderText
4
5
  * - 使用 autolinkRanges 得到全局的链接区间信息(start/end/url)。
5
6
  * - 在脱离文档的 parsed fragment(template.content)上按子节点边界切片,
6
- * 仅创建必要的 Text / shallow-clone element / <a> 节点。
7
+ * - 仅创建必要的 Text / shallow-clone element / <a> 节点。
7
8
  * - 对跨子节点的链接,使用 pendingAnchor 把分片累计到同一个 <a>,直到 range 结束。
8
- * - 最后一次性将构建好的 DocumentFragment 插回 pre 中,减少重排与 GC 压力。
9
+ * - 若检测到本次 data 是对上次 prevData append,则只处理并 append 新增部分(增量路径);
10
+ * - 否则视为非追加(首次渲染或变更),进行全量重建,并尽量保持用户视口位置;
11
+ * - 使用 rAF 批量追加以合并高频调用,避免频繁重排;
12
+ * - 在追加时只有用户接近底部才自动滚动,否则保持当前位置以便用户查看历史输出。
9
13
  */
10
14
  export declare function renderText(options: IRenderTextOptions): Promise<void>;
11
15
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"renderers.d.ts","sourceRoot":"","sources":["../src/renderers.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,kBAAkB,EAClB,mBAAmB,EACnB,sBAAsB,EACtB,iBAAiB,EACjB,kBAAkB,EACnB,MAAM,0BAA0B,CAAC;AAWlC;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwIrE;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAqCvE;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuErE;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+BnE;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyBnF"}
1
+ {"version":3,"file":"renderers.d.ts","sourceRoot":"","sources":["../src/renderers.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,kBAAkB,EAClB,mBAAmB,EACnB,sBAAsB,EACtB,iBAAiB,EACjB,kBAAkB,EACnB,MAAM,0BAA0B,CAAC;AAkElC,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,QAgB5C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAsSrE;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAqCvE;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuErE;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+BnE;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyBnF"}
package/es/renderers.js CHANGED
@@ -13,13 +13,81 @@ function _asyncToGenerator(fn) { return function () { var self = this, args = ar
13
13
  import { concatMultilineString } from '@difizen/libro-common';
14
14
  import { ansiSpan, autolinkRanges, createAnchorForUrl, evalInnerHTMLScriptTags, handleDefaults, handleUrls, pieceFromNodeSlice } from "./rendermime-utils.js";
15
15
 
16
+ // module-level state: per-host streaming render state
17
+
18
+ var hostStates = new WeakMap();
19
+
20
+ // Threshold in px to consider "user at bottom" (自动滚动触发阈值)
21
+ var AUTO_SCROLL_THRESHOLD = 50;
22
+
23
+ // Helper: is the user currently at (or near) the bottom of the pre container?
24
+ function isUserAtBottom(pre) {
25
+ var threshold = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : AUTO_SCROLL_THRESHOLD;
26
+ if (pre.scrollHeight <= pre.clientHeight) {
27
+ return true;
28
+ }
29
+ return pre.scrollHeight - (pre.scrollTop + pre.clientHeight) <= threshold;
30
+ }
31
+
32
+ // Schedule and perform append in next animation frame (batching multiple appends)
33
+ function scheduleAppend(host) {
34
+ var st = hostStates.get(host);
35
+ if (!st) {
36
+ return;
37
+ }
38
+ if (st.scheduled) {
39
+ return;
40
+ }
41
+ st.scheduled = true;
42
+
43
+ // capture whether to auto-scroll at the moment of scheduling
44
+ var pre = st.pre;
45
+ var shouldAutoAtSchedule = isUserAtBottom(pre);
46
+ st.rafId = requestAnimationFrame(function () {
47
+ st.scheduled = false;
48
+ if (!st.pendingFrag) {
49
+ return;
50
+ }
51
+ try {
52
+ pre.appendChild(st.pendingFrag);
53
+ if (shouldAutoAtSchedule) {
54
+ pre.scrollTop = pre.scrollHeight;
55
+ }
56
+ } finally {
57
+ st.pendingFrag = null;
58
+ }
59
+ });
60
+ }
61
+
62
+ // 清理函数:由上层在 host 不再使用时调用
63
+ export function disposeHost(host) {
64
+ var st = hostStates.get(host);
65
+ if (!st) {
66
+ return;
67
+ }
68
+ // 取消未执行的 rAF
69
+ if (st.rafId !== null) {
70
+ cancelAnimationFrame(st.rafId);
71
+ st.rafId = null;
72
+ }
73
+ // 丢弃 pending fragment 引用(允许 GC 回收其内部节点)
74
+ st.pendingFrag = null;
75
+ // 清除其它资源(如果有 event listeners、workers 等也在这里处理)
76
+ // e.g. if (st.worker) st.worker.terminate();
77
+ // 从 WeakMap 删除(可选,WeakMap 的条目在 key 不再可达时会自动回收)
78
+ hostStates.delete(host);
79
+ }
80
+
16
81
  /**
17
82
  * renderText
18
83
  * - 使用 autolinkRanges 得到全局的链接区间信息(start/end/url)。
19
84
  * - 在脱离文档的 parsed fragment(template.content)上按子节点边界切片,
20
- * 仅创建必要的 Text / shallow-clone element / <a> 节点。
85
+ * - 仅创建必要的 Text / shallow-clone element / <a> 节点。
21
86
  * - 对跨子节点的链接,使用 pendingAnchor 把分片累计到同一个 <a>,直到 range 结束。
22
- * - 最后一次性将构建好的 DocumentFragment 插回 pre 中,减少重排与 GC 压力。
87
+ * - 若检测到本次 data 是对上次 prevData append,则只处理并 append 新增部分(增量路径);
88
+ * - 否则视为非追加(首次渲染或变更),进行全量重建,并尽量保持用户视口位置;
89
+ * - 使用 rAF 批量追加以合并高频调用,避免频繁重排;
90
+ * - 在追加时只有用户接近底部才自动滚动,否则保持当前位置以便用户查看历史输出。
23
91
  */
24
92
  export function renderText(options) {
25
93
  var host = options.host,
@@ -27,40 +95,177 @@ export function renderText(options) {
27
95
  source = options.source,
28
96
  mimeType = options.mimeType;
29
97
  var data = concatMultilineString(source);
30
-
31
- // 对文本做 ansi -> span 的转换并 sanitize(仅允许 <span>)
32
- var content = sanitizer.sanitize(ansiSpan(data), {
33
- allowedTags: ['span']
34
- });
35
98
  if (mimeType === 'application/vnd.jupyter.stderr') {
36
99
  host.setAttribute('data-mime-type', 'application/vnd.jupyter.stderr');
37
100
  }
38
101
 
39
- // 确保存在 <pre>,但不要马上把 innerHTML 写入实际文档(我们将在脱离文档的 fragment 上操作)
102
+ // 确保存在 <pre> 且为 host 的子元素(只在首次创建时 append)
40
103
  var pre = host.querySelector('pre');
41
104
  if (!pre) {
42
105
  pre = document.createElement('pre');
43
106
  host.appendChild(pre);
107
+ } else if (pre.parentElement !== host) {
108
+ // 如果 pre 被移动过,确保再次挂回 host(避免每次 render 都移动)
109
+ host.appendChild(pre);
44
110
  }
45
111
 
46
- // 使用 template 元素来解析 HTML,但 template.content 尚未插入到文档中 -> 不触发重排
47
- var tpl = document.createElement('template');
48
- tpl.innerHTML = content;
49
- var parsedFrag = tpl.content;
112
+ // 获取或初始化该 host 的状态
113
+ var st = hostStates.get(host);
114
+ if (!st) {
115
+ st = {
116
+ pre: pre,
117
+ prevData: '',
118
+ pendingFrag: null,
119
+ scheduled: false,
120
+ rafId: null
121
+ };
122
+ hostStates.set(host, st);
123
+ } else {
124
+ st.pre = pre;
125
+ }
50
126
 
51
- // 全文文本(用于按字符位置计算 ranges)
52
- var fullText = parsedFrag.textContent || '';
53
- if (!fullText) {
54
- // 若没有文本,清空并返回(保持 class)
127
+ // data 为空,则立即清空并返回
128
+ if (!data) {
129
+ st.prevData = '';
130
+ st.pendingFrag = null;
131
+ st.scheduled = false;
55
132
  pre.innerHTML = '';
56
133
  host.classList.add('libro-text-render');
57
134
  return Promise.resolve(undefined);
58
135
  }
59
136
 
60
- // 计算链接 ranges
61
- var ranges = autolinkRanges(fullText);
137
+ // 判定是否为“追加”场景(本次 data 以 prevData 为前缀)
138
+ var prevData = st.prevData || '';
139
+ var isAppend = prevData.length > 0 && data.startsWith(prevData);
140
+ if (isAppend) {
141
+ var appendedData = data.slice(prevData.length);
142
+ if (appendedData.length === 0) {
143
+ // 无新增内容
144
+ return Promise.resolve(undefined);
145
+ }
146
+
147
+ // 对新增内容做 ansi->span 转换并 sanitize(仅允许 span)
148
+ var appendedContent = sanitizer.sanitize(ansiSpan(appendedData), {
149
+ allowedTags: ['span']
150
+ });
151
+
152
+ // 解析为脱离文档的 fragment(不会触发回流)
153
+ var tpl = document.createElement('template');
154
+ tpl.innerHTML = appendedContent;
155
+ var appendedFrag = tpl.content;
156
+
157
+ // 对 appendedFrag 的文本执行链接检测(基于 autolinkRanges)
158
+ // 注意:此处使用 appendedFrag.textContent(仅针对新增片段)
159
+ var appendedText = appendedFrag.textContent || '';
160
+ var _ranges = appendedText ? autolinkRanges(appendedText) : [];
161
+
162
+ // 根据 ranges 构造要追加到 DOM 的 outFrag(可包含文本/浅 cloned spans/anchor)
163
+ var _outFrag = document.createDocumentFragment();
164
+ var _pendingAnchor = null;
165
+ var _rangeIdx = 0;
166
+ var _globalOffset = 0;
167
+ var _childNodes = Array.from(appendedFrag.childNodes);
168
+ for (var ni = 0; ni < _childNodes.length; ni++) {
169
+ var node = _childNodes[ni];
170
+ var nodeText = node.textContent || '';
171
+ var nodeLen = nodeText.length;
172
+ if (nodeLen === 0) {
173
+ continue;
174
+ }
175
+ var localPos = 0;
176
+ while (localPos < nodeLen) {
177
+ var absPos = _globalOffset + localPos;
178
+ // 将 rangeIdx 推到第一个可能与当前绝对位置相关的 range 上
179
+ while (_rangeIdx < _ranges.length && _ranges[_rangeIdx].end <= absPos) {
180
+ _rangeIdx++;
181
+ }
182
+ var curRange = _rangeIdx < _ranges.length ? _ranges[_rangeIdx] : null;
183
+ if (_pendingAnchor) {
184
+ // 当前有未完成的 anchor(链接跨节点):尽量消费本节点的部分
185
+ var take = Math.min(nodeLen - localPos, (curRange ? curRange.end : absPos) - absPos);
186
+ var piece = pieceFromNodeSlice(node, localPos, localPos + take);
187
+ _pendingAnchor.appendChild(piece);
188
+ localPos += take;
189
+
190
+ // 若到达当前 range 的结尾,则把 pendingAnchor 闭合并追加
191
+ if (curRange && _globalOffset + localPos >= curRange.end) {
192
+ _outFrag.appendChild(_pendingAnchor);
193
+ _pendingAnchor = null;
194
+ _rangeIdx++;
195
+ }
196
+ continue;
197
+ }
198
+
199
+ // 若没有 pendingAnchor,判断当前片段是否是普通文本或链接起始处
200
+ if (!curRange || curRange.start >= _globalOffset + nodeLen) {
201
+ // 本节点剩余部分没有链接,全部作为普通片段
202
+ var _piece = pieceFromNodeSlice(node, localPos, nodeLen);
203
+ _outFrag.appendChild(_piece);
204
+ localPos = nodeLen;
205
+ } else if (curRange.start > absPos) {
206
+ // 链接在当前节点后面一段位置开始:先把中间普通文本片段追加
207
+ var until = Math.min(nodeLen, curRange.start - _globalOffset);
208
+ var _piece2 = pieceFromNodeSlice(node, localPos, until);
209
+ _outFrag.appendChild(_piece2);
210
+ localPos = until;
211
+ } else {
212
+ // 当前绝对位置位于链接范围内:创建 anchor 并消费该链接在当前节点的部分
213
+ _pendingAnchor = createAnchorForUrl(curRange.url);
214
+ var _take = Math.min(nodeLen - localPos, curRange.end - absPos);
215
+ var _piece3 = pieceFromNodeSlice(node, localPos, localPos + _take);
216
+ _pendingAnchor.appendChild(_piece3);
217
+ localPos += _take;
218
+
219
+ // 若链接在此节点内结束,立即关闭并追加
220
+ if (_globalOffset + localPos >= curRange.end) {
221
+ _outFrag.appendChild(_pendingAnchor);
222
+ _pendingAnchor = null;
223
+ _rangeIdx++;
224
+ }
225
+ }
226
+ } // end while localPos
227
+
228
+ _globalOffset += nodeLen;
229
+ } // end for nodes
230
+
231
+ if (_pendingAnchor) {
232
+ _outFrag.appendChild(_pendingAnchor);
233
+ _pendingAnchor = null;
234
+ }
235
+
236
+ // 将 outFrag 合并到 host 状态的 pendingFrag 中,以便批量追加
237
+ if (!st.pendingFrag) {
238
+ st.pendingFrag = document.createDocumentFragment();
239
+ }
240
+ Array.from(_outFrag.childNodes).forEach(function (n) {
241
+ return st.pendingFrag.appendChild(n);
242
+ });
243
+
244
+ // 更新 prevData 为最新的整个 data(提交此次 append)
245
+ st.prevData = data;
246
+
247
+ // 安排 rAF 批量追加(若已安排则无副作用)
248
+ scheduleAppend(host);
249
+ host.classList.add('libro-text-render');
250
+ return Promise.resolve(undefined);
251
+ }
252
+
253
+ // ---------- 非追加(首次渲染或全量替换)路径 ----------
254
+ // 记录替换前的滚动信息,以便替换后尽量保持视图位置
255
+ var prevScrollTop = pre.scrollTop;
256
+ var prevScrollHeight = pre.scrollHeight;
257
+
258
+ // 对整个 data 做 ansi->span 转换并 sanitize(脱离文档)
259
+ var content = sanitizer.sanitize(ansiSpan(data), {
260
+ allowedTags: ['span']
261
+ });
262
+ var tplFull = document.createElement('template');
263
+ tplFull.innerHTML = content;
264
+ var parsedFrag = tplFull.content;
62
265
 
63
- // 准备输出 fragment(最终将一次性 append 到 pre)
266
+ // 基于全文进行链接检测并构造 outFrag(逻辑与增量路径一致)
267
+ var fullText = parsedFrag.textContent || '';
268
+ var ranges = fullText ? autolinkRanges(fullText) : [];
64
269
  var outFrag = document.createDocumentFragment();
65
270
 
66
271
  // pendingAnchor 用于处理链接跨多个原始子节点的情形:
@@ -73,33 +278,33 @@ export function renderText(options) {
73
278
  // 遍历 parsedFrag 的顶层子节点(这些通常是由 sanitizer/ansiSpan 产生的 span/text 节点)
74
279
  var globalOffset = 0;
75
280
  var childNodes = Array.from(parsedFrag.childNodes);
76
- for (var ni = 0; ni < childNodes.length; ni++) {
77
- var node = childNodes[ni];
78
- var nodeText = node.textContent || '';
79
- var nodeLen = nodeText.length;
80
- if (nodeLen === 0) {
281
+ for (var _ni = 0; _ni < childNodes.length; _ni++) {
282
+ var _node = childNodes[_ni];
283
+ var _nodeText = _node.textContent || '';
284
+ var _nodeLen = _nodeText.length;
285
+ if (_nodeLen === 0) {
81
286
  // 空节点直接跳过(同时 pendingAnchor 不受影响)
82
287
  continue;
83
288
  }
84
- var localPos = 0; // 当前在该节点内的偏移
85
- while (localPos < nodeLen) {
86
- var absPos = globalOffset + localPos;
289
+ var _localPos = 0; // 当前在该节点内的偏移
290
+ while (_localPos < _nodeLen) {
291
+ var _absPos = globalOffset + _localPos;
87
292
  // 跳过已经结束在当前位置之前的 ranges(将 rangeIdx 推到第一个可能相关的 range)
88
- while (rangeIdx < ranges.length && ranges[rangeIdx].end <= absPos) {
293
+ while (rangeIdx < ranges.length && ranges[rangeIdx].end <= _absPos) {
89
294
  rangeIdx++;
90
295
  }
91
- var curRange = rangeIdx < ranges.length ? ranges[rangeIdx] : null;
296
+ var _curRange = rangeIdx < ranges.length ? ranges[rangeIdx] : null;
92
297
 
93
298
  // 如果当前有正在构建的 pendingAnchor(链接已经开始但尚未结束)
94
299
  if (pendingAnchor) {
95
300
  // 计算本次可以从该节点中“消费”的长度(尽量多取,直到链接结束或节点结束)
96
- var take = Math.min(nodeLen - localPos, (curRange ? curRange.end : absPos) - absPos);
97
- var piece = pieceFromNodeSlice(node, localPos, localPos + take);
98
- pendingAnchor.appendChild(piece);
99
- localPos += take;
301
+ var _take2 = Math.min(_nodeLen - _localPos, (_curRange ? _curRange.end : _absPos) - _absPos);
302
+ var _piece4 = pieceFromNodeSlice(_node, _localPos, _localPos + _take2);
303
+ pendingAnchor.appendChild(_piece4);
304
+ _localPos += _take2;
100
305
 
101
306
  // 若到达了当前 range 的结束位置,则关闭 pendingAnchor
102
- if (curRange && globalOffset + localPos >= curRange.end) {
307
+ if (_curRange && globalOffset + _localPos >= _curRange.end) {
103
308
  outFrag.appendChild(pendingAnchor);
104
309
  pendingAnchor = null;
105
310
  rangeIdx++; // 当前 range 完成,推进到下一个
@@ -109,28 +314,28 @@ export function renderText(options) {
109
314
  }
110
315
 
111
316
  // 没有 pendingAnchor:决定当前段是普通文本还是链接起点
112
- if (!curRange || curRange.start >= globalOffset + nodeLen) {
317
+ if (!_curRange || _curRange.start >= globalOffset + _nodeLen) {
113
318
  // 当前剩余的节点内容中,没有链接开始 -> 全部作为普通片段
114
- var _piece = pieceFromNodeSlice(node, localPos, nodeLen);
115
- outFrag.appendChild(_piece);
116
- localPos = nodeLen;
117
- } else if (curRange.start > absPos) {
319
+ var _piece5 = pieceFromNodeSlice(_node, _localPos, _nodeLen);
320
+ outFrag.appendChild(_piece5);
321
+ _localPos = _nodeLen;
322
+ } else if (_curRange.start > _absPos) {
118
323
  // 有链接,但还没到链接起点:把从 localPos 到链接起点之前的文本作为普通片段
119
- var until = Math.min(nodeLen, curRange.start - globalOffset);
120
- var _piece2 = pieceFromNodeSlice(node, localPos, until);
121
- outFrag.appendChild(_piece2);
122
- localPos = until;
324
+ var _until = Math.min(_nodeLen, _curRange.start - globalOffset);
325
+ var _piece6 = pieceFromNodeSlice(_node, _localPos, _until);
326
+ outFrag.appendChild(_piece6);
327
+ _localPos = _until;
123
328
  } else {
124
329
  // 当前绝对位置处于链接范围之内(curRange.start <= absPos < curRange.end)
125
330
  // 新建一个 anchor 并把该节点中属于该链接的部分 append 到 anchor
126
- pendingAnchor = createAnchorForUrl(curRange.url);
127
- var _take = Math.min(nodeLen - localPos, curRange.end - absPos);
128
- var _piece3 = pieceFromNodeSlice(node, localPos, localPos + _take);
129
- pendingAnchor.appendChild(_piece3);
130
- localPos += _take;
331
+ pendingAnchor = createAnchorForUrl(_curRange.url);
332
+ var _take3 = Math.min(_nodeLen - _localPos, _curRange.end - _absPos);
333
+ var _piece7 = pieceFromNodeSlice(_node, _localPos, _localPos + _take3);
334
+ pendingAnchor.appendChild(_piece7);
335
+ _localPos += _take3;
131
336
 
132
337
  // 如果这个 range 在当前节点内就结束,则立即关闭 pendingAnchor
133
- if (globalOffset + localPos >= curRange.end) {
338
+ if (globalOffset + _localPos >= _curRange.end) {
134
339
  outFrag.appendChild(pendingAnchor);
135
340
  pendingAnchor = null;
136
341
  rangeIdx++;
@@ -138,7 +343,7 @@ export function renderText(options) {
138
343
  // 否则 pendingAnchor 会在后续节点继续被填充
139
344
  }
140
345
  }
141
- globalOffset += nodeLen;
346
+ globalOffset += _nodeLen;
142
347
  }
143
348
 
144
349
  // 如果循环结束后仍有 pendingAnchor(理论上不应发生,但以防万一),追加它
@@ -147,9 +352,22 @@ export function renderText(options) {
147
352
  pendingAnchor = null;
148
353
  }
149
354
 
150
- // 一次性替换 pre 的内容:先清空再 append(减少中间重排)
151
- pre.innerHTML = '';
152
- pre.appendChild(outFrag);
355
+ // 在下一帧中执行 DOM 替换以避免同步布局抖动
356
+ requestAnimationFrame(function () {
357
+ pre.innerHTML = '';
358
+ pre.appendChild(outFrag);
359
+
360
+ // 若用户在底部则滚到底,否则按高度差修正 scrollTop 保持视图不变
361
+ if (isUserAtBottom(pre)) {
362
+ pre.scrollTop = pre.scrollHeight;
363
+ } else {
364
+ var newScrollHeight = pre.scrollHeight;
365
+ pre.scrollTop = Math.max(0, prevScrollTop + (newScrollHeight - prevScrollHeight));
366
+ }
367
+ });
368
+
369
+ // 更新 prevData(记录当前已渲染的原始字符串)
370
+ st.prevData = data;
153
371
  host.classList.add('libro-text-render');
154
372
  return Promise.resolve(undefined);
155
373
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@difizen/libro-rendermime",
3
- "version": "0.3.37",
3
+ "version": "0.3.38",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "libro",
@@ -32,9 +32,9 @@
32
32
  "src"
33
33
  ],
34
34
  "dependencies": {
35
- "@difizen/libro-common": "^0.3.37",
36
- "@difizen/libro-core": "^0.3.37",
37
- "@difizen/libro-markdown": "^0.3.37",
35
+ "@difizen/libro-common": "^0.3.38",
36
+ "@difizen/libro-core": "^0.3.38",
37
+ "@difizen/libro-markdown": "^0.3.38",
38
38
  "@difizen/mana-app": "latest",
39
39
  "lodash.escape": "^4.0.1"
40
40
  },
@@ -15,10 +15,11 @@ function getLastThreeAfterFirstTwo(arr: string[]): string[] {
15
15
  return arr.slice(startIndex);
16
16
  }
17
17
 
18
- export const RawTextRender: React.FC<{ model: BaseOutputView }> = (props: {
18
+ export const RawTextRender: React.FC<{
19
19
  model: BaseOutputView;
20
- }) => {
21
- const { model } = props;
20
+ refreshKey?: string;
21
+ }> = (props: { model: BaseOutputView; refreshKey?: string }) => {
22
+ const { model, refreshKey } = props;
22
23
  const renderTextRef = useRef<HTMLDivElement>(null);
23
24
  const renderTextContainerRef = useRef<HTMLDivElement>(null);
24
25
  const defaultRenderMime = useInject<IRenderMimeRegistry>(RenderMimeRegistry);
@@ -74,21 +75,13 @@ export const RawTextRender: React.FC<{ model: BaseOutputView }> = (props: {
74
75
  }
75
76
  }
76
77
  // eslint-disable-next-line react-hooks/exhaustive-deps
77
- }, [mimeType, dataContent]);
78
+ }, [mimeType, dataContent, refreshKey]);
78
79
 
79
80
  let content = null;
80
81
 
81
82
  if (isLargeOutputDisplay) {
82
83
  content = (
83
84
  <>
84
- <div
85
- className="libro-text-render"
86
- ref={renderTextRef}
87
- style={{
88
- overflowY: 'auto',
89
- maxHeight: 'unset',
90
- }}
91
- />
92
85
  {model.raw['display_text'] && (
93
86
  <div className="libro-text-display-action-container">
94
87
  <span>输出已被截断,点击可在滚动容器内</span>
@@ -133,14 +126,6 @@ export const RawTextRender: React.FC<{ model: BaseOutputView }> = (props: {
133
126
  } else {
134
127
  content = (
135
128
  <>
136
- <div
137
- className="libro-text-render"
138
- ref={renderTextRef}
139
- style={{
140
- overflowY: 'auto',
141
- maxHeight: '420px',
142
- }}
143
- />
144
129
  {model.raw['display_text'] && (
145
130
  <div className="libro-text-display-action-container">
146
131
  <span>当前处于滚动查看,点击可</span>
@@ -161,6 +146,14 @@ export const RawTextRender: React.FC<{ model: BaseOutputView }> = (props: {
161
146
 
162
147
  return (
163
148
  <div className="libro-text-render-container" ref={renderTextContainerRef}>
149
+ <div
150
+ className="libro-text-render"
151
+ ref={renderTextRef}
152
+ style={{
153
+ overflowY: 'auto',
154
+ maxHeight: isLargeOutputDisplay ? 'unset' : '420px',
155
+ }}
156
+ />
164
157
  {content}
165
158
  </div>
166
159
  );
package/src/renderers.ts CHANGED
@@ -17,52 +17,273 @@ import {
17
17
  pieceFromNodeSlice,
18
18
  } from './rendermime-utils.js';
19
19
 
20
+ // module-level state: per-host streaming render state
21
+ type HostState = {
22
+ pre: HTMLPreElement;
23
+ prevData: string; // previously rendered raw data (concatMultilineString result)
24
+ pendingFrag: DocumentFragment | null;
25
+ scheduled: boolean;
26
+ rafId: number | null; // 保存 requestAnimationFrame 返回值,便于取消
27
+ };
28
+
29
+ const hostStates = new WeakMap<HTMLElement, HostState>();
30
+
31
+ // Threshold in px to consider "user at bottom" (自动滚动触发阈值)
32
+ const AUTO_SCROLL_THRESHOLD = 50;
33
+
34
+ // Helper: is the user currently at (or near) the bottom of the pre container?
35
+ function isUserAtBottom(pre: HTMLElement, threshold = AUTO_SCROLL_THRESHOLD): boolean {
36
+ if (pre.scrollHeight <= pre.clientHeight) {
37
+ return true;
38
+ }
39
+ return pre.scrollHeight - (pre.scrollTop + pre.clientHeight) <= threshold;
40
+ }
41
+
42
+ // Schedule and perform append in next animation frame (batching multiple appends)
43
+ function scheduleAppend(host: HTMLElement) {
44
+ const st = hostStates.get(host);
45
+ if (!st) {
46
+ return;
47
+ }
48
+ if (st.scheduled) {
49
+ return;
50
+ }
51
+ st.scheduled = true;
52
+
53
+ // capture whether to auto-scroll at the moment of scheduling
54
+ const pre = st.pre;
55
+ const shouldAutoAtSchedule = isUserAtBottom(pre);
56
+
57
+ st.rafId = requestAnimationFrame(() => {
58
+ st.scheduled = false;
59
+ if (!st.pendingFrag) {
60
+ return;
61
+ }
62
+
63
+ try {
64
+ pre.appendChild(st.pendingFrag);
65
+ if (shouldAutoAtSchedule) {
66
+ pre.scrollTop = pre.scrollHeight;
67
+ }
68
+ } finally {
69
+ st.pendingFrag = null;
70
+ }
71
+ });
72
+ }
73
+
74
+ // 清理函数:由上层在 host 不再使用时调用
75
+ export function disposeHost(host: HTMLElement) {
76
+ const st = hostStates.get(host);
77
+ if (!st) {
78
+ return;
79
+ }
80
+ // 取消未执行的 rAF
81
+ if (st.rafId !== null) {
82
+ cancelAnimationFrame(st.rafId);
83
+ st.rafId = null;
84
+ }
85
+ // 丢弃 pending fragment 引用(允许 GC 回收其内部节点)
86
+ st.pendingFrag = null;
87
+ // 清除其它资源(如果有 event listeners、workers 等也在这里处理)
88
+ // e.g. if (st.worker) st.worker.terminate();
89
+ // 从 WeakMap 删除(可选,WeakMap 的条目在 key 不再可达时会自动回收)
90
+ hostStates.delete(host);
91
+ }
92
+
20
93
  /**
21
94
  * renderText
22
95
  * - 使用 autolinkRanges 得到全局的链接区间信息(start/end/url)。
23
96
  * - 在脱离文档的 parsed fragment(template.content)上按子节点边界切片,
24
- * 仅创建必要的 Text / shallow-clone element / <a> 节点。
97
+ * - 仅创建必要的 Text / shallow-clone element / <a> 节点。
25
98
  * - 对跨子节点的链接,使用 pendingAnchor 把分片累计到同一个 <a>,直到 range 结束。
26
- * - 最后一次性将构建好的 DocumentFragment 插回 pre 中,减少重排与 GC 压力。
99
+ * - 若检测到本次 data 是对上次 prevData append,则只处理并 append 新增部分(增量路径);
100
+ * - 否则视为非追加(首次渲染或变更),进行全量重建,并尽量保持用户视口位置;
101
+ * - 使用 rAF 批量追加以合并高频调用,避免频繁重排;
102
+ * - 在追加时只有用户接近底部才自动滚动,否则保持当前位置以便用户查看历史输出。
27
103
  */
28
104
  export function renderText(options: IRenderTextOptions): Promise<void> {
29
105
  const { host, sanitizer, source, mimeType } = options;
30
106
  const data = concatMultilineString(source);
31
107
 
32
- // 对文本做 ansi -> span 的转换并 sanitize(仅允许 <span>)
33
- const content = sanitizer.sanitize(ansiSpan(data), {
34
- allowedTags: ['span'],
35
- });
36
-
37
108
  if (mimeType === 'application/vnd.jupyter.stderr') {
38
109
  host.setAttribute('data-mime-type', 'application/vnd.jupyter.stderr');
39
110
  }
40
111
 
41
- // 确保存在 <pre>,但不要马上把 innerHTML 写入实际文档(我们将在脱离文档的 fragment 上操作)
112
+ // 确保存在 <pre> 且为 host 的子元素(只在首次创建时 append)
42
113
  let pre = host.querySelector('pre');
43
114
  if (!pre) {
44
115
  pre = document.createElement('pre');
45
116
  host.appendChild(pre);
117
+ } else if (pre.parentElement !== host) {
118
+ // 如果 pre 被移动过,确保再次挂回 host(避免每次 render 都移动)
119
+ host.appendChild(pre);
46
120
  }
47
121
 
48
- // 使用 template 元素来解析 HTML,但 template.content 尚未插入到文档中 -> 不触发重排
49
- const tpl = document.createElement('template');
50
- tpl.innerHTML = content;
51
- const parsedFrag = tpl.content;
122
+ // 获取或初始化该 host 的状态
123
+ let st = hostStates.get(host);
124
+
125
+ if (!st) {
126
+ st = {
127
+ pre,
128
+ prevData: '',
129
+ pendingFrag: null,
130
+ scheduled: false,
131
+ rafId: null,
132
+ };
133
+ hostStates.set(host, st);
134
+ } else {
135
+ st.pre = pre;
136
+ }
52
137
 
53
- // 全文文本(用于按字符位置计算 ranges)
54
- const fullText = parsedFrag.textContent || '';
55
- if (!fullText) {
56
- // 若没有文本,清空并返回(保持 class)
138
+ // data 为空,则立即清空并返回
139
+ if (!data) {
140
+ st.prevData = '';
141
+ st.pendingFrag = null;
142
+ st.scheduled = false;
57
143
  pre.innerHTML = '';
58
144
  host.classList.add('libro-text-render');
59
145
  return Promise.resolve(undefined);
60
146
  }
61
147
 
62
- // 计算链接 ranges
63
- const ranges = autolinkRanges(fullText);
148
+ // 判定是否为“追加”场景(本次 data 以 prevData 为前缀)
149
+ const prevData = st.prevData || '';
150
+ const isAppend = prevData.length > 0 && data.startsWith(prevData);
151
+
152
+ if (isAppend) {
153
+ const appendedData = data.slice(prevData.length);
154
+ if (appendedData.length === 0) {
155
+ // 无新增内容
156
+ return Promise.resolve(undefined);
157
+ }
158
+
159
+ // 对新增内容做 ansi->span 转换并 sanitize(仅允许 span)
160
+ const appendedContent = sanitizer.sanitize(ansiSpan(appendedData), {
161
+ allowedTags: ['span'],
162
+ });
163
+
164
+ // 解析为脱离文档的 fragment(不会触发回流)
165
+ const tpl = document.createElement('template');
166
+ tpl.innerHTML = appendedContent;
167
+ const appendedFrag = tpl.content;
168
+
169
+ // 对 appendedFrag 的文本执行链接检测(基于 autolinkRanges)
170
+ // 注意:此处使用 appendedFrag.textContent(仅针对新增片段)
171
+ const appendedText = appendedFrag.textContent || '';
172
+ const ranges = appendedText ? autolinkRanges(appendedText) : [];
173
+
174
+ // 根据 ranges 构造要追加到 DOM 的 outFrag(可包含文本/浅 cloned spans/anchor)
175
+ const outFrag = document.createDocumentFragment();
176
+ let pendingAnchor: HTMLAnchorElement | null = null;
177
+ let rangeIdx = 0;
178
+ let globalOffset = 0;
179
+ const childNodes = Array.from(appendedFrag.childNodes) as Node[];
180
+
181
+ for (let ni = 0; ni < childNodes.length; ni++) {
182
+ const node = childNodes[ni];
183
+ const nodeText = node.textContent || '';
184
+ const nodeLen = nodeText.length;
185
+ if (nodeLen === 0) {
186
+ continue;
187
+ }
188
+
189
+ let localPos = 0;
190
+ while (localPos < nodeLen) {
191
+ const absPos = globalOffset + localPos;
192
+ // 将 rangeIdx 推到第一个可能与当前绝对位置相关的 range 上
193
+ while (rangeIdx < ranges.length && ranges[rangeIdx].end <= absPos) {
194
+ rangeIdx++;
195
+ }
196
+ const curRange = rangeIdx < ranges.length ? ranges[rangeIdx] : null;
197
+
198
+ if (pendingAnchor) {
199
+ // 当前有未完成的 anchor(链接跨节点):尽量消费本节点的部分
200
+ const take = Math.min(
201
+ nodeLen - localPos,
202
+ (curRange ? curRange.end : absPos) - absPos,
203
+ );
204
+ const piece = pieceFromNodeSlice(node, localPos, localPos + take);
205
+ pendingAnchor.appendChild(piece);
206
+ localPos += take;
207
+
208
+ // 若到达当前 range 的结尾,则把 pendingAnchor 闭合并追加
209
+ if (curRange && globalOffset + localPos >= curRange.end) {
210
+ outFrag.appendChild(pendingAnchor);
211
+ pendingAnchor = null;
212
+ rangeIdx++;
213
+ }
214
+ continue;
215
+ }
216
+
217
+ // 若没有 pendingAnchor,判断当前片段是否是普通文本或链接起始处
218
+ if (!curRange || curRange.start >= globalOffset + nodeLen) {
219
+ // 本节点剩余部分没有链接,全部作为普通片段
220
+ const piece = pieceFromNodeSlice(node, localPos, nodeLen);
221
+ outFrag.appendChild(piece);
222
+ localPos = nodeLen;
223
+ } else if (curRange.start > absPos) {
224
+ // 链接在当前节点后面一段位置开始:先把中间普通文本片段追加
225
+ const until = Math.min(nodeLen, curRange.start - globalOffset);
226
+ const piece = pieceFromNodeSlice(node, localPos, until);
227
+ outFrag.appendChild(piece);
228
+ localPos = until;
229
+ } else {
230
+ // 当前绝对位置位于链接范围内:创建 anchor 并消费该链接在当前节点的部分
231
+ pendingAnchor = createAnchorForUrl(curRange.url);
232
+ const take = Math.min(nodeLen - localPos, curRange.end - absPos);
233
+ const piece = pieceFromNodeSlice(node, localPos, localPos + take);
234
+ pendingAnchor.appendChild(piece);
235
+ localPos += take;
236
+
237
+ // 若链接在此节点内结束,立即关闭并追加
238
+ if (globalOffset + localPos >= curRange.end) {
239
+ outFrag.appendChild(pendingAnchor);
240
+ pendingAnchor = null;
241
+ rangeIdx++;
242
+ }
243
+ }
244
+ } // end while localPos
245
+
246
+ globalOffset += nodeLen;
247
+ } // end for nodes
248
+
249
+ if (pendingAnchor) {
250
+ outFrag.appendChild(pendingAnchor);
251
+ pendingAnchor = null;
252
+ }
253
+
254
+ // 将 outFrag 合并到 host 状态的 pendingFrag 中,以便批量追加
255
+ if (!st.pendingFrag) {
256
+ st.pendingFrag = document.createDocumentFragment();
257
+ }
258
+ Array.from(outFrag.childNodes).forEach((n) => st!.pendingFrag!.appendChild(n));
259
+
260
+ // 更新 prevData 为最新的整个 data(提交此次 append)
261
+ st.prevData = data;
262
+
263
+ // 安排 rAF 批量追加(若已安排则无副作用)
264
+ scheduleAppend(host);
265
+
266
+ host.classList.add('libro-text-render');
267
+ return Promise.resolve(undefined);
268
+ }
269
+
270
+ // ---------- 非追加(首次渲染或全量替换)路径 ----------
271
+ // 记录替换前的滚动信息,以便替换后尽量保持视图位置
272
+ const prevScrollTop = pre.scrollTop;
273
+ const prevScrollHeight = pre.scrollHeight;
274
+
275
+ // 对整个 data 做 ansi->span 转换并 sanitize(脱离文档)
276
+ const content = sanitizer.sanitize(ansiSpan(data), {
277
+ allowedTags: ['span'],
278
+ });
279
+ const tplFull = document.createElement('template');
280
+ tplFull.innerHTML = content;
281
+ const parsedFrag = tplFull.content;
282
+
283
+ // 基于全文进行链接检测并构造 outFrag(逻辑与增量路径一致)
284
+ const fullText = parsedFrag.textContent || '';
285
+ const ranges = fullText ? autolinkRanges(fullText) : [];
64
286
 
65
- // 准备输出 fragment(最终将一次性 append 到 pre)
66
287
  const outFrag = document.createDocumentFragment();
67
288
 
68
289
  // pendingAnchor 用于处理链接跨多个原始子节点的情形:
@@ -75,6 +296,7 @@ export function renderText(options: IRenderTextOptions): Promise<void> {
75
296
  // 遍历 parsedFrag 的顶层子节点(这些通常是由 sanitizer/ansiSpan 产生的 span/text 节点)
76
297
  let globalOffset = 0;
77
298
  const childNodes = Array.from(parsedFrag.childNodes) as Node[];
299
+
78
300
  for (let ni = 0; ni < childNodes.length; ni++) {
79
301
  const node = childNodes[ni];
80
302
  const nodeText = node.textContent || '';
@@ -154,12 +376,24 @@ export function renderText(options: IRenderTextOptions): Promise<void> {
154
376
  pendingAnchor = null;
155
377
  }
156
378
 
157
- // 一次性替换 pre 的内容:先清空再 append(减少中间重排)
158
- pre.innerHTML = '';
159
- pre.appendChild(outFrag);
379
+ // 在下一帧中执行 DOM 替换以避免同步布局抖动
380
+ requestAnimationFrame(() => {
381
+ pre.innerHTML = '';
382
+ pre.appendChild(outFrag);
160
383
 
161
- host.classList.add('libro-text-render');
384
+ // 若用户在底部则滚到底,否则按高度差修正 scrollTop 保持视图不变
385
+ if (isUserAtBottom(pre)) {
386
+ pre.scrollTop = pre.scrollHeight;
387
+ } else {
388
+ const newScrollHeight = pre.scrollHeight;
389
+ pre.scrollTop = Math.max(0, prevScrollTop + (newScrollHeight - prevScrollHeight));
390
+ }
391
+ });
162
392
 
393
+ // 更新 prevData(记录当前已渲染的原始字符串)
394
+ st.prevData = data;
395
+
396
+ host.classList.add('libro-text-render');
163
397
  return Promise.resolve(undefined);
164
398
  }
165
399