From 877a5f79d1b333fd343c60b196c982d221d26532 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 27 Apr 2026 12:38:01 +0900 Subject: [PATCH] =?UTF-8?q?fix(study):=20iPadOS=20callout=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EC=B0=A8=EB=8B=A8=20=E2=80=94=20selectstart=20capt?= =?UTF-8?q?ure=20+=20body=20user-select=20=EA=B0=95=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스크린샷 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) --- .../src/routes/study/write/[id]/+page.svelte | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/frontend/src/routes/study/write/[id]/+page.svelte b/frontend/src/routes/study/write/[id]/+page.svelte index 9159b34..f2ee1ae 100644 --- a/frontend/src/routes/study/write/[id]/+page.svelte +++ b/frontend/src/routes/study/write/[id]/+page.svelte @@ -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 = ''; } });