@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.
- package/es/components/text-render.d.ts +2 -0
- package/es/components/text-render.d.ts.map +1 -1
- package/es/components/text-render.js +18 -24
- package/es/renderers.d.ts +6 -2
- package/es/renderers.d.ts.map +1 -1
- package/es/renderers.js +271 -53
- package/package.json +4 -4
- package/src/components/text-render.tsx +13 -20
- package/src/renderers.ts +257 -23
|
@@ -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;
|
|
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__*/
|
|
65
|
-
children: [/*#__PURE__*/
|
|
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__*/
|
|
106
|
-
children: [/*#__PURE__*/
|
|
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__*/
|
|
115
|
+
return /*#__PURE__*/_jsxs("div", {
|
|
129
116
|
className: "libro-text-render-container",
|
|
130
117
|
ref: renderTextContainerRef,
|
|
131
|
-
children:
|
|
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
|
-
*
|
|
7
|
+
* - 仅创建必要的 Text / shallow-clone element / <a> 节点。
|
|
7
8
|
* - 对跨子节点的链接,使用 pendingAnchor 把分片累计到同一个 <a>,直到 range 结束。
|
|
8
|
-
* -
|
|
9
|
+
* - 若检测到本次 data 是对上次 prevData 的 append,则只处理并 append 新增部分(增量路径);
|
|
10
|
+
* - 否则视为非追加(首次渲染或变更),进行全量重建,并尽量保持用户视口位置;
|
|
11
|
+
* - 使用 rAF 批量追加以合并高频调用,避免频繁重排;
|
|
12
|
+
* - 在追加时只有用户接近底部才自动滚动,否则保持当前位置以便用户查看历史输出。
|
|
9
13
|
*/
|
|
10
14
|
export declare function renderText(options: IRenderTextOptions): Promise<void>;
|
|
11
15
|
/**
|
package/es/renderers.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
*
|
|
85
|
+
* - 仅创建必要的 Text / shallow-clone element / <a> 节点。
|
|
21
86
|
* - 对跨子节点的链接,使用 pendingAnchor 把分片累计到同一个 <a>,直到 range 结束。
|
|
22
|
-
* -
|
|
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
|
|
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
|
-
//
|
|
47
|
-
var
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
//
|
|
61
|
-
var
|
|
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
|
-
//
|
|
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
|
|
77
|
-
var
|
|
78
|
-
var
|
|
79
|
-
var
|
|
80
|
-
if (
|
|
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
|
|
85
|
-
while (
|
|
86
|
-
var
|
|
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 <=
|
|
293
|
+
while (rangeIdx < ranges.length && ranges[rangeIdx].end <= _absPos) {
|
|
89
294
|
rangeIdx++;
|
|
90
295
|
}
|
|
91
|
-
var
|
|
296
|
+
var _curRange = rangeIdx < ranges.length ? ranges[rangeIdx] : null;
|
|
92
297
|
|
|
93
298
|
// 如果当前有正在构建的 pendingAnchor(链接已经开始但尚未结束)
|
|
94
299
|
if (pendingAnchor) {
|
|
95
300
|
// 计算本次可以从该节点中“消费”的长度(尽量多取,直到链接结束或节点结束)
|
|
96
|
-
var
|
|
97
|
-
var
|
|
98
|
-
pendingAnchor.appendChild(
|
|
99
|
-
|
|
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 (
|
|
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 (!
|
|
317
|
+
if (!_curRange || _curRange.start >= globalOffset + _nodeLen) {
|
|
113
318
|
// 当前剩余的节点内容中,没有链接开始 -> 全部作为普通片段
|
|
114
|
-
var
|
|
115
|
-
outFrag.appendChild(
|
|
116
|
-
|
|
117
|
-
} else if (
|
|
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
|
|
120
|
-
var
|
|
121
|
-
outFrag.appendChild(
|
|
122
|
-
|
|
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(
|
|
127
|
-
var
|
|
128
|
-
var
|
|
129
|
-
pendingAnchor.appendChild(
|
|
130
|
-
|
|
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 +
|
|
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 +=
|
|
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
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
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.
|
|
36
|
-
"@difizen/libro-core": "^0.3.
|
|
37
|
-
"@difizen/libro-markdown": "^0.3.
|
|
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<{
|
|
18
|
+
export const RawTextRender: React.FC<{
|
|
19
19
|
model: BaseOutputView;
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
*
|
|
97
|
+
* - 仅创建必要的 Text / shallow-clone element / <a> 节点。
|
|
25
98
|
* - 对跨子节点的链接,使用 pendingAnchor 把分片累计到同一个 <a>,直到 range 结束。
|
|
26
|
-
* -
|
|
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
|
|
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
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
//
|
|
63
|
-
const
|
|
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
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
379
|
+
// 在下一帧中执行 DOM 替换以避免同步布局抖动
|
|
380
|
+
requestAnimationFrame(() => {
|
|
381
|
+
pre.innerHTML = '';
|
|
382
|
+
pre.appendChild(outFrag);
|
|
160
383
|
|
|
161
|
-
|
|
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
|
|