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