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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user