편집 요약 없음 |
편집 요약 없음 |
||
| 57번째 줄: | 57번째 줄: | ||
var maskWidth = 0; | var maskWidth = 0; | ||
var maskHeight = 0; | var maskHeight = 0; | ||
var precomputedMasks = []; | |||
var frameSequence = []; | |||
var frameIndex = 0; | |||
var frameCount = prefersReducedMotion ? 1 : 24; | |||
var cycleTime = 36000; | |||
if (!plateCtx || !frameCtx || !maskCtx) return; | if (!plateCtx || !frameCtx || !maskCtx) return; | ||
| 129번째 줄: | 134번째 줄: | ||
plateCtx.globalAlpha = 1; | plateCtx.globalAlpha = 1; | ||
} | |||
function buildFrameSequence() { | |||
frameSequence = []; | |||
for (var i = 0; i < frameCount; i++) { | |||
frameSequence.push(i); | |||
} | |||
for (var j = frameCount - 2; j > 0; j--) { | |||
frameSequence.push(j); | |||
} | |||
if (!frameSequence.length) frameSequence.push(0); | |||
} | |||
function buildMaskFrame(fakeNow) { | |||
var target = document.createElement('canvas'); | |||
var targetCtx = target.getContext('2d', { alpha: true }); | |||
var image; | |||
var data; | |||
var p = 0; | |||
target.width = maskWidth; | |||
target.height = maskHeight; | |||
if (!targetCtx) return target; | |||
targetCtx.imageSmoothingEnabled = true; | |||
targetCtx.imageSmoothingQuality = 'low'; | |||
image = targetCtx.createImageData(maskWidth, maskHeight); | |||
data = image.data; | |||
for (var y = 0; y < maskHeight; y++) { | |||
var ny = maskHeight > 1 ? y / (maskHeight - 1) : 0; | |||
for (var x = 0; x < maskWidth; x++) { | |||
var nx = maskWidth > 1 ? x / (maskWidth - 1) : 0; | |||
var alpha = getFlowAlpha(nx, ny, fakeNow); | |||
var value = Math.round(clamp(alpha, 0.025, 0.96) * 255); | |||
data[p] = 255; | |||
data[p + 1] = 255; | |||
data[p + 2] = 255; | |||
data[p + 3] = value; | |||
p += 4; | |||
} | |||
} | |||
targetCtx.putImageData(image, 0, 0); | |||
return target; | |||
} | } | ||
| 144번째 줄: | 200번째 줄: | ||
maskCtx.imageSmoothingEnabled = true; | maskCtx.imageSmoothingEnabled = true; | ||
maskCtx.imageSmoothingQuality = 'low'; | maskCtx.imageSmoothingQuality = 'low'; | ||
maskImage = | maskImage = null; | ||
maskData = null; | |||
precomputedMasks = []; | |||
buildFrameSequence(); | |||
for (var i = 0; i < frameCount; i++) { | |||
precomputedMasks.push(buildMaskFrame((i / frameCount) * cycleTime)); | |||
} | |||
frameIndex = 0; | |||
} | } | ||
| 167번째 줄: | 232번째 줄: | ||
function getFlowAlpha(nx, ny, now) { | function getFlowAlpha(nx, ny, now) { | ||
/* | |||
v44 flow-field mask: | |||
- The halftone plate remains fixed. | |||
- Only this low-resolution brightness field breathes and travels. | |||
- Time is used as phase inside the imaginary cloth/net field, | |||
so the visible movement is brightness flow, not dot movement. | |||
*/ | |||
var flow = now * 0.000060; | |||
var slowFlow = now * 0.000032; | |||
var breath = now * 0.000085; | |||
var centerLine = 0.50 + | var centerLine = 0.50 + | ||
(Math.sin(((ny * 1.32) + 0.08) * TWO_PI) * 0.070) + | (Math.sin(((ny * 1.32) + 0.08 + (slowFlow * 0.45)) * TWO_PI) * 0.070) + | ||
(Math.sin(((ny * 3.18) + 0.34) * TWO_PI) * 0.030); | (Math.sin(((ny * 3.18) + 0.34 - (slowFlow * 0.28)) * TWO_PI) * 0.030); | ||
var u = nx - centerLine; | var u = nx - centerLine; | ||
| 176번째 줄: | 252번째 줄: | ||
var valley = gaussian(u, 0.150); | var valley = gaussian(u, 0.150); | ||
var leftRibbonCenter = -0.28 + (Math.sin((ny * 3.20 + 0.12) * TWO_PI) * 0.050); | var ribbonPulse = Math.sin((breath * 1.20) * TWO_PI) * 0.030; | ||
var rightRibbonCenter = 0.27 + (Math.sin((ny * 2.85 + 0.56) * TWO_PI) * 0.055); | var leftRibbonCenter = -0.28 + ribbonPulse + (Math.sin((ny * 3.20 + 0.12 + (slowFlow * 0.35)) * TWO_PI) * 0.050); | ||
var rightRibbonCenter = 0.27 - ribbonPulse + (Math.sin((ny * 2.85 + 0.56 - (slowFlow * 0.30)) * TWO_PI) * 0.055); | |||
var leftRibbon = gaussian(u - leftRibbonCenter, 0.105); | var leftRibbon = gaussian(u - leftRibbonCenter, 0.105); | ||
var rightRibbon = gaussian(u - rightRibbonCenter, 0.110); | var rightRibbon = gaussian(u - rightRibbonCenter, 0.110); | ||
var foldedU = u + | var foldedU = u + | ||
(Math.sin((ny * 4.40 + 0.22) * TWO_PI) * 0.050 * (0.3 + sideLift)) + | (Math.sin((ny * 4.40 + 0.22 + (flow * 0.34)) * TWO_PI) * 0.050 * (0.3 + sideLift)) + | ||
(Math.sin((ny * 7.20 + nx * 1.10) * TWO_PI) * 0.022); | (Math.sin((ny * 7.20 + nx * 1.10 - (flow * 0.22)) * TWO_PI) * 0.022); | ||
var verticalFold = Math.pow(0.5 + (0.5 * Math.cos(((foldedU * 3.05) + (Math.sin(ny | var verticalFold = Math.pow( | ||
var diagonalFold = Math.pow(0.5 + (0.5 * Math.cos(((foldedU * 1.80) - (ny * 1.12) + 0.18) * TWO_PI)), 2.1); | 0.5 + (0.5 * Math.cos(((foldedU * 3.05) + (Math.sin((ny * 2.35 - (flow * 0.80)) * TWO_PI) * 0.18) + (flow * 1.05)) * TWO_PI)), | ||
var waist = gaussian(ny - 0.50, 0.25) * gaussian(absU - 0.20, 0.19); | 2.5 | ||
); | |||
var diagonalFold = Math.pow( | |||
0.5 + (0.5 * Math.cos(((foldedU * 1.80) - (ny * 1.12) + 0.18 - (flow * 1.10)) * TWO_PI)), | |||
2.1 | |||
); | |||
var counterFold = Math.pow( | |||
0.5 + (0.5 * Math.cos(((foldedU * 2.35) + (ny * 0.92) - 0.10 + (flow * 0.72)) * TWO_PI)), | |||
2.25 | |||
); | |||
var waist = gaussian(ny - (0.50 + (Math.sin(flow * TWO_PI * 0.75) * 0.045)), 0.25) * gaussian(absU - 0.20, 0.19); | |||
var noise = hashNoise(Math.floor(nx * 997), Math.floor(ny * 997)); | var noise = hashNoise(Math.floor(nx * 997), Math.floor(ny * 997)); | ||
var grain = (noise - 0.5) * 0. | var grain = (noise - 0.5) * 0.035; | ||
var | var staticField = | ||
0. | 0.045 + | ||
(sideLift * 0. | (sideLift * 0.155) + | ||
((leftRibbon + rightRibbon) * 0. | ((leftRibbon + rightRibbon) * 0.120) + | ||
(verticalFold * (0. | (verticalFold * (0.035 + (sideLift * 0.075))) + | ||
(diagonalFold * 0. | (diagonalFold * 0.030) + | ||
(waist * 0. | (counterFold * 0.026) + | ||
(valley * 0. | (waist * 0.045) - | ||
(valley * 0.110) + | |||
grain; | grain; | ||
var streamA = Math.sin(((ny * 1.55) + (foldedU * 0.95) - (flow * 1.45)) * TWO_PI); | |||
var streamB = Math.sin(((ny * 2.30) - (foldedU * 1.15) + (flow * 1.10) + 0.27) * TWO_PI); | |||
var streamC = Math.sin(((ny * 3.65) + (absU * 1.20) - (flow * 0.86) + 0.42) * TWO_PI); | |||
var sideBreath = Math.sin((breath + (sideLift * 0.34) + (ny * 0.18)) * TWO_PI); | |||
var dynamicField = | |||
((leftRibbon + rightRibbon) * (0.125 + (0.065 * streamA))) + | |||
(verticalFold * (0.115 * (0.50 + (0.50 * streamB))) * (0.55 + sideLift)) + | |||
(diagonalFold * (0.075 * (0.50 + (0.50 * streamC)))) + | |||
(counterFold * 0.060 * (0.50 + (0.50 * sideBreath))) + | |||
(waist * 0.080 * (0.55 + (0.45 * Math.sin((breath * 0.80 - absU * 0.30) * TWO_PI)))); | |||
var alpha = staticField + dynamicField; | |||
/* Raise contrast so the flow remains visible after the low-res mask is scaled. */ | |||
alpha = 0.040 + ((alpha - 0.115) * 1.42) + (0.050 * Math.sin((breath * 0.52 + ny * 0.22) * TWO_PI)); | |||
return clamp(alpha, 0.018, 0.98); | |||
} | |||
function getCurrentMask() { | |||
if (!precomputedMasks.length) return maskCanvas; | |||
var index = frameSequence.length | |||
? frameSequence[frameIndex % frameSequence.length] | |||
: 0; | |||
return precomputedMasks[index] || precomputedMasks[0] || maskCanvas; | |||
} | } | ||
function | function drawFrame(maskFrame) { | ||
if (! | if (!maskFrame) return; | ||
ctx.clearRect(0, 0, width, height); | ctx.clearRect(0, 0, width, height); | ||
| 259번째 줄: | 339번째 줄: | ||
frameCtx.drawImage(plateCanvas, 0, 0); | frameCtx.drawImage(plateCanvas, 0, 0); | ||
frameCtx.globalCompositeOperation = 'destination-in'; | frameCtx.globalCompositeOperation = 'destination-in'; | ||
frameCtx.drawImage( | frameCtx.drawImage(maskFrame, 0, 0, maskWidth, maskHeight, 0, 0, width, height); | ||
frameCtx.globalCompositeOperation = 'source-over'; | frameCtx.globalCompositeOperation = 'source-over'; | ||
| 265번째 줄: | 345번째 줄: | ||
ctx.drawImage(frameCanvas, 0, 0); | ctx.drawImage(frameCanvas, 0, 0); | ||
ctx.globalAlpha = 1; | ctx.globalAlpha = 1; | ||
} | |||
function draw(now) { | |||
if (!running) { | |||
raf = null; | |||
return; | |||
} | |||
if (now - lastFrame < frameInterval) { | |||
raf = requestAnimationFrame(draw); | |||
return; | |||
} | |||
lastFrame = now; | |||
drawFrame(getCurrentMask()); | |||
frameIndex = (frameIndex + 1) % Math.max(1, frameSequence.length || precomputedMasks.length || 1); | |||
if (!prefersReducedMotion) { | if (!prefersReducedMotion) { | ||
raf = requestAnimationFrame(draw); | raf = requestAnimationFrame(draw); | ||
} else { | |||
raf = null; | |||
} | } | ||
} | } | ||
| 274번째 줄: | 372번째 줄: | ||
rebuildCanvases(); | rebuildCanvases(); | ||
lastFrame = 0; | lastFrame = 0; | ||
if (!raf) raf = requestAnimationFrame(draw); | drawFrame(getCurrentMask()); | ||
if (!raf && !prefersReducedMotion) { | |||
raf = requestAnimationFrame(draw); | |||
} | |||
} | } | ||
| 285번째 줄: | 387번째 줄: | ||
document.addEventListener('visibilitychange', function () { | document.addEventListener('visibilitychange', function () { | ||
running = !document.hidden; | running = !document.hidden; | ||
if (running && !raf) { | |||
if (!running && raf) { | |||
cancelAnimationFrame(raf); | |||
raf = null; | |||
return; | |||
} | |||
if (running && !raf && !prefersReducedMotion) { | |||
lastFrame = 0; | lastFrame = 0; | ||
raf = requestAnimationFrame(draw); | raf = requestAnimationFrame(draw); | ||
| 292번째 줄: | 401번째 줄: | ||
rebuildCanvases(); | rebuildCanvases(); | ||
raf = requestAnimationFrame(draw); | drawFrame(getCurrentMask()); | ||
if (!prefersReducedMotion) { | |||
raf = requestAnimationFrame(draw); | |||
} | |||
} | } | ||
2026년 5월 24일 (일) 16:01 판
mw.loader.load('https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js');
mw.loader.load('/index.php?title=MediaWiki:DevTools.js&action=raw&ctype=text/javascript');
mw.loader.load('/index.php?title=MediaWiki:CategoryNav.js&action=raw&ctype=text/javascript');
function loadLangScript(done) {
$.getScript('/index.php?title=미디어위키:Lang.js&action=raw&ctype=text/javascript')
.done(function() {
if (typeof done === 'function') done();
})
.fail(function(a, b, c) {
console.error('Lang.js load failed:', b, c);
if (typeof done === 'function') done();
});
}
function initHalftoneBackground() {
var canvasId = 'site-halftone-bg';
var existing = document.getElementById(canvasId);
var canvas = existing || document.createElement('canvas');
if (!existing) {
canvas.id = canvasId;
canvas.setAttribute('aria-hidden', 'true');
document.body.insertBefore(canvas, document.body.firstChild || null);
}
var ctx = canvas.getContext && canvas.getContext('2d', { alpha: true });
if (!ctx) return;
var prefersReducedMotion = false;
if (window.matchMedia) {
try {
prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
} catch (err) {}
}
var viewportWidth = 0;
var viewportHeight = 0;
var width = 0;
var height = 0;
var renderScale = 0.58;
var raf = null;
var lastFrame = 0;
var running = true;
var frameInterval = prefersReducedMotion ? 1000 : 140;
var TWO_PI = Math.PI * 2;
var plateCanvas = document.createElement('canvas');
var plateCtx = plateCanvas.getContext('2d', { alpha: true });
var frameCanvas = document.createElement('canvas');
var frameCtx = frameCanvas.getContext('2d', { alpha: true });
var maskCanvas = document.createElement('canvas');
var maskCtx = maskCanvas.getContext('2d', { alpha: true });
var maskImage = null;
var maskData = null;
var maskWidth = 0;
var maskHeight = 0;
var precomputedMasks = [];
var frameSequence = [];
var frameIndex = 0;
var frameCount = prefersReducedMotion ? 1 : 24;
var cycleTime = 36000;
if (!plateCtx || !frameCtx || !maskCtx) return;
var pulses = [
{ speed: 0.00030 },
{ speed: 0.00041 },
{ speed: 0.00053 },
{ speed: 0.00067 },
{ speed: 0.00079 }
];
function hashNoise(x, y) {
var n = Math.sin((x * 12.9898) + (y * 78.233)) * 43758.5453;
return n - Math.floor(n);
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function smoothstep(edge0, edge1, value) {
var t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - (2 * t));
}
function gaussian(value, radius) {
return Math.exp(-((value * value) / Math.max(0.0001, radius * radius)));
}
function chooseRenderScale(w) {
if (prefersReducedMotion) return 0.48;
if (w >= 2800) return 0.50;
if (w >= 2200) return 0.54;
return 0.58;
}
function resizeCanvas(target, targetCtx, w, h) {
target.width = w;
target.height = h;
if (targetCtx) {
targetCtx.imageSmoothingEnabled = true;
targetCtx.imageSmoothingQuality = 'low';
}
}
function rebuildPlate() {
plateCtx.clearRect(0, 0, width, height);
plateCtx.fillStyle = '#e2e2e2';
/*
Keep the optimized internal render resolution, but preserve the original
on-screen halftone proportions from the full-resolution version.
The canvas is scaled up by CSS, so spacing and dot size are multiplied
by renderScale before drawing.
*/
var targetSpacing = viewportWidth > 2600 ? 6 : 5;
var targetDotSize = viewportWidth > 2600 ? 1.18 : 1.08;
var spacing = targetSpacing * renderScale;
var dotSize = targetDotSize * renderScale;
var startX = 1 * renderScale;
var startY = 1 * renderScale;
for (var y = startY; y < height + spacing; y += spacing) {
for (var x = startX; x < width + spacing; x += spacing) {
var noise = hashNoise(Math.floor(x * 1.7), Math.floor(y * 1.7));
var size = dotSize + (noise * 0.14);
plateCtx.globalAlpha = 0.88 + (noise * 0.12);
plateCtx.fillRect(x, y, size, size);
}
}
plateCtx.globalAlpha = 1;
}
function buildFrameSequence() {
frameSequence = [];
for (var i = 0; i < frameCount; i++) {
frameSequence.push(i);
}
for (var j = frameCount - 2; j > 0; j--) {
frameSequence.push(j);
}
if (!frameSequence.length) frameSequence.push(0);
}
function buildMaskFrame(fakeNow) {
var target = document.createElement('canvas');
var targetCtx = target.getContext('2d', { alpha: true });
var image;
var data;
var p = 0;
target.width = maskWidth;
target.height = maskHeight;
if (!targetCtx) return target;
targetCtx.imageSmoothingEnabled = true;
targetCtx.imageSmoothingQuality = 'low';
image = targetCtx.createImageData(maskWidth, maskHeight);
data = image.data;
for (var y = 0; y < maskHeight; y++) {
var ny = maskHeight > 1 ? y / (maskHeight - 1) : 0;
for (var x = 0; x < maskWidth; x++) {
var nx = maskWidth > 1 ? x / (maskWidth - 1) : 0;
var alpha = getFlowAlpha(nx, ny, fakeNow);
var value = Math.round(clamp(alpha, 0.025, 0.96) * 255);
data[p] = 255;
data[p + 1] = 255;
data[p + 2] = 255;
data[p + 3] = value;
p += 4;
}
}
targetCtx.putImageData(image, 0, 0);
return target;
}
function resizeMask() {
maskWidth = Math.max(110, Math.ceil(width * 0.16));
maskHeight = Math.max(64, Math.ceil(height * 0.16));
if (viewportWidth >= 2400) {
maskWidth = Math.max(128, Math.ceil(width * 0.13));
maskHeight = Math.max(72, Math.ceil(height * 0.13));
}
maskCanvas.width = maskWidth;
maskCanvas.height = maskHeight;
maskCtx.imageSmoothingEnabled = true;
maskCtx.imageSmoothingQuality = 'low';
maskImage = null;
maskData = null;
precomputedMasks = [];
buildFrameSequence();
for (var i = 0; i < frameCount; i++) {
precomputedMasks.push(buildMaskFrame((i / frameCount) * cycleTime));
}
frameIndex = 0;
}
function rebuildCanvases() {
viewportWidth = Math.max(1, Math.ceil(window.innerWidth || document.documentElement.clientWidth || 1));
viewportHeight = Math.max(1, Math.ceil(window.innerHeight || document.documentElement.clientHeight || 1));
renderScale = chooseRenderScale(viewportWidth);
width = Math.max(1, Math.ceil(viewportWidth * renderScale));
height = Math.max(1, Math.ceil(viewportHeight * renderScale));
resizeCanvas(canvas, ctx, width, height);
canvas.style.width = viewportWidth + 'px';
canvas.style.height = viewportHeight + 'px';
resizeCanvas(plateCanvas, plateCtx, width, height);
resizeCanvas(frameCanvas, frameCtx, width, height);
resizeMask();
rebuildPlate();
}
function getFlowAlpha(nx, ny, now) {
/*
v44 flow-field mask:
- The halftone plate remains fixed.
- Only this low-resolution brightness field breathes and travels.
- Time is used as phase inside the imaginary cloth/net field,
so the visible movement is brightness flow, not dot movement.
*/
var flow = now * 0.000060;
var slowFlow = now * 0.000032;
var breath = now * 0.000085;
var centerLine = 0.50 +
(Math.sin(((ny * 1.32) + 0.08 + (slowFlow * 0.45)) * TWO_PI) * 0.070) +
(Math.sin(((ny * 3.18) + 0.34 - (slowFlow * 0.28)) * TWO_PI) * 0.030);
var u = nx - centerLine;
var absU = Math.abs(u);
var sideLift = smoothstep(0.065, 0.44, absU);
var valley = gaussian(u, 0.150);
var ribbonPulse = Math.sin((breath * 1.20) * TWO_PI) * 0.030;
var leftRibbonCenter = -0.28 + ribbonPulse + (Math.sin((ny * 3.20 + 0.12 + (slowFlow * 0.35)) * TWO_PI) * 0.050);
var rightRibbonCenter = 0.27 - ribbonPulse + (Math.sin((ny * 2.85 + 0.56 - (slowFlow * 0.30)) * TWO_PI) * 0.055);
var leftRibbon = gaussian(u - leftRibbonCenter, 0.105);
var rightRibbon = gaussian(u - rightRibbonCenter, 0.110);
var foldedU = u +
(Math.sin((ny * 4.40 + 0.22 + (flow * 0.34)) * TWO_PI) * 0.050 * (0.3 + sideLift)) +
(Math.sin((ny * 7.20 + nx * 1.10 - (flow * 0.22)) * TWO_PI) * 0.022);
var verticalFold = Math.pow(
0.5 + (0.5 * Math.cos(((foldedU * 3.05) + (Math.sin((ny * 2.35 - (flow * 0.80)) * TWO_PI) * 0.18) + (flow * 1.05)) * TWO_PI)),
2.5
);
var diagonalFold = Math.pow(
0.5 + (0.5 * Math.cos(((foldedU * 1.80) - (ny * 1.12) + 0.18 - (flow * 1.10)) * TWO_PI)),
2.1
);
var counterFold = Math.pow(
0.5 + (0.5 * Math.cos(((foldedU * 2.35) + (ny * 0.92) - 0.10 + (flow * 0.72)) * TWO_PI)),
2.25
);
var waist = gaussian(ny - (0.50 + (Math.sin(flow * TWO_PI * 0.75) * 0.045)), 0.25) * gaussian(absU - 0.20, 0.19);
var noise = hashNoise(Math.floor(nx * 997), Math.floor(ny * 997));
var grain = (noise - 0.5) * 0.035;
var staticField =
0.045 +
(sideLift * 0.155) +
((leftRibbon + rightRibbon) * 0.120) +
(verticalFold * (0.035 + (sideLift * 0.075))) +
(diagonalFold * 0.030) +
(counterFold * 0.026) +
(waist * 0.045) -
(valley * 0.110) +
grain;
var streamA = Math.sin(((ny * 1.55) + (foldedU * 0.95) - (flow * 1.45)) * TWO_PI);
var streamB = Math.sin(((ny * 2.30) - (foldedU * 1.15) + (flow * 1.10) + 0.27) * TWO_PI);
var streamC = Math.sin(((ny * 3.65) + (absU * 1.20) - (flow * 0.86) + 0.42) * TWO_PI);
var sideBreath = Math.sin((breath + (sideLift * 0.34) + (ny * 0.18)) * TWO_PI);
var dynamicField =
((leftRibbon + rightRibbon) * (0.125 + (0.065 * streamA))) +
(verticalFold * (0.115 * (0.50 + (0.50 * streamB))) * (0.55 + sideLift)) +
(diagonalFold * (0.075 * (0.50 + (0.50 * streamC)))) +
(counterFold * 0.060 * (0.50 + (0.50 * sideBreath))) +
(waist * 0.080 * (0.55 + (0.45 * Math.sin((breath * 0.80 - absU * 0.30) * TWO_PI))));
var alpha = staticField + dynamicField;
/* Raise contrast so the flow remains visible after the low-res mask is scaled. */
alpha = 0.040 + ((alpha - 0.115) * 1.42) + (0.050 * Math.sin((breath * 0.52 + ny * 0.22) * TWO_PI));
return clamp(alpha, 0.018, 0.98);
}
function getCurrentMask() {
if (!precomputedMasks.length) return maskCanvas;
var index = frameSequence.length
? frameSequence[frameIndex % frameSequence.length]
: 0;
return precomputedMasks[index] || precomputedMasks[0] || maskCanvas;
}
function drawFrame(maskFrame) {
if (!maskFrame) return;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, width, height);
/* Faint static plate so the halftone field never disappears completely. */
ctx.globalAlpha = 0.075;
ctx.drawImage(plateCanvas, 0, 0);
ctx.globalAlpha = 1;
frameCtx.clearRect(0, 0, width, height);
frameCtx.globalCompositeOperation = 'source-over';
frameCtx.globalAlpha = 1;
frameCtx.drawImage(plateCanvas, 0, 0);
frameCtx.globalCompositeOperation = 'destination-in';
frameCtx.drawImage(maskFrame, 0, 0, maskWidth, maskHeight, 0, 0, width, height);
frameCtx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.98;
ctx.drawImage(frameCanvas, 0, 0);
ctx.globalAlpha = 1;
}
function draw(now) {
if (!running) {
raf = null;
return;
}
if (now - lastFrame < frameInterval) {
raf = requestAnimationFrame(draw);
return;
}
lastFrame = now;
drawFrame(getCurrentMask());
frameIndex = (frameIndex + 1) % Math.max(1, frameSequence.length || precomputedMasks.length || 1);
if (!prefersReducedMotion) {
raf = requestAnimationFrame(draw);
} else {
raf = null;
}
}
function resize() {
rebuildCanvases();
lastFrame = 0;
drawFrame(getCurrentMask());
if (!raf && !prefersReducedMotion) {
raf = requestAnimationFrame(draw);
}
}
var resizeTimer = null;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(resize, 160);
});
document.addEventListener('visibilitychange', function () {
running = !document.hidden;
if (!running && raf) {
cancelAnimationFrame(raf);
raf = null;
return;
}
if (running && !raf && !prefersReducedMotion) {
lastFrame = 0;
raf = requestAnimationFrame(draw);
}
});
rebuildCanvases();
drawFrame(getCurrentMask());
if (!prefersReducedMotion) {
raf = requestAnimationFrame(draw);
}
}
var CLBI_SVG_BELL = '<svg class="profile-svg profile-svg-bell" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>';
var CLBI_SVG_BELL_DOT = '<svg class="profile-svg profile-svg-bell-dot" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M11.68 2.009A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673c-.824-.85-1.678-1.731-2.21-3.348"/><circle cx="18" cy="5" r="3"/></svg>';
var CLBI_SVG_LIST = '<svg class="profile-svg profile-svg-list" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>';
var CLBI_SVG_LANGUAGES = '<svg class="profile-svg profile-svg-languages" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>';
var CLBI_SVG_POWER = '<svg class="profile-svg profile-svg-power" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/></svg>';
var CLBI_SVG_SETTINGS = '<svg class="profile-svg profile-svg-settings" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>';
var CLBI_SVG_SCAN_TEXT = '<svg class="profile-svg profile-svg-scan-text" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M7 8h8"/><path d="M7 12h10"/><path d="M7 16h6"/></svg>';
var CLBI_SVG_SCAN_EYE = '<svg class="profile-svg profile-svg-scan-eye" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="12" cy="12" r="1"/><path d="M18.944 12.33a1 1 0 0 0 0-.66 7.5 7.5 0 0 0-13.888 0 1 1 0 0 0 0 .66 7.5 7.5 0 0 0 13.888 0"/></svg>';
var CLBI_SVG_NEWSPAPER = '<svg class="profile-svg profile-svg-newspaper" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18h-5"/><path d="M18 14h-8"/><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/><rect width="8" height="4" x="10" y="6" rx="1"/></svg>';
var CLBI_SVG_TROPHY = '<svg class="profile-svg profile-svg-trophy" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 14.66v1.626a2 2 0 0 1-.976 1.696A5 5 0 0 0 7 21.978"/><path d="M14 14.66v1.626a2 2 0 0 0 .976 1.696A5 5 0 0 1 17 21.978"/><path d="M18 9h1.5a1 1 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M6 9a6 6 0 0 0 12 0V3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1z"/><path d="M6 9H4.5a1 1 0 0 1 0-5H6"/></svg>';
var CLBI_SVG_PACKAGE = '<svg class="profile-svg profile-svg-package" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v6"/><path d="M16.76 3a2 2 0 0 1 1.8 1.1l2.23 4.479a2 2 0 0 1 .21.891V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9.472a2 2 0 0 1 .211-.894L5.45 4.1A2 2 0 0 1 7.24 3z"/><path d="M3.054 9.013h17.893"/></svg>';
var PROFILE_RENDER_TOKEN = 0;
function invalidateProfileRender() {
PROFILE_RENDER_TOKEN++;
}
$(function() {
initHalftoneBackground();
// ── 상단 네비게이션 바 ──
var navHtml =
'<div id="clbi-top-nav-wrap">' +
'<div id="clbi-top-nav">' +
'<div id="clbi-top-nav-main">' +
'<div id="clbi-top-nav-tabs">' +
'<a class="clbi-top-nav-item" href="/index.php/대문">' +
'<img class="clbi-tnav-icon" src="/index.php?title=특수:Redirect/file/Ic-main-menu-001.png" alt="">' +
'<span class="clbi-tnav-label">메인 메뉴</span>' +
'</a>' +
'<a class="clbi-top-nav-item" href="/index.php/프로젝트:소개">' +
'<img class="clbi-tnav-icon" src="/index.php?title=특수:Redirect/file/Ic-project-001.png" alt="">' +
'<span class="clbi-tnav-label">프로젝트</span>' +
'</a>' +
'<div class="clbi-top-nav-item" id="clbi-tnav-worldbuilding">' +
'<img class="clbi-tnav-icon" src="/index.php?title=특수:Redirect/file/Ic-worldbuilding-001.png" alt="">' +
'<span class="clbi-tnav-label">세계관</span>' +
'<span class="clbi-tnav-arrow">▾</span>' +
'</div>' +
'</div>' +
'<div id="clbi-top-nav-search">' +
'<input type="text" id="clbi-top-search-input" placeholder="검색...">' +
'</div>' +
'<div id="clbi-top-nav-right">' +
'<div class="clbi-top-nav-item" id="clbi-tnav-info">' +
'<span class="clbi-tnav-label">ℹ</span>' +
'</div>' +
'</div>' +
'</div>' +
'<div id="clbi-sub-worldbuilding">' +
'<div id="clbi-sub-worldbuilding-inner">' +
'<div class="clbi-tnav-sub-list">' +
'<a class="clbi-tnav-sub-item" href="/index.php/역사적_사건">역사적 사건</a>' +
'<a class="clbi-tnav-sub-item" href="/index.php/설정">설정</a>' +
'<a class="clbi-tnav-sub-item" href="/index.php/국가_및_조합">국가 및 조합</a>' +
'<a class="clbi-tnav-sub-item" href="/index.php/기업_및_공동체">기업 및 공동체</a>' +
'<a class="clbi-tnav-sub-item" href="/index.php/군_정치집단">군, 정치집단</a>' +
'<a class="clbi-tnav-sub-item" href="/index.php/인물">인물</a>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
$('.content-wrapper').before(navHtml);
$('#clbi-tnav-worldbuilding').on('click', function() {
var $menu = $('#clbi-sub-worldbuilding');
var $btn = $(this);
$menu.toggleClass('worldbuilding-open');
$btn.toggleClass('clbi-tnav-active', $menu.hasClass('worldbuilding-open'));
});
$('#clbi-top-search-input').on('keydown', function(e) {
if (e.key === 'Enter') {
var q = $(this).val().trim();
if (q) window.location.href = '/index.php?search=' + encodeURIComponent(q);
}
});
});
// 페이지 전환 사운드
var transitionSound = new Audio('/index.php?title=특수:Redirect/file/Sfx-ui-001.mp3');
(function() {
var master = parseFloat(localStorage.getItem('clbi-audio-master') || 80) / 100;
var sfx = parseFloat(localStorage.getItem('clbi-audio-sfx') || 60) / 100;
var sfxOn = localStorage.getItem('clbi-audio-sfxOn') !== 'false';
transitionSound.volume = sfxOn ? master * sfx : 0;
})();
function playStaticSound() {
var master = parseFloat(localStorage.getItem('clbi-audio-master') || 80) / 100;
var sfx = parseFloat(localStorage.getItem('clbi-audio-sfx') || 60) / 100;
var sfxOn = localStorage.getItem('clbi-audio-sfxOn') !== 'false';
if (!sfxOn) return;
transitionSound.volume = master * sfx;
transitionSound.currentTime = 0;
transitionSound.play();
}
// 현재 언어 감지
function getCurrentLang() {
var langData = document.getElementById('clbi-lang-data');
return langData ? (langData.getAttribute('data-lang') || 'ko') : 'ko';
}
function normalizePageName(value) {
return String(value || '')
.split('?')[0]
.replace(/^\/index\.php\//, '')
.replace(/_/g, ' ')
.trim();
}
function buildWikiPath(title) {
return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_'));
}
function getLangShortCode(lang) {
var map = { ko: 'KR', en: 'EN', zh: 'ZH', ja: 'JA', ru: 'RU', es: 'ES' };
return map[lang] || String(lang || '').toUpperCase();
}
function getLanguageTargetTitle(lang) {
var data = document.getElementById('clbi-lang-data');
if (!data || !lang) return '';
var keys = [
'data-' + lang,
'data-page-' + lang,
'data-title-' + lang,
'data-target-' + lang,
'data-lang-' + lang
];
for (var i = 0; i < keys.length; i++) {
var value = data.getAttribute(keys[i]);
if (value) return value;
}
return '';
}
var SIDEBAR_LANG_SVG_NS = 'http://www.w3.org/2000/svg';
var SIDEBAR_LANGUAGE_STATUS_TITLE = 'MediaWiki:LanguageStatus.json';
var SIDEBAR_LANGUAGE_LABELS = {
ko: '한국어',
en: 'English',
zh: '中文',
ja: '日本語',
ru: 'Русский',
es: 'Español'
};
var SIDEBAR_LANGUAGE_DIAL_LABELS = {
ko: '한국어',
en: 'ENG',
zh: '中文',
ja: '日本語',
ru: 'РУС',
es: 'ESP'
};
var SIDEBAR_LANGUAGE_STATUS_VALUES = {
available: true,
wip: true,
unavailable: true
};
var sidebarLanguageStatusRegistry = {};
var sidebarLanguageStatusLoaded = false;
var sidebarLanguageStatusLoading = false;
var sidebarLanguageStatusCallbacks = [];
var sidebarLanguageState = {
order: ['ko', 'en', 'zh', 'ja', 'ru', 'es'],
currentLang: 'ko',
baseIndex: 0,
selectedIndex: 0,
rotation: 0,
dragging: false,
dragMoved: false,
dragStartX: 0,
dragStartY: 0,
dragStartRotation: 0,
dragAxis: null,
pointerCaptured: false,
lastX: 0,
lastTime: 0,
releaseVelocity: 0,
suppressClickUntil: 0,
raf: null,
pendingRotation: 0,
snapTimer: null,
inertiaRaf: null,
navigateTimer: null,
bound: false,
boundElement: null,
rotor: null,
cx: 101,
cy: 119,
outerR: 109,
innerR: 28,
sectorAngle: 30,
halfSector: 15,
repeats: 8,
dragSensitivity: 0.58,
maxSpinVelocity: 1.75,
minSpinVelocity: 0.055,
spinDecel: 0.00185
};
function createSidebarLanguageSvgEl(tag) {
return document.createElementNS(SIDEBAR_LANG_SVG_NS, tag);
}
function normalizeSidebarLanguageIndex(index) {
var length = sidebarLanguageState.order.length;
var normalized = index % length;
return normalized < 0 ? normalized + length : normalized;
}
function getSidebarLanguageName(lang) {
return SIDEBAR_LANGUAGE_LABELS[lang] || String(lang || '').toUpperCase();
}
function getSidebarLanguageDialName(lang) {
return SIDEBAR_LANGUAGE_DIAL_LABELS[lang] || getSidebarLanguageName(lang);
}
function normalizeSidebarLanguageStatusValue(value) {
value = String(value == null ? '' : value).toLowerCase().trim();
return SIDEBAR_LANGUAGE_STATUS_VALUES[value] ? value : '';
}
function getSidebarLanguageStatusPageKey() {
var raw = String(mw.config.get('wgPageName') || '').trim();
var normalized = normalizePageName(raw);
return normalized || raw || '대문';
}
function getSidebarLanguageStatusEntry() {
var registry = sidebarLanguageStatusRegistry || {};
var pages = registry.pages && typeof registry.pages === 'object' ? registry.pages : registry;
var raw = String(mw.config.get('wgPageName') || '').trim();
var normalized = normalizePageName(raw);
var title = String(mw.config.get('wgTitle') || '').trim();
var keys = [
normalized,
raw,
raw.replace(/_/g, ' '),
normalized.replace(/ /g, '_'),
title,
title.replace(/_/g, ' ')
];
var i;
for (i = 0; i < keys.length; i += 1) {
if (keys[i] && pages[keys[i]] && typeof pages[keys[i]] === 'object') {
return pages[keys[i]];
}
}
return {};
}
function getSidebarLanguageStatusOverride(lang) {
var entry = getSidebarLanguageStatusEntry();
return normalizeSidebarLanguageStatusValue(entry[lang]);
}
function flushSidebarLanguageStatusCallbacks() {
var callbacks = sidebarLanguageStatusCallbacks.slice();
sidebarLanguageStatusCallbacks.length = 0;
callbacks.forEach(function(callback) {
if (typeof callback === 'function') {
callback(sidebarLanguageStatusRegistry);
}
});
}
function loadSidebarLanguageStatusRegistry(callback, force) {
if (typeof callback === 'function') {
sidebarLanguageStatusCallbacks.push(callback);
}
if (sidebarLanguageStatusLoaded && !force) {
flushSidebarLanguageStatusCallbacks();
return;
}
if (sidebarLanguageStatusLoading) return;
sidebarLanguageStatusLoading = true;
$.ajax({
url: mw.util.getUrl(SIDEBAR_LANGUAGE_STATUS_TITLE, {
action: 'raw',
ctype: 'application/json',
_: String(Date.now())
}),
dataType: 'text',
cache: false
}).done(function(text) {
var parsed = {};
try {
parsed = text ? JSON.parse(text) : {};
} catch (err) {
console.error('LanguageStatus.json parse failed:', err);
parsed = {};
}
sidebarLanguageStatusRegistry = parsed && typeof parsed === 'object' ? parsed : {};
}).fail(function() {
sidebarLanguageStatusRegistry = {};
}).always(function() {
sidebarLanguageStatusLoaded = true;
sidebarLanguageStatusLoading = false;
flushSidebarLanguageStatusCallbacks();
});
}
window.CLBI_LANGUAGE_STATUS = {
title: SIDEBAR_LANGUAGE_STATUS_TITLE,
languages: sidebarLanguageState.order.slice(),
labels: SIDEBAR_LANGUAGE_LABELS,
dialLabels: SIDEBAR_LANGUAGE_DIAL_LABELS,
getPageKey: getSidebarLanguageStatusPageKey,
getRegistry: function() {
return sidebarLanguageStatusRegistry || {};
},
reload: function(callback) {
sidebarLanguageStatusLoaded = false;
loadSidebarLanguageStatusRegistry(function() {
renderSidebarLanguageBox();
if (typeof callback === 'function') callback(sidebarLanguageStatusRegistry);
}, true);
},
refreshDial: function() {
renderSidebarLanguageBox();
}
};
function getSidebarLanguageMeta(lang) {
var currentLang = getCurrentLang();
var targetTitle = getLanguageTargetTitle(lang);
var isCurrent = lang === currentLang;
return {
lang: lang,
code: getLangShortCode(lang),
name: getSidebarLanguageName(lang),
dialName: getSidebarLanguageDialName(lang),
targetTitle: targetTitle,
isCurrent: isCurrent,
canMove: !!targetTitle && !isCurrent
};
}
function getSidebarLanguageStatus(meta) {
var override;
if (!meta) {
return {
className: 'is-locked',
label: 'UNAVAILABLE',
canApply: false
};
}
if (meta.isCurrent) {
return {
className: 'is-current',
label: 'CURRENT',
canApply: false
};
}
override = getSidebarLanguageStatusOverride(meta.lang);
if (override === 'wip') {
return {
className: 'is-locked',
label: 'WIP',
canApply: false
};
}
if (override === 'unavailable') {
return {
className: 'is-locked',
label: 'UNAVAILABLE',
canApply: false
};
}
if (override === 'available' || meta.targetTitle) {
return {
className: meta.targetTitle ? 'is-ready' : 'is-locked',
label: meta.targetTitle ? 'AVAILABLE' : 'UNAVAILABLE',
canApply: !!meta.targetTitle
};
}
return {
className: 'is-locked',
label: 'UNAVAILABLE',
canApply: false
};
}
function sidebarLanguageRad(deg) {
return (deg * Math.PI) / 180;
}
function sidebarLanguagePointAt(radius, deg) {
var state = sidebarLanguageState;
var angle = sidebarLanguageRad(deg);
return {
x: state.cx + Math.sin(angle) * radius,
y: state.cy - Math.cos(angle) * radius
};
}
function getSidebarLanguageSectorPath(start, end) {
var state = sidebarLanguageState;
var p1 = sidebarLanguagePointAt(state.outerR, start);
var p2 = sidebarLanguagePointAt(state.outerR, end);
var p3 = sidebarLanguagePointAt(state.innerR, end);
var p4 = sidebarLanguagePointAt(state.innerR, start);
var largeArc = Math.abs(end - start) > 180 ? 1 : 0;
return [
'M', p1.x.toFixed(3), p1.y.toFixed(3),
'A', state.outerR, state.outerR, 0, largeArc, 1, p2.x.toFixed(3), p2.y.toFixed(3),
'L', p3.x.toFixed(3), p3.y.toFixed(3),
'A', state.innerR, state.innerR, 0, largeArc, 0, p4.x.toFixed(3), p4.y.toFixed(3),
'Z'
].join(' ');
}
function getSidebarLanguageShellPath() {
return getSidebarLanguageSectorPath(-68, 68);
}
function getSidebarLanguageByStep(step) {
var state = sidebarLanguageState;
var index = normalizeSidebarLanguageIndex(state.baseIndex + step);
return {
index: index,
meta: getSidebarLanguageMeta(state.order[index])
};
}
function getSidebarLanguagePreviewIndex() {
var state = sidebarLanguageState;
var step = Math.round(-state.rotation / state.sectorAngle);
return normalizeSidebarLanguageIndex(state.baseIndex + step);
}
function getSidebarLanguagePreviewMeta() {
var state = sidebarLanguageState;
return getSidebarLanguageMeta(state.order[getSidebarLanguagePreviewIndex()]);
}
function makeSidebarLanguageSector(step) {
var state = sidebarLanguageState;
var item = getSidebarLanguageByStep(step);
var group = createSidebarLanguageSvgEl('g');
var path = createSidebarLanguageSvgEl('path');
var label = createSidebarLanguageSvgEl('text');
var labelY = state.cy - 78;
var angle = step * state.sectorAngle;
group.setAttribute('class', 'sidebar-lang-sector-group');
group.setAttribute('data-step', String(step));
group.setAttribute('data-index', String(item.index));
group.setAttribute('data-lang', item.meta.lang);
group.setAttribute('transform', 'rotate(' + angle + ' ' + state.cx + ' ' + state.cy + ')');
path.setAttribute('class', 'sidebar-lang-sector');
path.setAttribute('d', getSidebarLanguageSectorPath(-state.halfSector, state.halfSector));
label.setAttribute('class', 'sidebar-lang-sector-label');
label.setAttribute('x', String(state.cx));
label.setAttribute('y', String(labelY + 5));
label.textContent = item.meta.dialName || item.meta.name;
group.appendChild(path);
group.appendChild(label);
group.addEventListener('click', function(e) {
if (sidebarLanguageState.dragging || performance.now() < sidebarLanguageState.suppressClickUntil) return;
e.preventDefault();
e.stopPropagation();
cancelSidebarLanguageSpin();
snapSidebarLanguageToStep(parseInt(group.getAttribute('data-step') || '0', 10), true);
});
return group;
}
function renderSidebarLanguageWheel() {
var state = sidebarLanguageState;
var fan = document.getElementById('clbi-sidebar-lang-fan');
var svg;
var defs;
var clip;
var clipPath;
var shadowBlur;
var blur;
var fixedDepthGradient;
var shell;
var clipped;
var rotor;
var fixedDepthPath;
var fixedFocus;
var shadowSoft;
var shadowHard;
var rim;
var pointer;
var tri;
var line;
var step;
if (!fan) return;
fan.innerHTML = '';
svg = createSidebarLanguageSvgEl('svg');
svg.setAttribute('class', 'sidebar-lang-fan-svg');
svg.setAttribute('viewBox', '0 0 202 150');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
svg.setAttribute('role', 'img');
svg.setAttribute('aria-label', '언어 선택 다이얼');
defs = createSidebarLanguageSvgEl('defs');
clip = createSidebarLanguageSvgEl('clipPath');
clip.setAttribute('id', 'clbi-sidebar-language-fan-clip');
clipPath = createSidebarLanguageSvgEl('path');
clipPath.setAttribute('d', getSidebarLanguageShellPath());
clip.appendChild(clipPath);
shadowBlur = createSidebarLanguageSvgEl('filter');
shadowBlur.setAttribute('id', 'clbi-sidebar-language-shadow-blur');
shadowBlur.setAttribute('x', '-20%');
shadowBlur.setAttribute('y', '-20%');
shadowBlur.setAttribute('width', '140%');
shadowBlur.setAttribute('height', '140%');
blur = createSidebarLanguageSvgEl('feGaussianBlur');
blur.setAttribute('stdDeviation', '3');
shadowBlur.appendChild(blur);
fixedDepthGradient = createSidebarLanguageSvgEl('linearGradient');
fixedDepthGradient.setAttribute('id', 'clbi-sidebar-language-fixed-depth');
fixedDepthGradient.setAttribute('x1', '0');
fixedDepthGradient.setAttribute('y1', '0');
fixedDepthGradient.setAttribute('x2', '0');
fixedDepthGradient.setAttribute('y2', '1');
[
['0%', '#ffffff', '0.030'],
['34%', '#ffffff', '0.006'],
['58%', '#000000', '0.030'],
['100%', '#000000', '0.250']
].forEach(function(item) {
var stop = createSidebarLanguageSvgEl('stop');
stop.setAttribute('offset', item[0]);
stop.setAttribute('stop-color', item[1]);
stop.setAttribute('stop-opacity', item[2]);
fixedDepthGradient.appendChild(stop);
});
defs.appendChild(clip);
defs.appendChild(shadowBlur);
defs.appendChild(fixedDepthGradient);
svg.appendChild(defs);
shell = createSidebarLanguageSvgEl('path');
shell.setAttribute('class', 'sidebar-lang-shell');
shell.setAttribute('d', getSidebarLanguageShellPath());
svg.appendChild(shell);
clipped = createSidebarLanguageSvgEl('g');
clipped.setAttribute('clip-path', 'url(#clbi-sidebar-language-fan-clip)');
rotor = createSidebarLanguageSvgEl('g');
rotor.setAttribute('id', 'clbi-sidebar-lang-wheel-rotor');
rotor.setAttribute('class', 'sidebar-lang-wheel-rotor');
for (step = -state.repeats; step <= state.repeats; step += 1) {
rotor.appendChild(makeSidebarLanguageSector(step));
}
clipped.appendChild(rotor);
svg.appendChild(clipped);
fixedDepthPath = createSidebarLanguageSvgEl('path');
fixedDepthPath.setAttribute('class', 'sidebar-lang-fixed-depth');
fixedDepthPath.setAttribute('d', getSidebarLanguageShellPath());
svg.appendChild(fixedDepthPath);
fixedFocus = createSidebarLanguageSvgEl('path');
fixedFocus.setAttribute('class', 'sidebar-lang-fixed-focus');
fixedFocus.setAttribute('d', getSidebarLanguageSectorPath(-state.halfSector, state.halfSector));
svg.appendChild(fixedFocus);
shadowSoft = createSidebarLanguageSvgEl('path');
shadowSoft.setAttribute('class', 'sidebar-lang-inner-shadow-soft');
shadowSoft.setAttribute('d', getSidebarLanguageShellPath());
svg.appendChild(shadowSoft);
shadowHard = createSidebarLanguageSvgEl('path');
shadowHard.setAttribute('class', 'sidebar-lang-inner-shadow-hard');
shadowHard.setAttribute('d', getSidebarLanguageShellPath());
svg.appendChild(shadowHard);
rim = createSidebarLanguageSvgEl('path');
rim.setAttribute('class', 'sidebar-lang-rim');
rim.setAttribute('d', getSidebarLanguageShellPath());
svg.appendChild(rim);
pointer = createSidebarLanguageSvgEl('g');
pointer.setAttribute('class', 'sidebar-lang-fixed-pointer');
pointer.setAttribute('clip-path', 'url(#clbi-sidebar-language-fan-clip)');
tri = createSidebarLanguageSvgEl('path');
tri.setAttribute('class', 'sidebar-lang-pointer-triangle');
tri.setAttribute('d', 'M ' + (state.cx - 10) + ' 10 L ' + (state.cx + 10) + ' 10 L ' + state.cx + ' 26 Z');
pointer.appendChild(tri);
line = createSidebarLanguageSvgEl('line');
line.setAttribute('class', 'sidebar-lang-pointer-line');
line.setAttribute('x1', String(state.cx));
line.setAttribute('x2', String(state.cx));
line.setAttribute('y1', '24');
line.setAttribute('y2', '112');
pointer.appendChild(line);
svg.appendChild(pointer);
fan.appendChild(svg);
state.rotor = rotor;
setSidebarLanguageRotation(state.rotation, false);
}
function updateSidebarLanguageDial() {
var state = sidebarLanguageState;
var meta = getSidebarLanguagePreviewMeta();
var status = getSidebarLanguageStatus(meta);
var selector = document.getElementById('clbi-sidebar-lang-selector');
var apply = document.getElementById('clbi-sidebar-lang-apply');
var selectedValue = document.getElementById('clbi-sidebar-lang-selected-value');
var availabilityPanel = document.getElementById('clbi-sidebar-lang-availability-panel');
var availabilityValue = document.getElementById('clbi-sidebar-lang-availability-value');
if (selectedValue) {
selectedValue.textContent = meta.name;
}
if (availabilityPanel) {
availabilityPanel.classList.remove('is-ready', 'is-current', 'is-locked');
availabilityPanel.classList.add(status.className);
}
if (availabilityValue) {
availabilityValue.textContent = status.label;
}
if (apply) {
apply.classList.toggle('is-disabled', !status.canApply);
apply.setAttribute('aria-disabled', status.canApply ? 'false' : 'true');
apply.setAttribute('aria-label', status.canApply ? (meta.name + ' 적용') : (meta.isCurrent ? '현재 언어' : '사용할 수 없는 언어'));
}
if (selector) {
selector.setAttribute('data-selected-lang', meta.lang);
selector.setAttribute('data-selected-code', meta.code);
selector.classList.toggle('is-current', meta.isCurrent);
selector.classList.toggle('is-ready', status.canApply);
selector.classList.toggle('is-locked', !status.canApply && !meta.isCurrent);
selector.classList.toggle('is-dragging', !!state.dragging);
selector.classList.toggle('is-spinning', !!state.inertiaRaf);
}
return {
meta: meta,
status: status
};
}
function setSidebarLanguageRotation(value, animate) {
var state = sidebarLanguageState;
state.rotation = value;
updateSidebarLanguageDial();
if (!state.rotor) return;
if (animate) {
$('#clbi-sidebar-lang-selector').addClass('is-snapping');
} else {
$('#clbi-sidebar-lang-selector').removeClass('is-snapping');
}
state.rotor.style.transform = 'rotate(' + state.rotation.toFixed(3) + 'deg)';
}
function requestSidebarLanguageRotation(value) {
var state = sidebarLanguageState;
state.pendingRotation = value;
if (state.raf) return;
state.raf = requestAnimationFrame(function() {
state.raf = null;
setSidebarLanguageRotation(state.pendingRotation, false);
});
}
function cancelSidebarLanguageSpin() {
var state = sidebarLanguageState;
if (state.inertiaRaf) {
cancelAnimationFrame(state.inertiaRaf);
state.inertiaRaf = null;
}
$('#clbi-sidebar-lang-selector').removeClass('is-spinning');
}
function finishSidebarLanguageSnap(nearestIndex, callback) {
var state = sidebarLanguageState;
state.baseIndex = normalizeSidebarLanguageIndex(nearestIndex);
state.selectedIndex = state.baseIndex;
state.rotation = 0;
state.dragging = false;
$('#clbi-sidebar-lang-selector').removeClass('is-snapping is-dragging is-spinning');
renderSidebarLanguageWheel();
updateSidebarLanguageDial();
if (typeof callback === 'function') {
callback(getSidebarLanguageMeta(state.order[state.selectedIndex]));
}
}
function snapSidebarLanguageToStep(step, animate, callback) {
var state = sidebarLanguageState;
var targetRotation = -step * state.sectorAngle;
var nearestIndex = normalizeSidebarLanguageIndex(state.baseIndex + step);
cancelSidebarLanguageSpin();
clearTimeout(state.snapTimer);
state.selectedIndex = nearestIndex;
setSidebarLanguageRotation(targetRotation, !!animate);
state.snapTimer = setTimeout(function() {
finishSidebarLanguageSnap(nearestIndex, callback);
}, animate ? 230 : 0);
}
function snapSidebarLanguageNearest(callback) {
var state = sidebarLanguageState;
var step = Math.round(-state.rotation / state.sectorAngle);
snapSidebarLanguageToStep(step, true, callback);
}
function startSidebarLanguageInertiaSpin(initialVelocity) {
var state = sidebarLanguageState;
var velocity;
var lastFrame;
cancelSidebarLanguageSpin();
velocity = Math.max(-state.maxSpinVelocity, Math.min(state.maxSpinVelocity, initialVelocity));
if (Math.abs(velocity) < state.minSpinVelocity) {
snapSidebarLanguageNearest();
return;
}
$('#clbi-sidebar-lang-selector').addClass('is-spinning');
lastFrame = performance.now();
function frame(now) {
var dt = Math.min(34, Math.max(1, now - lastFrame));
var sign = velocity < 0 ? -1 : 1;
var nextSpeed;
lastFrame = now;
state.rotation += velocity * dt;
setSidebarLanguageRotation(state.rotation, false);
nextSpeed = Math.max(0, Math.abs(velocity) - (state.spinDecel * dt));
velocity = sign * nextSpeed;
if (nextSpeed <= state.minSpinVelocity) {
state.inertiaRaf = null;
$('#clbi-sidebar-lang-selector').removeClass('is-spinning');
snapSidebarLanguageNearest();
return;
}
state.inertiaRaf = requestAnimationFrame(frame);
}
state.inertiaRaf = requestAnimationFrame(frame);
}
function scheduleSidebarLanguageNavigation(meta) {
var status = getSidebarLanguageStatus(meta);
if (!meta || !status.canApply) return;
clearTimeout(sidebarLanguageState.navigateTimer);
sidebarLanguageState.navigateTimer = setTimeout(function() {
var title = getLanguageTargetTitle(meta.lang);
if (!title || meta.lang === getCurrentLang()) return;
window.location.href = buildWikiPath(title);
}, 70);
}
function setSidebarLanguageSelection(lang) {
var state = sidebarLanguageState;
var index = state.order.indexOf(lang);
if (index < 0) index = state.order.indexOf(getCurrentLang());
if (index < 0) index = 0;
if (state.raf) {
cancelAnimationFrame(state.raf);
state.raf = null;
}
cancelSidebarLanguageSpin();
clearTimeout(state.snapTimer);
state.currentLang = lang;
state.baseIndex = index;
state.selectedIndex = index;
state.rotation = 0;
state.dragging = false;
state.dragMoved = false;
state.releaseVelocity = 0;
renderSidebarLanguageWheel();
updateSidebarLanguageDial();
}
function moveSidebarLanguageSelection(delta) {
snapSidebarLanguageToStep(-delta, true);
}
function bindSidebarLanguageSelector() {
var state = sidebarLanguageState;
var selector = document.getElementById('clbi-sidebar-lang-selector');
var fan = document.getElementById('clbi-sidebar-lang-fan');
var apply = document.getElementById('clbi-sidebar-lang-apply');
if (!selector || !fan || !apply) return;
if (state.bound && state.boundElement === selector) return;
state.bound = true;
state.boundElement = selector;
fan.addEventListener('pointerdown', function(e) {
cancelSidebarLanguageSpin();
clearTimeout(state.snapTimer);
state.dragging = true;
state.dragMoved = false;
state.dragStartX = e.clientX;
state.dragStartY = e.clientY || 0;
state.dragStartRotation = state.rotation;
state.dragAxis = null;
state.pointerCaptured = false;
state.lastX = e.clientX;
state.lastTime = performance.now();
state.releaseVelocity = 0;
selector.classList.add('is-dragging');
selector.classList.remove('is-snapping');
/*
Vertical page scrolling must stay available when the pointer starts on
the language dial. Capture and preventDefault are delayed until a
horizontal drag is confirmed.
*/
});
fan.addEventListener('pointermove', function(e) {
var now;
var totalDx;
var totalDy;
var frameDx;
var dt;
var instantVelocity;
if (!state.dragging) return;
totalDx = e.clientX - state.dragStartX;
totalDy = (e.clientY || 0) - state.dragStartY;
if (!state.dragAxis && (Math.abs(totalDx) > 4 || Math.abs(totalDy) > 4)) {
state.dragAxis = Math.abs(totalDx) >= Math.abs(totalDy) ? 'x' : 'y';
if (state.dragAxis === 'y') {
state.dragging = false;
state.dragMoved = false;
state.dragAxis = null;
state.pointerCaptured = false;
selector.classList.remove('is-dragging');
return;
}
if (fan.setPointerCapture && e.pointerId != null) {
try {
fan.setPointerCapture(e.pointerId);
state.pointerCaptured = true;
} catch (err) {
state.pointerCaptured = false;
}
}
}
if (state.dragAxis !== 'x') return;
now = performance.now();
frameDx = e.clientX - state.lastX;
dt = Math.max(1, now - state.lastTime);
if (Math.abs(totalDx) > 3) state.dragMoved = true;
instantVelocity = (frameDx * state.dragSensitivity) / dt;
state.releaseVelocity = (state.releaseVelocity * 0.62) + (instantVelocity * 0.38);
state.lastX = e.clientX;
state.lastTime = now;
requestSidebarLanguageRotation(state.dragStartRotation + totalDx * state.dragSensitivity);
e.preventDefault();
e.stopPropagation();
});
function finishDrag(e) {
var velocityAge;
var throwVelocity;
var wasHorizontal;
if (!state.dragging) return;
wasHorizontal = state.dragAxis === 'x';
state.dragging = false;
selector.classList.remove('is-dragging');
if (fan.releasePointerCapture && state.pointerCaptured && e && e.pointerId != null) {
try { fan.releasePointerCapture(e.pointerId); } catch (err) {}
}
state.pointerCaptured = false;
state.dragAxis = null;
if (!wasHorizontal && !state.dragMoved) {
return;
}
velocityAge = performance.now() - state.lastTime;
throwVelocity = velocityAge > 120 ? 0 : state.releaseVelocity;
if (state.dragMoved) {
state.suppressClickUntil = performance.now() + 180;
}
if (state.dragMoved && Math.abs(throwVelocity) >= state.minSpinVelocity) {
startSidebarLanguageInertiaSpin(throwVelocity);
} else {
snapSidebarLanguageNearest();
}
if (e) {
e.preventDefault();
e.stopPropagation();
}
}
fan.addEventListener('pointerup', finishDrag);
fan.addEventListener('pointercancel', finishDrag);
fan.addEventListener('lostpointercapture', function() {
if (!state.dragging) return;
state.dragging = false;
state.pointerCaptured = false;
state.dragAxis = null;
selector.classList.remove('is-dragging');
if (state.dragMoved && Math.abs(state.releaseVelocity) >= state.minSpinVelocity) {
state.suppressClickUntil = performance.now() + 180;
startSidebarLanguageInertiaSpin(state.releaseVelocity);
} else {
snapSidebarLanguageNearest();
}
});
apply.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
snapSidebarLanguageNearest(function(meta) {
scheduleSidebarLanguageNavigation(meta);
});
});
selector.addEventListener('keydown', function(e) {
if (e.key === 'ArrowLeft') {
moveSidebarLanguageSelection(-1);
e.preventDefault();
}
if (e.key === 'ArrowRight') {
moveSidebarLanguageSelection(1);
e.preventDefault();
}
if (e.key === 'Enter' || e.key === ' ') {
apply.click();
e.preventDefault();
}
});
}
function renderSidebarLanguageBox() {
bindSidebarLanguageSelector();
setSidebarLanguageSelection(getCurrentLang());
if (!sidebarLanguageStatusLoaded) {
loadSidebarLanguageStatusRegistry(function() {
setSidebarLanguageSelection(getCurrentLang());
});
}
}
function loadRecentChangesList(targetSelector, limit) {
var $target = $(targetSelector);
if (!$target.length) return;
var lang = getCurrentLang();
var t = (window.LANG && window.LANG[lang]) ? window.LANG[lang] : (window.LANG ? window.LANG.ko : null);
var isNewsList = $target.closest('.clbi-left-news-box').length > 0;
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
$target.html((t && t.loading) ? t.loading : '불러오는 중...');
$.getJSON(
'/api.php?action=query&list=recentchanges&rclimit=' + encodeURIComponent(limit || 5) + '&rcprop=title|timestamp|user&format=json&rcnamespace=0&rctype=edit|new',
function(data) {
var items = data && data.query ? data.query.recentchanges : [];
var html = '';
if (!items || !items.length) {
$target.html('표시할 변경 사항이 없습니다.');
return;
}
$.each(items, function(i, item) {
var label = timeAgo(item.timestamp);
var title = item.title || '';
var userName = item.user || 'Unknown';
var pageHref = buildWikiPath(title);
var avatarSrc = '/index.php?title=특수:Redirect/file/Pfp-' + encodeURIComponent(userName) + '.png';
if (isNewsList) {
html +=
'<a href="' + escapeHtml(pageHref) + '" class="news-recent-item">' +
'<img class="news-recent-avatar" src="' + escapeHtml(avatarSrc) + '" alt="" onerror="this.onerror=null;this.src=\'/index.php?title=특수:Redirect/file/Pfp-default.png\';">' +
'<div class="news-recent-main">' +
'<div class="news-recent-title-wrap">' +
'<span class="news-recent-title">' + escapeHtml(title) + '</span>' +
'</div>' +
'<div class="news-recent-meta">' +
'<span class="news-recent-user">@' + escapeHtml(userName) + '</span>' +
'</div>' +
'</div>' +
'<span class="news-recent-time">' + escapeHtml(label) + '</span>' +
'</a>';
} else {
html +=
'<div class="clbi-recent-item">' +
'<div class="clbi-recent-title-wrap">' +
'<a href="' + escapeHtml(pageHref) + '" class="clbi-recent-title">' + escapeHtml(title) + '</a>' +
'</div>' +
'<span class="clbi-recent-time">' + escapeHtml(label) + '</span>' +
'</div>';
}
});
$target.html(html);
$target.find(isNewsList ? '.news-recent-item' : '.clbi-recent-item').each(function() {
var wrap = $(this).find(isNewsList ? '.news-recent-title-wrap' : '.clbi-recent-title-wrap');
var title = $(this).find(isNewsList ? '.news-recent-title' : '.clbi-recent-title');
if (!wrap.length || !title.length) return;
var wrapW = wrap.width();
var titleW = title[0].scrollWidth;
if (titleW > wrapW + 20) {
var duration = titleW / 40;
title.css({
animation: 'clbi-scroll ' + duration + 's linear infinite',
'--scroll-dist': '-' + (titleW - wrapW + 8) + 'px'
});
}
});
}
).fail(function() {
var lang = getCurrentLang();
var t = (window.LANG && window.LANG[lang]) ? window.LANG[lang] : (window.LANG ? window.LANG.ko : null);
$target.html((t && t.loadFail) ? t.loadFail : '불러오기 실패');
});
}
// 국가_및_조합 전용 왼쪽 사이드바 이미지
function updateLeftSidebarNationsImage() {
$('#clbi-left-nations-image').remove();
}
function setProfileActionLabel(selector, text) {
var target = $(selector);
var label = target.find('.profile-action-label');
if (label.length) {
label.text(text);
} else {
target.text(text);
}
}
// 사이드바 업데이트
function updateSidebar() {
if (!window.LANG) {
setTimeout(updateSidebar, 100);
return;
}
var currentLang = getCurrentLang();
var t = (window.LANG && window.LANG[currentLang]) ? window.LANG[currentLang] : window.LANG.ko;
var newsTitle = t.news || '뉴스';
var changelogTitle = t.changelog || '체인지로그';
var recentTitle = t.recentChanges || '최근 변경';
var languageTitle = t.language || '언어';
$('#clbi-title-left-language').text(languageTitle);
renderSidebarLanguageBox();
$('#clbi-title-left-news').text(newsTitle);
$('#clbi-left-news-changelog-main').text(changelogTitle);
$('#clbi-left-news-recent-main').text(recentTitle);
$('#clbi-title-search a').text(t.search);
$('#clbi-search-input').attr('placeholder', t.search + '...');
$('#clbi-title-recent a').text(recentTitle);
$('#clbi-title-guide-label').text(t.guide);
$('#clbi-guide-link').text(t.getStarted);
$('#clbi-title-links-label').text(t.links);
setProfileActionLabel('#clbi-btn-contribution', t.contribution);
setProfileActionLabel('#clbi-btn-watchlist', t.watchlist);
setProfileActionLabel('#clbi-btn-preferences', t.preferences);
setProfileActionLabel('#clbi-btn-logout', t.logout);
setProfileActionLabel('#clbi-btn-login', t.login);
var pageName = normalizePageName(mw.config.get('wgPageName'));
var specialPage = String(mw.config.get('wgCanonicalSpecialPageName') || '');
$('#clbi-left-news-changelog-main').text(changelogTitle);
$('#clbi-left-news-recent-title').text('RECENT CHANGES');
$('.clbi-user-btn').removeClass('clbi-user-btn-active');
if (
specialPage === 'Contributions' ||
specialPage === '기여' ||
pageName.indexOf('특수:기여') === 0 ||
pageName.indexOf('Special:Contributions') === 0
) {
$('#clbi-btn-contribution').addClass('clbi-user-btn-active');
}
if (specialPage === 'Watchlist') {
$('#clbi-btn-watchlist').addClass('clbi-user-btn-active');
}
if (
specialPage === '설정' ||
pageName === '특수:설정' ||
pageName === 'Special:설정'
) {
$('#clbi-btn-preferences').addClass('clbi-user-btn-active');
}
$('.toggleBtn').each(function() {
var btn = $(this);
if (!$('#' + btn.data('target')).hasClass('folding-open')) {
btn.text(t.expand);
} else {
btn.text(t.collapse);
}
});
updateLeftSidebarNationsImage();
}
function canShowContentTools() {
// 비로그인 사용자는 편집/역사/공유 버튼을 숨김
if (!mw.config.get('wgUserName')) {
return false;
}
// MediaWiki가 현재 문서를 편집 가능하지 않다고 판단하면 숨김
var isEditable = mw.config.get('wgIsProbablyEditable');
if (isEditable === false) {
return false;
}
var relevantEditable = mw.config.get('wgRelevantPageIsProbablyEditable');
if (relevantEditable === false) {
return false;
}
return true;
}
function moveCatlinksToBottom() {
var main = $('.liberty-content-main');
var parserOutput = $('.liberty-content-main .mw-parser-output').first();
var catlinks = $('.catlinks');
if (!main.length || !catlinks.length) return;
catlinks.each(function () {
var cat = $(this);
if (parserOutput.length) {
cat.appendTo(parserOutput);
} else {
cat.appendTo(main);
}
});
}
// 대문 스타일
function initCategoryNavIfAvailable(root) {
/*
CategoryNav.js는 대문 카테고리 네비를 SVG로 생성한다.
Common.js가 SPA로 본문을 갈아끼운 뒤에는 MediaWiki 원래 페이지 로드와 달리
CategoryNav.js의 초기 DOMContentLoaded만으로는 새 mount를 다시 잡지 못할 수 있다.
CategoryNav.js 자체도 mw.hook('wikipage.content')를 듣지만, 로드 순서와 SPA 타이밍이
엇갈릴 수 있으므로 Common.js 쪽에서도 존재 여부를 확인한 뒤 한 번 더 호출한다.
이 함수는 CategoryNav.js가 아직 로드되지 않았으면 아무 것도 하지 않는다.
*/
if (
window.CLBI &&
window.CLBI.categoryNav &&
typeof window.CLBI.categoryNav.init === 'function'
) {
window.CLBI.categoryNav.init(root || document);
}
}
function removeLegacyMainPageHero() {
/*
기존 대문 전용 레거시 요소 정리
-----------------------------------------
이전 대문 구조에서는 Common.js가 본문 바깥에 #clbi-main-logo를 직접 삽입하고,
본문 안의 #clbi-main-crt-hero를 #clbi-main-crt-hero-wrap으로 감싸서
.liberty-content-main 위쪽으로 재배치했다.
새 대문은 본문 내부의 .main-portal이 로고, 알림, 카테고리 네비, 이미지 피드,
방명록, 상태 패널을 모두 담당한다. 따라서 Common.js가 별도 로고나 CRT 래퍼를
삽입하면 새 로고/콘텐츠와 중복된다.
여기서는 JS가 만들던 바깥 로고와 CRT 래퍼를 제거하고, 예전 대문 원본이나
캐시된 렌더 결과에 남아 있을 수 있는 #clbi-main-crt-hero도 제거한다.
*/
$('#clbi-main-logo').remove();
$('#clbi-main-crt-hero-wrap').remove();
$('#clbi-main-crt-hero').remove();
}
function applyMainPageStyle() {
var specialPage = mw.config.get('wgCanonicalSpecialPageName');
if (specialPage === 'Preferences') return;
var pageName = normalizePageName(mw.config.get('wgPageName'));
var namespaceNumber = mw.config.get('wgNamespaceNumber');
var isMainPage = (pageName === '대문');
var isUserProfilePage = (namespaceNumber === 2);
var isScreenDoc = ($('.screen-header').length > 0);
var hideTools = (isMainPage || isUserProfilePage || !canShowContentTools());
$('body').toggleClass('user-profile-page', isUserProfilePage);
// 모든 문서에서 분류 바를 본문 컨테이너 아래로 이동
moveCatlinksToBottom();
if (isMainPage) {
$('.liberty-content-header').css('display', 'none');
$('.mw-page-title-main').addClass('clbi-hide');
$('.catlinks').css('display', 'none');
$('.liberty-content-main').css('border-radius', '0');
// 새 대문은 .main-portal 본문 구조가 로고/히어로를 담당한다.
// Common.js의 구식 바깥 로고/CRT 재배치 루틴은 사용하지 않는다.
removeLegacyMainPageHero();
$('#clbi-tools-box').remove();
$('.content-tools').css('display', 'none');
$('.liberty-content').addClass('content-tools-hidden');
initCategoryNavIfAvailable(document);
} else if (isUserProfilePage) {
$('.liberty-content-header').css('display', 'none');
$('.mw-page-title-main, .mw-page-title-namespace, .mw-page-title-separator').addClass('clbi-hide');
$('.catlinks').css('display', 'none');
$('.liberty-content-main').css('border-radius', '0');
$('#clbi-main-logo').remove();
$('#clbi-main-crt-hero-wrap').remove();
$('#clbi-main-crt-hero').remove();
$('#clbi-tools-box').remove();
$('.content-tools').css('display', 'none');
$('.liberty-content').addClass('content-tools-hidden');
} else if (isScreenDoc) {
$('.liberty-content-header').css('display', 'none');
$('.mw-page-title-main').addClass('clbi-hide');
$('.catlinks').css('display', '');
$('.liberty-content-main').css('border-radius', '0');
$('#clbi-main-logo').remove();
$('#clbi-main-crt-hero-wrap').remove();
if ($('#clbi-tools-box').length === 0 && canShowContentTools()) {
var $toolsBox = $('<div id="clbi-tools-box" class="clbi-left-box"></div>');
var $toolsTitle = $('<div class="clbi-left-title"><span class="clbi-icon" style="--icon:var(--ic-ui-003)"></span> 관리</div>');
var $toolsContent = $('<div class="clbi-left-content"></div>');
$toolsContent.append($('.content-tools .btn-group').clone(true));
$toolsBox.append($toolsTitle).append($toolsContent);
$('#clbi-left-sidebar').append($toolsBox);
}
$('.content-tools').css('display', 'none');
$('.liberty-content').addClass('content-tools-hidden');
} else {
$('.liberty-content-header').css('display', '');
$('.mw-page-title-main, .mw-page-title-namespace, .mw-page-title-separator').removeClass('clbi-hide');
$('.catlinks').css('display', '');
$('.liberty-content-main').css('border-radius', '0');
$('#clbi-main-logo').remove();
$('#clbi-main-crt-hero-wrap').remove();
$('#clbi-tools-box').remove();
}
if (!isUserProfilePage) {
$('.profile-card').remove();
$('.user-profile-portal').removeClass('user-profile-portal');
}
if (hideTools) {
$('.content-tools').css('display', 'none');
$('.liberty-content').addClass('content-tools-hidden');
} else if (!isScreenDoc && !isUserProfilePage) {
$('.content-tools').css('display', '');
$('.liberty-content').removeClass('content-tools-hidden');
}
updateSidebar();
}
// 본문 기본 목차 제거
function removeNativeTocFromContent() {
$('.liberty-content-main #toc, .liberty-content-main .toc').remove();
}
// 왼쪽 목차: MediaWiki 문단 ID 가져오기
function getHeadingId(heading) {
if (heading.id) {
return heading.id;
}
var headline = heading.querySelector('.mw-headline[id]');
if (headline && headline.id) {
return headline.id;
}
return '';
}
// 왼쪽 목차: MediaWiki 문단 제목 텍스트 가져오기
function getHeadingText(heading) {
var headline = heading.querySelector('.mw-headline');
var source = headline || heading;
var clone = source.cloneNode(true);
$(clone).find('.mw-editsection, .mw-editsection-bracket, .mw-editsection-divider').remove();
return (clone.textContent || '')
.replace(/\s+/g, ' ')
.trim();
}
// 왼쪽 목차: 긴 제목에 자동 스크롤 적용
function initTocTitleScroll(root) {
var $items = root
? $(root).find('.toc-scroll-text')
: $('#side-toc-box .toc-scroll-text');
$items.each(function () {
var $text = $(this);
var $wrap = $text.closest('.toc-scroll-wrap');
if (!$wrap.length) return;
var wrapW = Math.floor($wrap.width());
var textW = Math.ceil(this.scrollWidth);
// 왼쪽 목차: 레이아웃 계산이 끝나지 않았으면 이번 실행에서는 건드리지 않는다.
if (!wrapW || !textW) return;
if (textW <= wrapW + 12) {
// 왼쪽 목차: 칸을 넘지 않는 제목은 전체 텍스트를 그대로 보여준다.
$wrap.removeClass('is-scrolling');
if ($text.data('toc-scroll-enabled')) {
$text.css({
animation: '',
'animation-delay': '',
'--scroll-dist': ''
});
$text.removeData('toc-scroll-enabled');
$text.removeData('toc-scroll-key');
}
return;
}
var scrollDist = '-' + (textW - wrapW + 10) + 'px';
var duration = Math.max(7, textW / 38) * 1.25;
var scrollKey = scrollDist + '|' + duration;
// 왼쪽 목차: 긴 제목에는 오른쪽 페이드와 스크롤을 적용한다.
$wrap.addClass('is-scrolling');
// 왼쪽 목차: 같은 값으로 이미 적용된 애니메이션은 다시 초기화하지 않는다.
if ($text.data('toc-scroll-key') === scrollKey) {
return;
}
$text.data('toc-scroll-enabled', true);
$text.data('toc-scroll-key', scrollKey);
$text.css({
// 왼쪽 목차: 페이지 진입 직후에는 잠시 읽을 시간을 준 뒤 흐르게 한다.
animation: 'toc-scroll-blink-reset ' + duration + 's linear infinite',
'animation-delay': '1s',
'--scroll-dist': scrollDist
});
});
}
// 목차를 왼쪽 사이드바에 새로 생성
function moveTocToLeftSidebar() {
// 왼쪽 목차: MediaWiki가 만든 원래 목차는 본문에서 제거한다.
removeNativeTocFromContent();
var leftSidebar = document.getElementById('clbi-left-sidebar');
if (!leftSidebar) return;
var content =
document.querySelector('.liberty-content-main .mw-parser-output') ||
document.querySelector('.liberty-content-main');
if (!content) return;
var headings = Array.prototype.slice.call(
content.querySelectorAll('h2, h3')
).filter(function (heading) {
if (heading.closest('#toc, .toc, #side-toc-box')) return false;
var id = getHeadingId(heading);
var text = getHeadingText(heading);
if (!id || !text) return false;
return true;
});
var tocKey = headings.map(function (heading) {
return getHeadingId(heading) + '|' + getHeadingText(heading);
}).join('||');
var existingBox = document.getElementById('side-toc-box');
// 왼쪽 목차: 같은 문서에서 같은 목차를 이미 만들었다면 다시 지우고 만들지 않는다.
if (existingBox && existingBox.getAttribute('data-toc-key') === tocKey) {
initTocTitleScroll(existingBox);
return;
}
if (existingBox) {
existingBox.remove();
}
if (!headings.length) return;
var tocBox = document.createElement('div');
tocBox.className = 'clbi-left-box';
tocBox.id = 'side-toc-box';
tocBox.setAttribute('data-toc-key', tocKey);
var title = document.createElement('div');
title.className = 'clbi-left-title';
// 왼쪽 목차: 박스 제목은 Lang.js의 현재 UI 언어를 따른다.
var currentLang = getCurrentLang();
var t = (window.LANG && window.LANG[currentLang]) ? window.LANG[currentLang] : window.LANG.ko;
var tocTitleText = (t && t.toc) ? t.toc : '목차';
title.innerHTML =
'<span class="sidebar-title-svg" aria-hidden="true">' + CLBI_SVG_LIST + '</span> ' + tocTitleText;
var body = document.createElement('div');
body.className = 'clbi-left-content toc-sidebar-content';
var list = document.createElement('ul');
list.className = 'generated-toc';
headings.forEach(function (heading) {
var id = getHeadingId(heading);
var text = getHeadingText(heading);
var level = heading.tagName.toLowerCase() === 'h3' ? 3 : 2;
var item = document.createElement('li');
item.className = 'toc-level-' + level;
var link = document.createElement('a');
link.setAttribute('href', '#' + id);
// 왼쪽 목차: 긴 제목 스크롤을 위해 텍스트를 별도 span으로 감싼다.
var textWrap = document.createElement('span');
textWrap.className = 'toc-scroll-wrap';
var textSpan = document.createElement('span');
textSpan.className = 'toc-scroll-text';
textSpan.textContent = text;
textWrap.appendChild(textSpan);
link.appendChild(textWrap);
item.appendChild(link);
list.appendChild(item);
});
body.appendChild(list);
tocBox.appendChild(title);
tocBox.appendChild(body);
leftSidebar.appendChild(tocBox);
// 왼쪽 목차: DOM 배치가 끝난 뒤 긴 제목 스크롤 여부를 계산한다.
requestAnimationFrame(function () {
initTocTitleScroll(tocBox);
setTimeout(function () {
initTocTitleScroll(tocBox);
}, 120);
});
}
// 초기화 함수
function initSidebars() {
var header = $('.liberty-content-header');
var content = $('.liberty-content');
if (header.length && content.length) {
header.prependTo(content);
}
if ($('#clbi-right-sidebar').length === 0) {
var username = mw.config.get('wgUserName');
var isLoggedIn = username !== null;
var avatarSrc = isLoggedIn
? '/index.php?title=특수:Redirect/file/Pfp-' + username + '.png'
: '/index.php?title=특수:Redirect/file/Pfp-default.png';
var userBox;
if (isLoggedIn) {
userBox =
'<div class="clbi-right-box profile-card-box">' +
'<div id="clbi-user-avatar-wrap" class="profile-identity-panel">' +
'<div class="profile-avatar-stage">' +
'<img id="clbi-user-avatar" src="' + avatarSrc + '" onerror="this.onerror=null;this.src=\'/index.php?title=특수:Redirect/file/Pfp-default.png\';">' +
'</div>' +
'<div id="clbi-user-name-row" class="profile-name-row">' +
'<a href="/index.php/사용자:' + username + '" id="clbi-user-name">' + username + '</a>' +
'</div>' +
'</div>' +
'<div class="clbi-right-content profile-action-box">' +
'<div class="profile-quick-actions" aria-label="프로필 빠른 메뉴">' +
'<button type="button" class="profile-quick-btn" id="profile-quick-inventory" aria-label="인벤토리"><span class="profile-quick-icon" aria-hidden="true">' + CLBI_SVG_PACKAGE + '</span><span class="profile-quick-tip" aria-hidden="true">인벤토리</span></button>' +
'<button type="button" class="profile-quick-btn" id="profile-quick-achievements" aria-label="업적"><span class="profile-quick-icon" aria-hidden="true">' + CLBI_SVG_TROPHY + '</span><span class="profile-quick-tip" aria-hidden="true">업적</span></button>' +
'<button type="button" class="profile-quick-btn" id="profile-quick-notifications" aria-label="알림"><span id="profile-quick-notification-icon" class="profile-quick-icon" aria-hidden="true">' + CLBI_SVG_BELL + '</span><span class="profile-quick-tip" aria-hidden="true">알림</span></button>' +
'</div>' +
'<a href="/index.php/특수:기여/' + username + '" class="clbi-user-btn" id="clbi-btn-contribution"><span class="profile-action-icon" aria-hidden="true">' + CLBI_SVG_SCAN_TEXT + '</span><span class="profile-action-label">기여</span><i class="hn hn-angle-right-solid profile-action-arrow" aria-hidden="true"></i></a>' +
'<a href="/index.php/특수:주시문서목록" class="clbi-user-btn" id="clbi-btn-watchlist"><span class="profile-action-icon" aria-hidden="true">' + CLBI_SVG_SCAN_EYE + '</span><span class="profile-action-label">주시문서 목록</span><i class="hn hn-angle-right-solid profile-action-arrow" aria-hidden="true"></i></a>' +
'<a href="/index.php/특수:설정" class="clbi-user-btn" id="clbi-btn-preferences"><span class="profile-action-icon" aria-hidden="true">' + CLBI_SVG_SETTINGS + '</span><span class="profile-action-label">설정</span><i class="hn hn-angle-right-solid profile-action-arrow" aria-hidden="true"></i></a>' +
'<a href="/index.php?title=특수:로그아웃&returnto=대문" class="clbi-user-btn clbi-user-btn-logout" id="clbi-btn-logout"><span class="profile-action-icon" aria-hidden="true">' + CLBI_SVG_POWER + '</span><span class="profile-action-label">로그아웃</span><i class="hn hn-angle-right-solid profile-action-arrow" aria-hidden="true"></i></a>' +
'</div>' +
'</div>';
} else {
userBox =
'<div class="clbi-right-box profile-card-box">' +
'<div id="clbi-user-avatar-wrap" class="profile-identity-panel">' +
'<div class="profile-avatar-stage">' +
'<img id="clbi-user-avatar" src="/index.php?title=특수:Redirect/file/Pfp-default.png">' +
'</div>' +
'<div id="clbi-user-name-row" class="profile-name-row profile-name-row-guest">' +
'<span id="clbi-user-name">Guest</span>' +
'</div>' +
'</div>' +
'<div class="clbi-right-content profile-action-box">' +
'<a href="/index.php?title=특수:로그인&returnto=대문" class="clbi-user-btn" id="clbi-btn-login"><span class="profile-action-icon" aria-hidden="true">' + CLBI_SVG_POWER + '</span><span class="profile-action-label">로그인</span><i class="hn hn-angle-right-solid profile-action-arrow" aria-hidden="true"></i></a>' +
'</div>' +
'</div>';
}
var sidebar = userBox;
$('.content-wrapper').append('<div id="clbi-right-sidebar">' + sidebar + '</div>');
}
if ($('#clbi-left-sidebar').length === 0) {
var leftSidebar =
'<div id="clbi-left-sidebar">' +
'<div class="clbi-left-box clbi-left-lang-box">' +
'<div class="clbi-left-title">' +
'<span class="sidebar-title-svg clbi-left-language-icon" aria-hidden="true">' + CLBI_SVG_LANGUAGES + '</span> ' +
'<span id="clbi-title-left-language">언어</span>' +
'</div>' +
'<div class="clbi-left-content sidebar-lang-box">' +
'<div id="clbi-sidebar-lang-selector" class="sidebar-lang-selector sidebar-lang-dial" tabindex="0" role="group" aria-label="언어 선택">' +
'<div id="clbi-sidebar-lang-dial-stage" class="sidebar-lang-dial-stage">' +
'<div id="clbi-sidebar-lang-fan" class="sidebar-lang-fan" aria-hidden="true"></div>' +
'<div id="clbi-sidebar-lang-selected-panel" class="sidebar-lang-status-panel sidebar-lang-status-left" aria-hidden="true">' +
'<span id="clbi-sidebar-lang-selected-value" class="sidebar-lang-status-value">한국어</span>' +
'</div>' +
'<div id="clbi-sidebar-lang-availability-panel" class="sidebar-lang-status-panel sidebar-lang-status-right is-current" aria-hidden="true">' +
'<span id="clbi-sidebar-lang-availability-value" class="sidebar-lang-status-value">CURRENT</span>' +
'</div>' +
'<button type="button" id="clbi-sidebar-lang-apply" class="sidebar-lang-apply" aria-label="언어 적용">' +
'<span class="sidebar-lang-apply-mark" aria-hidden="true">✓</span>' +
'</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="clbi-left-box clbi-left-news-box">' +
'<div class="clbi-left-title">' +
'<span class="news-title-icon sidebar-title-svg" aria-hidden="true">' + CLBI_SVG_NEWSPAPER + '</span> ' +
'<span id="clbi-title-left-news">뉴스</span>' +
'</div>' +
'<div class="clbi-left-content clbi-news-box">' +
'<div class="news-feed-title" id="clbi-left-news-changelog-title">CHANGELOG</div>' +
'<div class="news-left-changelog-feed">' +
'<a href="/index.php/체인지로그" class="news-post-item">' +
'<div class="news-post-title-wrap">' +
'<span class="news-post-title" id="clbi-left-news-changelog-main">체인지로그</span>' +
'<i class="hn hn-angle-right-solid news-post-arrow" aria-hidden="true"></i>' +
'</div>' +
'<span class="news-post-tag">POST</span>' +
'</a>' +
'</div>' +
'<div class="news-divider"></div>' +
'<div class="news-feed-title" id="clbi-left-news-recent-title">RECENT CHANGES</div>' +
'<div class="news-left-recent-feed" id="clbi-left-recent-list">불러오는 중...</div>' +
'</div>' +
'</div>' +
'</div>';
$('.content-wrapper').prepend(leftSidebar);
renderSidebarLanguageBox();
loadRecentChangesList('#clbi-left-recent-list', 10);
updateLeftSidebarNationsImage();
}
applyMainPageStyle();
initCategoryNavIfAvailable(document);
if (window.ProgressSystemWebUi && typeof window.ProgressSystemWebUi.boot === 'function') {
window.ProgressSystemWebUi.boot('initSidebars');
}
$('#side-toc-box').remove();
mw.loader.using(['mediawiki.api']).then(function() {
setTimeout(function() {
initNotifications();
initProfile();
moveTocToLeftSidebar();
}, 300);
setTimeout(moveTocToLeftSidebar, 800);
setTimeout(moveTocToLeftSidebar, 1500);
});
}
$(function() {
loadLangScript(function() {
setTimeout(function() {
initSidebars();
}, 100);
});
});
$(document).on('click.profileQuickPlaceholder', '#profile-quick-inventory, #profile-quick-achievements', function(e) {
e.preventDefault();
e.stopPropagation();
});
// SPA 네비게이션
function shouldSkip(url) {
return url.match(/action=edit|action=submit|action=history|action=delete|action=protect|action=purge|특수:로그인|특수:로그아웃|Special:UserLogin|Special:UserLogout|특수:사용자정보|특수:비밀번호바꾸기|uselang=/);
}
$(function() {
if (window._spaInitialized) return;
window._spaInitialized = true;
function isInternal(url) {
var a = document.createElement('a');
a.href = url;
return a.hostname === window.location.hostname;
}
function loadPage(url) {
invalidateProfileRender();
fetch(url)
.then(function(res) {
return res.text();
})
.then(function(html) {
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
var scripts = doc.querySelectorAll('script');
for (var i = 0; i < scripts.length; i++) {
var src = scripts[i].textContent;
if (src.indexOf('wgNamespaceNumber') !== -1) {
var match = src.match(/"wgNamespaceNumber":(-?\d+)/);
if (match) mw.config.set('wgNamespaceNumber', parseInt(match[1], 10));
var matchTitle = src.match(/"wgTitle":"([^"]+)"/);
if (matchTitle) mw.config.set('wgTitle', matchTitle[1]);
var matchPage = src.match(/"wgPageName":"([^"]+)"/);
if (matchPage) mw.config.set('wgPageName', matchPage[1]);
var matchArticle = src.match(/"wgArticleId":(\d+)/);
if (matchArticle) {
mw.config.set('wgArticleId', parseInt(matchArticle[1], 10));
} else {
mw.config.set('wgArticleId', 0);
}
var matchIsMainPage = src.match(/"wgIsMainPage":(true|false)/);
if (matchIsMainPage) {
mw.config.set('wgIsMainPage', matchIsMainPage[1] === 'true');
} else {
mw.config.set('wgIsMainPage', false);
}
var matchSpecial = src.match(/"wgCanonicalSpecialPageName":"([^"]+)"/);
if (matchSpecial) {
mw.config.set('wgCanonicalSpecialPageName', matchSpecial[1]);
} else {
mw.config.set('wgCanonicalSpecialPageName', false);
}
break;
}
}
var newContent = doc.querySelector('.liberty-content-main');
var newTitle = doc.querySelector('.mw-page-title-main');
var newHead = doc.querySelector('title');
var newHeader = doc.querySelector('.liberty-content-header');
if (newContent) {
$('#side-toc-box').remove();
$('.profile-card').remove();
$('.user-profile-portal').removeClass('user-profile-portal');
$('.liberty-content-main').html(newContent.innerHTML);
$('.profile-card').remove();
$('body').removeClass('page-loading');
}
if (newTitle) {
$('.mw-page-title-main').html(newTitle.innerHTML);
}
if (newHead) {
document.title = newHead.textContent;
}
if (newHeader) {
$('.liberty-content-header').html(newHeader.innerHTML);
}
window.scrollTo(0, 0);
mw.hook('wikipage.content').fire($('.liberty-content-main'));
applyMainPageStyle();
initCategoryNavIfAvailable(document);
if (window.ProgressSystemWebUi && typeof window.ProgressSystemWebUi.handleSpaPageView === 'function') {
window.ProgressSystemWebUi.handleSpaPageView();
} else if (window.ProgressSystemWebUi && typeof window.ProgressSystemWebUi.boot === 'function') {
window.ProgressSystemWebUi.boot('spa');
}
$('#side-toc-box').remove();
setTimeout(moveTocToLeftSidebar, 100);
setTimeout(moveTocToLeftSidebar, 500);
setTimeout(moveTocToLeftSidebar, 1200);
mw.loader.using(['mediawiki.api']).then(function() {
initProfile();
moveTocToLeftSidebar();
});
});
}
// 목차 링크는 전용 처리
$(document).on('click', '#side-toc-box a, #toc a, .toc a', function(e) {
var href = $(this).attr('href');
if (!href || href.charAt(0) !== '#') return;
var rawId = href.slice(1);
if (!rawId) return;
var decodedId = rawId;
try {
decodedId = decodeURIComponent(rawId);
} catch (err) {
decodedId = rawId;
}
var target = document.getElementById(decodedId);
if (!target && window.CSS && CSS.escape) {
target = document.querySelector('#' + CSS.escape(decodedId));
}
if (!target) return;
e.preventDefault();
e.stopPropagation();
var scrollTarget = target.closest('h2, h3') || target;
scrollTarget.scrollIntoView({
behavior: 'auto',
block: 'start'
});
history.replaceState(null, '', '#' + rawId);
});
$(document).on('click', 'a', function(e) {
// 휠 클릭, 새 탭 열기, 보조키 이동은 브라우저 기본 동작을 유지한다.
if (e.which && e.which !== 1) return;
if (e.button && e.button !== 0) return;
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
var href = $(this).attr('href');
if (!href) return;
// 목차 링크는 별도 핸들러에서 처리
if ($(this).closest('#side-toc-box, #toc, .toc').length) return;
// 단순 해시 링크는 SPA 가로채기 제외
if (href.startsWith('#')) return;
var link = document.createElement('a');
link.href = href;
var samePath = decodeURIComponent(link.pathname) === decodeURIComponent(window.location.pathname);
var sameSearch = (link.search || '') === (window.location.search || '');
if (link.hash && samePath && sameSearch) return;
var currentBase = window.location.href.split('#')[0];
var targetBase = link.href.split('#')[0];
if (link.hash && currentBase === targetBase) return;
if (!isInternal(href)) return;
if (shouldSkip(href)) return;
e.preventDefault();
playStaticSound();
$('body').addClass('page-loading');
history.pushState(null, '', href);
loadPage(href);
});
window.addEventListener('popstate', function() {
loadPage(window.location.href);
});
});
// 시간 계산 함수
function timeAgo(timestamp) {
var now = new Date();
var date = new Date(timestamp);
var diff = Math.floor((now - date) / 1000);
if (diff < 60) return diff + '초 전';
if (diff < 3600) return Math.floor(diff / 60) + '분 전';
if (diff < 86400) return Math.floor(diff / 3600) + '시간 전';
return Math.floor(diff / 86400) + '일 전';
}
// 펼접 토글
// 펼접 토글
function getFoldTexts() {
var lang = getCurrentLang();
return (window.LANG && window.LANG[lang])
? window.LANG[lang]
: (window.LANG ? window.LANG.ko : { expand: '펼치기', collapse: '접기' });
}
function refreshOpenAncestors($start) {
$start.parents('[id^="collapsible"]').each(function () {
var $parent = $(this);
if (!$parent.hasClass('folding-open')) return;
// 이미 fully open 상태면 굳이 다시 잠그지 않음
if ($parent.data('fold-state') === 'open') {
return;
}
$parent.css('max-height', this.scrollHeight + 'px');
});
}
function bindInnerResizeUpdates($target) {
// 이미지 늦게 로드될 때 높이 갱신
$target.find('img').off('.foldimg').on('load.foldimg', function () {
if ($target.hasClass('folding-open')) {
if ($target.data('fold-state') !== 'open') {
$target.css('max-height', $target[0].scrollHeight + 'px');
}
refreshOpenAncestors($target);
}
});
}
function openFold($target, $btn) {
var t = getFoldTexts();
$target.data('fold-state', 'opening');
$target.addClass('folding-open');
// 열린 뒤 자연 확장 가능하게 만들기 위해 먼저 px로 열기
$target.css('max-height', '0px');
$target[0].offsetHeight;
$target.css('max-height', $target[0].scrollHeight + 'px');
$btn.text(t.collapse);
bindInnerResizeUpdates($target);
// 바깥 펼접 즉시 갱신
refreshOpenAncestors($target);
// 전환 끝나면 none으로 풀어서 중첩 펼접/동적 내용 증가를 자연스럽게 허용
$target.off('transitionend.foldopen').on('transitionend.foldopen', function (e) {
if (e.target !== this) return;
if (!$target.hasClass('folding-open')) return;
$target.css('max-height', 'none');
$target.data('fold-state', 'open');
refreshOpenAncestors($target);
});
// 늦게 렌더되는 콘텐츠 대응
requestAnimationFrame(function () {
if ($target.hasClass('folding-open') && $target.data('fold-state') !== 'open') {
$target.css('max-height', $target[0].scrollHeight + 'px');
refreshOpenAncestors($target);
}
});
setTimeout(function () {
if ($target.hasClass('folding-open') && $target.data('fold-state') !== 'open') {
$target.css('max-height', $target[0].scrollHeight + 'px');
refreshOpenAncestors($target);
}
}, 80);
setTimeout(function () {
if ($target.hasClass('folding-open') && $target.data('fold-state') !== 'open') {
$target.css('max-height', $target[0].scrollHeight + 'px');
refreshOpenAncestors($target);
}
}, 220);
}
function closeFold($target, $btn) {
var t = getFoldTexts();
// none 상태에서 닫으면 transition이 안 되므로 실제 높이로 고정
if ($target.css('max-height') === 'none' || $target.data('fold-state') === 'open') {
$target.css('max-height', $target[0].scrollHeight + 'px');
} else {
$target.css('max-height', $target[0].scrollHeight + 'px');
}
$target.data('fold-state', 'closing');
$target[0].offsetHeight;
$target.css('max-height', '0px');
$target.removeClass('folding-open');
$btn.text(t.expand);
refreshOpenAncestors($target);
setTimeout(function () {
refreshOpenAncestors($target);
$target.data('fold-state', 'closed');
}, 250);
}
$(function () {
$(document)
.off('click.clbiToggle')
.on('click.clbiToggle', '.toggleBtn', function () {
var $btn = $(this);
var targetId = $btn.data('target');
var $target = $('#' + targetId);
if (!$target.length) return;
var scrollY = window.scrollY;
if ($target.hasClass('folding-open')) {
closeFold($target, $btn);
} else {
openFold($target, $btn);
}
window.scrollTo(0, scrollY);
});
});
// ========== 프로필 시스템 ==========
function initProfile() {
$('.profile-card').remove();
$('.user-profile-portal').removeClass('user-profile-portal');
var token = ++PROFILE_RENDER_TOKEN;
var ns = mw.config.get('wgNamespaceNumber');
var title = mw.config.get('wgTitle');
var specialPage = mw.config.get('wgCanonicalSpecialPageName');
var isProfileSettings = specialPage === '사용자정보';
$('body').toggleClass('user-profile-page', ns === 2);
$('body').toggleClass('user-profile-settings-page', isProfileSettings);
if (ns === 2) {
var profileUser = title.split('/')[0];
renderProfile(profileUser, token);
}
if (isProfileSettings) {
initUserProfilePage();
}
}
function renderProfile(username, token) {
var api = new mw.Api();
api.get({
action: 'query',
list: 'users',
ususers: username,
usprop: 'editcount'
}).then(function(data) {
if (token !== PROFILE_RENDER_TOKEN) return;
if (mw.config.get('wgNamespaceNumber') !== 2) return;
var currentTitle = String(mw.config.get('wgTitle') || '').split('/')[0];
if (currentTitle !== username) return;
var user = data.query.users[0];
var contentEl = document.getElementById('mw-content-text');
if (!contentEl) return;
var pageContent = contentEl.querySelector('.mw-parser-output') || contentEl;
injectProfileCard(username, user, pageContent);
});
}
function injectProfileCard(username, userData, container) {
var isOwnPage = mw.config.get('wgUserName') === username;
var editCount = (userData && userData.editcount) ? userData.editcount : 0;
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
container.classList.add('user-profile-portal');
var safeUsername = escapeHtml(username);
var avatarSrc = '/index.php?title=특수:Redirect/file/Pfp-' + encodeURIComponent(username) + '.png&width=220';
var fallbackSrc = '/index.php?title=특수:Redirect/file/Pfp-default.png&width=220';
var editBtn = isOwnPage
? '<a href="/index.php/특수:사용자정보" class="profile-edit-btn"><span class="profile-edit-label">프로필 수정</span><span class="profile-edit-arrow">›</span></a>'
: '';
var progressHtml = isOwnPage
? '<div class="profile-page-progress is-syncing" data-profile-progress>' +
'<div class="profile-section-title">LEVEL RECORD</div>' +
'<div class="profile-page-progress-body">' +
'<div class="profile-page-progress-row">' +
'<span class="profile-page-level">SYNC</span>' +
'<span class="profile-page-total-xp">— XP</span>' +
'</div>' +
'<div class="profile-page-xp-bar" aria-hidden="true"><div class="profile-page-xp-fill"></div></div>' +
'<div class="profile-page-progress-sub">SYNCING</div>' +
'<div class="profile-page-progress-meta">TODAY — · DISCOVERED —</div>' +
'</div>' +
'</div>'
: '';
var card = document.createElement('div');
card.className = 'profile-card profile-page-console';
card.innerHTML =
'<div class="profile-card-titlebar">' +
'<span>USER PROFILE</span>' +
'<span>OFFICIAL ARCHIVE</span>' +
'</div>' +
'<div class="profile-card-body">' +
'<div class="profile-identity-row">' +
'<div class="profile-avatar-bay">' +
'<img src="' + avatarSrc + '" onerror="this.onerror=null;this.src=\'' + fallbackSrc + '\';" alt="' + safeUsername + '">' +
'</div>' +
'<div class="profile-info-panel">' +
'<div class="profile-nameplate">' +
'<h2 class="profile-username">' + safeUsername + '</h2>' +
editBtn +
'</div>' +
'<div class="profile-name" data-field="name"></div>' +
'<div class="profile-role" data-field="role"></div>' +
'<div class="profile-discord" data-field="discord"></div>' +
'</div>' +
'</div>' +
'<div class="profile-lower-grid">' +
'<div class="profile-bio-panel">' +
'<div class="profile-section-title">BIOGRAPHY</div>' +
'<div class="profile-bio" data-field="bio"></div>' +
'</div>' +
'<div class="profile-stats-panel">' +
'<div class="profile-section-title">RECORD</div>' +
'<div class="profile-stats">' +
progressHtml +
'<div class="profile-stat-grid">' +
'<div class="profile-stat">' +
'<span class="clbi-stat-value">' + editCount + '</span>' +
'<span class="clbi-stat-label">수정 횟수</span>' +
'</div>' +
'<div class="profile-stat" data-contrib-pages-stat>' +
'<span class="clbi-stat-value" data-contrib-pages-value>SYNC</span>' +
'<span class="clbi-stat-label">기여 문서</span>' +
'</div>' +
'</div>' +
'<div class="profile-info-grid">' +
'<div class="profile-info-stat">' +
'<span class="profile-info-label">TIME</span>' +
'<span class="profile-info-value" data-profile-time>UTC --:--</span>' +
'</div>' +
'<div class="profile-info-stat">' +
'<span class="profile-info-label">LANGUAGE</span>' +
'<span class="profile-info-value" data-profile-language>—</span>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
$('.profile-card').remove();
container.insertBefore(card, container.firstChild);
loadProfileFields(username, card);
loadProfileContributionPages(username, card);
updateProfilePageEnvironment(card, null);
if (isOwnPage) {
loadProfileProgressForUserPage(card);
}
}
function getProfileLanguageLabel() {
var lang = getCurrentLang();
return SIDEBAR_LANGUAGE_LABELS[lang] || (lang ? lang.toUpperCase() : '—');
}
function updateProfilePageEnvironment(card, summary) {
if (!card) return;
var timezone = summary && summary.timezone ? summary.timezone : 'UTC';
var timeEl = card.querySelector('[data-profile-time]');
var langEl = card.querySelector('[data-profile-language]');
if (timeEl) {
try {
timeEl.textContent = timezone + ' ' + new Intl.DateTimeFormat('ko-KR', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: timezone
}).format(new Date());
} catch (err) {
timeEl.textContent = 'UTC ' + new Intl.DateTimeFormat('ko-KR', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'UTC'
}).format(new Date());
}
}
if (langEl) {
langEl.textContent = getProfileLanguageLabel();
}
}
function loadProfileContributionPages(username, card) {
if (!username || !card) return;
if (!mw.loader || typeof mw.loader.using !== 'function') return;
var valueEl = card.querySelector('[data-contrib-pages-value]');
if (valueEl) valueEl.textContent = 'SYNC';
mw.loader.using(['mediawiki.api']).then(function () {
var api = new mw.Api();
var pages = Object.create(null);
var cont = {};
var guard = 0;
function requestNext() {
guard++;
var params = Object.assign({
action: 'query',
list: 'usercontribs',
ucuser: username,
ucnamespace: 0,
ucprop: 'title',
uclimit: 'max',
format: 'json',
formatversion: 2
}, cont);
return api.get(params).then(function (data) {
var rows = data && data.query && data.query.usercontribs ? data.query.usercontribs : [];
rows.forEach(function (row) {
if (row && row.title) pages[row.title] = true;
});
if (data && data.continue && data.continue.uccontinue && guard < 40) {
cont = data.continue;
return requestNext();
}
if (valueEl) valueEl.textContent = Object.keys(pages).length;
});
}
requestNext().fail(function () {
if (valueEl) valueEl.textContent = '—';
});
});
}
function loadProfileProgressForUserPage(card) {
if (!mw.config.get('wgUserName')) return;
if (!mw.loader || typeof mw.loader.using !== 'function') return;
mw.loader.using(['mediawiki.api']).then(function () {
var api = new mw.Api();
api.get({
action: 'progress_summary',
format: 'json',
formatversion: 2
}).then(function (data) {
var payload = data && data.progress_summary;
if (!payload || !payload.available || !payload.summary) return;
updateUserPageProgress(card, payload.summary);
updateProfilePageEnvironment(card, payload.summary);
});
});
}
function updateUserPageProgress(card, summary) {
var panel = card.querySelector('[data-profile-progress]');
if (!panel || !summary) return;
var level = summary.level || 1;
var totalXp = summary.totalXp || 0;
var xpIntoLevel = summary.xpIntoLevel || 0;
var xpForNext = summary.xpForNextLevel || 1;
var percent = Math.max(0, Math.min(100, summary.progressPercent || 0));
var isMaxLevel = !!summary.isMaxLevel;
var dailyXp = summary.dailyXp || 0;
var discoveries = summary.discoveryCount || 0;
panel.classList.remove('is-syncing');
panel.classList.toggle('is-max-level', isMaxLevel);
var levelEl = panel.querySelector('.profile-page-level');
var totalEl = panel.querySelector('.profile-page-total-xp');
var fillEl = panel.querySelector('.profile-page-xp-fill');
var subEl = panel.querySelector('.profile-page-progress-sub');
var metaEl = panel.querySelector('.profile-page-progress-meta');
if (levelEl) levelEl.textContent = (isMaxLevel ? 'MAX ' : 'LVL ') + level;
if (totalEl) totalEl.textContent = totalXp + ' XP';
if (fillEl) fillEl.style.width = percent + '%';
if (subEl) subEl.textContent = isMaxLevel ? 'MAX LEVEL' : (xpIntoLevel + ' / ' + xpForNext + ' TO NEXT');
if (metaEl) metaEl.textContent = 'TODAY ' + dailyXp + ' XP · DISCOVERED ' + discoveries;
}
function loadProfileFields(username, card) {
var api = new mw.Api();
api.get({
action: 'userprofile',
user: username
}).then(function(data) {
var profile = data.userprofile;
updateProfileFields(card, {
name: profile.name || '',
discord: profile.discord || '',
role: profile.role || '',
bio: profile.bio || ''
});
}).fail(function() {
updateProfileFields(card, {
name: '',
discord: '',
role: '',
bio: ''
});
});
}
function updateProfileFields(card, data) {
var nameEl = card.querySelector('[data-field="name"]');
var roleEl = card.querySelector('[data-field="role"]');
var discordEl = card.querySelector('[data-field="discord"]');
var bioEl = card.querySelector('[data-field="bio"]');
if (nameEl) nameEl.textContent = data.name || '';
if (roleEl) roleEl.textContent = data.role || '';
if (discordEl) discordEl.textContent = data.discord ? ('디스코드: ' + data.discord) : '';
if (bioEl) bioEl.textContent = data.bio || '';
}
// ========== 프로필 시스템 끝 ==========
// ========== 알림 시스템 ==========
function ensureNotificationPopup() {
if (document.getElementById('clbi-notification-popup')) return;
var popup = document.createElement('div');
popup.id = 'clbi-notification-popup';
popup.style.cssText =
'display:none;position:fixed;z-index:99999;width:320px;max-height:420px;' +
'background:#0a0909;border:2px solid #854369;border-radius:5px;' +
'box-shadow:0 0 0 1px #1a1a1a, 0 8px 24px rgba(0,0,0,0.55);overflow:hidden;';
popup.innerHTML =
'<div style="padding:10px 12px;border-bottom:2px solid #854369;background:linear-gradient(to bottom, #171114 0%, #0a0909 100%);color:#E2E2E2;font-size:13px;font-weight:700;display:flex;align-items:center;justify-content:space-between;gap:8px;">' +
'<span>알림</span>' +
'<button type="button" id="clbi-notification-readall" style="background:#171717;border:1px solid #854369;border-radius:6px;color:#E2E2E2;font-size:11px;font-weight:700;padding:4px 8px;cursor:pointer;">전체 읽음</button>' +
'</div>' +
'<div id="clbi-notification-list" style="max-height:320px;overflow-y:auto;padding:8px 0;color:#E2E2E2;font-size:12px;">불러오는 중...</div>' +
'<div style="padding:8px;border-top:1px solid #2a2a2a;background:#111;">' +
'<a href="/index.php?title=Special:Notifications" id="clbi-notification-more" style="display:block;width:100%;text-align:center;padding:8px 10px;border-radius:6px;background:#171717;border:1px solid #854369;color:#E2E2E2 !important;text-decoration:none !important;font-size:12px;font-weight:700;">더보기</a>' +
'</div>';
document.body.appendChild(popup);
}
function positionNotificationPopup() {
var btn = document.getElementById('profile-quick-notifications');
var popup = document.getElementById('clbi-notification-popup');
if (!btn || !popup) return;
var rect = btn.getBoundingClientRect();
var top = rect.bottom + 6;
var left = rect.left + (rect.width / 2) - (popup.offsetWidth / 2);
if (left < 8) left = 8;
if (left + popup.offsetWidth > window.innerWidth - 8) {
left = window.innerWidth - popup.offsetWidth - 8;
}
if (top + popup.offsetHeight > window.innerHeight - 8) {
top = Math.max(8, rect.top - popup.offsetHeight - 6);
}
popup.style.top = top + 'px';
popup.style.left = left + 'px';
}
function parseNotificationItemsFromHtml(html) {
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
var selectors = [
'.mw-echo-ui-notificationItemWidget',
'.mw-echo-ui-notificationsInboxWidgetRow',
'.echo-ui-notificationItemWidget',
'li[data-notification-id]',
'.mw-echo-notifications-list li'
];
var items = [];
for (var i = 0; i < selectors.length; i++) {
items = Array.prototype.slice.call(doc.querySelectorAll(selectors[i]));
if (items.length) break;
}
return items.slice(0, 5).map(function(item) {
var link = item.querySelector('a[href]');
var href = link ? link.getAttribute('href') : '/index.php?title=Special:Notifications';
var text = (item.textContent || '').replace(/\s+/g, ' ').trim();
var notificationId =
item.getAttribute('data-notification-id') ||
item.getAttribute('data-id') ||
item.getAttribute('data-notification') ||
'';
if (!notificationId) {
var anyWithId = item.querySelector('[data-notification-id], [data-id], [data-notification]');
if (anyWithId) {
notificationId =
anyWithId.getAttribute('data-notification-id') ||
anyWithId.getAttribute('data-id') ||
anyWithId.getAttribute('data-notification') ||
'';
}
}
if (href && href.indexOf('http') !== 0) {
href = href.charAt(0) === '/'
? href
: '/index.php' + (href.charAt(0) === '?' ? href : '/' + href);
}
return {
id: notificationId,
href: href,
text: text || '알림'
};
});
}
function setNotificationIcon(hasItems) {
var quickIcon = document.getElementById('profile-quick-notification-icon');
var svg = hasItems ? CLBI_SVG_BELL_DOT : CLBI_SVG_BELL;
if (quickIcon) {
quickIcon.innerHTML = svg;
quickIcon.classList.toggle('has-notifications', !!hasItems);
}
}
function renderNotificationPopup(items) {
var list = document.getElementById('clbi-notification-list');
var badge = document.getElementById('clbi-notification-badge');
if (!list) return;
if (!items || !items.length) {
list.innerHTML = '<div style="padding:14px 12px;color:#999;">표시할 알림이 없습니다.</div>';
if (badge) badge.style.display = 'none';
setNotificationIcon(false);
return;
}
var html = '';
for (var i = 0; i < items.length; i++) {
html +=
'<a href="' + items[i].href + '" class="clbi-notification-item" data-notification-id="' + (items[i].id || '') + '" style="display:block;padding:10px 12px;color:#E2E2E2 !important;text-decoration:none !important;border-bottom:1px solid #1f1f1f;line-height:1.5;">' +
items[i].text +
'</a>';
}
list.innerHTML = html;
if (badge) {
badge.textContent = items.length;
badge.style.display = 'block';
}
setNotificationIcon(true);
}
function loadNotificationsIntoPopup() {
var list = document.getElementById('clbi-notification-list');
if (list) {
list.innerHTML = '<div style="padding:14px 12px;color:#999;">불러오는 중...</div>';
}
fetch('/index.php?title=Special:Notifications', { credentials: 'same-origin' })
.then(function(res) {
return res.text();
})
.then(function(html) {
var items = parseNotificationItemsFromHtml(html);
renderNotificationPopup(items);
})
.catch(function(err) {
console.error(err);
if (list) {
list.innerHTML = '<div style="padding:14px 12px;color:#999;">알림을 불러오지 못했습니다.</div>';
}
});
}
function markAllNotificationsRead() {
return new mw.Api().postWithToken('csrf', {
action: 'echomarkread',
list: 'all'
});
}
function markNotificationReadById(notificationId) {
if (!notificationId) {
return $.Deferred().resolve().promise();
}
return new mw.Api().postWithToken('csrf', {
action: 'echomarkread',
list: notificationId
});
}
function initNotifications() {
var quickBtn = document.getElementById('profile-quick-notifications');
if (!quickBtn) return;
ensureNotificationPopup();
loadNotificationsIntoPopup();
$(document)
.off('click.clbiNotificationToggle')
.on('click.clbiNotificationToggle', '#profile-quick-notifications', function(e) {
e.preventDefault();
e.stopPropagation();
var popup = document.getElementById('clbi-notification-popup');
if (!popup) return;
if (popup.style.display === 'none' || popup.style.display === '') {
popup.style.display = 'block';
positionNotificationPopup();
loadNotificationsIntoPopup();
} else {
popup.style.display = 'none';
}
});
$(document)
.off('click.clbiNotificationOutside')
.on('click.clbiNotificationOutside', function(e) {
var popup = document.getElementById('clbi-notification-popup');
var quickToggle = document.getElementById('profile-quick-notifications');
if (!popup) return;
if (!popup.contains(e.target) && (!quickToggle || !quickToggle.contains(e.target))) {
popup.style.display = 'none';
}
});
$(document)
.off('click.clbiNotificationReadAll')
.on('click.clbiNotificationReadAll', '#clbi-notification-readall', function(e) {
e.preventDefault();
e.stopPropagation();
var button = this;
button.disabled = true;
button.textContent = '처리 중...';
markAllNotificationsRead()
.then(function() {
loadNotificationsIntoPopup();
})
.always(function() {
button.disabled = false;
button.textContent = '전체 읽음';
});
});
$(document)
.off('click.clbiNotificationItem')
.on('click.clbiNotificationItem', '.clbi-notification-item', function(e) {
e.preventDefault();
e.stopPropagation();
var href = this.getAttribute('href');
var notificationId = this.getAttribute('data-notification-id') || '';
markNotificationReadById(notificationId).always(function() {
loadNotificationsIntoPopup();
if (href) {
window.location.href = href;
}
});
});
$(window)
.off('resize.clbiNotification')
.on('resize.clbiNotification', function() {
var popup = document.getElementById('clbi-notification-popup');
if (popup && popup.style.display === 'block') {
positionNotificationPopup();
}
});
}
// ========== 알림 시스템 끝 ==========
function initUserProfilePage() {
$('body').addClass('user-profile-settings-page');
var saveBtn = document.getElementById('pref-save');
if (!saveBtn) return;
function getPrefRow(id) {
var el = document.getElementById(id);
if (!el) return null;
return el.closest('.clbi-pref-row') || el.parentNode;
}
function removePrefRow(id) {
var row = getPrefRow(id);
if (row && row.parentNode) {
row.parentNode.removeChild(row);
}
}
function createPrefSection(className, titleText) {
var section = document.createElement('div');
section.className = 'clbi-pref-section ' + className;
var title = document.createElement('div');
title.className = 'clbi-pref-section-title';
title.textContent = titleText;
var body = document.createElement('div');
body.className = 'clbi-pref-section-body';
section.appendChild(title);
section.appendChild(body);
return {
section: section,
body: body
};
}
function moveRowToSection(id, targetBody, className) {
var row = getPrefRow(id);
if (!row || !targetBody) return false;
row.classList.add('clbi-pref-row-key-' + className);
targetBody.appendChild(row);
return true;
}
function rebuildProfileSettingsLayout() {
var root = document.querySelector('.clbi-prefs-profile');
if (!root || root.dataset.profileSettingsReworked === '1') return;
root.dataset.profileSettingsReworked = '1';
root.classList.add('profile-settings-console');
removePrefRow('pref-badges');
var originalRows = Array.prototype.slice.call(root.querySelectorAll('.clbi-pref-row'));
var actionNodes = [];
if (saveBtn.parentNode === root || saveBtn.closest('.clbi-prefs-profile') === root) {
actionNodes.push(saveBtn);
}
var statusNode = document.getElementById('pref-status');
if (statusNode && statusNode.closest('.clbi-prefs-profile') === root) {
actionNodes.push(statusNode);
}
var main = document.createElement('div');
main.className = 'clbi-pref-main-grid';
var media = createPrefSection('clbi-pref-section-media', 'PROFILE IMAGE');
var identity = createPrefSection('clbi-pref-section-identity', 'IDENTITY RECORD');
var bio = createPrefSection('clbi-pref-section-bio', 'BIOGRAPHY');
var account = createPrefSection('clbi-pref-section-account', 'ACCOUNT CONTACT');
var misc = createPrefSection('clbi-pref-section-misc', 'OTHER OPTIONS');
main.appendChild(media.section);
main.appendChild(identity.section);
main.appendChild(bio.section);
main.appendChild(account.section);
main.appendChild(misc.section);
root.innerHTML = '';
root.appendChild(main);
moveRowToSection('pref-pfp-preview', media.body, 'pfp');
moveRowToSection('pref-pfp-btn', media.body, 'pfp');
moveRowToSection('pref-pfp-input', media.body, 'pfp');
moveRowToSection('pref-name', identity.body, 'name');
moveRowToSection('pref-role', identity.body, 'role');
moveRowToSection('pref-discord', identity.body, 'discord');
moveRowToSection('pref-bio', bio.body, 'bio');
moveRowToSection('pref-new-email', account.body, 'email');
moveRowToSection('pref-email-password', account.body, 'email');
moveRowToSection('pref-email-save', account.body, 'email');
originalRows.forEach(function (row) {
if (!row.parentNode && !row.className.match(/clbi-pref-row-key-/)) {
misc.body.appendChild(row);
}
});
if (!misc.body.children.length) {
misc.section.parentNode.removeChild(misc.section);
}
var actions = document.createElement('div');
actions.className = 'clbi-pref-actions';
if (saveBtn) actions.appendChild(saveBtn);
if (statusNode) actions.appendChild(statusNode);
root.appendChild(actions);
}
rebuildProfileSettingsLayout();
var api = new mw.Api();
var selectedFile = null;
var cropper = null;
if (!document.getElementById('clbi-gallery-modal')) {
var gModal = document.createElement('div');
gModal.id = 'clbi-gallery-modal';
gModal.style.cssText =
'display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:99999;align-items:center;justify-content:center;';
gModal.innerHTML =
'<div style="background:#1e1e1e;border:2px solid #854369;border-radius:12px;padding:24px;max-width:480px;width:90%;display:flex;flex-direction:column;gap:16px;">' +
'<div style="display:flex;justify-content:space-between;align-items:center;">' +
'<span style="font-size:14px;font-weight:700;color:#e2e2e2;">프로필 사진 선택</span>' +
'<button type="button" id="clbi-gallery-close" style="background:none;border:none;color:#aaa;font-size:18px;cursor:pointer;">✕</button>' +
'</div>' +
'<button type="button" id="clbi-gallery-upload-btn" style="background:#2a2a2a;border:2px dashed #854369;border-radius:8px;padding:32px;color:#e2e2e2;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:8px;font-size:13px;width:100%;">' +
'<span style="font-size:32px;">🖼️</span>새 사진 업로드' +
'</button>' +
'<div id="clbi-gallery-history-section" style="display:none;">' +
'<div style="font-size:11px;color:#888;margin-bottom:8px;">이전 사진 — 클릭하면 바로 적용</div>' +
'<div id="clbi-gallery-history" style="display:flex;gap:8px;flex-wrap:wrap;"></div>' +
'</div>' +
'</div>';
document.body.appendChild(gModal);
}
if (!document.getElementById('clbi-crop-modal')) {
var cModal = document.createElement('div');
cModal.id = 'clbi-crop-modal';
cModal.style.cssText =
'display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:99999;align-items:center;justify-content:center;';
cModal.innerHTML =
'<div style="background:#1e1e1e;border:2px solid #854369;border-radius:12px;padding:24px;max-width:500px;width:90%;display:flex;flex-direction:column;gap:16px;">' +
'<div style="font-size:14px;font-weight:700;color:#e2e2e2;">사진 조정</div>' +
'<div style="width:100%;max-height:380px;overflow:hidden;border-radius:8px;">' +
'<img id="clbi-crop-image" style="max-width:100%;">' +
'</div>' +
'<div style="display:flex;gap:8px;justify-content:flex-end;">' +
'<button type="button" id="clbi-crop-cancel" style="background:#2a2a2a;color:#e2e2e2;border:1px solid #444;padding:8px 16px;border-radius:6px;cursor:pointer;">취소</button>' +
'<button type="button" id="clbi-crop-confirm" style="background:#854369;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;">확정</button>' +
'</div>' +
'</div>';
document.body.appendChild(cModal);
}
var gModal = document.getElementById('clbi-gallery-modal');
var cModal = document.getElementById('clbi-crop-modal');
var cropImage = document.getElementById('clbi-crop-image');
var pfpInput = document.getElementById('pref-pfp-input');
function openGallery() {
gModal.style.display = 'flex';
var username = mw.config.get('wgUserName');
api.get({
action: 'query',
titles: '파일:Pfp-' + username + '.png',
prop: 'imageinfo',
iiprop: 'url|timestamp',
iilimit: 6
}).then(function(data) {
var pages = data.query.pages;
var page = pages[Object.keys(pages)[0]];
if (!page.imageinfo || page.imageinfo.length === 0) return;
var historyEl = document.getElementById('clbi-gallery-history');
var sectionEl = document.getElementById('clbi-gallery-history-section');
historyEl.innerHTML = '';
page.imageinfo.forEach(function(info, idx) {
var wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;cursor:pointer;';
var img = document.createElement('img');
img.src = info.url;
img.style.cssText =
'width:72px;height:72px;object-fit:cover;border-radius:8px;border:2px solid #444;flex-shrink:0;';
if (idx === 0) {
img.style.borderColor = '#854369';
var badge = document.createElement('div');
badge.textContent = '현재';
badge.style.cssText =
'position:absolute;bottom:4px;left:50%;transform:translateX(-50%);background:#854369;color:#fff;font-size:9px;padding:1px 6px;border-radius:10px;';
wrap.appendChild(badge);
}
img.addEventListener('mouseenter', function() {
if (idx !== 0) img.style.borderColor = '#854369';
});
img.addEventListener('mouseleave', function() {
if (idx !== 0) img.style.borderColor = '#444';
});
img.addEventListener('click', function() {
fetch(info.url)
.then(function(r) {
return r.blob();
})
.then(function(blob) {
selectedFile = new File([blob], 'profile.png', { type: 'image/png' });
document.getElementById('pref-pfp-preview').src = URL.createObjectURL(blob);
gModal.style.display = 'none';
document.getElementById('pref-pfp-btn').textContent = '✓ 사진 선택됨';
});
});
wrap.appendChild(img);
historyEl.appendChild(wrap);
});
sectionEl.style.display = 'block';
});
}
function openCrop(src) {
cropImage.src = src;
cModal.style.display = 'flex';
if (cropper) {
cropper.destroy();
cropper = null;
}
setTimeout(function() {
cropper = new Cropper(cropImage, {
aspectRatio: 1,
viewMode: 1,
dragMode: 'move',
autoCropArea: 0.8,
cropBoxResizable: true,
cropBoxMovable: true
});
}, 150);
}
document.getElementById('pref-pfp-btn').addEventListener('click', function() {
openGallery();
});
document.getElementById('clbi-gallery-upload-btn').addEventListener('click', function() {
pfpInput.click();
});
document.getElementById('clbi-gallery-close').addEventListener('click', function() {
gModal.style.display = 'none';
});
pfpInput.addEventListener('change', function() {
var file = this.files[0];
if (!file) return;
gModal.style.display = 'none';
var reader = new FileReader();
reader.onload = function(e) {
openCrop(e.target.result);
};
reader.readAsDataURL(file);
});
document.getElementById('clbi-crop-cancel').addEventListener('click', function() {
cModal.style.display = 'none';
if (cropper) {
cropper.destroy();
cropper = null;
}
pfpInput.value = '';
});
document.getElementById('clbi-crop-confirm').addEventListener('click', function() {
if (!cropper) return;
var canvas = cropper.getCroppedCanvas({ width: 256, height: 256 });
if (!canvas) return;
canvas.toBlob(function(blob) {
selectedFile = new File([blob], 'profile.png', { type: 'image/png' });
document.getElementById('pref-pfp-preview').src = URL.createObjectURL(blob);
cModal.style.display = 'none';
cropper.destroy();
cropper = null;
document.getElementById('pref-pfp-btn').textContent = '✓ 사진 선택됨';
}, 'image/png');
});
var emailSaveBtn = document.getElementById('pref-email-save');
if (emailSaveBtn) {
emailSaveBtn.addEventListener('click', function() {
var statusEl = document.getElementById('pref-email-status');
var newEmail = document.getElementById('pref-new-email').value;
var password = document.getElementById('pref-email-password').value;
if (!newEmail || !password) {
statusEl.textContent = '이메일과 비밀번호를 입력해주세요.';
return;
}
statusEl.textContent = '변경 중...';
api.postWithToken('csrf', {
action: 'changeemail',
email: newEmail,
password: password
}).then(function() {
statusEl.textContent = '✓ 이메일 변경됨';
document.getElementById('pref-new-email').value = '';
document.getElementById('pref-email-password').value = '';
setTimeout(function() {
statusEl.textContent = '';
}, 3000);
}).fail(function(code, data) {
var msg = data && data.error && data.error.info ? data.error.info : '변경 실패';
statusEl.textContent = msg;
});
});
}
saveBtn.addEventListener('click', function() {
var statusEl = document.getElementById('pref-status');
statusEl.textContent = '저장 중...';
var promises = [];
if (selectedFile) {
var username = mw.config.get('wgUserName');
promises.push(
api.postWithToken('csrf', {
action: 'upload',
filename: 'Pfp-' + username + '.png',
ignorewarnings: true,
file: selectedFile,
format: 'json'
}, {
contentType: 'multipart/form-data'
})
);
}
var fields = ['name', 'discord', 'role', 'bio'];
for (var i = 0; i < fields.length; i++) {
var el = document.getElementById('pref-' + fields[i]);
if (!el) continue;
promises.push(
api.postWithToken('csrf', {
action: 'options',
optionname: 'profile-' + fields[i],
optionvalue: el.value
})
);
}
$.when.apply($, promises)
.then(function() {
statusEl.textContent = '✓ 저장됨';
selectedFile = null;
document.getElementById('pref-pfp-btn').textContent = '사진 선택';
setTimeout(function() {
statusEl.textContent = '';
}, 2000);
})
.fail(function() {
statusEl.textContent = '저장 실패';
});
});
}
/* =========================================
Banner / CRT Page Monitor thumbnail slices
- base 이미지는 틀에 들어간 파일 문법 그대로 사용
- slice 레이어에는 300px MediaWiki 썸네일만 삽입
========================================= */
(function ($, mw) {
var thumbCache = {};
function parseSliceWidth(value) {
var parsed = parseInt(value, 10);
if (!isFinite(parsed) || parsed < 120) {
return 300;
}
return parsed;
}
function getImageSrc(img) {
return img ? (img.currentSrc || img.getAttribute('src') || img.src || '') : '';
}
function getFileNameFromSrc(src) {
var a;
var parts;
var fileName;
if (!src) return '';
a = document.createElement('a');
a.href = src;
parts = (a.pathname || '').split('/').filter(function (part) {
return !!part;
});
if (!parts.length) return '';
fileName = parts.pop();
/*
* MediaWiki thumb URL 예시:
* /images/thumb/a/ab/File.png/1000px-File.png
* /images/thumb/a/ab/File.svg/1000px-File.svg.png
*
* 이 경우 실제 파일명은 마지막 조각이 아니라 그 앞 조각이다.
*/
if (/^\d+px-/.test(fileName) && parts.length) {
fileName = parts.pop();
}
fileName = fileName.replace(/^\d+px-/, '');
try {
fileName = decodeURIComponent(fileName);
} catch (e) {}
return fileName;
}
function resolveThumbUrl(img, width, callback) {
var src = getImageSrc(img);
var fileName = getFileNameFromSrc(src);
var cacheKey;
var entry;
if (!src) return;
if (!fileName || !mw || !mw.loader) {
callback(src);
return;
}
cacheKey = fileName + '|' + width;
entry = thumbCache[cacheKey];
if (entry) {
if (entry.resolved) {
callback(entry.url || src);
} else {
entry.callbacks.push(callback);
}
return;
}
entry = {
resolved: false,
url: '',
callbacks: [callback]
};
thumbCache[cacheKey] = entry;
function finish(url) {
var callbacks = entry.callbacks.slice();
var i;
entry.resolved = true;
entry.url = url || src;
entry.callbacks = [];
for (i = 0; i < callbacks.length; i++) {
callbacks[i](entry.url);
}
}
mw.loader.using('mediawiki.api').done(function () {
var api = new mw.Api();
api.get({
action: 'query',
titles: 'File:' + fileName,
prop: 'imageinfo',
iiprop: 'url',
iiurlwidth: width,
formatversion: 2
}).done(function (data) {
var page;
var info;
if (
data &&
data.query &&
data.query.pages &&
data.query.pages.length
) {
page = data.query.pages[0];
if (
page &&
page.imageinfo &&
page.imageinfo.length
) {
info = page.imageinfo[0];
}
}
finish((info && (info.thumburl || info.url)) || src);
}).fail(function () {
finish(src);
});
}).fail(function () {
finish(src);
});
}
function applySliceImages(frame, thumbUrl) {
var slices;
var i;
var img;
if (!frame || !thumbUrl) return;
slices = frame.querySelectorAll('.crt-page-monitor-slice');
for (i = 0; i < slices.length; i++) {
slices[i].innerHTML = '';
img = document.createElement('img');
img.className = 'crt-page-monitor-slice-img';
img.src = thumbUrl;
img.alt = '';
img.decoding = 'async';
img.loading = 'eager';
img.setAttribute('aria-hidden', 'true');
slices[i].appendChild(img);
}
frame.setAttribute('data-crt-slices-ready', '1');
}
function initBannerFrame(frame) {
var baseImg;
var width;
if (!frame) return;
if (frame.getAttribute('data-crt-slices-ready') === '1') return;
baseImg = frame.querySelector('.crt-page-monitor-image-base img');
if (!baseImg) return;
width = parseSliceWidth(frame.getAttribute('data-crt-slice-width'));
resolveThumbUrl(baseImg, width, function (thumbUrl) {
if (!frame || !frame.parentNode) return;
applySliceImages(frame, thumbUrl);
});
}
function initBannerFrames(root) {
var scope = root && root.querySelectorAll ? root : document;
var frames = scope.querySelectorAll('.crt-page-monitor-frame');
var i;
for (i = 0; i < frames.length; i++) {
initBannerFrame(frames[i]);
}
}
$(function () {
initBannerFrames(document);
});
if (mw && mw.hook) {
mw.hook('wikipage.content').add(function ($content) {
initBannerFrames($content && $content[0] ? $content[0] : document);
});
}
})(jQuery, window.mw);
/* =========================================
Doc Tab System — Q/E 단축키 탭 전환
글리치 플리커 + RGB split + 방향 슬라이드
========================================= */
(function () {
'use strict';
var keydownBound = false;
function initDocTabs() {
var tabBars = document.querySelectorAll('.doc-tab-bar');
if (!tabBars.length) return;
tabBars.forEach(function (bar) {
if (bar.getAttribute('data-tabs-init')) return;
bar.setAttribute('data-tabs-init', '1');
var tabs = Array.from(bar.querySelectorAll('.doc-tab'));
if (!tabs.length) return;
var panel = bar.closest('.doc-panel');
var display = panel ? panel.querySelector('.doc-display') : null;
if (!display) display = document.getElementById('doc-main-display');
if (!display) return;
tabs.forEach(function (tab, i) {
tab.addEventListener('click', function () {
var currentIdx = tabs.findIndex(function (t) {
return t.classList.contains('active');
});
if (currentIdx === i) return;
switchTab(tabs, display, i, i > currentIdx ? 1 : -1);
});
});
var initIdx = tabs.findIndex(function (t) { return t.classList.contains('active'); });
if (initIdx !== -1) {
var initRef = tabs[initIdx].dataset.ref;
var initEl = initRef ? document.getElementById(initRef) : null;
display.innerHTML = initEl ? initEl.innerHTML : (tabs[initIdx].dataset.content || '');
}
});
if (!keydownBound) {
keydownBound = true;
document.addEventListener('keydown', function (e) {
var tag = document.activeElement.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
var bar = document.querySelector('.doc-tab-bar');
if (!bar) return;
var tabs = Array.from(bar.querySelectorAll('.doc-tab'));
var panel = bar.closest('.doc-panel');
var display = panel ? panel.querySelector('.doc-display') : null;
if (!display) display = document.getElementById('doc-main-display');
if (!display) return;
var activeIdx = tabs.findIndex(function (t) {
return t.classList.contains('active');
});
if (activeIdx === -1) return;
if (e.key === 'q' || e.key === 'Q') {
e.preventDefault();
var prev = (activeIdx - 1 + tabs.length) % tabs.length;
if (prev !== activeIdx) switchTab(tabs, display, prev, -1);
} else if (e.key === 'e' || e.key === 'E') {
e.preventDefault();
var next = (activeIdx + 1) % tabs.length;
if (next !== activeIdx) switchTab(tabs, display, next, 1);
}
});
}
}
var isAnimating = false;
function switchTab(tabs, display, nextIdx, dir) {
if (isAnimating) return;
isAnimating = true;
tabs.forEach(function (t) { t.classList.remove('active'); });
tabs[nextIdx].classList.add('active');
var ref = tabs[nextIdx].dataset.ref;
var nextContent;
if (ref) {
var refEl = document.getElementById(ref);
nextContent = refEl ? refEl.innerHTML : '';
} else {
nextContent = tabs[nextIdx].dataset.content || '';
}
glitchOut(display, dir, function () {
display.innerHTML = nextContent;
glitchIn(display, dir, function () {
isAnimating = false;
});
});
}
function glitchOut(el, dir, cb) {
var duration = 160;
var start = null;
var slideX = dir * 16;
function step(ts) {
if (!start) start = ts;
var p = Math.min((ts - start) / duration, 1);
var ease = p * p;
var tx = slideX * ease;
var skew = dir * ease * 1.0;
var opacity = 1 - ease;
var rgb = ease * 5;
el.style.transform = 'translateX(' + tx + 'px) skewX(' + skew + 'deg)';
el.style.opacity = opacity;
el.style.filter =
'drop-shadow(' + (-rgb) + 'px 0 0 rgba(80,160,255,0.75)) ' +
'drop-shadow(' + rgb + 'px 0 0 rgba(255,55,90,0.65)) ' +
'brightness(' + (1 + ease * 0.25) + ')';
if (p < 1) {
requestAnimationFrame(step);
} else {
el.style.opacity = '0';
cb();
}
}
requestAnimationFrame(step);
}
function glitchIn(el, dir, cb) {
var duration = 200;
var start = null;
var startX = -dir * 16;
el.style.transform = 'translateX(' + startX + 'px) skewX(' + (-dir * 1.0) + 'deg)';
el.style.opacity = '0';
function step(ts) {
if (!start) start = ts;
var p = Math.min((ts - start) / duration, 1);
var ease = 1 - Math.pow(1 - p, 3);
var tx = startX * (1 - ease);
var skew = -dir * 1.0 * (1 - ease);
var opacity = ease;
var rgb = (1 - ease) * 3;
var brightness = 1 + (1 - ease) * 0.35;
el.style.transform = 'translateX(' + tx + 'px) skewX(' + skew + 'deg)';
el.style.opacity = opacity;
el.style.filter =
'drop-shadow(' + (-rgb) + 'px 0 0 rgba(80,160,255,0.65)) ' +
'drop-shadow(' + rgb + 'px 0 0 rgba(255,55,90,0.55)) ' +
'brightness(' + brightness + ')';
if (p < 1) {
requestAnimationFrame(step);
} else {
el.style.transform = '';
el.style.opacity = '';
el.style.filter = '';
cb();
}
}
requestAnimationFrame(step);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initDocTabs);
} else {
initDocTabs();
}
if (typeof mw !== 'undefined' && mw.hook) {
mw.hook('wikipage.content').add(function () {
initDocTabs();
});
}
})();
/* =========================================
Doc Section Switch — 좌측 섹션 전환
========================================= */
$(document).on('click', '.doc-nav-item[data-section]', function () {
var name = $(this).attr('data-section');
var display = document.getElementById('doc-main-display');
var titleEl = document.getElementById('doc-center-title');
var tabBar = document.getElementById('doc-tab-bar-text');
if (!display) return;
$('.doc-nav-item[data-section]').removeClass('active');
$('.doc-nav-item[data-section="' + name + '"]').addClass('active');
if (name === 'text') {
if (titleEl) titleEl.textContent = '개요';
if (tabBar) $(tabBar).show();
var activeTab = tabBar ? tabBar.querySelector('.doc-tab.active') : null;
if (!activeTab && tabBar) activeTab = tabBar.querySelector('.doc-tab');
if (activeTab) {
var ref = activeTab.dataset.ref;
var refEl = ref ? document.getElementById(ref) : null;
display.innerHTML = refEl ? refEl.innerHTML : (activeTab.dataset.content || '');
}
} else {
if (titleEl) titleEl.textContent = name === 'factions' ? '세력' : name === 'people' ? '인물' : name;
if (tabBar) $(tabBar).hide();
var refEl = document.getElementById('doc-content-' + name);
display.innerHTML = refEl ? refEl.innerHTML : '';
}
});
/* =========================================
CRT WebGL Renderer — cool-retro-term IBM DOS style
========================================= */
(function () {
'use strict';
function createNoiseTexture(gl) {
var size = 512;
var data = new Uint8Array(size * size * 4);
var s = 12345;
function rand() {
s = (s * 1664525 + 1013904223) & 0xffffffff;
return (s >>> 0) / 0xffffffff;
}
for (var i = 0; i < data.length; i++) {
data[i] = (rand() * 255) | 0;
}
var tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, size, size, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
return tex;
}
var VERT = [
'attribute vec2 a_pos;',
'varying vec2 v_uv;',
'void main() {',
' v_uv = vec2(a_pos.x * 0.5 + 0.5, 0.5 - a_pos.y * 0.5);',
' gl_Position = vec4(a_pos, 0.0, 1.0);',
'}'
].join('\n');
var FRAG = [
'precision mediump float;',
'uniform sampler2D u_tex;',
'uniform sampler2D u_noise;',
'uniform vec2 u_res;',
'uniform vec2 u_imgSize;',
'uniform float u_time;',
'uniform vec2 u_noiseScale;',
'varying vec2 v_uv;',
'float sum2(vec2 v) { return v.x + v.y; }',
'float min2(vec2 v) { return min(v.x, v.y); }',
'float rgb2grey(vec3 v) { return dot(v, vec3(0.21, 0.72, 0.04)); }',
'vec2 coverUV(vec2 uv) {',
' float imgAR = u_imgSize.x / u_imgSize.y;',
' float scrAR = u_res.x / u_res.y;',
' float scale = imgAR / scrAR;',
' float offsetY = (1.0 - scale) * 0.5;',
' return vec2(uv.x, uv.y * scale + offsetY);',
'}',
'vec2 barrel(vec2 v, vec2 cc, float k) {',
' float ar = u_res.x / u_res.y;',
' vec2 c2 = cc;',
' if (ar > 1.0) c2.x /= ar; else c2.y *= ar;',
' float dist = dot(c2, c2) * k;',
' return v - cc * (1.0 + dist) * dist;',
'}',
'vec4 sampleInitialNoise(float t) {',
' return texture2D(u_noise, vec2(fract(t/2048.0), fract(t/1048576.0)));',
'}',
'vec4 sampleScreenNoise(vec2 uv) {',
' return texture2D(u_noise, u_noiseScale * uv);',
'}',
'vec3 applyRgbShift(vec2 texUV, float shift) {',
' vec2 d = vec2(shift, 0.0);',
' vec3 r = texture2D(u_tex, clamp(texUV + d, 0.0, 1.0)).rgb;',
' vec3 c = texture2D(u_tex, texUV).rgb;',
' vec3 l = texture2D(u_tex, clamp(texUV - d, 0.0, 1.0)).rgb;',
' return vec3(',
' l.r*0.10 + r.r*0.30 + c.r*0.60,',
' l.g*0.20 + r.g*0.20 + c.g*0.60,',
' l.b*0.30 + r.b*0.10 + c.b*0.60',
' );',
'}',
'vec3 applyBloom(vec2 texUV, float strength) {',
' vec2 px = 2.0 / u_res;',
' vec3 acc = vec3(0.0);',
' acc += texture2D(u_tex, clamp(texUV + vec2( px.x, 0.0), 0.0, 1.0)).rgb;',
' acc += texture2D(u_tex, clamp(texUV + vec2(-px.x, 0.0), 0.0, 1.0)).rgb;',
' acc += texture2D(u_tex, clamp(texUV + vec2( 0.0, px.y), 0.0, 1.0)).rgb;',
' acc += texture2D(u_tex, clamp(texUV + vec2( 0.0, -px.y), 0.0, 1.0)).rgb;',
' acc += texture2D(u_tex, clamp(texUV + vec2( px.x, px.y), 0.0, 1.0)).rgb * 0.5;',
' acc += texture2D(u_tex, clamp(texUV + vec2(-px.x, px.y), 0.0, 1.0)).rgb * 0.5;',
' acc += texture2D(u_tex, clamp(texUV + vec2( px.x, -px.y), 0.0, 1.0)).rgb * 0.5;',
' acc += texture2D(u_tex, clamp(texUV + vec2(-px.x, -px.y), 0.0, 1.0)).rgb * 0.5;',
' return acc / 6.0 * strength;',
'}',
'vec3 applyScanlines(vec2 uv, vec3 col) {',
' float line = mod(uv.y * u_res.y, 2.0);',
' vec3 hi = ((1.0 + 0.30) - 0.2 * col) * col;',
' vec3 lo = ((1.0 - 0.30) + 0.1 * col) * col;',
' return line < 1.0 ? lo : hi;',
'}',
'vec3 applyRasterization(vec2 uv, vec3 col) {',
' float t = u_time;',
' vec2 noiseUV = uv + vec2(fract(t * 0.030), fract(t * 0.060));',
' float wobbleX = (texture2D(u_noise, noiseUV * 0.8).r - 0.5) * 0.0018;',
' float wobbleY = (texture2D(u_noise, noiseUV * 0.8 + 0.5).r - 0.5) * 0.0008;',
' vec2 wobbledUV = clamp(uv + vec2(wobbleX, wobbleY), 0.0, 1.0);',
' vec3 wobbled = texture2D(u_tex, wobbledUV).rgb;',
' return mix(col, wobbled, 0.35);',
'}',
'float glowingLine(vec2 uv, float t) {',
' float pos = fract(t * 0.2);',
' float lineY = pos * (u_res.y + 330.0) - 120.0;',
' float y = uv.y * u_res.y;',
' return fract(smoothstep(-300.0, 0.0, y - lineY));',
'}',
'vec2 applyHSync(vec2 uv, vec4 noise, float strength) {',
' float randval = strength - noise.r;',
' float scale = step(0.0, randval) * randval * strength;',
' float freq = mix(4.0, 40.0, noise.g);',
' uv.x += sin((uv.y + u_time * 0.001) * freq) * scale;',
' return uv;',
'}',
'void main() {',
' vec2 cc = vec2(0.5) - v_uv;',
' float curvature = 0.18;',
' vec2 uv = barrel(v_uv, cc, curvature);',
' float inScreen = min2(step(vec2(0.0), uv) - step(vec2(1.0), uv));',
' if (inScreen < 0.5) { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); return; }',
' vec2 texUV = clamp(coverUV(uv), 0.0, 1.0);',
' vec4 initNoise = sampleInitialNoise(u_time);',
' vec4 screenNoise = sampleScreenNoise(uv);',
' texUV = applyHSync(texUV, initNoise, 0.006);',
' texUV = clamp(texUV, 0.0, 1.0);',
' texUV += (vec2(screenNoise.b, screenNoise.a) - 0.5) * 0.0006;',
' texUV = clamp(texUV, 0.0, 1.0);',
' vec3 col = applyRgbShift(texUV, 0.003);',
' col += applyBloom(texUV, 0.22);',
' vec2 bpx = 1.5 / u_res;',
' vec3 blurCol = vec3(0.0);',
' blurCol += texture2D(u_tex, clamp(texUV + vec2(-bpx.x, -bpx.y), 0.0, 1.0)).rgb;',
' blurCol += texture2D(u_tex, clamp(texUV + vec2( 0.0, -bpx.y), 0.0, 1.0)).rgb;',
' blurCol += texture2D(u_tex, clamp(texUV + vec2( bpx.x, -bpx.y), 0.0, 1.0)).rgb;',
' blurCol += texture2D(u_tex, clamp(texUV + vec2(-bpx.x, 0.0 ), 0.0, 1.0)).rgb;',
' blurCol += texture2D(u_tex, clamp(texUV + vec2( bpx.x, 0.0 ), 0.0, 1.0)).rgb;',
' blurCol += texture2D(u_tex, clamp(texUV + vec2(-bpx.x, bpx.y), 0.0, 1.0)).rgb;',
' blurCol += texture2D(u_tex, clamp(texUV + vec2( 0.0, bpx.y), 0.0, 1.0)).rgb;',
' blurCol += texture2D(u_tex, clamp(texUV + vec2( bpx.x, bpx.y), 0.0, 1.0)).rgb;',
' col = mix(col, blurCol / 8.0, 0.40);',
' col = applyScanlines(uv, col);',
' col = applyRasterization(texUV, col);',
' float glow = glowingLine(uv, u_time);',
' col += glow * 0.08 * vec3(0.85, 0.95, 1.0);',
' float dist = length(cc);',
' col += screenNoise.a * 0.07 * (1.0 - dist * 1.3);',
' float grey = rgb2grey(col);',
' vec3 phosphor = vec3(0.75, 0.88, 1.0);',
' col = mix(col, grey * phosphor, 0.35);',
' vec2 vig = v_uv * (1.0 - v_uv);',
' col *= pow(vig.x * vig.y * 15.0, 0.25);',
' col *= 1.0 + (initNoise.g - 0.5) * 0.06;',
' col += vec3(0.012) * (1.0 - dist) * (1.0 - dist);',
' col = pow(clamp(col, 0.0, 1.0), vec3(0.90));',
' gl_FragColor = vec4(col, 1.0);',
'}'
].join('\n');
function initCRTCanvas(screen, imgEl) {
var existing = screen.querySelector('.crt-webgl-canvas');
if (existing) existing.remove();
var canvas = document.createElement('canvas');
canvas.className = 'crt-webgl-canvas';
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;z-index:19;pointer-events:none;display:block;';
screen.appendChild(canvas);
var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) return;
function compile(type, src) {
var s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.error('[CRT shader]', gl.getShaderInfoLog(s));
}
return s;
}
var prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(prog);
gl.useProgram(prog);
var buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
var aPos = gl.getAttribLocation(prog, 'a_pos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
var uTex = gl.getUniformLocation(prog, 'u_tex');
var uNoise = gl.getUniformLocation(prog, 'u_noise');
var uRes = gl.getUniformLocation(prog, 'u_res');
var uImgSize = gl.getUniformLocation(prog, 'u_imgSize');
var uTime = gl.getUniformLocation(prog, 'u_time');
var uNoiseSc = gl.getUniformLocation(prog, 'u_noiseScale');
var imgTex = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, imgTex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.activeTexture(gl.TEXTURE1);
createNoiseTexture(gl);
var texReady = false;
function uploadImg() {
if (!imgEl || !imgEl.complete || !imgEl.naturalWidth) return;
try {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, imgTex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, imgEl);
texReady = true;
} catch(e) { console.error('[CRT] tex:', e); }
}
var lastW = 0, lastH = 0;
function resize() {
var w = screen.offsetWidth, h = screen.offsetHeight;
if (w === lastW && h === lastH) return;
lastW = w; lastH = h;
canvas.width = w; canvas.height = h;
gl.viewport(0, 0, w, h);
}
var raf;
var t0 = performance.now();
function render() {
raf = requestAnimationFrame(render);
if (!texReady) { uploadImg(); return; }
resize();
var t = (performance.now() - t0) / 1000;
gl.uniform1i(uTex, 0);
gl.uniform1i(uNoise, 1);
gl.uniform2f(uRes, canvas.width, canvas.height);
gl.uniform2f(uImgSize, imgEl.naturalWidth, imgEl.naturalHeight);
gl.uniform1f(uTime, t);
gl.uniform2f(uNoiseSc, canvas.width / 512.0, canvas.height / 512.0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
if (imgEl.complete && imgEl.naturalWidth) { uploadImg(); }
else { imgEl.addEventListener('load', uploadImg); }
render();
screen._crtCleanup = function () { cancelAnimationFrame(raf); };
}
function initAllCRTScreens(root) {
var scope = root && root.querySelectorAll ? root : document;
scope.querySelectorAll('.crt-page-monitor-screen').forEach(function (screen) {
if (screen.getAttribute('data-crt-webgl') === '1') return;
screen.setAttribute('data-crt-webgl', '1');
var frame = screen.closest('.crt-page-monitor-frame');
if (!frame) return;
var imgEl = frame.querySelector('.crt-page-monitor-slice-img, .crt-page-monitor-image-base img');
if (imgEl && imgEl.complete && imgEl.naturalWidth) {
initCRTCanvas(screen, imgEl);
} else if (imgEl) {
imgEl.addEventListener('load', function () { initCRTCanvas(screen, imgEl); });
} else {
var obs = new MutationObserver(function () {
var img = frame.querySelector('.crt-page-monitor-slice-img');
if (!img) return;
obs.disconnect();
if (img.complete && img.naturalWidth) {
initCRTCanvas(screen, img);
} else {
img.addEventListener('load', function () { initCRTCanvas(screen, img); });
}
});
obs.observe(frame, { childList: true, subtree: true });
}
});
}
$(function () { initAllCRTScreens(document); });
if (typeof mw !== 'undefined' && mw.hook) {
mw.hook('wikipage.content').add(function ($c) {
document.querySelectorAll('.crt-page-monitor-screen').forEach(function (s) {
if (s._crtCleanup) s._crtCleanup();
s.removeAttribute('data-crt-webgl');
});
initAllCRTScreens($c && $c[0] ? $c[0] : document);
});
}
})();
/* =========================================
Progress System UI
MediaWiki:Common.js controlled frontend
========================================= */
(function (mw, $) {
'use strict';
if (window.ProgressSystemWebUiInitialized) return;
window.ProgressSystemWebUiInitialized = true;
var api = null;
function withApi(done, fail) {
if (api) {
done(api);
return;
}
if (!mw.loader || typeof mw.loader.using !== 'function') {
if (typeof fail === 'function') fail();
return;
}
mw.loader.using(['mediawiki.api']).then(function () {
api = new mw.Api();
done(api);
}, function () {
if (typeof fail === 'function') fail();
});
}
var inFlightPageIds = new Set();
var handledPageIds = new Set();
var notificationQueue = [];
var notificationActive = false;
var summaryRequested = false;
var currentSummary = null;
var pendingSummary = null;
var pendingOptions = null;
var visibilityBound = false;
var barTimerA = null;
var barTimerB = null;
var barTimerC = null;
function isLoggedIn() {
return !!mw.config.get('wgUserName');
}
function getPageId() {
var id = parseInt(mw.config.get('wgArticleId') || 0, 10);
return Number.isFinite(id) ? id : 0;
}
function isRewardableClientSide() {
if (!isLoggedIn()) return false;
if (parseInt(mw.config.get('wgNamespaceNumber'), 10) !== 0) return false;
if (mw.config.get('wgIsMainPage')) return false;
if (getPageId() <= 0) return false;
return true;
}
function getPanelHtml() {
return '' +
'<div id="progress-panel" class="profile-progress-block is-syncing" aria-live="polite" data-progress-state="syncing">' +
'<div class="progress-title-row" hidden></div>' +
'<div class="progress-level-row">' +
'<span class="progress-level-label">SYNC</span>' +
'<span class="progress-total-xp">— XP</span>' +
'</div>' +
'<div class="progress-xp-bar" aria-hidden="true">' +
'<div class="progress-xp-gain"></div>' +
'<div class="progress-xp-fill"></div>' +
'</div>' +
'<div class="progress-sub-row">' +
'<span class="progress-xp-next">SYNCING</span>' +
'<span class="progress-daily-xp">TODAY —</span>' +
'</div>' +
'<div class="progress-discovery-row">DISCOVERED —</div>' +
'</div>';
}
function getDividerHtml() {
return '<div id="profile-progress-divider" class="profile-divider" aria-hidden="true"></div>';
}
function setPanelSync($panel) {
if (!$panel || !$panel.length) return;
$panel.addClass('is-syncing').removeClass('is-max-level').attr('data-progress-state', 'syncing');
$panel.find('.progress-title-row').text('').prop('hidden', true);
$panel.find('.progress-level-label').text('SYNC');
$panel.find('.progress-total-xp').text('— XP');
$panel.find('.progress-xp-next').text('SYNCING');
$panel.find('.progress-daily-xp').text('TODAY —');
$panel.find('.progress-discovery-row').text('DISCOVERED —');
$panel.find('.progress-xp-fill').css({ transition: 'none', width: '0%' });
$panel.find('.progress-xp-gain').css({ transition: 'none', width: '0%', opacity: 0 });
}
function placePanel($panel) {
var $right = $('#clbi-right-sidebar');
if (!$right.length) return false;
var $userBox = $right.children('.clbi-right-box').first();
if (!$userBox.length) return false;
var $buttonArea = $userBox.children('.clbi-right-content').first();
var $oldFallback = $panel.closest('.progress-panel-fallback');
if ($buttonArea.length) {
var $divider = $('#profile-progress-divider');
$panel.insertBefore($buttonArea);
if (!$divider.length) {
$divider = $(getDividerHtml());
}
$divider.insertAfter($panel);
} else {
$('#profile-progress-divider').remove();
$userBox.append($panel);
}
if ($oldFallback.length && !$oldFallback.find('#progress-panel').length) {
$oldFallback.remove();
}
return true;
}
function ensurePanel() {
if (!isLoggedIn()) return $();
var $right = $('#clbi-right-sidebar');
if (!$right.length) return $();
var $panel = $('#progress-panel');
if (!$panel.length) {
$panel = $(getPanelHtml());
if (!placePanel($panel)) return $();
setPanelSync($panel);
} else {
$panel.addClass('profile-progress-block');
placePanel($panel);
if (!currentSummary && $panel.attr('data-progress-state') !== 'syncing') {
setPanelSync($panel);
}
}
return $('#progress-panel');
}
function clampPercent(value) {
return Math.max(0, Math.min(100, value || 0));
}
function hasXpNotification(items) {
if (!items || !items.length) return false;
return items.some(function (item) {
return item && item.type === 'xp' && parseInt(item.amount || 0, 10) > 0;
});
}
function clearBarTimers() {
[barTimerA, barTimerB, barTimerC].forEach(function (timer) {
if (timer) clearTimeout(timer);
});
barTimerA = null;
barTimerB = null;
barTimerC = null;
}
function setBarInstant($fill, $gain, percent) {
clearBarTimers();
percent = clampPercent(percent);
$fill.css({ transition: 'none', width: percent + '%' });
$gain.css({ transition: 'none', left: '0%', width: '0%', opacity: 0 });
if ($fill[0]) $fill[0].offsetHeight;
$fill.css({ transition: '' });
$gain.css({ transition: '' });
}
function animateGain($fill, $gain, fromPercent, toPercent, levelChanged) {
clearBarTimers();
fromPercent = clampPercent(fromPercent);
toPercent = clampPercent(toPercent);
$fill.css({ transition: 'none', width: fromPercent + '%' });
if (levelChanged) {
var firstDelta = Math.max(0, 100 - fromPercent);
$gain.css({
transition: 'none',
opacity: firstDelta > 0 ? 1 : 0,
left: fromPercent + '%',
width: firstDelta + '%'
});
if ($fill[0]) $fill[0].offsetHeight;
barTimerA = setTimeout(function () {
$fill.css({
transition: 'width 540ms cubic-bezier(0.22, 0.7, 0.18, 1)',
width: '100%'
});
}, 260);
barTimerB = setTimeout(function () {
$fill.css({ transition: 'none', width: '0%' });
$gain.css({ transition: 'none', opacity: toPercent > 0 ? 1 : 0, left: '0%', width: toPercent + '%' });
if ($fill[0]) $fill[0].offsetHeight;
$fill.css({
transition: 'width 460ms cubic-bezier(0.22, 0.7, 0.18, 1)',
width: toPercent + '%'
});
}, 860);
barTimerC = setTimeout(function () {
$gain.css({ transition: 'opacity 180ms ease', opacity: 0 });
}, 1380);
return;
}
var delta = Math.max(0, toPercent - fromPercent);
if (delta <= 0.15) {
setBarInstant($fill, $gain, toPercent);
return;
}
$gain.css({
transition: 'none',
opacity: 1,
left: fromPercent + '%',
width: delta + '%'
});
if ($fill[0]) $fill[0].offsetHeight;
barTimerA = setTimeout(function () {
$fill.css({
transition: 'width 560ms cubic-bezier(0.22, 0.7, 0.18, 1)',
width: toPercent + '%'
});
}, 260);
barTimerB = setTimeout(function () {
$gain.css({ transition: 'opacity 180ms ease', opacity: 0 });
}, 940);
}
function updatePanel(summary, options) {
if (!summary) return;
options = options || {};
var $panel = ensurePanel();
if (!$panel.length) {
pendingSummary = $.extend({}, summary);
pendingOptions = $.extend({}, options);
return;
}
var level = summary.level || 1;
var totalXp = summary.totalXp || 0;
var xpIntoLevel = summary.xpIntoLevel || 0;
var xpForNext = summary.xpForNextLevel || 1;
var percent = clampPercent(summary.progressPercent);
var isMaxLevel = !!summary.isMaxLevel;
var dailyXp = summary.dailyXp || 0;
var discoveries = summary.discoveryCount || 0;
var title = summary.equippedTitle || summary.title || '';
$panel.removeClass('is-syncing').toggleClass('is-max-level', isMaxLevel).attr('data-progress-state', 'ready');
$panel.find('.progress-level-label').text((isMaxLevel ? 'MAX ' : 'LVL ') + level);
$panel.find('.progress-total-xp').text(totalXp + ' XP');
$panel.find('.progress-xp-next').text(isMaxLevel ? 'MAX LEVEL' : (xpIntoLevel + ' / ' + xpForNext + ' TO NEXT'));
$panel.find('.progress-daily-xp').text('TODAY ' + dailyXp + ' XP');
$panel.find('.progress-discovery-row').text('DISCOVERED ' + discoveries);
var $title = $panel.find('.progress-title-row');
if (title) {
$title.text(title).prop('hidden', false);
} else {
$title.text('').prop('hidden', true);
}
var $fill = $panel.find('.progress-xp-fill');
var $gain = $panel.find('.progress-xp-gain');
var animate = !!options.animateGain && currentSummary && totalXp > (currentSummary.totalXp || 0);
if (animate) {
animateGain(
$fill,
$gain,
clampPercent(currentSummary.progressPercent),
percent,
level !== (currentSummary.level || 1)
);
} else {
setBarInstant($fill, $gain, percent);
}
currentSummary = $.extend({}, summary);
pendingSummary = null;
pendingOptions = null;
}
function requestSummary() {
if (!isLoggedIn()) return;
if (summaryRequested) return;
summaryRequested = true;
withApi(function (api) {
api.get({
action: 'progress_summary',
format: 'json',
formatversion: 2
}).then(function (data) {
var payload = data && data.progress_summary;
if (payload && payload.available && payload.summary) {
updatePanel(payload.summary, { animateGain: false });
}
}).always(function () {
summaryRequested = false;
});
}, function () {
summaryRequested = false;
});
}
function queueNotifications(items) {
if (!items || !items.length) return;
items.forEach(function (item) {
if (!item) return;
notificationQueue.push(item);
});
showNextNotification();
}
function notificationText(item) {
if (item.type === 'xp') {
return '+' + (item.amount || 0) + ' XP · ' + (item.label || '문서 열람');
}
if (item.type === 'achievement') {
var xp = item.amount ? ' · +' + item.amount + ' XP' : '';
return '업적 달성 · ' + (item.label || '새 업적') + xp;
}
if (item.type === 'level') {
return item.label || '레벨 상승';
}
return item.label || '보상 획득';
}
function showNextNotification() {
if (notificationActive) return;
if (!notificationQueue.length) return;
notificationActive = true;
var item = notificationQueue.shift();
var $root = $('#progress-toast-root');
if (!$root.length) {
$('body').append('<div id="progress-toast-root"></div>');
$root = $('#progress-toast-root');
}
var $toast = $('<div class="progress-toast"></div>');
$toast.text(notificationText(item));
$root.append($toast);
requestAnimationFrame(function () {
$toast.addClass('is-visible');
});
setTimeout(function () {
$toast.removeClass('is-visible');
setTimeout(function () {
$toast.remove();
notificationActive = false;
showNextNotification();
}, 220);
}, 2600);
}
function applyPendingSummaryIfPossible() {
if (!pendingSummary) return;
updatePanel(pendingSummary, pendingOptions || { animateGain: false });
}
function handlePageView() {
ensurePanel();
applyPendingSummaryIfPossible();
if (!isRewardableClientSide()) {
requestSummary();
return;
}
var pageId = getPageId();
if (handledPageIds.has(pageId)) {
requestSummary();
return;
}
if (inFlightPageIds.has(pageId)) {
requestSummary();
return;
}
inFlightPageIds.add(pageId);
withApi(function (api) {
api.postWithToken('csrf', {
action: 'progress_view',
format: 'json',
formatversion: 2,
errorformat: 'plaintext',
pageid: pageId
}).then(function (data) {
var payload = data && data.progress_view;
if (!payload) return;
handledPageIds.add(pageId);
var animate = hasXpNotification(payload.notifications);
if (payload.summary) {
updatePanel(payload.summary, { animateGain: animate });
}
if (payload.notifications && payload.notifications.length) {
queueNotifications(payload.notifications);
}
}).catch(function () {
requestSummary();
}).always(function () {
inFlightPageIds.delete(pageId);
});
}, function () {
inFlightPageIds.delete(pageId);
requestSummary();
});
}
function bindVisibilitySync() {
if (visibilityBound) return;
visibilityBound = true;
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') {
requestSummary();
}
});
}
function bootProgressSystem(reason) {
ensurePanel();
applyPendingSummaryIfPossible();
if (isRewardableClientSide()) {
handlePageView();
} else {
requestSummary();
}
}
function handleSpaPageView() {
ensurePanel();
applyPendingSummaryIfPossible();
requestAnimationFrame(function () {
setTimeout(function () {
handlePageView();
}, 80);
});
}
function applySummary(summary, options) {
updatePanel(summary, options || { animateGain: false });
}
window.ProgressSystemWebUi = {
boot: bootProgressSystem,
requestSummary: requestSummary,
applySummary: applySummary,
handlePageView: handlePageView,
handleSpaPageView: handleSpaPageView,
ensurePanel: ensurePanel
};
$(function () {
bindVisibilitySync();
bootProgressSystem('documentReady');
});
mw.hook('wikipage.content').add(function () {
ensurePanel();
applyPendingSummaryIfPossible();
});
})(mediaWiki, jQuery);