fix(study): iPadOS callout 메뉴 차단 — selectstart capture + body user-select 강제

스크린샷 root cause: ㄱ stroke 후 iPadOS Apple Pencil Scribble / Apple Intelligence
가 펜 stroke 를 텍스트 선택 제스처로 해석 → "복사하기 / 선택 영역 찾기 / 찾아보기 /
번역" callout 메뉴 등장 → 메뉴 떠 있는 동안 펜 입력이 메뉴 인터랙션으로 흡수되어
캔버스 stroke 차단 (체감상 ㄱ→ㅏ hang). 메뉴 등장 시 페이지 fit 변경이 사용자에겐
"1사분면 확대" 로 인식. 즉 두 증상 모두 같은 root cause.

element CSS user-select:none 만으로는 OS 레벨 Pencil 인식 차단 못 함.

Fix:
- document.addEventListener('selectstart', ..., { capture: true }) — 모든 자식의
  selection start 를 capture phase 에서 가로채기 + preventDefault.
- selectionchange 시 즉시 removeAllRanges — 어떤 경로로든 selection 이 잡히면 해제.
- document.documentElement / document.body 에 webkitUserSelect=none, userSelect=none,
  webkitTouchCallout=none inline 강제. Svelte 컴포넌트 스코프가 닿지 않는 root
  element 가 selection origin 인 케이스 차단.
- onDestroy 에서 모두 원복 (다른 페이지 selection 영향 없음).

OS 레벨 추가 비활성화 옵션 (사용자 직접): iPadOS 설정 > Apple Pencil > Scribble.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-27 12:38:01 +09:00
parent 3cb065c7e3
commit 877a5f79d1
@@ -65,12 +65,40 @@
};
}
// iPadOS Apple Pencil Scribble / Apple Intelligence 가 펜 stroke 를 텍스트 선택
// 제스처로 해석해 callout 메뉴 ("복사하기 / 선택 영역 찾기 / 찾아보기 / 번역") 를
// 띄우면 펜 입력이 메뉴 인터랙션으로 흡수되어 캔버스 stroke 가 막힘 (ㄱ→ㅏ hang
// 의 진짜 원인). element CSS user-select:none 만으로는 OS 레벨 동작을 못 막음.
// → document 레벨 selectstart capture 차단 + selectionchange clear 로 selection
// 이 어떤 경로로든 잡히면 즉시 해제.
function clearSelection() {
const sel = document.getSelection?.();
if (sel && sel.rangeCount > 0) { try { sel.removeAllRanges(); } catch {} }
}
function blockSelectStart(e) {
e.preventDefault();
clearSelection();
}
onMount(() => {
load();
document.addEventListener('gesturestart', blockGesture, { passive: false });
document.addEventListener('gesturechange', blockGesture, { passive: false });
document.addEventListener('gestureend', blockGesture, { passive: false });
document.addEventListener('wheel', blockPinchWheel, { passive: false });
// capture phase 로 모든 자식 element 의 selectstart 차단.
document.addEventListener('selectstart', blockSelectStart, { capture: true, passive: false });
document.addEventListener('selectionchange', clearSelection);
// html/body 까지 user-select:none 강제 — Svelte 컴포넌트 스코프가 닿지 않는
// root element 가 selection origin 이 되는 케이스 차단. onDestroy 에서 복원.
document.documentElement.style.webkitUserSelect = 'none';
document.documentElement.style.userSelect = 'none';
document.body.style.webkitUserSelect = 'none';
document.body.style.userSelect = 'none';
// @ts-ignore - vendor prop
document.documentElement.style.webkitTouchCallout = 'none';
// @ts-ignore
document.body.style.webkitTouchCallout = 'none';
});
onDestroy(() => {
if (typeof document !== 'undefined') {
@@ -78,6 +106,17 @@
document.removeEventListener('gesturechange', blockGesture);
document.removeEventListener('gestureend', blockGesture);
document.removeEventListener('wheel', blockPinchWheel);
document.removeEventListener('selectstart', blockSelectStart, { capture: true });
document.removeEventListener('selectionchange', clearSelection);
// 다른 페이지에서 selection 가능하도록 원복.
document.documentElement.style.webkitUserSelect = '';
document.documentElement.style.userSelect = '';
document.body.style.webkitUserSelect = '';
document.body.style.userSelect = '';
// @ts-ignore
document.documentElement.style.webkitTouchCallout = '';
// @ts-ignore
document.body.style.webkitTouchCallout = '';
}
});