Picviewer Simple

Simple image viewer — hover icon or double click opens a minimal zoom+rotate viewer.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Picviewer Simple
// @version      1.1
// @description  Simple image viewer — hover icon or double click opens a minimal zoom+rotate viewer.
// @author       almahmud
// @homepageURL    https://thealmahmud.blogspot.com/
// @namespace https://greasyfork.org/users/1238578
// @license      GPL-2.0
// @match        *://*/*
// @grant        none
// @run-at       document-end
// @icon        https://i.ibb.co.com/CKS9K5v2/picviewer-simple.png
// ==/UserScript==

(function () {
  'use strict';

  // ---------- Config ----------
  const ICON_SIZE = 22;    // px
  const ICON_RAISE_PX = 8; //
  const RIGHT_OFFSET = 8;  //
  const GLOBAL_Z = 2147483647;

  // Viewer config
  const ZOOM_STEP = 0.12;
  const ZOOM_MIN = 0.1;
  const ZOOM_MAX = 8;
  const ROTATE_STEP = 90;

  // ---------- Viewer overlay & UI  ----------
  const overlay = document.createElement('div');
  overlay.id = 'pv-overlay-simple';
  overlay.tabIndex = -1;
  Object.assign(overlay.style, {
    position: 'fixed',
    inset: '0',
    display: 'none',
    alignItems: 'center',
    justifyContent: 'center',
    background: 'rgba(0,0,0,0.85)',
    zIndex: String(GLOBAL_Z - 1),
    backdropFilter: 'blur(2px)'
  });

  const panel = document.createElement('div');
  Object.assign(panel.style, {
    position: 'relative',
    maxWidth: '95vw',
    maxHeight: '95vh',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    flexDirection: 'column'
  });

  const vimg = document.createElement('img');
  Object.assign(vimg.style, {
    maxWidth: '92vw',
    maxHeight: '80vh',
    transition: 'transform 0.12s ease',
    cursor: 'grab',
    userSelect: 'none',
    // keep image below controls
    position: 'relative',
    zIndex: '0'
  });

  // Controls look
  const controls = document.createElement('div');
  Object.assign(controls.style, {
    marginTop: '10px',
    display: 'flex',
    gap: '8px',
    alignItems: 'center',
    background: 'rgba(255,255,255,0.04)',
    padding: '6px 8px',
    borderRadius: '6px',
    zIndex: String(GLOBAL_Z),
    pointerEvents: 'auto',
    color: '#fff',
    fontSize: '13px'
  });

  function makeBtn(text, title) {
    const b = document.createElement('button');
    b.type = 'button';
    b.textContent = text;
    b.title = title || text;
    Object.assign(b.style, {
      background: 'rgba(255,255,255,0.06)',
      color: '#fff',
      border: '1px solid rgba(255,255,255,0.06)',
      padding: '6px 8px',
      borderRadius: '4px',
      cursor: 'pointer',
      fontSize: '14px'
    });
    b.addEventListener('mouseenter', () => b.style.filter = 'brightness(1.06)');
    b.addEventListener('mouseleave', () => b.style.filter = 'none');
    return b;
  }

  const btnZoomIn = makeBtn('+', 'Zoom in (+)');
  const btnZoomOut = makeBtn('−', 'Zoom out (-)');
  const btnRotateLeft = makeBtn('⟲', 'Rotate left ([)');
  const btnRotateRight = makeBtn('⟳', 'Rotate right (])');
  const btnReset = makeBtn('Reset', 'Reset (0)');
  const btnClose = makeBtn('Close', 'Close (Esc)');

  controls.append(btnZoomOut, btnZoomIn, btnRotateLeft, btnRotateRight, btnReset, btnClose);

  panel.appendChild(vimg);
  panel.appendChild(controls);
  overlay.appendChild(panel);
  document.body.appendChild(overlay);

  // Viewer state & helpers
  let scale = 1, rotation = 0, viewerOpen = false;
  function applyTransform() { vimg.style.transform = `rotate(${rotation}deg) scale(${scale})`; }
  function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
  function openViewer(src) {
    vimg.src = src;
    scale = 1;
    rotation = 0;
    applyTransform();
    overlay.style.display = 'flex';
    viewerOpen = true;
    setTimeout(() => overlay.focus && overlay.focus(), 0);
  }
  function closeViewer() {
    overlay.style.display = 'none';
    viewerOpen = false;
    vimg.src = '';
  }
  function zoomBy(delta) {
    scale = clamp(parseFloat((scale + delta).toFixed(3)), ZOOM_MIN, ZOOM_MAX);
    applyTransform();
  }
  function rotateBy(deg) {
    rotation = (rotation + deg) % 360;
    applyTransform();
  }

  // Controls events
  btnZoomIn.addEventListener('click', () => zoomBy(ZOOM_STEP));
  btnZoomOut.addEventListener('click', () => zoomBy(-ZOOM_STEP));
  btnRotateLeft.addEventListener('click', () => rotateBy(-ROTATE_STEP));
  btnRotateRight.addEventListener('click', () => rotateBy(ROTATE_STEP));
  btnReset.addEventListener('click', () => { scale = 1; rotation = 0; applyTransform(); });
  btnClose.addEventListener('click', closeViewer);

  overlay.addEventListener('click', (e) => { if (e.target === overlay) closeViewer(); });
  overlay.addEventListener('wheel', (e) => { if (!viewerOpen) return; e.preventDefault(); zoomBy(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP); }, { passive: false });

  window.addEventListener('keydown', (e) => {
    if (!viewerOpen) return;
    const a = document.activeElement;
    if (a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || a.isContentEditable)) return;
    if (e.key === 'Escape') closeViewer();
    else if (e.key === '+' || e.key === '=') { e.preventDefault(); zoomBy(ZOOM_STEP); }
    else if (e.key === '-' || e.key === '_') { e.preventDefault(); zoomBy(-ZOOM_STEP); }
    else if (e.key === '[') { e.preventDefault(); rotateBy(-ROTATE_STEP); }
    else if (e.key === ']') { e.preventDefault(); rotateBy(ROTATE_STEP); }
    else if (e.key === '0') { e.preventDefault(); scale = 1; rotation = 0; applyTransform(); }
  });

  // ---------- Small magnifier icon per-image ----------
  const icons = new WeakMap(); // img -> { icon, handlers }

  const baseStyle = document.createElement('style');
  baseStyle.textContent = `
    .pv-mini-icon {
      position: absolute;
      width: ${ICON_SIZE}px;
      height: ${ICON_SIZE}px;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      background: rgba(0,0,0,0.14);
      color: #fff;
      border-radius: 4px;
      cursor: pointer;
      opacity: 0;
      z-index: ${GLOBAL_Z};
      pointer-events: none;
      box-shadow: 0 1px 3px rgba(0,0,0,0.22);
    }
    .pv-mini-icon.show { opacity: 0.92; pointer-events: auto; }
    .pv-mini-icon svg { width: 12px; height: 12px; stroke-width: 1.6; }
  `;
  document.head.appendChild(baseStyle);

  function makeSVG() {
    return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
      <circle cx="11" cy="11" r="7"></circle>
      <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
    </svg>`;
  }

  function positionIconFor(img, icon) {
    const rect = img.getBoundingClientRect();
    if (rect.width === 0 || rect.height === 0) { icon.style.display = 'none'; return; }
    icon.style.display = '';
    const inset = 6;
    const top = Math.max(0, rect.top + window.scrollY + inset - ICON_RAISE_PX);
    const left = Math.max(0, rect.left + window.scrollX + rect.width - ICON_SIZE - inset + RIGHT_OFFSET);
    icon.style.top = top + 'px';
    icon.style.left = left + 'px';
  }

  function addIcon(img) {
    if (!img || icons.has(img)) return;
    // create icon
    const icon = document.createElement('div');
    icon.className = 'pv-mini-icon';
    icon.innerHTML = makeSVG();
    icon.style.position = 'absolute';
    icon.style.width = ICON_SIZE + 'px';
    icon.style.height = ICON_SIZE + 'px';
    document.body.appendChild(icon);

    const show = () => { positionIconFor(img, icon); icon.classList.add('show'); };
    const hide = () => { icon.classList.remove('show'); };

    img.addEventListener('mouseenter', show, { passive: true });
    img.addEventListener('mouseleave', hide, { passive: true });

    // keep icon visible while hovering the icon itself
    icon.addEventListener('mouseenter', show);
    icon.addEventListener('mouseleave', hide);

    // click opens viewer and prevent navigation
    const onClick = (e) => {
      e.preventDefault();
      e.stopPropagation();
      const src = img.currentSrc || img.src || img.getAttribute('data-src') || img.getAttribute('data-original');
      if (src) openViewer(src);
    };
    icon.addEventListener('click', onClick);

    icons.set(img, { icon, show, hide, onClick });
    // initial position
    positionIconFor(img, icon);
  }

  function removeIcon(img) {
    const ent = icons.get(img);
    if (!ent) return;
    try {
      img.removeEventListener('mouseenter', ent.show);
      img.removeEventListener('mouseleave', ent.hide);
      ent.icon.removeEventListener('mouseenter', ent.show);
      ent.icon.removeEventListener('mouseleave', ent.hide);
      ent.icon.removeEventListener('click', ent.onClick);
      if (ent.icon.parentNode) ent.icon.parentNode.removeChild(ent.icon);
    } catch (err) {}
    icons.delete(img);
  }

  function scanAndAttach(root = document) {
    const imgs = root.querySelectorAll ? root.querySelectorAll('img') : [];
    for (const im of imgs) addIcon(im);
  }

  // initial scan
  scanAndAttach(document);

  // keep icons positioned on scroll/resize
  let scheduled = false;
  function updateAll() {
    if (scheduled) return;
    scheduled = true;
    requestAnimationFrame(() => {
      scheduled = false;
      for (const im of document.images || []) {
        const ent = icons.get(im);
        if (ent) positionIconFor(im, ent.icon);
      }
    });
  }
  window.addEventListener('scroll', updateAll, true);
  window.addEventListener('resize', updateAll);

  // Mutation observer for dynamic images
  const mo = new MutationObserver((records) => {
    for (const rec of records) {
      for (const n of rec.addedNodes) {
        if (!(n instanceof Element)) continue;
        if (n.tagName === 'IMG') addIcon(n);
        else {
          const imgs = n.querySelectorAll && n.querySelectorAll('img');
          if (imgs) for (const im of imgs) addIcon(im);
        }
      }
      for (const n of rec.removedNodes) {
        if (!(n instanceof Element)) continue;
        if (n.tagName === 'IMG') removeIcon(n);
        else {
          const imgs = n.querySelectorAll && n.querySelectorAll('img');
          if (imgs) for (const im of imgs) removeIcon(im);
        }
      }
    }
    updateAll();
  });
  mo.observe(document.documentElement || document.body, { childList: true, subtree: true });

  // dblclick still opens viewer
  document.addEventListener('dblclick', (e) => {
    const im = e.target && (e.target.tagName === 'IMG' ? e.target : e.target.closest && e.target.closest('img'));
    if (!im) return;
    const src = im.currentSrc || im.src || im.getAttribute('data-src') || im.getAttribute('data-original');
    if (!src) return;
    openViewer(src);
    e.preventDefault();
    e.stopPropagation();
  }, { passive: false });

  // periodic cleanup for removed images
  setInterval(() => {
    for (const im of Array.from(document.images || [])) {
      if (!document.body.contains(im)) removeIcon(im);
    }
  }, 7000);

  // expose debug API
  window.PicViewer = { open: openViewer, close: closeViewer };

})();