Enhanced Telegraph image viewer with auto-navigation, state memory, page jump, and UI controls. Keyboard shortcuts: 'g' to start/stop auto-next, 'f' to set interval. Based on SparkZhang's script: https://greasyfork.org/zh-CN/scripts/481178
// ==UserScript==
// @name Telegraph Auto Next
// @license MIT
// @namespace http://tampermonkey.net/
// @version 1.3.5
// @description Enhanced Telegraph image viewer with auto-navigation, state memory, page jump, and UI controls. Keyboard shortcuts: 'g' to start/stop auto-next, 'f' to set interval. Based on SparkZhang's script: https://greasyfork.org/zh-CN/scripts/481178
// @author CurssedCoffin (perfected with gemini) https://github.com/CurssedCoffin. Original by SparkZhang (https://greasyfork.org/zh-CN/users/1226354-sparkzhang)
// @match https://telegra.ph/*
// @match https://reader.960173.xyz/*
// @match https://graph.org/*
// @exclude https://telegra.ph/
// @exclude https://graph.org/
// @icon https://www.google.com/s2/favicons?sz=64&domain=telegra.ph
// @resource fa https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.13.0/css/all.min.css
// @require https://unpkg.com/vue@3/dist/vue.global.js
// @grant unsafeWindow
// @grant GM_addStyle
// @grant GM_getResourceText
// @run-at document-idle
// ==/UserScript==
/* global Vue */
if (location.hostname === 'graph.org') {
location.replace(location.href.replace('graph.org', 'telegra.ph'));
}
unsafeWindow.Vue = Vue;
init();
function init() {
if (location.pathname === '/' || location.pathname === '') return;
var urls = getImageUrls();
if (urls.length > 0) {
injectStyle();
createWrapper();
createVue(urls, getTitle());
}
}
function injectStyle() {
var fa = GM_getResourceText("fa");
if (fa) {
fa = fa.replaceAll("../webfonts", "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.13.0/webfonts");
GM_addStyle(fa);
}
GM_addStyle(`
.tl_page_wrap, header, main, nav.main-nav, .content-wrapper, #rwl-iqxin { display:none !important; }
#app { left: 0; top: 0; z-index: 9999; position: absolute; width:100%; min-height: 100vh; background-color: #000; }
.photo-viewer { background-color: #000; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
.photo-viewer-header, .photo-viewer-footer {
height: 40px; display: flex; align-items: center; justify-content: center; background-color: #383838; position: relative; flex-shrink: 0;
}
.photo-viewer-header, .photo-viewer-content, .photo-viewer-footer { color: #fff; text-align: center; }
.photo-viewer-click-left, .photo-viewer-click-right {
top: 0; left: 0; width:50%; height: 100%; z-index: 999; position: absolute; cursor: pointer;
}
.photo-viewer-click-right{ left: 50%; }
.photo-viewer-content {
font-size: 0; position: relative; flex-grow: 1; display: flex; align-items: center; justify-content: center; overflow: hidden;
}
.photo-viewer-content img { max-width: 100%; max-height: 100%; object-fit: contain; }
.photo-viewer-btn { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: 0.2s; }
.photo-viewer-btn:hover { background-color: #525252; }
.photo-viewer-counter { padding: 0 10px; display: flex; align-items: center; gap: 5px; }
.photo-viewer-footer-controls { position: absolute; right: 10px; display: flex; align-items: center; gap: 10px; height: 100%; }
.ui-input {
background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff; text-align: center; border-radius: 4px; outline: none; font-size: 14px;
}
.ui-input:focus { border-color: #00ff00; background: rgba(255, 255, 255, 0.2); }
.page-jump-input { width: 45px; height: 24px; }
.interval-input { width: 50px; height: 24px; }
.ui-input::-webkit-inner-spin-button, .ui-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.auto-active { color: #00ff00; }
`);
}
function createWrapper() {
var app = document.createElement("div");
app.id = "app";
document.body.appendChild(app);
}
function getTitle() {
return document.querySelector(".tl_article_header h1, header h1, title")?.innerText || "Untitled";
}
function getImageUrls() {
var urls =[];
var imgs = document.querySelectorAll(".figure_wrapper img, article img, .image-item, .content-wrapper img, main img");
imgs.forEach(img => { if (img.src) urls.push(img.src); });
return urls;
}
function createVue(urls, title) {
const { createApp, ref, computed, watch, onMounted } = Vue;
const app = createApp({
template: `<photo-viewer :urls="urls" :title="title"/>`,
setup() { return { urls: ref(urls), title: ref(title) }; }
});
app.component("PhotoViewer", {
props:["urls", "title"],
template: `
<div class="photo-viewer">
<div class="photo-viewer-header">{{title}}</div>
<div class="photo-viewer-content" ref="content">
<div class="photo-viewer-click-left" @click.stop="clickLeft"></div>
<div class="photo-viewer-click-right" @click.stop="clickRight"></div>
</div>
<div class="photo-viewer-footer">
<div class="photo-viewer-btn" @click="first"><i class="fa fa-chevron-left"></i><i class="fa fa-chevron-left"></i></div>
<div class="photo-viewer-btn" @click="prev"><i class="fa fa-chevron-left"></i></div>
<div class="photo-viewer-counter">
<input type="number" class="ui-input page-jump-input" v-model.lazy="inputPage" @focus="isPageInputActive = true" @blur="handlePageBlur" @keyup.enter="$event.target.blur()"/>
<span>/ {{total}}</span>
</div>
<div class="photo-viewer-btn" @click="next"><i class="fa fa-chevron-right"></i></div>
<div class="photo-viewer-btn" @click="last"><i class="fa fa-chevron-right"></i><i class="fa fa-chevron-right"></i></div>
<div class="photo-viewer-footer-controls">
<div class="photo-viewer-btn" :class="{'auto-active': !!autoTimer}" @click="toggleAutoPlay">
<i class="fa" :class="autoTimer ? 'fa-pause' : 'fa-play'"></i>
</div>
<div style="display:flex; align-items:center; gap:5px; font-size:13px;">
<input type="number" step="0.1" class="ui-input interval-input" v-model="inputInterval" @keyup.enter="$event.target.blur()"/>
<span>s</span>
</div>
</div>
</div>
</div>`,
setup(props) {
const currentIndex = ref(0);
const content = ref(null);
const autoTimer = ref(null);
const autoInterval = ref(parseFloat(localStorage.getItem('tg_auto_interval')) || 3);
const inputPage = ref(1);
const isPageInputActive = ref(false);
const inputInterval = ref(autoInterval.value);
const imgs = ref(props.urls.map(url => {
var img = document.createElement("img");
img.src = url;
return img;
}));
const img = computed(() => imgs.value[currentIndex.value]);
const total = computed(() => props.urls.length);
watch(img, (newImg, oldImg) => {
if (oldImg) oldImg.remove();
if (content.value) content.value.appendChild(newImg);
if (!isPageInputActive.value) inputPage.value = currentIndex.value + 1;
});
watch(inputInterval, (newVal) => {
let val = parseFloat(newVal);
if (!isNaN(val) && val > 0) {
autoInterval.value = val;
localStorage.setItem('tg_auto_interval', val);
if (autoTimer.value) startAutoPlay();
}
});
const handlePageBlur = () => {
isPageInputActive.value = false;
let page = parseInt(inputPage.value);
if (!isNaN(page)) {
if (page < 1) page = 1;
if (page > total.value) page = total.value;
currentIndex.value = page - 1;
}
inputPage.value = currentIndex.value + 1;
};
const stopAutoPlay = (clearMemory = true) => {
if (autoTimer.value) { clearInterval(autoTimer.value); autoTimer.value = null; }
if (clearMemory) localStorage.setItem('tg_auto_enabled', 'false');
};
const startAutoPlay = () => {
stopAutoPlay(false);
if (autoInterval.value > 0) {
autoTimer.value = setInterval(next, autoInterval.value * 1000);
localStorage.setItem('tg_auto_enabled', 'true');
}
};
const toggleAutoPlay = () => { autoTimer.value ? stopAutoPlay(true) : startAutoPlay(); };
const first = () => { currentIndex.value = 0; };
const last = () => { if (total.value > 0) currentIndex.value = total.value - 1; };
const prev = () => { if (currentIndex.value > 0) currentIndex.value--; };
// 【修改点】在这里:判断如果已经到了最后一张,调用 stopAutoPlay(false),停止计时器但不清空 global 记忆。
const next = () => {
if (currentIndex.value < total.value - 1) currentIndex.value++;
else stopAutoPlay(false);
};
onMounted(() => {
document.addEventListener("keydown", e => {
if (e.target.tagName === 'INPUT') return;
if (e.key === "ArrowRight") next();
else if (e.key === "ArrowLeft") prev();
else if (e.key === "g") toggleAutoPlay();
else if (e.key === "f") {
let input = prompt("Enter auto-next interval (seconds):", autoInterval.value);
if (input !== null) inputInterval.value = input;
}
});
if (content.value) content.value.appendChild(img.value);
if (localStorage.getItem('tg_auto_enabled') === 'true') startAutoPlay();
})
return {
imgs, img, currentIndex, total, inputPage, inputInterval, isPageInputActive,
first, last, prev, next, content, autoTimer, toggleAutoPlay, handlePageBlur,
clickLeft: prev, clickRight: next
};
}
});
app.mount("#app");
}