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 เพื่อติดตั้งสคริปต์นี้

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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 });
})();