MediaWiki:KuranokaPageTransition.js
MediaWiki界面页面
更多操作
注意:在发布之后,您可能需要清除浏览器缓存才能看到所作出的更改的影响。
- Firefox或Safari:按住Shift的同时单击刷新,或按Ctrl-F5或Ctrl-R(Mac为⌘-R)
- Google Chrome:按Ctrl-Shift-R(Mac为⌘-Shift-R)
- Edge:按住Ctrl的同时单击刷新,或按Ctrl-F5。
/* =================================================================
KURANOKA PAGE TRANSITION V3 — ASHES / REBIRTH
Paste into MediaWiki:KuranokaPageTransition.js (replace V2).
Load ONLY on wiki pages Kuranoka and Kuronata via Citizen.js.
Needs the matching KuranokaPageTransition.css. No libraries.
================================================================= */
(function () {
'use strict';
// The destination iframe loads this script too. NEVER nest previews.
if (window !== window.top || window.__kkptV3) return;
window.__kkptV3 = true;
if (!window.mw || !mw.config) return;
if (mw.config.get('wgNamespaceNumber') !== 0 || mw.config.get('wgAction') !== 'view') return;
const page = mw.config.get('wgPageName');
if (page !== 'Kuranoka' && page !== 'Kuronata') return;
const burn = page === 'Kuranoka';
const target = burn ? 'Kuronata' : 'Kuranoka';
const targetUrl = '/wiki/' + encodeURIComponent(target);
const DURATION = burn ? 3900 : 3200;
const startClip = burn ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)';
const warmedImages = new Map();
const rand = (min, max) => min + Math.random() * (max - min);
const smooth = t => t * t * (3 - 2 * t);
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const frame = () => new Promise(resolve => requestAnimationFrame(resolve));
let iframe = null;
let warmPromise = null;
let loaded = false;
let busy = false;
let button = null;
// Look at actual CSS in BOTH documents. This finds Background template
// body backgrounds as well as CSS pseudo-element backdrops.
function findBackgroundUrls(doc) {
const urls = new Set();
const nodes = [doc.documentElement, doc.body];
const selectors = [
'#mw-content-text', '.mw-parser-output', '.citizen-page-container',
'.citizen-body-container', '.mw-page-container',
'.background-theme', '[class*="background"]', '[id*="background"]',
'[style*="background"]', '[data-background]'
];
selectors.forEach(selector => {
try {
doc.querySelectorAll(selector).forEach((node, index) => {
if (index < 45 && !nodes.includes(node)) nodes.push(node);
});
} catch (e) { /* selector may not exist in an older skin */ }
});
function add(value) {
if (!value || typeof value !== 'string') return;
const urlPattern = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
let match;
let hasCssUrl = false;
while ((match = urlPattern.exec(value))) {
hasCssUrl = true;
if (match[2]) {
try { urls.add(new URL(match[2], doc.baseURI).href); } catch (e) { /* ignore */ }
}
}
if (!hasCssUrl && /^(https?:\/\/|\/|\.\/|\.\.\/).+\.(?:png|jpe?g|webp|avif|gif|svg)(?:[?#].*)?$/i.test(value)) {
try { urls.add(new URL(value, doc.baseURI).href); } catch (e) { /* ignore */ }
}
}
nodes.forEach(node => {
if (!node) return;
try {
const win = doc.defaultView;
[null, '::before', '::after'].forEach(pseudo => {
const style = win.getComputedStyle(node, pseudo);
if (style) {
add(style.backgroundImage);
add(style.maskImage);
add(style.getPropertyValue('--background-image'));
add(style.getPropertyValue('--bg-image'));
}
});
} catch (e) { /* ignore unsupported pseudo styles */ }
// MediaWiki Background templates often expose URLs as data-*.
if (node.dataset) Object.entries(node.dataset).forEach(([key, value]) => {
if (/background|wallpaper|image|backdrop|bg/i.test(key)) add(value);
});
});
return [...urls];
}
function decodeBackground(url) {
if (warmedImages.has(url)) return warmedImages.get(url);
const image = new Image();
image.decoding = 'async';
image.fetchPriority = 'high';
image.src = url;
const promise = new Promise(resolve => {
if (image.complete) return resolve(image.naturalWidth > 0);
image.onload = () => resolve(true);
image.onerror = () => resolve(false);
}).then(async success => {
if (success && image.decode) {
try { await image.decode(); } catch (e) { /* already loaded */ }
}
return success;
});
// Keep an Image object alive so the browser can reuse its decoded data.
warmedImages.set(url, promise);
if (!window.__kkptImagePool) window.__kkptImagePool = [];
window.__kkptImagePool.push(image);
return promise;
}
async function warmBackgrounds(doc) {
const urls = findBackgroundUrls(doc);
if (urls.length) await Promise.all(urls.map(decodeBackground));
return urls.length;
}
function createPreviewEarly() {
if (warmPromise) return warmPromise;
iframe = document.createElement('iframe');
iframe.id = 'kkpt-preview';
iframe.title = target + ' — 预载画面';
iframe.tabIndex = -1;
iframe.setAttribute('aria-hidden', 'true');
iframe.setAttribute('loading', 'eager');
iframe.style.clipPath = startClip;
warmPromise = new Promise(resolve => {
let settled = false;
function done(ok) {
if (settled) return;
settled = true;
loaded = ok;
resolve(ok);
}
iframe.addEventListener('load', async function () {
try {
const doc = iframe.contentDocument;
if (!doc || !doc.querySelector('#mw-content-text')) return done(false);
// Allow background JavaScript / async MediaWiki styles to run.
await pause(200);
const fonts = doc.fonts && doc.fonts.ready;
if (fonts) await Promise.race([fonts.catch(() => {}), pause(1300)]);
await warmBackgrounds(doc);
await pause(170);
await warmBackgrounds(doc); // catches late body/background CSS
iframe.contentWindow.scrollTo(0, 0);
done(true);
} catch (err) {
console.warn('[KuranokaPageTransition] Unable to pre-render the next page:', err);
done(false);
}
}, { once: true });
iframe.addEventListener('error', () => done(false), { once: true });
document.body.appendChild(iframe);
iframe.src = targetUrl;
});
// Warm the current page as well, in case an incoming navigation needs it.
warmBackgrounds(document).catch(() => {});
setTimeout(() => warmBackgrounds(document).catch(() => {}), 500);
return warmPromise;
}
function makeSprite(stops) {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 96;
const c = canvas.getContext('2d');
const g = c.createRadialGradient(48, 48, 0, 48, 48, 48);
stops.forEach(stop => g.addColorStop(stop[0], stop[1]));
c.fillStyle = g;
c.fillRect(0, 0, 96, 96);
return canvas;
}
const SPRITES = {
fire: makeSprite([[0, '#ffffff'], [.11, '#fff6b3'], [.35, '#ffb71e'], [.68, '#f7330988'], [1, '#a3000000']]),
spark: makeSprite([[0, '#ffffff'], [.16, '#fff3b8'], [.48, '#ff7900bb'], [1, '#ff230000']]),
smoke: makeSprite([[0, '#170c0de0'], [.36, '#201b20a1'], [.72, '#302b3260'], [1, '#11111100']]),
ash: makeSprite([[0, '#d2b2a9'], [.28, '#897471bb'], [1, '#55555500']]),
sky: makeSprite([[0, '#ffffff'], [.16, '#eeffff'], [.44, '#79d5ffbb'], [1, '#3c92ff00']])
};
function runAnimation() {
return new Promise(resolve => {
const canvas = document.createElement('canvas');
canvas.id = 'kkpt-canvas';
canvas.setAttribute('aria-hidden', 'true');
document.body.append(canvas);
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) { canvas.remove(); resolve(); return; }
let width = 0, height = 0;
function resize() {
width = window.innerWidth;
height = window.innerHeight;
const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.6);
canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
}
resize();
const particles = [];
const shockwaves = [];
const maxParticles = width < 700 ? 580 : 1100;
const start = performance.now();
let previous = start;
let burstClock = 0;
function addParticle(x, y) {
if (particles.length >= maxParticles) return;
const r = Math.random();
const type = burn
? (r < .49 ? 'fire' : r < .70 ? 'spark' : r < .90 ? 'smoke' : 'ash')
: (r < .68 ? 'sky' : r < .85 ? 'spark' : 'smoke');
const isSmoke = type === 'smoke';
const isDust = type === 'ash';
const life = isSmoke ? rand(.9, 1.85) : isDust ? rand(.8, 2.3) : rand(.35, 1.05);
particles.push({
type, x: x + rand(-24, 24), y: y + rand(-22, 22),
vx: burn ? rand(-255, 96) : rand(-86, 210),
vy: isSmoke ? rand(-115, -25) : isDust ? rand(-95, 25) : rand(-280, -52),
size: isSmoke ? rand(38, 102) : isDust ? rand(3, 11) :
type === 'spark' ? rand(3, 10) : rand(14, 42),
life, remain: life, angle: rand(-Math.PI, Math.PI), spin: rand(-1.2, 1.2)
});
}
function paint(now) {
const dt = Math.min(.05, Math.max(.001, (now - previous) / 1000));
previous = now;
const progress = Math.min(1, (now - start) / DURATION);
const ease = smooth(progress);
const wobble = Math.pow(Math.sin(Math.PI * progress), .7);
const direction = burn ? -1 : 1;
const center = burn
? width + 120 - (width + 260) * ease
: -120 + (width + 260) * ease;
function edge(y) {
return center + wobble * (
32 * Math.sin(y * .012 + now * .0037) +
21 * Math.sin(y * .035 - now * .0061) +
12 * Math.sin(y * .079 + now * .0107) +
7 * Math.sin(y * .156 - now * .014)
);
}
const pts = [];
const count = Math.max(35, Math.ceil(height / 17));
for (let i = 0; i <= count; i++) {
const y = height * i / count;
pts.push([edge(y), y]);
}
const pathToEdge = pts.slice().reverse().map(p =>
p[0].toFixed(1) + 'px ' + p[1].toFixed(1) + 'px'
).join(',');
iframe.style.clipPath = burn
? 'polygon(100% 0,100% 100%,' + pathToEdge + ')'
: 'polygon(0 0,0 100%,' + pathToEdge + ')';
ctx.clearRect(0, 0, width, height);
// Flare spreading over the whole screen makes this feel like
// the world's light changes, rather than a moving divider.
const flash = .025 * (1 + Math.sin(now * .048)) +
.07 * Math.pow(Math.max(0, Math.sin(now * .009)), 12);
ctx.fillStyle = burn
? 'rgba(108,13,5,' + (flash * wobble).toFixed(3) + ')'
: 'rgba(48,135,205,' + (flash * wobble).toFixed(3) + ')';
ctx.fillRect(0, 0, width, height);
function trace(offset) {
ctx.beginPath();
pts.forEach((p, i) => {
if (i === 0) ctx.moveTo(p[0] + offset, p[1]);
else ctx.lineTo(p[0] + offset, p[1]);
});
}
// A broad charred-paper zone is consumed BEFORE the bright core.
// Its gradient is on the remaining old page side of the flame.
ctx.save();
const x0 = burn ? -133 : 133;
const x1 = burn ? 18 : -18;
const dark = ctx.createLinearGradient(center + x0, 0, center + x1, 0);
if (burn) {
dark.addColorStop(0, '#130d0d00');
dark.addColorStop(.35, '#150c0eaa');
dark.addColorStop(.70, '#10080ce9');
dark.addColorStop(.91, '#48170df5');
dark.addColorStop(1, '#fa42156a');
} else {
dark.addColorStop(0, '#0b1f3100');
dark.addColorStop(.35, '#052744aa');
dark.addColorStop(.7, '#041c35e9');
dark.addColorStop(.91, '#215b8bf5');
dark.addColorStop(1, '#8ce8ff77');
}
ctx.beginPath();
pts.forEach((p, i) => {
const jitter = 10 * Math.sin(p[1] * .11 + now * .007);
const x = p[0] + x0 + jitter;
if (i === 0) ctx.moveTo(x, p[1]);
else ctx.lineTo(x, p[1]);
});
pts.slice().reverse().forEach(p => ctx.lineTo(p[0] + x1, p[1]));
ctx.closePath();
ctx.fillStyle = dark;
ctx.fill();
ctx.restore();
// Smoke is below the light-emitting flames.
ctx.save();
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.remain -= dt;
if (p.remain <= 0) { particles.splice(i, 1); continue; }
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vx += (p.type === 'smoke' ? -4 : direction * 15) * dt;
p.vy -= 11 * dt;
p.angle += p.spin * dt;
if (p.type !== 'smoke' && p.type !== 'ash') continue;
const age = 1 - p.remain / p.life;
const size = p.size * (1 + age * (p.type === 'smoke' ? 1.7 : .6));
ctx.globalAlpha = Math.min(1, p.remain * 2) *
(p.type === 'smoke' ? .52 : .35);
ctx.drawImage(SPRITES[p.type], p.x - size, p.y - size, 2 * size, 2 * size);
}
ctx.restore();
// Broken filament cracks penetrating the unburnt page.
// They flicker independently instead of making a tidy wall.
if (burn) {
ctx.save();
for (let y = 38; y < height; y += 103) {
const a = edge(y), skew = Math.sin(y * .13 + now * .0017);
ctx.lineWidth = 1.6;
ctx.strokeStyle = 'rgba(38,6,4,.8)';
ctx.beginPath();
ctx.moveTo(a - 8, y);
ctx.lineTo(a - 35, y - 13 * skew);
ctx.lineTo(a - 59, y + 12);
ctx.lineTo(a - 93 - 20 * skew, y - 19);
ctx.lineTo(a - 127, y - 6);
ctx.stroke();
ctx.lineWidth = .7;
ctx.strokeStyle = 'rgba(255,99,23,.66)';
ctx.stroke();
ctx.beginPath();
ctx.moveTo(a - 61, y + 12);
ctx.lineTo(a - 77, y + 31);
ctx.lineTo(a - 91, y + 40);
ctx.stroke();
}
ctx.restore();
}
// Blooming outer corona, molten core, then white-hot ignition.
ctx.save();
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.shadowColor = burn ? '#fb2509' : '#86e6ff';
ctx.shadowBlur = burn ? 79 : 88;
trace(0);
ctx.strokeStyle = burn ? 'rgba(242,35,6,.6)' : 'rgba(74,168,255,.65)';
ctx.lineWidth = burn ? 65 : 73;
ctx.stroke();
ctx.shadowBlur = 39;
ctx.strokeStyle = burn ? 'rgba(255,110,10,.88)' : 'rgba(130,223,255,.91)';
ctx.lineWidth = burn ? 29 : 35;
ctx.stroke();
ctx.shadowBlur = 18;
ctx.strokeStyle = burn ? '#ffe564' : '#e5fbff';
ctx.lineWidth = 9;
ctx.stroke();
ctx.shadowBlur = 9;
ctx.strokeStyle = '#fffdf0';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
// Aggressive curling tongues. Flame tips tear leftwards into
// old Kuranoka; on the way back, electric shards tear right.
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (let y = -60; y < height + 60; y += burn ? 17 : 26) {
const x = edge(y);
const flicker = Math.sin(now * .015 + y * .093);
const lift = (burn ? 55 : 68) +
(burn ? 65 : 43) * Math.abs(Math.sin(y * .043 - now * .009)) +
18 * (flicker + 1);
const lean = (burn ? -1 : 1) * lift;
ctx.fillStyle = burn ? 'rgba(255,53,8,.44)' : 'rgba(31,138,255,.33)';
ctx.beginPath();
ctx.moveTo(x - lean * .05, y + 19);
ctx.bezierCurveTo(x + lean * .14, y - 18,
x + lean * .88, y - lift * .47, x + lean, y - lift);
ctx.bezierCurveTo(x + lean * .35, y - lift * .53,
x - lean * .13, y + 13, x + 16, y + 27);
ctx.closePath();
ctx.fill();
ctx.fillStyle = burn ? 'rgba(255,231,89,.47)' : 'rgba(222,250,255,.52)';
ctx.beginPath();
ctx.moveTo(x + 4, y + 11);
ctx.quadraticCurveTo(x + lean * .32, y - lift * .31,
x + lean * .70, y - lift * .67);
ctx.quadraticCurveTo(x - 6, y - lift * .21, x + 7, y + 11);
ctx.closePath();
ctx.fill();
}
ctx.restore();
// Impacts regularly burst along the travelling front.
burstClock += dt;
if (burstClock > (burn ? .18 : .26) && progress > .05 && progress < .95) {
burstClock = 0;
const y = rand(0, height);
shockwaves.push({x: edge(y), y, age: 0, max: rand(52, 115)});
for (let j = 0; j < (burn ? 18 : 12); j++) addParticle(edge(y), y);
}
for (let i = shockwaves.length - 1; i >= 0; i--) {
const s = shockwaves[i];
s.age += dt;
if (s.age > .52) { shockwaves.splice(i, 1); continue; }
const t = s.age / .52;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = (1 - t) * .67;
ctx.strokeStyle = burn ? '#ffce75' : '#d3ffff';
ctx.lineWidth = 4 * (1 - t) + .3;
ctx.shadowBlur = 32;
ctx.shadowColor = burn ? '#ff3f05' : '#72d5ff';
ctx.beginPath();
ctx.ellipse(s.x, s.y, s.max * t, s.max * t * .7, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
const density = width < 700 ? 13 : 29;
for (let i = 0; i < density; i++) {
const y = rand(0, height);
addParticle(edge(y), y);
}
ctx.save();
ctx.globalCompositeOperation = 'lighter';
particles.forEach(p => {
if (p.type === 'smoke' || p.type === 'ash') return;
const age = 1 - p.remain / p.life;
const fade = Math.min(1, p.remain * 3) * (1 - age * .65);
ctx.globalAlpha = fade * (p.type === 'spark' ? .97 : .72);
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
const xsize = p.size * (p.type === 'fire' ? .8 : 1);
const ysize = p.size * (p.type === 'fire' ? 1.7 : 1);
ctx.drawImage(SPRITES[p.type], -xsize, -ysize, xsize * 2, ysize * 2);
ctx.rotate(-p.angle);
ctx.translate(-p.x, -p.y);
});
ctx.restore();
if (progress < 1) {
requestAnimationFrame(paint);
} else {
// The iframe is fully visible BEFORE the old document
// navigates. No handoff cover, no blue/red solid flash.
iframe.style.clipPath = 'inset(0)';
canvas.style.opacity = '0';
resolve();
}
}
requestAnimationFrame(paint);
});
}
async function begin() {
if (!warmPromise) createPreviewEarly();
button.classList.add('kkpt-loading');
button.querySelector('.kkpt-sub').textContent = 'PREPARING THE OTHER SIDE...';
// Usually instantaneous: the iframe and both wallpapers began loading
// when the page itself was opened, not when this button was clicked.
const ready = await Promise.race([warmPromise, pause(12000).then(() => false)]);
if (!ready || !loaded) {
button.classList.remove('kkpt-loading');
button.querySelector('.kkpt-sub').textContent = '预览未就绪 · 点击普通跳转';
// Failure path is a normal link, not a fake blue page reveal.
button.dataset.kkptFallback = '1';
busy = false;
return;
}
button.classList.remove('kkpt-loading');
button.classList.add('kkpt-running');
button.querySelector('.kkpt-sub').textContent = burn ? 'THE WORLD IS BURNING' : 'BREAK THROUGH THE DARK';
await runAnimation();
// Let the compositor present the REAL destination iframe for a few
// frames. On navigation, native cross-document View Transition takes
// its old snapshot from this already-revealed destination.
button.classList.add('kkpt-vanish');
await frame();
await frame();
await pause(90);
window.location.assign(targetUrl);
}
function makeButton() {
if (!document.body || document.getElementById('kkpt-toggle')) return;
button = document.createElement('a');
button.id = 'kkpt-toggle';
button.href = targetUrl;
button.setAttribute('aria-label', burn ? '燃尽当前世界,进入 Kuronata' : '破晓,返回 Kuranoka');
const icon = document.createElement('span');
icon.className = 'kkpt-icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = burn ? '✦' : '☀';
const label = document.createElement('span');
label.className = 'kkpt-text';
const title = document.createElement('span');
title.textContent = target.toUpperCase();
const sub = document.createElement('span');
sub.className = 'kkpt-sub';
sub.textContent = burn ? 'BURN THE WORLD' : 'RETURN TO THE SKY';
label.append(title, sub);
button.append(icon, label);
document.body.append(button);
// Preload starts right now, not on click.
createPreviewEarly();
button.addEventListener('click', event => {
if (event.defaultPrevented || event.button !== 0 || event.ctrlKey ||
event.metaKey || event.shiftKey || event.altKey) return;
if (button.dataset.kkptFallback === '1') return; // normal navigation
event.preventDefault();
if (busy) return;
busy = true;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
window.location.assign(targetUrl);
return;
}
begin().catch(err => {
console.error('[KuranokaPageTransition] V3 error:', err);
// Unexpected errors go to the real page, never a blank cover.
window.location.assign(targetUrl);
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', makeButton, { once: true });
} else {
makeButton();
}
// Back/forward cache can revive a page with the iframe still fully open.
// Restore the original scene when users return with the Back button.
window.addEventListener('pageshow', event => {
if (!event.persisted) return;
document.getElementById('kkpt-canvas')?.remove();
if (iframe) iframe.style.clipPath = startClip;
if (button) {
button.classList.remove('kkpt-vanish', 'kkpt-running', 'kkpt-loading');
const subtitle = button.querySelector('.kkpt-sub');
if (subtitle) subtitle.textContent = burn ? 'BURN THE WORLD' : 'RETURN TO THE SKY';
}
busy = false;
});
}());