Greasy Fork is available in English.

RisuRealm UI Plus

RisuRealm의 긴 텍스트 넘침을 수정하고 페이지 이동 및 오른쪽 고정 스크롤 버튼을 추가합니다.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         RisuRealm UI Plus
// @namespace    https://realm.risuai.net/
// @version      1.0.9
// @license      MIT
// @description  Fix long text overflow and add quick page jump controls to RisuRealm.
// @description:en Fix long text overflow and add quick page jump controls to RisuRealm.
// @description:zh-CN 修复 RisuRealm 长文本溢出,并增强分页跳转与右侧悬浮滚动按钮。
// @description:ko RisuRealm의 긴 텍스트 넘침을 수정하고 페이지 이동 및 오른쪽 고정 스크롤 버튼을 추가합니다.
// @description:ja RisuRealm の長いテキストのはみ出しを修正し、ページ移動と右側固定スクロールボタンを追加します。
// @match        https://realm.risuai.net/*
// @icon         https://realm.risuai.net/favicon.ico
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  window.__RisuRealmUiPlus = true;

  const css = `
    html,
    body {
      max-width: 100% !important;
      overflow-x: hidden !important;
      overflow-x: clip !important;
    }

    body :where(p, li, a, pre, code),
    body :where(article, section, div, span):not(:has(img, video, canvas, svg)) {
      overflow-wrap: anywhere !important;
      word-break: break-word;
    }

    body :where(pre, code) {
      white-space: pre-wrap !important;
    }

    .rrp-pager {
      display: none !important;
    }

    .rrui-native-hidden {
      display: none !important;
    }

    .rrui-pager {
      align-items: center;
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      justify-content: center;
      margin: 16px auto 10px;
      max-width: min(100%, 720px);
      padding: 0 12px;
    }

    .rrui-pager button,
    .rrui-pager input {
      background: #e5e7eb;
      border: 1px solid rgba(17, 24, 39, 0.12);
      border-radius: 999px;
      box-sizing: border-box;
      color: #1f2937;
      flex: 0 0 auto;
      font: 600 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      height: 36px;
      white-space: nowrap;
    }

    .rrui-pager button {
      cursor: pointer;
      min-width: 36px;
      padding: 0 13px;
    }

    .rrui-pager button:disabled {
      cursor: not-allowed;
      opacity: 0.45;
    }

    .rrui-pager .rrui-page-current:disabled {
      background: #f43f5e;
      border-color: #fb7185;
      color: #fff;
      opacity: 1;
    }

    .rrui-pager input {
      max-width: 120px;
      min-width: 42px;
      padding: 0 10px;
      text-align: center;
      width: auto;
    }

    .rrui-pager input::-webkit-inner-spin-button,
    .rrui-pager input::-webkit-outer-spin-button {
      -webkit-appearance: none;
      margin: 0;
    }

    .rrui-page-label {
      color: rgba(17, 24, 39, 0.72);
      font: 600 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }

    .rrui-jump-row {
      align-items: center;
      display: flex;
      flex: 0 0 100%;
      flex-wrap: wrap;
      gap: 8px;
      justify-content: center;
      margin-top: 2px;
    }

    .rrui-scroll-controls {
      bottom: 92px;
      display: flex;
      flex-direction: column;
      gap: 8px;
      position: fixed;
      right: 16px;
      z-index: 9999;
    }

    .rrui-scroll-controls button {
      align-items: center;
      background: #e5e7eb;
      border: 1px solid rgba(17, 24, 39, 0.14);
      border-radius: 999px;
      box-shadow: 0 8px 22px rgba(15, 23, 42, 0.16);
      color: #374151;
      cursor: pointer;
      display: flex;
      font: 800 18px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      height: 44px;
      justify-content: center;
      min-width: 44px;
      padding: 0;
      width: 44px;
    }

    .rrui-scroll-controls button[hidden],
    .rrui-scroll-controls[hidden] {
      display: none !important;
    }

    @media (max-width: 640px) {
      .rrui-scroll-controls {
        bottom: 82px;
        right: 10px;
      }
    }
  `;

  let pager;
  let scrollControls;
  let scheduled = false;
  let scrollScheduled = false;
  const FILTER_PARAMS = ['sort', 'mode', 'nsfw'];

  addStyle(css);
  repairCurrentUrl();
  ready(() => {
    render();
    ensureScrollControls();
    updateScrollControls();
    observe();
    patchHistory();
    document.addEventListener('click', normalizeLinkNavigation, true);
    window.addEventListener('scroll', scheduleScrollControls, { passive: true });
    window.addEventListener('resize', scheduleScrollControls);
    window.addEventListener('popstate', scheduleRender);
  });

  function ready(callback) {
    if (document.body) {
      callback();
      return;
    }
    document.addEventListener('DOMContentLoaded', callback, { once: true });
  }

  function render() {
    const nativePager = findNativePager();
    if (!nativePager || !isListPage()) {
      if (pager) pager.remove();
      pager = null;
      document.querySelectorAll('.rrui-native-hidden').forEach((node) => node.classList.remove('rrui-native-hidden'));
      return;
    }

    nativePager.classList.add('rrui-native-hidden');
    if (!pager) {
      pager = document.createElement('form');
      pager.className = 'rrui-pager';
      pager.addEventListener('submit', (event) => {
        event.preventDefault();
        const inputNode = pager.querySelector('input');
        goToPage(Number(inputNode && inputNode.value));
      });
      nativePager.insertAdjacentElement('afterend', pager);
    } else if (pager.previousElementSibling !== nativePager) {
      nativePager.insertAdjacentElement('afterend', pager);
    }

    const page = currentPage();
    if (pager.dataset.rruiPage === String(page)) return;
    pager.dataset.rruiPage = String(page);
    pager.innerHTML = '';
    pager.append(
      button('首页', () => goToPage(1), page <= 1),
      button('-10', () => goToPage(page - 10), page <= 1),
      button('上一页', () => goToPage(page - 1), page <= 1),
      ...pageButtons(page),
      button('下一页', () => goToPage(page + 1)),
      button('+10', () => goToPage(page + 10)),
      jumpRow(page)
    );
  }

  function pageButtons(page) {
    const nodes = [];
    const start = Math.max(1, page - 2);
    const end = page + 2;

    if (start > 1) {
      nodes.push(label('...'));
    }

    for (let n = start; n <= end; n += 1) {
      const node = button(String(n), () => goToPage(n), n === page);
      if (n === page) node.classList.add('rrui-page-current');
      nodes.push(node);
    }

    nodes.push(label('...'));
    return nodes;
  }

  function jumpRow(page) {
    const node = document.createElement('div');
    node.className = 'rrui-jump-row';
    node.append(
      label('第'),
      input(page),
      label('页'),
      button('跳转', () => goToPage(Number(node.querySelector('input').value)))
    );
    return node;
  }

  function findNativePager() {
    return Array.from(document.querySelectorAll('div.mt-4.w-full.flex.justify-center.items-center'))
      .find((node) => node.querySelectorAll('button').length >= 2 && /^\d+$/.test((node.textContent || '').trim()));
  }

  function isListPage() {
    return location.pathname === '/' || location.pathname === '';
  }

  function isListPath(pathname) {
    return pathname === '/' || pathname === '';
  }

  function currentPage() {
    const page = Number(new URL(location.href).searchParams.get('page') || 1);
    return Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
  }

  function goToPage(page) {
    if (!Number.isFinite(page)) return;
    const nextPage = Math.max(1, Math.floor(page));
    const url = new URL(location.href);
    if (!url.searchParams.has('sort')) url.searchParams.set('sort', '');
    url.searchParams.set('page', String(nextPage));
    normalizeListQuery(url);
    location.href = url.href;
  }

  function normalizeListUrl(rawUrl) {
    if (rawUrl == null) return rawUrl;
    let url;
    try {
      url = new URL(rawUrl, location.href);
    } catch (error) {
      return rawUrl;
    }
    if (url.origin !== location.origin || !isListPath(url.pathname)) return rawUrl;

    const current = new URL(location.href);
    const filterChanged = FILTER_PARAMS.some((key) => (current.searchParams.get(key) || '') !== (url.searchParams.get(key) || ''));
    if (filterChanged) url.searchParams.set('page', '1');
    if (url.searchParams.has('page') && !url.searchParams.has('sort')) url.searchParams.set('sort', '');
    normalizeListQuery(url);
    return url.href;
  }

  function normalizeListQuery(url) {
    const page = Number(url.searchParams.get('page') || 1);
    if ((url.searchParams.get('sort') || '') === 'recommended' && Number.isFinite(page) && page > 1) {
      url.searchParams.set('sort', '');
    }

    const original = new URLSearchParams(url.search);
    const ordered = new URLSearchParams();
    ['sort', 'mode', 'page', 'nsfw'].forEach((key) => {
      if (original.has(key)) ordered.set(key, original.get(key) || '');
    });
    original.forEach((value, key) => {
      if (!['sort', 'mode', 'page', 'nsfw'].includes(key)) ordered.append(key, value);
    });
    url.search = ordered.toString();
  }

  function repairCurrentUrl() {
    const nextUrl = normalizeListUrl(location.href);
    if (nextUrl !== location.href) location.replace(nextUrl);
  }

  function normalizeLinkNavigation(event) {
    if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
    const target = event.target && (event.target.nodeType === 1 ? event.target : event.target.parentElement);
    const link = target && target.closest ? target.closest('a[href]') : null;
    if (!link) return;
    const nextUrl = normalizeListUrl(link.getAttribute('href'));
    if (nextUrl === link.href || nextUrl === link.getAttribute('href')) return;
    event.preventDefault();
    location.href = nextUrl;
  }

  function button(text, onClick, disabled) {
    const node = document.createElement('button');
    node.type = 'button';
    node.textContent = text;
    node.disabled = Boolean(disabled);
    node.addEventListener('click', onClick);
    return node;
  }

  function input(page) {
    const node = document.createElement('input');
    node.type = 'number';
    node.min = '1';
    node.step = '1';
    node.inputMode = 'numeric';
    node.value = String(page);
    node.setAttribute('aria-label', '页码');
    resizePageInput(node);
    node.addEventListener('input', () => resizePageInput(node));
    return node;
  }

  function resizePageInput(node) {
    const digits = Math.max(1, String(node.value || '').length);
    node.style.width = `${Math.min(120, Math.max(42, 24 + digits * 9))}px`;
  }

  function label(text) {
    const node = document.createElement('span');
    node.className = 'rrui-page-label';
    node.textContent = text;
    return node;
  }

  function ensureScrollControls() {
    if (scrollControls) return;
    scrollControls = document.createElement('div');
    scrollControls.className = 'rrui-scroll-controls';
    scrollControls.append(
      scrollButton('↑', '回到顶部', () => scrollToEdge('top')),
      scrollButton('↓', '回到底部', () => scrollToEdge('bottom'))
    );
    document.body.append(scrollControls);
  }

  function scrollButton(text, title, onClick) {
    const node = button(text, onClick);
    node.title = title;
    node.setAttribute('aria-label', title);
    return node;
  }

  function scrollToEdge(edge) {
    const height = Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
    window.scrollTo({ top: edge === 'top' ? 0 : height, behavior: 'smooth' });
  }

  function scheduleScrollControls() {
    if (scrollScheduled) return;
    scrollScheduled = true;
    requestAnimationFrame(() => {
      scrollScheduled = false;
      updateScrollControls();
    });
  }

  function updateScrollControls() {
    if (!scrollControls) return;
    const scrollTop = window.scrollY || document.documentElement.scrollTop || 0;
    const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
    const up = scrollControls.children[0];
    const down = scrollControls.children[1];

    scrollControls.hidden = maxScroll <= 8;
    up.hidden = scrollTop <= 8;
    down.hidden = scrollTop >= maxScroll - 8;
  }

  function scheduleRender() {
    if (scheduled) return;
    scheduled = true;
    requestAnimationFrame(() => {
      scheduled = false;
      render();
      scheduleScrollControls();
    });
  }

  function observe() {
    new MutationObserver(() => {
      scheduleRender();
      scheduleScrollControls();
    }).observe(document.body, { childList: true, subtree: true });
  }

  function patchHistory() {
    ['pushState', 'replaceState'].forEach((method) => {
      const original = history[method];
      history[method] = function patchedHistoryMethod() {
        if (arguments.length > 2) arguments[2] = normalizeListUrl(arguments[2]);
        const result = original.apply(this, arguments);
        scheduleRender();
        return result;
      };
    });
  }

  function addStyle(text) {
    const style = document.createElement('style');
    style.textContent = text;
    (document.head || document.documentElement).append(style);
  }
})();