pygpt-net 2.6.34__py3-none-any.whl → 2.6.35__py3-none-any.whl
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.
- pygpt_net/CHANGELOG.txt +7 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/controller/chat/common.py +8 -2
- pygpt_net/controller/chat/handler/stream_worker.py +55 -43
- pygpt_net/controller/painter/common.py +13 -1
- pygpt_net/controller/painter/painter.py +11 -2
- pygpt_net/core/bridge/bridge.py +1 -5
- pygpt_net/core/bridge/context.py +81 -36
- pygpt_net/core/bridge/worker.py +3 -1
- pygpt_net/core/ctx/bag.py +4 -0
- pygpt_net/core/events/app.py +10 -17
- pygpt_net/core/events/base.py +17 -25
- pygpt_net/core/events/control.py +9 -17
- pygpt_net/core/events/event.py +9 -62
- pygpt_net/core/events/kernel.py +8 -17
- pygpt_net/core/events/realtime.py +8 -17
- pygpt_net/core/events/render.py +9 -17
- pygpt_net/core/render/web/body.py +394 -36
- pygpt_net/core/render/web/pid.py +39 -24
- pygpt_net/core/render/web/renderer.py +146 -40
- pygpt_net/data/config/config.json +4 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/css/web-blocks.css +3 -2
- pygpt_net/data/css/web-chatgpt.css +3 -1
- pygpt_net/data/css/web-chatgpt_wide.css +3 -1
- pygpt_net/data/locale/locale.de.ini +1 -0
- pygpt_net/data/locale/locale.en.ini +3 -2
- pygpt_net/data/locale/locale.es.ini +1 -0
- pygpt_net/data/locale/locale.fr.ini +1 -0
- pygpt_net/data/locale/locale.it.ini +1 -0
- pygpt_net/data/locale/locale.pl.ini +2 -1
- pygpt_net/data/locale/locale.uk.ini +1 -0
- pygpt_net/data/locale/locale.zh.ini +1 -0
- pygpt_net/provider/api/google/__init__.py +14 -5
- pygpt_net/provider/api/openai/__init__.py +13 -10
- pygpt_net/provider/core/config/patch.py +9 -0
- pygpt_net/ui/layout/chat/painter.py +63 -4
- pygpt_net/ui/widget/draw/painter.py +702 -106
- pygpt_net/ui/widget/textarea/web.py +2 -0
- {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/METADATA +9 -2
- {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/RECORD +44 -44
- {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/entry_points.txt +0 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
7
|
# MIT License #
|
|
8
8
|
# Created By : Marcin Szczygliński #
|
|
9
|
-
# Updated Date: 2025.09.
|
|
9
|
+
# Updated Date: 2025.09.04 00:00:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
12
|
import os
|
|
@@ -35,6 +35,8 @@ class Body:
|
|
|
35
35
|
<!DOCTYPE html>
|
|
36
36
|
<html>
|
|
37
37
|
<head>
|
|
38
|
+
<meta charset="utf-8">
|
|
39
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
38
40
|
<style>
|
|
39
41
|
"""
|
|
40
42
|
_HTML_P1 = """
|
|
@@ -45,6 +47,10 @@ class Body:
|
|
|
45
47
|
<script type="text/javascript" src="qrc:///js/katex.min.js"></script>
|
|
46
48
|
<script>
|
|
47
49
|
const DEBUG_MODE = false;
|
|
50
|
+
let bridgeConnected = false;
|
|
51
|
+
let streamHandler;
|
|
52
|
+
let nodeHandler;
|
|
53
|
+
let nodeReplaceHandler;
|
|
48
54
|
let scrollTimeout = null;
|
|
49
55
|
let prevScroll = 0;
|
|
50
56
|
let bridge;
|
|
@@ -67,8 +73,9 @@ class Body:
|
|
|
67
73
|
let highlightScheduled = false;
|
|
68
74
|
let pendingHighlightRoot = null;
|
|
69
75
|
let pendingHighlightMath = false;
|
|
76
|
+
let highlightRAF = 0; // RAF id for highlight batcher
|
|
70
77
|
let scrollScheduled = false;
|
|
71
|
-
|
|
78
|
+
|
|
72
79
|
// Auto-follow state: when false, live stream auto-scroll is suppressed
|
|
73
80
|
let autoFollow = true;
|
|
74
81
|
let lastScrollTop = 0;
|
|
@@ -76,9 +83,24 @@ class Body:
|
|
|
76
83
|
let userInteracted = false;
|
|
77
84
|
const AUTO_FOLLOW_REENABLE_PX = 8; // px from bottom to re-enable auto-follow
|
|
78
85
|
|
|
86
|
+
// FAB thresholds
|
|
87
|
+
const SHOW_DOWN_THRESHOLD_PX = 0; // show "down" only when farther than this from bottom
|
|
88
|
+
let currentFabAction = 'none'; // tracks current FAB state to avoid redundant work
|
|
89
|
+
|
|
79
90
|
// timers
|
|
80
91
|
let tipsTimers = [];
|
|
81
92
|
|
|
93
|
+
// observers
|
|
94
|
+
let roDoc = null;
|
|
95
|
+
let roContainer = null;
|
|
96
|
+
|
|
97
|
+
// FAB (scroll-to-top/bottom) scheduling
|
|
98
|
+
let scrollFabUpdateScheduled = false;
|
|
99
|
+
|
|
100
|
+
// Streaming micro-batching config
|
|
101
|
+
const STREAM_MAX_PER_FRAME = 64; // defensive upper bound of operations per frame
|
|
102
|
+
const STREAM_EMERGENCY_COALESCE_LEN = 1500; // when queue length is high, coalesce aggressively
|
|
103
|
+
|
|
82
104
|
// clear previous references
|
|
83
105
|
function resetEphemeralDomRefs() {
|
|
84
106
|
domLastCodeBlock = null;
|
|
@@ -93,6 +115,26 @@ class Body:
|
|
|
93
115
|
tipsTimers = [];
|
|
94
116
|
}
|
|
95
117
|
|
|
118
|
+
function teardown() {
|
|
119
|
+
// Cancel timers/RAF/observers to prevent background CPU usage
|
|
120
|
+
stopTipsTimers();
|
|
121
|
+
try {
|
|
122
|
+
if (streamRAF) {
|
|
123
|
+
cancelAnimationFrame(streamRAF);
|
|
124
|
+
streamRAF = 0;
|
|
125
|
+
}
|
|
126
|
+
if (highlightRAF) {
|
|
127
|
+
cancelAnimationFrame(highlightRAF);
|
|
128
|
+
highlightRAF = 0;
|
|
129
|
+
}
|
|
130
|
+
} catch (e) { /* ignore */ }
|
|
131
|
+
// Clear streaming queue to release memory immediately
|
|
132
|
+
streamQ.length = 0;
|
|
133
|
+
scrollFabUpdateScheduled = false;
|
|
134
|
+
scrollScheduled = false;
|
|
135
|
+
highlightScheduled = false;
|
|
136
|
+
}
|
|
137
|
+
|
|
96
138
|
history.scrollRestoration = "manual";
|
|
97
139
|
document.addEventListener('keydown', function(event) {
|
|
98
140
|
if (event.ctrlKey && event.key === 'f') {
|
|
@@ -124,6 +166,60 @@ class Body:
|
|
|
124
166
|
els.footer = document.getElementById('_footer_');
|
|
125
167
|
els.loader = document.getElementById('_loader_');
|
|
126
168
|
els.tips = document.getElementById('tips');
|
|
169
|
+
// FAB refs
|
|
170
|
+
els.scrollFab = document.getElementById('scrollFab');
|
|
171
|
+
els.scrollFabIcon = document.getElementById('scrollFabIcon');
|
|
172
|
+
}
|
|
173
|
+
function bridgeConnect() {
|
|
174
|
+
// Idempotent connect
|
|
175
|
+
if (!bridge || !bridge.chunk || typeof bridge.chunk.connect !== 'function') return false;
|
|
176
|
+
if (bridgeConnected) return true;
|
|
177
|
+
|
|
178
|
+
// Ensure handler exists and is stable (same identity for disconnect/connect)
|
|
179
|
+
if (!streamHandler) {
|
|
180
|
+
streamHandler = (name, html, chunk, replace, isCode) => {
|
|
181
|
+
appendStream(name, html, chunk, replace, isCode);
|
|
182
|
+
};
|
|
183
|
+
nodeHandler = (html) => {
|
|
184
|
+
appendNode(html);
|
|
185
|
+
};
|
|
186
|
+
nodeReplaceHandler = (html) => {
|
|
187
|
+
replaceNodes(html);
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
bridge.chunk.connect(streamHandler);
|
|
192
|
+
bridge.node.connect(nodeHandler);
|
|
193
|
+
bridge.nodeReplace.connect(nodeReplaceHandler);
|
|
194
|
+
bridgeConnected = true;
|
|
195
|
+
return true;
|
|
196
|
+
} catch (e) {
|
|
197
|
+
log(e);
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function bridgeDisconnect() {
|
|
203
|
+
// Idempotent disconnect
|
|
204
|
+
if (!bridge || !bridge.chunk || typeof bridge.chunk.disconnect !== 'function') return false;
|
|
205
|
+
if (!bridgeConnected) return true;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
bridge.chunk.disconnect(streamHandler);
|
|
209
|
+
bridge.node.disconnect(nodeHandler);
|
|
210
|
+
bridge.nodeReplace.disconnect(nodeReplaceHandler);
|
|
211
|
+
} catch (e) { /* ignore */ }
|
|
212
|
+
bridgeConnected = false;
|
|
213
|
+
|
|
214
|
+
// Stop scheduled work and release pending chunks immediately
|
|
215
|
+
try { if (streamRAF) { cancelAnimationFrame(streamRAF); streamRAF = 0; } } catch (e) { /* ignore */ }
|
|
216
|
+
streamQ.length = 0;
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function bridgeReconnect() {
|
|
221
|
+
bridgeDisconnect();
|
|
222
|
+
return bridgeConnect();
|
|
127
223
|
}
|
|
128
224
|
function scheduleHighlight(root, withMath = true) {
|
|
129
225
|
const scope = root && root.nodeType === 1 ? root : document;
|
|
@@ -135,29 +231,37 @@ class Body:
|
|
|
135
231
|
if (withMath) pendingHighlightMath = true;
|
|
136
232
|
if (highlightScheduled) return;
|
|
137
233
|
highlightScheduled = true;
|
|
138
|
-
|
|
234
|
+
if (highlightRAF) {
|
|
235
|
+
// Ensure we do not queue multiple highlight frames
|
|
236
|
+
cancelAnimationFrame(highlightRAF);
|
|
237
|
+
highlightRAF = 0;
|
|
238
|
+
}
|
|
239
|
+
highlightRAF = requestAnimationFrame(function() {
|
|
139
240
|
try {
|
|
140
241
|
highlightCodeInternal(pendingHighlightRoot || document, pendingHighlightMath);
|
|
141
242
|
} finally {
|
|
142
243
|
highlightScheduled = false;
|
|
143
244
|
pendingHighlightRoot = null;
|
|
144
245
|
pendingHighlightMath = false;
|
|
246
|
+
highlightRAF = 0;
|
|
145
247
|
}
|
|
146
248
|
});
|
|
147
249
|
}
|
|
148
250
|
function highlightCodeInternal(root, withMath) {
|
|
149
|
-
(root || document).querySelectorAll('pre code
|
|
251
|
+
(root || document).querySelectorAll('pre code').forEach(el => {
|
|
252
|
+
try { if (el.dataset) delete el.dataset.highlighted; } catch (e) {}
|
|
150
253
|
hljs.highlightElement(el);
|
|
151
254
|
});
|
|
152
255
|
if (withMath) {
|
|
153
256
|
renderMath(root);
|
|
257
|
+
if (DEBUG_MODE) log("math");
|
|
154
258
|
}
|
|
155
259
|
if (DEBUG_MODE) log("execute highlight");
|
|
156
|
-
restoreCollapsedCode(root);
|
|
157
260
|
}
|
|
158
261
|
function highlightCode(withMath = true, root = null) {
|
|
159
262
|
if (DEBUG_MODE) log("queue highlight, withMath: " + withMath);
|
|
160
|
-
|
|
263
|
+
highlightCodeInternal(root || document, withMath); // prevent blink on fast updates
|
|
264
|
+
// scheduleHighlight(root || document, withMath); // disabled
|
|
161
265
|
}
|
|
162
266
|
function hideTips() {
|
|
163
267
|
if (tips_hidden) return;
|
|
@@ -228,6 +332,8 @@ class Body:
|
|
|
228
332
|
requestAnimationFrame(function() {
|
|
229
333
|
scrollScheduled = false;
|
|
230
334
|
scrollToBottom(live);
|
|
335
|
+
// keep FAB state in sync after any programmatic scroll
|
|
336
|
+
scheduleScrollFabUpdate();
|
|
231
337
|
});
|
|
232
338
|
}
|
|
233
339
|
// Force immediate scroll to bottom (pre-interaction bootstrap)
|
|
@@ -240,14 +346,14 @@ class Body:
|
|
|
240
346
|
const el = document.scrollingElement || document.documentElement;
|
|
241
347
|
const marginPx = 450;
|
|
242
348
|
const behavior = (live === true) ? 'instant' : 'smooth';
|
|
243
|
-
|
|
349
|
+
|
|
244
350
|
// Respect user-follow state during live updates
|
|
245
351
|
if (live === true && autoFollow !== true) {
|
|
246
352
|
// Keep prevScroll consistent for potential consumers
|
|
247
353
|
prevScroll = el.scrollHeight;
|
|
248
354
|
return;
|
|
249
355
|
}
|
|
250
|
-
|
|
356
|
+
|
|
251
357
|
// Allow initial auto-follow before any user interaction
|
|
252
358
|
if ((live === true && userInteracted === false) || isNearBottom(marginPx) || live == false) {
|
|
253
359
|
el.scrollTo({ top: el.scrollHeight, behavior });
|
|
@@ -285,7 +391,9 @@ class Body:
|
|
|
285
391
|
element.insertAdjacentHTML('beforeend', content);
|
|
286
392
|
highlightCode(true, element);
|
|
287
393
|
scrollToBottom(false); // without schedule
|
|
288
|
-
|
|
394
|
+
scheduleScrollFabUpdate();
|
|
395
|
+
}
|
|
396
|
+
clearHighlightCache();
|
|
289
397
|
}
|
|
290
398
|
function replaceNodes(content) {
|
|
291
399
|
if (DEBUG_MODE) {
|
|
@@ -300,7 +408,9 @@ class Body:
|
|
|
300
408
|
element.insertAdjacentHTML('beforeend', content);
|
|
301
409
|
highlightCode(true, element);
|
|
302
410
|
scrollToBottom(false); // without schedule
|
|
411
|
+
scheduleScrollFabUpdate();
|
|
303
412
|
}
|
|
413
|
+
clearHighlightCache();
|
|
304
414
|
}
|
|
305
415
|
function clean() {
|
|
306
416
|
if (DEBUG_MODE) {
|
|
@@ -323,6 +433,12 @@ class Body:
|
|
|
323
433
|
}
|
|
324
434
|
*/
|
|
325
435
|
}
|
|
436
|
+
function clearHighlightCache() {
|
|
437
|
+
const elements = document.querySelectorAll('pre code');
|
|
438
|
+
elements.forEach(function(el) {
|
|
439
|
+
try { if (el.dataset) delete el.dataset.highlighted; } catch (e) {}
|
|
440
|
+
});
|
|
441
|
+
}
|
|
326
442
|
function appendExtra(id, content) {
|
|
327
443
|
hideTips();
|
|
328
444
|
prevScroll = 0;
|
|
@@ -413,8 +529,10 @@ class Body:
|
|
|
413
529
|
log("STREAM END");
|
|
414
530
|
}
|
|
415
531
|
clearOutput();
|
|
532
|
+
bridgeReconnect();
|
|
416
533
|
}
|
|
417
534
|
function enqueueStream(name_header, content, chunk, replace = false, is_code_block = false) {
|
|
535
|
+
// Push incoming chunk; scheduling is done with RAF to batch DOM ops
|
|
418
536
|
streamQ.push({name_header, content, chunk, replace, is_code_block});
|
|
419
537
|
if (!streamRAF) {
|
|
420
538
|
streamRAF = requestAnimationFrame(drainStream);
|
|
@@ -422,16 +540,53 @@ class Body:
|
|
|
422
540
|
}
|
|
423
541
|
function drainStream() {
|
|
424
542
|
streamRAF = 0;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
543
|
+
let processed = 0;
|
|
544
|
+
|
|
545
|
+
// Emergency coalescing if queue grows too large
|
|
546
|
+
const shouldAggressiveCoalesce = streamQ.length >= STREAM_EMERGENCY_COALESCE_LEN;
|
|
547
|
+
|
|
548
|
+
while (streamQ.length && processed < STREAM_MAX_PER_FRAME) {
|
|
549
|
+
let {name_header, content, chunk, replace, is_code_block} = streamQ.shift();
|
|
550
|
+
|
|
551
|
+
// Coalesce contiguous simple appends to reduce DOM churn
|
|
552
|
+
if (!replace && !content && (chunk && chunk.length > 0)) {
|
|
553
|
+
// Collect chunks into an array to avoid O(n^2) string concatenation
|
|
554
|
+
const chunks = [chunk];
|
|
555
|
+
while (streamQ.length) {
|
|
556
|
+
const next = streamQ[0];
|
|
557
|
+
if (!next.replace && !next.content && next.is_code_block === is_code_block && next.name_header === name_header) {
|
|
558
|
+
chunks.push(next.chunk);
|
|
559
|
+
streamQ.shift();
|
|
560
|
+
if (!shouldAggressiveCoalesce) {
|
|
561
|
+
// Light coalescing per frame is enough under normal conditions
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
} else {
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
chunk = chunks.join('');
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
applyStream(name_header, content, chunk, replace, is_code_block);
|
|
572
|
+
processed++;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// If there are remaining items re-schedule next frame
|
|
576
|
+
if (streamQ.length) {
|
|
577
|
+
streamRAF = requestAnimationFrame(drainStream);
|
|
428
578
|
}
|
|
429
579
|
}
|
|
580
|
+
// Public API: enqueue and process in the next animation frame
|
|
430
581
|
function appendStream(name_header, content, chunk, replace = false, is_code_block = false) {
|
|
582
|
+
enqueueStream(name_header, content, chunk, replace, is_code_block);
|
|
583
|
+
}
|
|
584
|
+
// Internal: performs actual DOM updates for a single merged chunk
|
|
585
|
+
function applyStream(name_header, content, chunk, replace = false, is_code_block = false) {
|
|
431
586
|
dropIfDetached(); // clear references to detached elements
|
|
432
587
|
hideTips();
|
|
433
588
|
if (DEBUG_MODE) {
|
|
434
|
-
log("
|
|
589
|
+
log("APPLY CHUNK: {" + chunk + "}, CONTENT: {"+content+"}, replace: " + replace + ", is_code_block: " + is_code_block);
|
|
435
590
|
}
|
|
436
591
|
const element = getStreamContainer();
|
|
437
592
|
let msg;
|
|
@@ -456,7 +611,9 @@ class Body:
|
|
|
456
611
|
msg = box.querySelector('.msg');
|
|
457
612
|
}
|
|
458
613
|
if (msg) {
|
|
459
|
-
if (replace) {
|
|
614
|
+
if (replace) {
|
|
615
|
+
domLastCodeBlock = null;
|
|
616
|
+
domLastParagraphBlock = null;
|
|
460
617
|
msg.replaceChildren();
|
|
461
618
|
if (content) {
|
|
462
619
|
msg.insertAdjacentHTML('afterbegin', content);
|
|
@@ -466,17 +623,23 @@ class Body:
|
|
|
466
623
|
doMath = false;
|
|
467
624
|
}
|
|
468
625
|
highlightCode(doMath, msg);
|
|
469
|
-
domLastCodeBlock = null;
|
|
470
|
-
domLastParagraphBlock = null;
|
|
471
626
|
} else {
|
|
472
627
|
if (is_code_block) {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
628
|
+
// Try to reuse cached last code block; fallback to cheap lastElementChild check
|
|
629
|
+
let lastCodeBlock = domLastCodeBlock;
|
|
630
|
+
if (!lastCodeBlock || !msg.contains(lastCodeBlock)) {
|
|
631
|
+
const last = msg.lastElementChild;
|
|
632
|
+
if (last && last.tagName === 'PRE') {
|
|
633
|
+
const codeEl = last.querySelector('code');
|
|
634
|
+
if (codeEl) {
|
|
635
|
+
lastCodeBlock = codeEl;
|
|
636
|
+
}
|
|
637
|
+
} else {
|
|
638
|
+
// Fallback scan only when necessary
|
|
639
|
+
const codes = msg.querySelectorAll('pre code');
|
|
640
|
+
if (codes.length > 0) {
|
|
641
|
+
lastCodeBlock = codes[codes.length - 1];
|
|
642
|
+
}
|
|
480
643
|
}
|
|
481
644
|
}
|
|
482
645
|
if (lastCodeBlock) {
|
|
@@ -610,10 +773,12 @@ class Body:
|
|
|
610
773
|
const outputEl = element.querySelector('.tool-output');
|
|
611
774
|
if (outputEl) {
|
|
612
775
|
const contentEl = outputEl.querySelector('.content');
|
|
613
|
-
if (contentEl
|
|
614
|
-
contentEl.style.display
|
|
615
|
-
|
|
616
|
-
|
|
776
|
+
if (contentEl) {
|
|
777
|
+
if (contentEl.style.display === 'none') {
|
|
778
|
+
contentEl.style.display = 'block';
|
|
779
|
+
} else {
|
|
780
|
+
contentEl.style.display = 'none';
|
|
781
|
+
}
|
|
617
782
|
}
|
|
618
783
|
const toggleEl = outputEl.querySelector('.toggle-cmd-output img');
|
|
619
784
|
if (toggleEl) {
|
|
@@ -660,10 +825,12 @@ class Body:
|
|
|
660
825
|
clearStreamBefore();
|
|
661
826
|
domLastCodeBlock = null;
|
|
662
827
|
domLastParagraphBlock = null;
|
|
828
|
+
domOutputStream = null; // release handle to allow GC on old container subtree
|
|
663
829
|
const element = els.appendOutput || document.getElementById('_append_output_');
|
|
664
830
|
if (element) {
|
|
665
831
|
element.replaceChildren();
|
|
666
832
|
}
|
|
833
|
+
clearHighlightCache();
|
|
667
834
|
}
|
|
668
835
|
function clearLive() {
|
|
669
836
|
const element = els.appendLive || document.getElementById('_append_live_');
|
|
@@ -736,7 +903,7 @@ class Body:
|
|
|
736
903
|
const source = wrapper.querySelector('code');
|
|
737
904
|
if (source && collapsed_idx.includes(index)) {
|
|
738
905
|
source.style.display = 'none';
|
|
739
|
-
const collapseBtn = wrapper.querySelector('
|
|
906
|
+
const collapseBtn = wrapper.querySelector('code-header-collapse');
|
|
740
907
|
if (collapseBtn) {
|
|
741
908
|
const collapseSpan = collapseBtn.querySelector('span');
|
|
742
909
|
if (collapseSpan) {
|
|
@@ -803,12 +970,122 @@ class Body:
|
|
|
803
970
|
el.classList.add('hidden');
|
|
804
971
|
}
|
|
805
972
|
}
|
|
973
|
+
|
|
974
|
+
// ---------- scroll bottom ----------
|
|
975
|
+
function hasVerticalScroll() {
|
|
976
|
+
const el = document.scrollingElement || document.documentElement;
|
|
977
|
+
return (el.scrollHeight - el.clientHeight) > 1;
|
|
978
|
+
}
|
|
979
|
+
function distanceToBottomPx() {
|
|
980
|
+
const el = document.scrollingElement || document.documentElement;
|
|
981
|
+
return el.scrollHeight - el.clientHeight - el.scrollTop;
|
|
982
|
+
}
|
|
983
|
+
function isAtBottom(thresholdPx = 2) {
|
|
984
|
+
return distanceToBottomPx() <= thresholdPx;
|
|
985
|
+
}
|
|
986
|
+
function isLoaderHidden() {
|
|
987
|
+
const el = els.loader || document.getElementById('_loader_');
|
|
988
|
+
// If loader element is missing treat as hidden to avoid blocking FAB unnecessarily
|
|
989
|
+
return !el || el.classList.contains('hidden');
|
|
990
|
+
}
|
|
991
|
+
function updateScrollFab() {
|
|
992
|
+
const btn = els.scrollFab || document.getElementById('scrollFab');
|
|
993
|
+
const icon = els.scrollFabIcon || document.getElementById('scrollFabIcon');
|
|
994
|
+
if (!btn || !icon) return;
|
|
995
|
+
|
|
996
|
+
const hasScroll = hasVerticalScroll();
|
|
997
|
+
if (!hasScroll) {
|
|
998
|
+
btn.classList.remove('visible');
|
|
999
|
+
currentFabAction = 'none';
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const atBottom = isAtBottom();
|
|
1004
|
+
const dist = distanceToBottomPx();
|
|
1005
|
+
const loaderHidden = isLoaderHidden();
|
|
1006
|
+
|
|
1007
|
+
// Determine desired action and visibility based on requirements:
|
|
1008
|
+
// - Show "down" only when at least SHOW_DOWN_THRESHOLD_PX away from bottom.
|
|
1009
|
+
// - Show "up" only when loader-global has the 'hidden' class.
|
|
1010
|
+
// - Otherwise hide the FAB to prevent overlap and noise.
|
|
1011
|
+
let action = 'none'; // 'up' | 'down' | 'none'
|
|
1012
|
+
if (atBottom) {
|
|
1013
|
+
if (loaderHidden) {
|
|
1014
|
+
action = 'up';
|
|
1015
|
+
} else {
|
|
1016
|
+
action = 'none';
|
|
1017
|
+
}
|
|
1018
|
+
} else {
|
|
1019
|
+
if (dist >= SHOW_DOWN_THRESHOLD_PX) {
|
|
1020
|
+
action = 'down';
|
|
1021
|
+
} else {
|
|
1022
|
+
action = 'none';
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (action === 'none') {
|
|
1027
|
+
btn.classList.remove('visible');
|
|
1028
|
+
currentFabAction = 'none';
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Update icon and semantics only if changed to avoid redundant 'load' events
|
|
1033
|
+
if (action !== currentFabAction) {
|
|
1034
|
+
if (action === 'up') {
|
|
1035
|
+
if (icon.src !== ICON_COLLAPSE) icon.src = ICON_COLLAPSE;
|
|
1036
|
+
btn.title = "Go to top";
|
|
1037
|
+
} else {
|
|
1038
|
+
if (icon.src !== ICON_EXPAND) icon.src = ICON_EXPAND;
|
|
1039
|
+
btn.title = "Go to bottom";
|
|
1040
|
+
}
|
|
1041
|
+
btn.setAttribute('aria-label', btn.title);
|
|
1042
|
+
currentFabAction = action;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Finally show
|
|
1046
|
+
btn.classList.add('visible');
|
|
1047
|
+
}
|
|
1048
|
+
function scheduleScrollFabUpdate() {
|
|
1049
|
+
if (scrollFabUpdateScheduled) return;
|
|
1050
|
+
scrollFabUpdateScheduled = true;
|
|
1051
|
+
requestAnimationFrame(function() {
|
|
1052
|
+
scrollFabUpdateScheduled = false;
|
|
1053
|
+
updateScrollFab();
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
function scrollToTopUser() {
|
|
1057
|
+
// Explicit user-driven scroll disables auto-follow
|
|
1058
|
+
userInteracted = true;
|
|
1059
|
+
autoFollow = false;
|
|
1060
|
+
try {
|
|
1061
|
+
const el = document.scrollingElement || document.documentElement;
|
|
1062
|
+
el.scrollTo({ top: 0, behavior: 'smooth' });
|
|
1063
|
+
} catch (e) {
|
|
1064
|
+
// Fallback in environments without smooth scrolling support
|
|
1065
|
+
const el = document.scrollingElement || document.documentElement;
|
|
1066
|
+
el.scrollTop = 0;
|
|
1067
|
+
}
|
|
1068
|
+
scheduleScrollFabUpdate();
|
|
1069
|
+
}
|
|
1070
|
+
function scrollToBottomUser() {
|
|
1071
|
+
// User action to go to bottom re-enables auto-follow
|
|
1072
|
+
userInteracted = true;
|
|
1073
|
+
autoFollow = true;
|
|
1074
|
+
try {
|
|
1075
|
+
const el = document.scrollingElement || document.documentElement;
|
|
1076
|
+
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
|
1077
|
+
} catch (e) {
|
|
1078
|
+
const el = document.scrollingElement || document.documentElement;
|
|
1079
|
+
el.scrollTop = el.scrollHeight;
|
|
1080
|
+
}
|
|
1081
|
+
scheduleScrollFabUpdate();
|
|
1082
|
+
}
|
|
1083
|
+
// ---------- end of scroll bottom ----------
|
|
1084
|
+
|
|
806
1085
|
document.addEventListener('DOMContentLoaded', function() {
|
|
807
1086
|
new QWebChannel(qt.webChannelTransport, function (channel) {
|
|
808
1087
|
bridge = channel.objects.bridge;
|
|
809
|
-
|
|
810
|
-
appendStream(name, html, chunk, replace, isCode);
|
|
811
|
-
});
|
|
1088
|
+
bridgeConnect();
|
|
812
1089
|
if (bridge.js_ready) bridge.js_ready();
|
|
813
1090
|
});
|
|
814
1091
|
initDomRefs();
|
|
@@ -844,12 +1121,12 @@ class Body:
|
|
|
844
1121
|
autoFollow = false;
|
|
845
1122
|
}
|
|
846
1123
|
}, { passive: true });
|
|
847
|
-
|
|
1124
|
+
|
|
848
1125
|
// Track scroll direction and restore auto-follow when user returns to bottom
|
|
849
1126
|
window.addEventListener('scroll', function() {
|
|
850
1127
|
const el = document.scrollingElement || document.documentElement;
|
|
851
1128
|
const top = el.scrollTop;
|
|
852
|
-
|
|
1129
|
+
|
|
853
1130
|
// User scrolled up (ignore tiny jitter)
|
|
854
1131
|
if (top + 1 < lastScrollTop) {
|
|
855
1132
|
autoFollow = false;
|
|
@@ -861,6 +1138,24 @@ class Body:
|
|
|
861
1138
|
}
|
|
862
1139
|
lastScrollTop = top;
|
|
863
1140
|
}, { passive: true });
|
|
1141
|
+
|
|
1142
|
+
// Scroll-to-top/bottom FAB wiring
|
|
1143
|
+
if (els.scrollFab) {
|
|
1144
|
+
els.scrollFab.addEventListener('click', function(ev) {
|
|
1145
|
+
ev.preventDefault();
|
|
1146
|
+
if (isAtBottom()) {
|
|
1147
|
+
scrollToTopUser();
|
|
1148
|
+
} else {
|
|
1149
|
+
scrollToBottomUser();
|
|
1150
|
+
}
|
|
1151
|
+
}, { passive: false });
|
|
1152
|
+
}
|
|
1153
|
+
window.addEventListener('scroll', scheduleScrollFabUpdate, { passive: true });
|
|
1154
|
+
window.addEventListener('resize', scheduleScrollFabUpdate, { passive: true });
|
|
1155
|
+
|
|
1156
|
+
// Initial state
|
|
1157
|
+
scheduleScrollFabUpdate();
|
|
1158
|
+
|
|
864
1159
|
container.addEventListener('click', function(event) {
|
|
865
1160
|
const copyButton = event.target.closest('.code-header-copy');
|
|
866
1161
|
if (copyButton) {
|
|
@@ -935,6 +1230,10 @@ class Body:
|
|
|
935
1230
|
}
|
|
936
1231
|
}
|
|
937
1232
|
});
|
|
1233
|
+
|
|
1234
|
+
// Cleanup on page lifecycle changes
|
|
1235
|
+
window.addEventListener('pagehide', teardown, { passive: true });
|
|
1236
|
+
window.addEventListener('beforeunload', teardown, { passive: true });
|
|
938
1237
|
});
|
|
939
1238
|
setTimeout(cycleTips, 10000); // after 10 seconds
|
|
940
1239
|
</script>
|
|
@@ -953,6 +1252,9 @@ class Body:
|
|
|
953
1252
|
</div>
|
|
954
1253
|
<div id="tips" class="tips"></div>
|
|
955
1254
|
</div>
|
|
1255
|
+
<button id="scrollFab" class="scroll-fab" type="button" title="Go to top" aria-label="Go to top">
|
|
1256
|
+
<img id="scrollFabIcon" src="" alt="Scroll">
|
|
1257
|
+
</button>
|
|
956
1258
|
</body>
|
|
957
1259
|
</html>
|
|
958
1260
|
"""
|
|
@@ -1003,6 +1305,51 @@ class Body:
|
|
|
1003
1305
|
}
|
|
1004
1306
|
"""
|
|
1005
1307
|
|
|
1308
|
+
# CSS for the scroll-to-top/bottom
|
|
1309
|
+
_SCROLL_FAB_CSS = """
|
|
1310
|
+
#scrollFab.scroll-fab {
|
|
1311
|
+
position: fixed;
|
|
1312
|
+
//left: 50%;
|
|
1313
|
+
right: 16px;
|
|
1314
|
+
bottom: 16px;
|
|
1315
|
+
width: 40px;
|
|
1316
|
+
height: 40px;
|
|
1317
|
+
border: none;
|
|
1318
|
+
background: transparent;
|
|
1319
|
+
padding: 0;
|
|
1320
|
+
margin: 0;
|
|
1321
|
+
display: none;
|
|
1322
|
+
align-items: center;
|
|
1323
|
+
justify-content: center;
|
|
1324
|
+
z-index: 2147483647;
|
|
1325
|
+
cursor: pointer;
|
|
1326
|
+
opacity: .65;
|
|
1327
|
+
transition: opacity .2s ease, transform .2s ease;
|
|
1328
|
+
//transform: translate(-50%, 0);
|
|
1329
|
+
will-change: transform, opacity;
|
|
1330
|
+
pointer-events: auto;
|
|
1331
|
+
-webkit-tap-highlight-color: transparent;
|
|
1332
|
+
}
|
|
1333
|
+
#scrollFab.scroll-fab.visible {
|
|
1334
|
+
display: inline-flex;
|
|
1335
|
+
}
|
|
1336
|
+
#scrollFab.scroll-fab:hover {
|
|
1337
|
+
opacity: 1;
|
|
1338
|
+
//transform: translate(-50%, -1px);
|
|
1339
|
+
}
|
|
1340
|
+
#scrollFab.scroll-fab img {
|
|
1341
|
+
width: 100%;
|
|
1342
|
+
height: 100%;
|
|
1343
|
+
display: block;
|
|
1344
|
+
pointer-events: none;
|
|
1345
|
+
}
|
|
1346
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1347
|
+
#scrollFab.scroll-fab {
|
|
1348
|
+
transition: none;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
"""
|
|
1352
|
+
|
|
1006
1353
|
def __init__(self, window=None):
|
|
1007
1354
|
"""
|
|
1008
1355
|
HTML Body
|
|
@@ -1052,9 +1399,13 @@ class Body:
|
|
|
1052
1399
|
syntax_style = self.window.core.config.get("render.code_syntax") or "default"
|
|
1053
1400
|
|
|
1054
1401
|
theme_css = self.window.controller.theme.markdown.get_web_css().replace('%fonts%', fonts_path)
|
|
1055
|
-
parts = [
|
|
1056
|
-
|
|
1057
|
-
|
|
1402
|
+
parts = [
|
|
1403
|
+
self._SPINNER,
|
|
1404
|
+
theme_css,
|
|
1405
|
+
"pre { color: #fff; }" if syntax_style in self._syntax_dark else "pre { color: #000; }",
|
|
1406
|
+
self.highlight.get_style_defs(),
|
|
1407
|
+
self._SCROLL_FAB_CSS, # keep FAB styles last to ensure precedence
|
|
1408
|
+
]
|
|
1058
1409
|
return "\n".join(parts)
|
|
1059
1410
|
|
|
1060
1411
|
def prepare_action_icons(self, ctx: CtxItem) -> str:
|
|
@@ -1309,11 +1660,18 @@ class Body:
|
|
|
1309
1660
|
styles_css = self.prepare_styles()
|
|
1310
1661
|
tips_json = self.get_all_tips()
|
|
1311
1662
|
|
|
1663
|
+
# Build file:// paths for FAB icons
|
|
1664
|
+
app_path = self.window.core.config.get_app_path().replace("\\", "/")
|
|
1665
|
+
expand_path = os.path.join(app_path, "data", "icons", "expand.svg").replace("\\", "/")
|
|
1666
|
+
collapse_path = os.path.join(app_path, "data", "icons", "collapse.svg").replace("\\", "/")
|
|
1667
|
+
icons_js = f';const ICON_EXPAND="file://{expand_path}";const ICON_COLLAPSE="file://{collapse_path}";'
|
|
1668
|
+
|
|
1312
1669
|
return ''.join((
|
|
1313
1670
|
self._HTML_P0,
|
|
1314
1671
|
styles_css,
|
|
1315
1672
|
self._HTML_P1,
|
|
1316
1673
|
str(pid),
|
|
1674
|
+
icons_js,
|
|
1317
1675
|
self._HTML_P2,
|
|
1318
1676
|
tips_json,
|
|
1319
1677
|
self._HTML_P3,
|