diff --git a/frontend/src/lib/components/HandwriteCanvas.svelte b/frontend/src/lib/components/HandwriteCanvas.svelte index 43e2f0f..f6ee202 100644 --- a/frontend/src/lib/components/HandwriteCanvas.svelte +++ b/frontend/src/lib/components/HandwriteCanvas.svelte @@ -61,12 +61,6 @@ 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(initialStrokes?.strokes ?? []); let undoStack = $state([]); // redo 큐 (clear 또는 undo 로 빠진 stroke) let inflight: Stroke | null = $state(null); @@ -103,7 +97,7 @@ if (!raw) return; const parsed = JSON.parse(raw) as StrokesJson; if ((initialStrokes?.strokes?.length ?? 0) === 0 && parsed.strokes.length > 0) { - setStrokes(parsed.strokes); + strokes = parsed.strokes; scheduleSave(); } } catch { @@ -124,11 +118,6 @@ 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(); } @@ -188,44 +177,18 @@ 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); - 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); + drawTraceBackground(ctx); + for (const s of strokes) { + drawStroke(ctx, s); + } + if (inflight) { + drawStroke(ctx, inflight); } - if (inflight) drawStroke(ctx, inflight); } // RAF throttle — pointermove 가 60Hz 보다 빠르게 들어와도 redraw 는 frame 당 1회. @@ -290,7 +253,7 @@ (hit ? removed : keep).push(s); } if (removed.length === 0) return false; - setStrokes(keep); + strokes = keep; undoStack = [...undoStack, ...removed]; return true; } @@ -303,7 +266,7 @@ // 이전 stroke 의 inflight 가 어떤 이유로 살아있다면 강제 finalize (race 방어). if (inflight) { if (inflight.points.length > 1) { - setStrokes([...strokes, inflight]); + strokes = [...strokes, inflight]; isDirty = true; backupToLocalStorage(); } @@ -381,7 +344,7 @@ } if (inflight.points.length > 1) { - setStrokes([...strokes, inflight]); + strokes = [...strokes, inflight]; undoStack = []; isDirty = true; backupToLocalStorage(); @@ -395,7 +358,7 @@ function undo() { if (strokes.length === 0) return; const last = strokes[strokes.length - 1]; - setStrokes(strokes.slice(0, -1)); + strokes = strokes.slice(0, -1); undoStack = [...undoStack, last]; isDirty = true; backupToLocalStorage(); @@ -406,7 +369,7 @@ if (undoStack.length === 0) return; const last = undoStack[undoStack.length - 1]; undoStack = undoStack.slice(0, -1); - setStrokes([...strokes, last]); + strokes = [...strokes, last]; isDirty = true; backupToLocalStorage(); scheduleSave(); @@ -416,7 +379,7 @@ if (strokes.length === 0) return; if (!confirm('이 세션의 모든 stroke 를 지웁니다. 계속할까요?')) return; undoStack = [...undoStack, ...strokes]; - setStrokes([]); + strokes = []; isDirty = true; backupToLocalStorage(); scheduleSave(); diff --git a/frontend/src/routes/study/write/[id]/+page.svelte b/frontend/src/routes/study/write/[id]/+page.svelte index 952962d..f723fda 100644 --- a/frontend/src/routes/study/write/[id]/+page.svelte +++ b/frontend/src/routes/study/write/[id]/+page.svelte @@ -36,19 +36,17 @@ } } - // 페이지 줌 차단 — 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) + // 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(); } - function blockPinchWheel(e) { - // Mac trackpad 핀치 = wheel event + ctrlKey synthesized. 페이지 줌 차단. - if (e.ctrlKey || e.metaKey) e.preventDefault(); - } + // 추가: button 클릭 직전의 자동 zoom (focus zoom) 차단 — pointerdown 에서 + // 즉시 blur 처리해 입력 포커스 유발 zoom 발생 안 하도록. // 모든 button 공통 inline style — 더블탭 줌 / long-press 메뉴 / 텍스트 선택 차단. const BTN_STYLE = @@ -62,8 +60,6 @@ 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') { @@ -72,7 +68,6 @@ document.removeEventListener('gestureend', blockGesture); document.removeEventListener('touchstart', blockMultiTouch); document.removeEventListener('touchmove', blockMultiTouch); - document.removeEventListener('wheel', blockPinchWheel); } });