fix(study): RAF redraw throttle + autosave 비동기 + gesture document-level

여전히 발생하는 입력 누락 / 지우개 누르면 확대 재시도.

P1 줌 차단 강화:
- gesturestart/change/end 를 document level 로 다시 등록 (element-level
  ongesturestart 가 일부 iPad Safari 빌드에서 미발화)
- touchstart/touchmove 의 e.touches.length > 1 도 preventDefault — gesture
  이벤트 자체가 안 들어오는 경우의 핀치 zoom 백업 방어

P2 입력 누락 — 입력 루프와 redraw/저장 분리:
- pointermove 의 redraw() 를 RAF throttle (scheduleRedraw) — 60Hz 보다 빠른
  pointermove 에서 매번 redraw 하던 부담 제거. input 처리 즉시, render 는 frame 당 1회.
- autosave: 5 stroke 즉시 flush 제거 — 빠른 필기 중 JSON.stringify 부하 차단.
  3초 idle debounce 만 유지.
- onChange 호출을 setTimeout 0 으로 다음 macrotask 에 ship — 직렬화가
  pointer event 와 충돌 안 함.
This commit is contained in:
Hyungi Ahn
2026-04-27 10:38:05 +09:00
parent 1a560b5fde
commit 38e916643d
2 changed files with 56 additions and 18 deletions
@@ -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) ──
@@ -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 {