mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-10-26 01:54:36 +08:00
- Move the scroll-to-bottom button to the right, aligned with the composer card's right edge, and shrink it to 32px (web and desktop) - Web: load earlier messages automatically when the reader scrolls near the top, replacing the "Load earlier messages" button Co-authored-by: cowagent <cow@cowagent.ai>
186 lines
7.2 KiB
JavaScript
186 lines
7.2 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const vm = require('node:vm');
|
|
const path = require('node:path');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
|
|
function setup() {
|
|
const frames = new Map();
|
|
let nextId = 0;
|
|
let top = 900;
|
|
let writes = 0;
|
|
let onScroll;
|
|
let onResize;
|
|
const listeners = {};
|
|
const historyLoads = [];
|
|
const classes = new Set(['hidden']);
|
|
const button = { classList: { toggle(name, hidden) { if (hidden) classes.add(name); else classes.delete(name); }, contains(name) { return classes.has(name); } } };
|
|
const box = {
|
|
scrollHeight: 1000, clientHeight: 100, clientWidth: 600,
|
|
get scrollTop() { return top; },
|
|
getBoundingClientRect() { return { right: 600 }; },
|
|
set scrollTop(value) { writes++; top = Math.max(0, Math.min(value, this.scrollHeight - this.clientHeight)); },
|
|
addEventListener(name, callback) { listeners[name] = callback; if (name === 'scroll') onScroll = callback; },
|
|
};
|
|
const context = vm.createContext({
|
|
messagesDiv: box,
|
|
document: { getElementById(id) { return id === 'scroll-to-bottom-btn' ? button : null; } },
|
|
requestAnimationFrame(callback) { frames.set(++nextId, callback); return nextId; },
|
|
cancelAnimationFrame(id) { frames.delete(id); },
|
|
ResizeObserver: class { constructor(callback) { onResize = callback; } observe() {} },
|
|
historyHasMore: false, historyLoading: false, historyPage: 0,
|
|
loadHistory(page) { historyLoads.push(page); context.historyLoading = true; },
|
|
});
|
|
const utils = fs.readFileSync(path.join(ROOT, 'channel/web/static/js/core/utils.js'), 'utf8');
|
|
const state = fs.readFileSync(path.join(ROOT, 'channel/web/static/js/chat/state.js'), 'utf8');
|
|
vm.runInContext(utils.slice(utils.indexOf('let _scrollRafId'), utils.indexOf('function applyHighlighting')), context);
|
|
vm.runInContext(state.slice(state.indexOf('let _autoScrollEnabled'), state.indexOf('// Intercept internal navigation')), context);
|
|
return {
|
|
box, frames, button, classes, listeners, historyLoads, run: code => vm.runInContext(code, context),
|
|
flush() { const batch = [...frames.values()]; frames.clear(); batch.forEach(callback => callback()); },
|
|
scroll(value, intent = null) {
|
|
top = value;
|
|
if (intent === 'up') listeners.wheel({ deltaY: -1 });
|
|
else if (intent === 'down') listeners.wheel({ deltaY: 1 });
|
|
else if (intent === 'scrollbar') listeners.pointerdown({ target: box, clientX: 600 });
|
|
onScroll();
|
|
},
|
|
touch(startY, endY, flush = false) {
|
|
listeners.touchstart({ touches: [{ clientY: startY }] });
|
|
listeners.touchmove({ touches: [{ clientY: endY }] });
|
|
if (flush) this.flush();
|
|
listeners.touchend();
|
|
},
|
|
resize() { onResize(); }, writes: () => writes,
|
|
};
|
|
}
|
|
|
|
test('already at bottom does not suppress the next upward gesture', () => {
|
|
const s = setup();
|
|
s.run('scrollChatToBottom(true)');
|
|
assert.equal(s.run('_programmaticScroll'), false);
|
|
assert.equal(s.writes(), 0);
|
|
s.scroll(880, 'up');
|
|
assert.equal(s.run('_autoScrollEnabled'), false);
|
|
});
|
|
|
|
test('stream updates coalesce and queued scrolling respects a later user pause', () => {
|
|
const s = setup();
|
|
s.scroll(900); // establish the current position as the browser scroll event does
|
|
s.run('scrollChatToBottom(); scrollChatToBottom(); scrollChatToBottom()');
|
|
assert.equal(s.frames.size, 1);
|
|
s.scroll(700, 'up');
|
|
s.flush();
|
|
assert.equal(s.box.scrollTop, 700);
|
|
assert.equal(s.writes(), 0);
|
|
});
|
|
|
|
test('force cancels queued work and reaches the current bottom immediately', () => {
|
|
const s = setup();
|
|
s.box.scrollHeight = 1200;
|
|
s.run('scrollChatToBottom(); scrollChatToBottom(true)');
|
|
assert.equal(s.frames.size, 0);
|
|
assert.equal(s.box.scrollTop, 1100);
|
|
assert.equal(s.run('_lastScrollTop'), 1100);
|
|
});
|
|
|
|
test('viewport shrink follows the bottom; history reading survives viewport resizing', () => {
|
|
const s = setup();
|
|
s.box.clientHeight = 50;
|
|
s.resize();
|
|
s.flush();
|
|
assert.equal(s.box.scrollTop, 950);
|
|
s.scroll(950); // consume our programmatic scroll event
|
|
s.scroll(600, 'up');
|
|
s.box.clientHeight = 80;
|
|
s.resize();
|
|
assert.equal(s.frames.size, 0);
|
|
assert.equal(s.box.scrollTop, 600);
|
|
assert.equal(s.run('_autoScrollEnabled'), false);
|
|
});
|
|
|
|
test('short conversations clamp the bottom to zero', () => {
|
|
const s = setup();
|
|
s.box.scrollHeight = 50;
|
|
s.box.clientHeight = 100;
|
|
s.run('scrollChatToBottom(true)');
|
|
assert.equal(s.box.scrollTop, 0);
|
|
});
|
|
|
|
test('touch dragging pauses follow before the stream frame runs', () => {
|
|
const s = setup();
|
|
s.run('scrollChatToBottom();');
|
|
assert.equal(s.frames.size, 1);
|
|
s.touch(300, 340, false);
|
|
assert.equal(s.run('_autoScrollEnabled'), false);
|
|
assert.equal(s.frames.size, 0);
|
|
s.flush();
|
|
assert.equal(s.box.scrollTop, 900);
|
|
});
|
|
|
|
test('tap without movement keeps follow enabled', () => {
|
|
const s = setup();
|
|
s.touch(300, 300);
|
|
assert.equal(s.run('_autoScrollEnabled'), true);
|
|
});
|
|
|
|
test('programmatic scroll events preserve follow intent and button reflects detached reading', () => {
|
|
const s = setup();
|
|
s.box.scrollHeight = 1400;
|
|
s.run('scrollChatToBottom(true)');
|
|
assert.equal(s.run('_autoScrollEnabled'), true);
|
|
s.scroll(700, 'up');
|
|
assert.equal(s.run('_autoScrollEnabled'), false);
|
|
assert.equal(s.classes.has('hidden'), false);
|
|
s.box.scrollHeight += 300;
|
|
s.run('_updateScrollToBottomBtn()');
|
|
assert.equal(s.classes.has('hidden'), false);
|
|
s.run('_lastScrollTop = 700');
|
|
s.box.clientHeight = 300;
|
|
s.scroll(1400, 'down');
|
|
assert.equal(s.run('_autoScrollEnabled'), true);
|
|
s.run('_updateScrollToBottomBtn()');
|
|
assert.equal(s.classes.has('hidden'), true);
|
|
});
|
|
|
|
test('scrolling up near the top loads the next history page once', () => {
|
|
const s = setup();
|
|
s.run('historyHasMore = true; historyPage = 1');
|
|
s.scroll(500, 'up');
|
|
assert.deepEqual(s.historyLoads, []);
|
|
s.scroll(150, 'up');
|
|
assert.deepEqual(s.historyLoads, [2]);
|
|
s.scroll(50, 'up');
|
|
assert.deepEqual(s.historyLoads, [2]);
|
|
s.run('historyLoading = false; historyPage = 2');
|
|
s.scroll(100, 'up');
|
|
assert.deepEqual(s.historyLoads, [2, 3]);
|
|
});
|
|
|
|
test('programmatic scrolls near the top and exhausted history never load pages', () => {
|
|
const s = setup();
|
|
s.run('historyHasMore = true; historyPage = 1');
|
|
s.scroll(0);
|
|
assert.deepEqual(s.historyLoads, []);
|
|
s.run('historyHasMore = false');
|
|
s.scroll(0, 'up');
|
|
s.touch(300, 340);
|
|
assert.deepEqual(s.historyLoads, []);
|
|
});
|
|
|
|
test('composer viewport resize follows only while enabled and keeps the return button clickable', () => {
|
|
const s = setup();
|
|
s.run('_autoScrollEnabled = false');
|
|
s.box.scrollTop = 700;
|
|
s.box.clientHeight = 50;
|
|
s.resize();
|
|
s.flush();
|
|
assert.equal(s.box.scrollTop, 700);
|
|
assert.equal(s.classes.has('hidden'), false);
|
|
s.run('scrollChatToBottom(true)');
|
|
assert.equal(s.box.scrollTop, 950);
|
|
assert.equal(s.run('_autoScrollEnabled'), true);
|
|
});
|