Two-Finger Selection Expander

Long-press with two fingers to select a whole block. Hold one finger down and tap above/below with another to extend the selection block by block.

이 스크립트를 설치하려면 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         Two-Finger Selection Expander
// @namespace    http://tampermonkey.net/
// @version      2.1
// @description  Long-press with two fingers to select a whole block. Hold one finger down and tap above/below with another to extend the selection block by block.
// @author       AI
// @license      MIT
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // ---- 설정값 ----
    const LONG_PRESS_MS = 500;
    const RELEASE_GRACE_MS = 250;
    const EXTEND_GRANULARITIES = ['sentence', 'line', 'paragraph']; // modify()로 시도할 단위들
    const SCROLL_MARGIN_RATIO = 0.1; // 새로 확장된 부분과 화면 가장자리 사이에 둘 여백 비율
    const SCROLL_BEHAVIOR = 'auto'; // 스크롤 애니메이션 방식. 'auto'는 즉시 이동

    const BLOCK_DISPLAY_VALUES = new Set([
        'block', 'list-item', 'table', 'table-row', 'table-row-group', 'flex', 'grid', 'flow-root'
    ]);

    // 흔한 블록 태그는 getComputedStyle(강제 레이아웃 계산 유발) 없이 태그명만으로 빠르게 판별.
    // CSS로 display를 바꾼 특이 케이스만 아래에서 getComputedStyle로 폴백한다.
    const BLOCK_TAG_NAMES = new Set([
        'P', 'DIV', 'LI', 'UL', 'OL', 'TABLE', 'TR', 'TD', 'TH', 'THEAD', 'TBODY', 'TFOOT',
        'BLOCKQUOTE', 'PRE', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SECTION', 'ARTICLE',
        'HEADER', 'FOOTER', 'ASIDE', 'NAV', 'FIGURE', 'FIGCAPTION', 'DD', 'DT', 'DL',
        'FORM', 'ADDRESS', 'MAIN', 'HR'
    ]);

    // ---- 상태 ----
    let baseTouchIds = null;
    let touchStartTime = 0;
    let activated = false;
    let activationTimer = null;

    let pendingHoldId = null;
    let pendingReleaseTimer = null;

    let extendModeActive = false;
    let holdTouchId = null;

    function resetAll() {
        baseTouchIds = null;
        activated = false;
        clearTimeout(activationTimer);
        activationTimer = null;

        clearTimeout(pendingReleaseTimer);
        pendingReleaseTimer = null;
        pendingHoldId = null;

        extendModeActive = false;
        holdTouchId = null;
    }

    // touches 목록에서 excludeId가 아닌 첫 번째 터치를 찾는다 (배열 생성 없이).
    function findOtherTouch(touches, excludeId) {
        for (let i = 0; i < touches.length; i++) {
            if (touches[i].identifier !== excludeId) return touches[i];
        }
        return null;
    }

    // touches(TouchList) 안에 해당 identifier가 있는지 확인 (배열 생성 없이).
    function touchListHasId(touches, id) {
        for (let i = 0; i < touches.length; i++) {
            if (touches[i].identifier === id) return true;
        }
        return false;
    }

    // 부모로 한 단계씩 올라가되, 자식이 하나뿐인 래퍼 요소처럼 선택 범위가
    // 시각적으로 전혀 넓어지지 않는 조상은 건너뛰고, 실제로 텍스트가 늘어나는
    // 첫 조상에서 확장한다. (문단마다 래퍼 div가 여러 겹인 사이트에서
    // "여러 번 눌러도 안 바뀌다가 한 번에 문서 전체로 점프"하는 문제를 방지)
    function runParentExpand() {
        const selection = window.getSelection();
        if (selection.rangeCount === 0) return;

        const currentLen = selection.toString().trim().length;

        const range = selection.getRangeAt(0);
        let candidate = range.startContainer.parentNode;

        while (candidate && candidate.nodeType === Node.ELEMENT_NODE) {
            const candidateLen = (candidate.textContent || '').trim().length;
            if (candidateLen > currentLen) break;
            candidate = candidate.parentNode;
        }

        if (candidate && candidate.nodeType === Node.ELEMENT_NODE) {
            selection.selectAllChildren(candidate);
        }
    }

    // ---- block-level 요소 판별 ----
    function isBlockElement(el) {
        if (!el || el.nodeType !== Node.ELEMENT_NODE) return false;
        if (el === document.body || el === document.documentElement) return false;
        if (BLOCK_TAG_NAMES.has(el.tagName)) return true;
        let style;
        try { style = window.getComputedStyle(el); } catch (e) { return false; }
        return BLOCK_DISPLAY_VALUES.has(style.display);
    }

    function hasVisibleText(el) {
        return !!(el && el.textContent && el.textContent.trim().length > 0);
    }

    // startNode 기준으로 다음/이전 block 요소를 찾는다.
    // 같은 레벨의 형제를 먼저 훑고, 없으면 부모로 올라가서 그 형제들을 훑는 방식으로
    // 트리를 타고 올라가며 탐색한다.
    function findAdjacentBlock(startNode, direction) {
        let el = startNode.nodeType === Node.ELEMENT_NODE ? startNode : startNode.parentElement;
        while (el && el.parentElement && !isBlockElement(el)) {
            el = el.parentElement;
        }
        if (!el) return null;

        let current = el;
        while (current && current !== document.body && current !== document.documentElement) {
            let sibling = direction === 'next' ? current.nextElementSibling : current.previousElementSibling;
            while (sibling) {
                if (isBlockElement(sibling) && hasVisibleText(sibling)) {
                    return sibling;
                }
                sibling = direction === 'next' ? sibling.nextElementSibling : sibling.previousElementSibling;
            }
            current = current.parentElement;
        }
        return null;
    }

    // modify()로 진전이 없을 때, 인접 block 요소를 통째로 선택에 포함시키는 수동 fallback
    function extendToAdjacentBlock(selection, extendTop) {
        const focusNode = selection.focusNode;
        if (!focusNode) return false;

        const block = findAdjacentBlock(focusNode, extendTop ? 'previous' : 'next');
        if (!block) return false;

        const targetRange = document.createRange();
        try {
            targetRange.selectNodeContents(block);
            targetRange.collapse(extendTop); // extendTop=true -> 시작으로, false -> 끝으로
            selection.extend(targetRange.startContainer, targetRange.startOffset);
        } catch (e) {
            return false;
        }
        return true;
    }

    // ---- anchor/focus를 현재 Range의 실제 시작/끝 기준으로 재설정 ----
    function normalizeAnchorFocus(selection, extendTop) {
        const range = selection.getRangeAt(0);
        if (extendTop) {
            selection.setBaseAndExtent(
                range.endContainer, range.endOffset,
                range.startContainer, range.startOffset
            );
        } else {
            selection.setBaseAndExtent(
                range.startContainer, range.startOffset,
                range.endContainer, range.endOffset
            );
        }
    }

    // 이번 탭으로 "새로 추가된 부분"만 잘라서 그 위치가 화면 밖이면 정확히 그만큼만 스크롤
    function scrollToNewEdge(beforeNode, beforeOffset, selection, extendTop) {
        if (!selection.focusNode) return;

        const incrementRange = document.createRange();
        try {
            if (extendTop) {
                incrementRange.setStart(selection.focusNode, selection.focusOffset);
                incrementRange.setEnd(beforeNode, beforeOffset);
            } else {
                incrementRange.setStart(beforeNode, beforeOffset);
                incrementRange.setEnd(selection.focusNode, selection.focusOffset);
            }
        } catch (e) {
            return; // 새 지점과 이전 지점이 비교 불가능한 위치면 스크롤 생략
        }

        const rect = incrementRange.getBoundingClientRect();
        if (!rect || (rect.width === 0 && rect.height === 0)) return;

        const margin = window.innerHeight * SCROLL_MARGIN_RATIO;

        if (extendTop && rect.top < margin) {
            window.scrollBy({ top: rect.top - margin, behavior: SCROLL_BEHAVIOR });
        } else if (!extendTop && rect.bottom > window.innerHeight - margin) {
            window.scrollBy({ top: rect.bottom - (window.innerHeight - margin), behavior: SCROLL_BEHAVIOR });
        }
    }

    // ---- 한 블록 확장: sentence -> line -> paragraph -> 인접 block 직접 연결 ----
    function extendSelectionByBlock(extendTop) {
        const selection = window.getSelection();
        if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;

        try {
            normalizeAnchorFocus(selection, extendTop);
        } catch (e) {
            resetAll();
            return;
        }

        const direction = extendTop ? 'backward' : 'forward';
        const beforeNode = selection.focusNode;
        const beforeOffset = selection.focusOffset;
        let moved = false;

        for (const granularity of EXTEND_GRANULARITIES) {
            try {
                selection.modify('extend', direction, granularity);
            } catch (e) {
                continue;
            }
            if (selection.focusNode !== beforeNode || selection.focusOffset !== beforeOffset) {
                moved = true;
                break;
            }
        }

        if (!moved) {
            // modify()가 문단 경계 등에서 막혔으면 DOM에서 인접 block을 직접 찾아 이어붙인다
            moved = extendToAdjacentBlock(selection, extendTop);
        }

        if (!moved) return; // 문서의 시작/끝에 도달 - 더 확장할 게 없음

        if (selection.rangeCount === 0 || selection.isCollapsed) {
            resetAll();
            return;
        }

        scrollToNewEdge(beforeNode, beforeOffset, selection, extendTop);
    }

    function triggerExtend(clientY) {
        const selection = window.getSelection();
        if (selection.rangeCount > 0 && !selection.isCollapsed) {
            const extendTop = clientY < window.innerHeight / 2;
            extendSelectionByBlock(extendTop);
        }
    }

    // iOS Safari 네이티브 텍스트 선택 세션(콜아웃 메뉴 + 돋보기)을 강제로 리셋한다.
    // -webkit-user-select를 순간적으로 껐다 켜서 WebKit의 텍스트
    // 인터랙션 세션을 인위적으로 파기(teardown)시키면, 매번 "동작하는 경로"를
    // 타도록 유도할 수 있다.
    function forceNativeSelectionReset() {
        const selection = window.getSelection();
        let savedRange = null;
        if (selection.rangeCount > 0 && !selection.isCollapsed) {
            savedRange = selection.getRangeAt(0).cloneRange();
        }

        const el = document.documentElement;
        const prevUserSelect = el.style.webkitUserSelect;
        const prevTouchCallout = el.style.webkitTouchCallout;

        el.style.webkitUserSelect = 'none';
        el.style.webkitTouchCallout = 'none';
        void el.offsetHeight; // reflow 강제: 스타일 변경이 실제로 적용되도록

        el.style.webkitUserSelect = prevUserSelect || '';
        el.style.webkitTouchCallout = prevTouchCallout || '';

        if (savedRange) {
            selection.removeAllRanges();
            selection.addRange(savedRange);
        }
    }

    // ---- 터치 이벤트 ----
    document.addEventListener("touchstart", (event) => {
        if (extendModeActive) {
            // 유지 손가락 외에 새 손가락이 닿으면 그 위치로 확장 (배열 생성 없이 처리)
            if (event.touches.length === 2 && touchListHasId(event.touches, holdTouchId)) {
                const newTouch = findOtherTouch(event.touches, holdTouchId);
                if (newTouch) triggerExtend(newTouch.clientY);
            }
            return;
        }

        if (pendingReleaseTimer !== null) {
            clearTimeout(pendingReleaseTimer);
            pendingReleaseTimer = null;

            const newTouch = findOtherTouch(event.touches, pendingHoldId);
            extendModeActive = true;
            holdTouchId = pendingHoldId;
            pendingHoldId = null;

            if (newTouch) triggerExtend(newTouch.clientY);
            return;
        }

        if (event.touches.length === 2) {
            baseTouchIds = [event.touches[0].identifier, event.touches[1].identifier];
            touchStartTime = Date.now();
            activated = false;
            clearTimeout(activationTimer);
            activationTimer = setTimeout(() => {
                activated = true;
                forceNativeSelectionReset();
            }, LONG_PRESS_MS);
        }
        // 우리 제스처와 무관한 터치(손가락 1개, 3개 이상 등)는 여기서 아무 일도 하지 않고 끝난다.
    }, { passive: true });

    document.addEventListener("touchend", (event) => {
        if (extendModeActive) {
            if (!touchListHasId(event.touches, holdTouchId)) {
                resetAll();
            }
            return;
        }

        if (pendingReleaseTimer !== null) {
            if (!touchListHasId(event.touches, pendingHoldId)) {
                clearTimeout(pendingReleaseTimer);
                runParentExpand();
                resetAll();
            }
            return;
        }

        if (!baseTouchIds) return; // 우리 제스처와 무관한 touchend는 즉시 종료

        const stillHasBoth = touchListHasId(event.touches, baseTouchIds[0]) &&
            touchListHasId(event.touches, baseTouchIds[1]);
        if (stillHasBoth) return;

        const remaining = baseTouchIds.filter(id => touchListHasId(event.touches, id));

        if (activated && remaining.length === 1 && event.touches.length === 1) {
            pendingHoldId = remaining[0];
            clearTimeout(activationTimer);
            pendingReleaseTimer = setTimeout(() => {
                extendModeActive = true;
                holdTouchId = pendingHoldId;
                pendingHoldId = null;
                pendingReleaseTimer = null;
            }, RELEASE_GRACE_MS);
            return;
        }

        if (activated) {
            runParentExpand();
        }
        resetAll();
    }, { passive: true });

    document.addEventListener("touchcancel", () => {
        resetAll();
    }, { passive: true });
})();