diff --git a/frontend/src/lib/components/HandwriteCanvas.svelte b/frontend/src/lib/components/HandwriteCanvas.svelte index 9049c2b..f6ee202 100644 --- a/frontend/src/lib/components/HandwriteCanvas.svelte +++ b/frontend/src/lib/components/HandwriteCanvas.svelte @@ -118,7 +118,7 @@ canvas.style.height = `${cssHeight}px`; const ctx = canvas.getContext('2d'); if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - redraw(); + scheduleRedraw(); } // ── render — perfect-freehand 표준 getSvgPathFromStroke + Path2D fill. @@ -191,6 +191,18 @@ } } + // RAF throttle — pointermove 가 60Hz 보다 빠르게 들어와도 redraw 는 frame 당 1회. + // input 처리를 redraw 와 분리해서 빠른 입력 누락 방지. + let rafScheduled = false; + function scheduleRedraw() { + if (rafScheduled) return; + rafScheduled = true; + requestAnimationFrame(() => { + rafScheduled = false; + redraw(); + }); + } + // ── pointer 헬퍼 ── function getLocalXY(e: PointerEvent): [number, number] { if (!canvas) return [0, 0]; @@ -270,7 +282,7 @@ isDirty = true; backupToLocalStorage(); scheduleSave(); - redraw(); + scheduleRedraw(); } return; } @@ -279,7 +291,7 @@ id: `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, points: [[x, y, normalizePressure(e.pressure)]], }; - redraw(); + scheduleRedraw(); } function onPointerMove(e: PointerEvent) { @@ -295,7 +307,7 @@ isDirty = true; backupToLocalStorage(); scheduleSave(); - redraw(); + scheduleRedraw(); } return; } @@ -311,7 +323,7 @@ } else { inflight.points.push([x, y, normalizePressure(e.pressure)]); } - redraw(); + scheduleRedraw(); } function endStroke(e: PointerEvent) { @@ -339,7 +351,7 @@ scheduleSave(); } inflight = null; - redraw(); + scheduleRedraw(); } // ── undo/redo/clear ── @@ -351,7 +363,7 @@ isDirty = true; backupToLocalStorage(); scheduleSave(); - redraw(); + scheduleRedraw(); } function redo() { if (undoStack.length === 0) return; @@ -361,7 +373,7 @@ isDirty = true; backupToLocalStorage(); scheduleSave(); - redraw(); + scheduleRedraw(); } function clearAll() { if (strokes.length === 0) return; @@ -371,16 +383,16 @@ isDirty = true; backupToLocalStorage(); scheduleSave(); - redraw(); + scheduleRedraw(); } - // ── 자동 저장 디바운스 ── + // ── 자동 저장 디바운스 ── stroke 입력 루프와 완전 분리. + // - 빈도: 3초 idle 만 (5 stroke 즉시 flush 제거 — 빠른 필기 중 직렬화 부하 방지) + // - 호출은 setTimeout 0 으로 다음 macrotask 에 ship → JSON.stringify 가 + // pointermove 와 충돌하지 않도록. function scheduleSave() { if (saveTimer) clearTimeout(saveTimer); - saveTimer = window.setTimeout(flushSave, 5000) as unknown as number; - if (strokes.length > 0 && strokes.length % 5 === 0) { - flushSave(); - } + saveTimer = window.setTimeout(flushSave, 3000) as unknown as number; } function flushSave() { if (saveTimer) { @@ -389,7 +401,8 @@ } if (!isDirty) return; isDirty = false; - onChange?.({ version: 1, strokes }); + const snapshot = { version: 1 as const, strokes }; + setTimeout(() => onChange?.(snapshot), 0); } // ── snapshot (PNG) ── diff --git a/frontend/src/routes/study/write/[id]/+page.svelte b/frontend/src/routes/study/write/[id]/+page.svelte index 97cddcb..f723fda 100644 --- a/frontend/src/routes/study/write/[id]/+page.svelte +++ b/frontend/src/routes/study/write/[id]/+page.svelte @@ -6,7 +6,7 @@ * 모바일에서도 캔버스가 화면을 거의 전부 차지하도록. * 메타 편집 / asset 목록은 헤더 "패널" 버튼으로 열고 닫는다. */ - import { onMount } from 'svelte'; + import { onMount, onDestroy } from 'svelte'; import { page } from '$app/stores'; import { api, uploadFile } from '$lib/api'; import { addToast } from '$lib/stores/toast'; @@ -36,15 +36,40 @@ } } - // iOS Safari 핀치/zoom gesture 차단 — 페이지 root 영역에만 적용 (영역 제한). + // iOS Safari 핀치/zoom gesture 차단 — document level 등록 (element-level + // ongesturestart 가 일부 iPad Safari 빌드에서 미발화하는 케이스 방어). + // 페이지 unmount 시 onDestroy 에서 cleanup. function blockGesture(e) { e.preventDefault(); } + // 추가: touchstart 가 두 손가락이면 preventDefault — 일부 Safari 빌드에서 + // gesture 이벤트 미발화 시에도 핀치 제스처 자체를 차단. + function blockMultiTouch(e) { + if (e.touches && e.touches.length > 1) e.preventDefault(); + } + // 추가: button 클릭 직전의 자동 zoom (focus zoom) 차단 — pointerdown 에서 + // 즉시 blur 처리해 입력 포커스 유발 zoom 발생 안 하도록. // 모든 button 공통 inline style — 더블탭 줌 / long-press 메뉴 / 텍스트 선택 차단. const BTN_STYLE = 'touch-action: manipulation; user-select: none; -webkit-user-select: none; ' + '-webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;'; - onMount(() => { load(); }); + onMount(() => { + load(); + document.addEventListener('gesturestart', blockGesture, { passive: false }); + document.addEventListener('gesturechange', blockGesture, { passive: false }); + document.addEventListener('gestureend', blockGesture, { passive: false }); + document.addEventListener('touchstart', blockMultiTouch, { passive: false }); + document.addEventListener('touchmove', blockMultiTouch, { passive: false }); + }); + onDestroy(() => { + if (typeof document !== 'undefined') { + document.removeEventListener('gesturestart', blockGesture); + document.removeEventListener('gesturechange', blockGesture); + document.removeEventListener('gestureend', blockGesture); + document.removeEventListener('touchstart', blockMultiTouch); + document.removeEventListener('touchmove', blockMultiTouch); + } + }); async function patchSession(partial) { try {