fix(study): offscreen buffer canvas + 데스크톱 trackpad pinch 차단
P1 데스크톱 trackpad pinch 줌 차단 (Chrome/Firefox macOS): - wheel + ctrlKey/metaKey preventDefault 추가 (페이지 zoom 방지) - 데스크톱 Chrome 은 gesture 이벤트 미발화, wheel + ctrlKey 만 발화 - 사용자 사진 8854/8855: 모드 토글 사이 trackpad pinch 로 페이지 zoom 발생 P2 iPad 입력 씹힘 — main thread 블록 해소: - offscreen buffer canvas 도입. 완료 stroke 들은 buffer 에 한 번만 perfect-freehand getStroke + Path2D fill 로 그림. - 매 frame 의 redraw 는 ctx.drawImage(buffer) + inflight 만 처리. - strokes 변경 시만 bufferDirty=true → 다음 redraw 에서 rebuild. - iPad CPU 에서 33+ stroke 매 frame 재계산이 16ms 초과해 pointer event 누락하던 문제 해소. Helper: - setStrokes(next): strokes 재할당 시 buffer rebuild 자동 마킹. 모든 strokes 갱신 (snapshot, eraseAt, finalize, undo, redo, clear, restoreFromLocalStorage) 에 적용.
This commit is contained in:
@@ -61,6 +61,12 @@
|
||||
let cssWidth = $state(800);
|
||||
let cssHeight = $state(600);
|
||||
|
||||
// 완료된 stroke 들을 캐싱하는 offscreen buffer. iPad 성능 핵심 — 매 frame 마다 모든
|
||||
// stroke 의 perfect-freehand 재계산이 main thread 블록 → pointer event 누락.
|
||||
// strokes 배열 변경 시만 rebuild, 매 frame redraw 는 drawImage + inflight 만.
|
||||
let buffer: HTMLCanvasElement | null = null;
|
||||
let bufferDirty = true;
|
||||
|
||||
let strokes = $state<Stroke[]>(initialStrokes?.strokes ?? []);
|
||||
let undoStack = $state<Stroke[]>([]); // redo 큐 (clear 또는 undo 로 빠진 stroke)
|
||||
let inflight: Stroke | null = $state(null);
|
||||
@@ -97,7 +103,7 @@
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as StrokesJson;
|
||||
if ((initialStrokes?.strokes?.length ?? 0) === 0 && parsed.strokes.length > 0) {
|
||||
strokes = parsed.strokes;
|
||||
setStrokes(parsed.strokes);
|
||||
scheduleSave();
|
||||
}
|
||||
} catch {
|
||||
@@ -118,6 +124,11 @@
|
||||
canvas.style.height = `${cssHeight}px`;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
// buffer 도 같은 internal 크기. transform 은 rebuildBuffer 안에서 별도 적용.
|
||||
if (!buffer) buffer = document.createElement('canvas');
|
||||
buffer.width = canvas.width;
|
||||
buffer.height = canvas.height;
|
||||
bufferDirty = true;
|
||||
scheduleRedraw();
|
||||
}
|
||||
|
||||
@@ -177,18 +188,44 @@
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// strokes 배열을 변경할 때 항상 사용 (buffer rebuild 마킹 + 자동 저장 트리거).
|
||||
function setStrokes(next: Stroke[]) {
|
||||
strokes = next;
|
||||
bufferDirty = true;
|
||||
}
|
||||
|
||||
// 완료 stroke 들을 buffer canvas 에 한 번만 그림. strokes 변경 시만 호출.
|
||||
function rebuildBuffer() {
|
||||
if (!canvas || !buffer) return;
|
||||
const bctx = buffer.getContext('2d');
|
||||
if (!bctx) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
bctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
bctx.clearRect(0, 0, cssWidth, cssHeight);
|
||||
drawTraceBackground(bctx);
|
||||
for (const s of strokes) {
|
||||
drawStroke(bctx, s);
|
||||
}
|
||||
bufferDirty = false;
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
if (bufferDirty) rebuildBuffer();
|
||||
|
||||
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
||||
drawTraceBackground(ctx);
|
||||
for (const s of strokes) {
|
||||
drawStroke(ctx, s);
|
||||
}
|
||||
if (inflight) {
|
||||
drawStroke(ctx, inflight);
|
||||
if (buffer) {
|
||||
// buffer 는 internal pixel (cssWidth*dpr) 크기, canvas 도 같은 크기.
|
||||
// 현재 ctx 는 setTransform(dpr,...) 으로 1 unit = 1 css px 좌표계.
|
||||
// drawImage 의 dst (0, 0, cssWidth, cssHeight) 에 그리면 정확히 매핑됨.
|
||||
ctx.drawImage(buffer, 0, 0, cssWidth, cssHeight);
|
||||
} else {
|
||||
drawTraceBackground(ctx);
|
||||
for (const s of strokes) drawStroke(ctx, s);
|
||||
}
|
||||
if (inflight) drawStroke(ctx, inflight);
|
||||
}
|
||||
|
||||
// RAF throttle — pointermove 가 60Hz 보다 빠르게 들어와도 redraw 는 frame 당 1회.
|
||||
@@ -253,7 +290,7 @@
|
||||
(hit ? removed : keep).push(s);
|
||||
}
|
||||
if (removed.length === 0) return false;
|
||||
strokes = keep;
|
||||
setStrokes(keep);
|
||||
undoStack = [...undoStack, ...removed];
|
||||
return true;
|
||||
}
|
||||
@@ -266,7 +303,7 @@
|
||||
// 이전 stroke 의 inflight 가 어떤 이유로 살아있다면 강제 finalize (race 방어).
|
||||
if (inflight) {
|
||||
if (inflight.points.length > 1) {
|
||||
strokes = [...strokes, inflight];
|
||||
setStrokes([...strokes, inflight]);
|
||||
isDirty = true;
|
||||
backupToLocalStorage();
|
||||
}
|
||||
@@ -344,7 +381,7 @@
|
||||
}
|
||||
|
||||
if (inflight.points.length > 1) {
|
||||
strokes = [...strokes, inflight];
|
||||
setStrokes([...strokes, inflight]);
|
||||
undoStack = [];
|
||||
isDirty = true;
|
||||
backupToLocalStorage();
|
||||
@@ -358,7 +395,7 @@
|
||||
function undo() {
|
||||
if (strokes.length === 0) return;
|
||||
const last = strokes[strokes.length - 1];
|
||||
strokes = strokes.slice(0, -1);
|
||||
setStrokes(strokes.slice(0, -1));
|
||||
undoStack = [...undoStack, last];
|
||||
isDirty = true;
|
||||
backupToLocalStorage();
|
||||
@@ -369,7 +406,7 @@
|
||||
if (undoStack.length === 0) return;
|
||||
const last = undoStack[undoStack.length - 1];
|
||||
undoStack = undoStack.slice(0, -1);
|
||||
strokes = [...strokes, last];
|
||||
setStrokes([...strokes, last]);
|
||||
isDirty = true;
|
||||
backupToLocalStorage();
|
||||
scheduleSave();
|
||||
@@ -379,7 +416,7 @@
|
||||
if (strokes.length === 0) return;
|
||||
if (!confirm('이 세션의 모든 stroke 를 지웁니다. 계속할까요?')) return;
|
||||
undoStack = [...undoStack, ...strokes];
|
||||
strokes = [];
|
||||
setStrokes([]);
|
||||
isDirty = true;
|
||||
backupToLocalStorage();
|
||||
scheduleSave();
|
||||
|
||||
@@ -36,17 +36,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
// iOS Safari 핀치/zoom gesture 차단 — document level 등록 (element-level
|
||||
// ongesturestart 가 일부 iPad Safari 빌드에서 미발화하는 케이스 방어).
|
||||
// 페이지 unmount 시 onDestroy 에서 cleanup.
|
||||
// 페이지 줌 차단 — iOS Safari + 데스크톱 Chrome/Firefox 모두 커버:
|
||||
// 1) gesturestart/change/end (Safari) — iOS / macOS Safari 핀치줌
|
||||
// 2) touchstart/touchmove with > 1 fingers — iOS Safari multi-touch zoom
|
||||
// 3) wheel with ctrlKey/metaKey — Chrome/Firefox 데스크톱 trackpad pinch
|
||||
// (macOS trackpad 핀치는 wheel + ctrlKey 로 dispatch)
|
||||
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 발생 안 하도록.
|
||||
function blockPinchWheel(e) {
|
||||
// Mac trackpad 핀치 = wheel event + ctrlKey synthesized. 페이지 줌 차단.
|
||||
if (e.ctrlKey || e.metaKey) e.preventDefault();
|
||||
}
|
||||
|
||||
// 모든 button 공통 inline style — 더블탭 줌 / long-press 메뉴 / 텍스트 선택 차단.
|
||||
const BTN_STYLE =
|
||||
@@ -60,6 +62,8 @@
|
||||
document.addEventListener('gestureend', blockGesture, { passive: false });
|
||||
document.addEventListener('touchstart', blockMultiTouch, { passive: false });
|
||||
document.addEventListener('touchmove', blockMultiTouch, { passive: false });
|
||||
// wheel 은 페이지 단위 (target=window) 가 아니라 document — passive: false 필수.
|
||||
document.addEventListener('wheel', blockPinchWheel, { passive: false });
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
@@ -68,6 +72,7 @@
|
||||
document.removeEventListener('gestureend', blockGesture);
|
||||
document.removeEventListener('touchstart', blockMultiTouch);
|
||||
document.removeEventListener('touchmove', blockMultiTouch);
|
||||
document.removeEventListener('wheel', blockPinchWheel);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user