Add collapsible draggable study panel with saved layout
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
// ==UserScript==
|
||||
// @name 学员中心 · 2倍速学习助手
|
||||
// @namespace local.icampus.study
|
||||
// @version 1.4.1
|
||||
// @description 支持全过程静音、切换标签页继续2倍速播放与保存进度,自动测验及错题重试。
|
||||
// @version 1.4.2
|
||||
// @description 支持面板收纳与拖拽、全过程静音、后台2倍速播放、自动测验及错题重试。
|
||||
// @match https://icampus.yunyinsh.com/course/*
|
||||
// @run-at document-start
|
||||
// @grant none
|
||||
@@ -73,6 +73,11 @@
|
||||
const minutes=Math.ceil(Math.max(0,seconds)/60);
|
||||
return minutes>=60?`${Math.floor(minutes/60)}小时${minutes%60}分`:`${minutes}分钟`;
|
||||
}
|
||||
function panelPosition(x,y,width,height,viewportWidth,viewportHeight) {
|
||||
const maxX=Math.max(8,viewportWidth-width-8),maxY=Math.max(8,viewportHeight-height-8);
|
||||
return {x:Math.max(8,Math.min(maxX,Number.isFinite(x)?x:16)),
|
||||
y:Math.max(8,Math.min(maxY,Number.isFinite(y)?y:16))};
|
||||
}
|
||||
function videoPlan(detail) {
|
||||
if (!detail?.course || !Array.isArray(detail.resources)) return {kind: 'wait'};
|
||||
const resources = [...detail.resources].sort((a, b) => a.sortNo - b.sortNo);
|
||||
@@ -136,7 +141,7 @@
|
||||
} catch { return null; }
|
||||
}
|
||||
const core = {normalize, route, endpoint, bodyJSON, courseDone, examPassed, videoPlan, resolveCards, leaseAvailable,
|
||||
questionKey, selectionKey, chooseAnswer, learnCard, quizEndpoint, learningPercent, overallProgress, durationText};
|
||||
questionKey, selectionKey, chooseAnswer, learnCard, quizEndpoint, learningPercent, overallProgress, durationText, panelPosition};
|
||||
if (typeof module === 'object' && module.exports && typeof window === 'undefined') { module.exports = core; return; }
|
||||
if (window.top !== window.self || document.getElementById('icampus-study-helper')) return;
|
||||
|
||||
@@ -148,6 +153,7 @@
|
||||
const INTENT_KEY = 'icampus.study.v1.enabled';
|
||||
const MUTED_KEY = 'icampus.study.v1.mutedFallback';
|
||||
const FULL_MUTED_KEY = 'icampus.study.v1.fullMuted';
|
||||
const PANEL_KEY = 'icampus.study.v1.panel';
|
||||
let fullMutedEnabled = false;
|
||||
try { fullMutedEnabled = localStorage.getItem(FULL_MUTED_KEY) === 'on'; } catch {}
|
||||
const mutedPlayers = new Map();
|
||||
@@ -180,6 +186,7 @@
|
||||
let runNumber = 0;
|
||||
let status = '就绪:刷新后,在课程列表或学习页点击开始。';
|
||||
let panel;
|
||||
let fitPanel = () => {};
|
||||
let mode;
|
||||
let active = null;
|
||||
let routeSince = Date.now();
|
||||
@@ -817,7 +824,10 @@
|
||||
panel.querySelector('[data-quiz-only]').disabled = armed;
|
||||
panel.querySelector('[data-background-state]').textContent = backgroundEnabled
|
||||
? `${isHidden()?'后台':'前台'} · 切标签页继续播放已开启` : '后台模式已关闭';
|
||||
panel.querySelector('[data-mini-state]').textContent=armed?(running?'运行中':'等待恢复'):'未启动';
|
||||
panel.querySelector('[data-expand]').title=`${status}\n拖动可移动,点击展开面板`;
|
||||
mode.disabled = armed;
|
||||
fitPanel();
|
||||
}
|
||||
function renderOverall() {
|
||||
const current=details.get(route(location.hash).id)?.course;
|
||||
@@ -840,14 +850,75 @@
|
||||
}
|
||||
panel.querySelector('[data-estimate]').textContent=estimate;
|
||||
}
|
||||
function setupPanelMovement(host) {
|
||||
const section=panel.querySelector('[data-main-panel]');
|
||||
const header=panel.querySelector('[data-drag-handle]');
|
||||
const compact=panel.querySelector('[data-expand]');
|
||||
let collapsed=false,position=null,drag=null,suppressClick=false;
|
||||
try {
|
||||
const raw=localStorage.getItem(PANEL_KEY);
|
||||
const saved=raw && raw.length<2048?JSON.parse(raw):null;
|
||||
collapsed=saved?.collapsed===true;
|
||||
if(Number.isFinite(saved?.x) && Number.isFinite(saved?.y)) position={x:saved.x,y:saved.y};
|
||||
} catch { /* Invalid layout settings use the default location. */ }
|
||||
function save() {
|
||||
try { localStorage.setItem(PANEL_KEY,JSON.stringify({...position,collapsed})); } catch {}
|
||||
}
|
||||
function rect() {return host.getBoundingClientRect?.() || {left:16,top:16,width:collapsed?240:322,height:collapsed?44:600};}
|
||||
fitPanel=()=>{
|
||||
const box=rect();
|
||||
position=panelPosition(position?.x ?? box.left,position?.y ?? box.top,box.width,box.height,
|
||||
window.innerWidth || 1024,window.innerHeight || 768);
|
||||
host.style.left=`${position.x}px`;host.style.top=`${position.y}px`;host.style.bottom='auto';
|
||||
};
|
||||
function setCollapsed(value) {
|
||||
collapsed=value;section.hidden=value;compact.hidden=!value;
|
||||
compact.ariaExpanded=String(!value);
|
||||
fitPanel();save();
|
||||
}
|
||||
section.hidden=collapsed;compact.hidden=!collapsed;compact.ariaExpanded=String(!collapsed);
|
||||
fitPanel();
|
||||
panel.querySelector('[data-collapse]').onclick=()=>setCollapsed(true);
|
||||
compact.onclick=event=>{
|
||||
const dragged=suppressClick;suppressClick=false;
|
||||
if(dragged && event?.detail!==0) return;
|
||||
setCollapsed(false);
|
||||
};
|
||||
for(const handle of [header,compact]) {
|
||||
handle.onpointerdown=event=>{
|
||||
if(event.button!==0 || event.isPrimary===false || drag) return;
|
||||
if(handle===header && event.target.closest?.('button, input, select, a, summary')) return;
|
||||
suppressClick=false;
|
||||
const box=rect();
|
||||
drag={id:event.pointerId,handle,startX:event.clientX,startY:event.clientY,x:box.left,y:box.top,moved:false};
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
};
|
||||
handle.onpointermove=event=>{
|
||||
if(!drag || drag.id!==event.pointerId) return;
|
||||
const dx=event.clientX-drag.startX,dy=event.clientY-drag.startY;
|
||||
if(!drag.moved && Math.hypot(dx,dy)<4) return;
|
||||
drag.moved=true;
|
||||
position={x:drag.x+dx,y:drag.y+dy};fitPanel();event.preventDefault();
|
||||
};
|
||||
function endDrag(event) {
|
||||
if(!drag || drag.id!==event.pointerId) return;
|
||||
if(drag.moved) {suppressClick=handle===compact;fitPanel();save();}
|
||||
drag=null;
|
||||
if(handle.hasPointerCapture?.(event.pointerId)) handle.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
handle.onpointerup=endDrag;handle.onpointercancel=endDrag;handle.onlostpointercapture=endDrag;
|
||||
}
|
||||
window.addEventListener('resize',()=>{fitPanel();save();});
|
||||
}
|
||||
function mount() {
|
||||
if (!document.body) return;
|
||||
const host = document.createElement('div'); host.id = 'icampus-study-helper';
|
||||
host.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:2147483646';
|
||||
host.style.maxHeight='calc(100vh - 32px)'; host.style.overflowY='auto';
|
||||
host.style.maxHeight='calc(100vh - 16px)'; host.style.overflowY='auto';
|
||||
panel = host.attachShadow({mode: 'open'});
|
||||
panel.innerHTML = `<style>:host{font:14px/1.6 system-ui;color:#eaf0fa}section{width:290px;background:#17243a;border:1px solid #6883a6;border-radius:12px;padding:15px;box-shadow:0 5px 24px #0005}strong{font-size:16px}p{margin:9px 0;white-space:pre-line}small{color:#b7c7df}button,select{font:inherit;border-radius:6px;border:1px solid #7186a5;padding:5px 9px;margin:3px 5px 3px 0}button{cursor:pointer;background:#dbeaff;color:#123}button:disabled{opacity:.5;cursor:default}select{width:100%;background:#fff;color:#123}[data-status]{min-height:45px}details{margin-top:8px}pre{white-space:pre-wrap;max-height:160px;overflow:auto;font-size:12px}</style>
|
||||
<section><strong>2倍速学习助手 · 1.4.1</strong><p data-status></p><small data-progress></small>
|
||||
panel.innerHTML = `<style>:host{display:block;font:14px/1.6 system-ui;color:#eaf0fa}[hidden]{display:none!important}section{box-sizing:border-box;width:322px;max-width:calc(100vw - 16px);background:#17243a;border:1px solid #6883a6;border-radius:12px;padding:15px;box-shadow:0 5px 24px #0005}strong{font-size:14px}p{margin:9px 0;white-space:pre-line}small{color:#b7c7df}button,select{font:inherit;border-radius:6px;border:1px solid #7186a5;padding:5px 9px;margin:3px 5px 3px 0}button{cursor:pointer;background:#dbeaff;color:#123}button:disabled{opacity:.5;cursor:default}select{width:100%;background:#fff;color:#123}[data-status]{min-height:45px}details{margin-top:8px}pre{white-space:pre-wrap;max-height:160px;overflow:auto;font-size:12px}[data-drag-handle]{display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:grab;touch-action:none;user-select:none}[data-collapse]{flex-shrink:0;margin:0}.compact{display:flex;align-items:center;gap:10px;max-width:calc(100vw - 16px);box-sizing:border-box;margin:0;padding:10px 13px;border-radius:999px;background:#17243a;color:#eaf0fa;cursor:grab;touch-action:none;user-select:none}.compact [data-mini-state]{color:#b7c7df;font-size:12px}.compact:focus-visible,button:focus-visible{outline:2px solid #a3ccff;outline-offset:-3px}</style>
|
||||
<button class="compact" data-expand hidden aria-expanded="false" aria-label="展开学习助手面板"><span>2×学习助手</span><span data-mini-state></span><span>展开</span></button>
|
||||
<section data-main-panel aria-label="学习助手面板"><header data-drag-handle title="按住标题拖动面板"><strong>2倍速学习助手 · 1.4.2</strong><button data-collapse aria-label="收起学习助手面板">收起</button></header><p data-status></p><small data-progress></small>
|
||||
<p data-overall></p><progress data-overall-bar aria-label="整体课程完成百分比" max="100" value="0" style="width:100%"></progress>
|
||||
<p data-estimate style="font-size:12px;color:#b7c7df"></p>
|
||||
<select aria-label="播放范围"><option value="all">按列表顺序继续课程</option><option value="current">仅当前课程</option></select>
|
||||
@@ -894,7 +965,7 @@
|
||||
panel.querySelector('[data-stop]').onclick = () => stop();
|
||||
autoQuiz.onchange=()=>{persistIntent();if(waiting)waiting.nextAt=0;};
|
||||
panel.querySelector('[data-copy]').onclick = () => { panel.querySelector('pre').textContent = logs.map(x => `${x.time} ${x.message}`).join('\n'); };
|
||||
document.body.append(host); render();
|
||||
document.body.append(host);setupPanelMovement(host);render();
|
||||
try {
|
||||
const resume=JSON.parse(sessionStorage.getItem(RESUME_KEY) || 'null');
|
||||
sessionStorage.removeItem(RESUME_KEY);
|
||||
|
||||
Reference in New Issue
Block a user