diff --git a/frontend/src/lib/components/HandwriteCanvas.svelte b/frontend/src/lib/components/HandwriteCanvas.svelte index ac96127..0517b44 100644 --- a/frontend/src/lib/components/HandwriteCanvas.svelte +++ b/frontend/src/lib/components/HandwriteCanvas.svelte @@ -21,12 +21,17 @@ import IconButton from '$lib/components/ui/IconButton.svelte'; type Point = [number, number, number]; // [x, y, pressure] + // refW / refH = stroke 가 그려진 시점의 cssWidth / cssHeight. drawStroke 시 + // ctx.scale(cssWidth/refW, cssHeight/refH) 로 비례 보정 → 창 크기 변화 / button + // click 으로 인한 layout shift 후에도 stroke 가 *원래 시각적 위치/비율* 유지 + // (사용자 보고: "펜/지우개 누르면 해당 부분 확대"의 진짜 root cause = stroke + // 좌표가 절대 px 라 cssWidth 변경 시 비례 깨짐). // _path2d / _size 는 런타임 캐시 — 직렬화 시 제외 (밑줄 prefix 가 marker). - // 매 RAF frame 마다 모든 stroke 의 perfect-freehand outline 을 재계산하던 - // R3 hot path 를 stroke 당 1회로 줄여 frame budget 초과 (= 입력 적체) 방지. type Stroke = { id: string; points: Point[]; + refW?: number; + refH?: number; _path2d?: Path2D; _size?: number; }; @@ -103,10 +108,16 @@ // ── localStorage ── const lsKey = $derived(`study_session_${sessionId}_strokes`); - // Path2D 등 런타임 캐시 (`_` prefix) 제외하고 직렬화. 서버/localStorage 에는 순수 - // {id, points} 만 저장. - function serializableStrokes(): Pick[] { - return strokes.map((s) => ({ id: s.id, points: s.points })); + // Path2D 등 런타임 캐시 (`_` prefix) 제외하고 직렬화. refW/refH 는 그렸을 시점의 + // cssWidth/cssHeight — 다른 환경 (창 크기 다른 데스크톱, 모바일 등) 에서 load 시 + // 비례 보정에 사용. 없으면 load 시점의 cssWidth/cssHeight 가 기준이 됨. + function serializableStrokes(): Pick[] { + return strokes.map((s) => ({ + id: s.id, + points: s.points, + refW: s.refW, + refH: s.refH, + })); } // backup 은 stroke 완료마다 호출되지만 실제 sync I/O (JSON.stringify + localStorage // .setItem) 는 500ms idle 시 1회만. stroke 73 × 30점 = 65KB+ JSON 의 sync write 가 @@ -223,8 +234,23 @@ if (!ctx) return; ctx.clearRect(0, 0, cssWidth, cssHeight); drawTraceBackground(ctx); - for (const s of strokes) drawStroke(ctx, s, false); - if (inflight) drawStroke(ctx, inflight, true); + for (const s of strokes) drawStrokeScaled(ctx, s, false); + if (inflight) drawStrokeScaled(ctx, inflight, true); + } + + // stroke 의 refW/refH 와 현재 cssWidth/cssHeight 비례로 ctx.scale 적용 후 그림. + // refW 없는 (legacy) stroke 는 현재 cssWidth/cssHeight 기준 = 1배 (load 시점 기준). + function drawStrokeScaled(ctx: CanvasRenderingContext2D, s: Stroke, isInflight: boolean) { + const sx = s.refW && s.refW > 0 ? cssWidth / s.refW : 1; + const sy = s.refH && s.refH > 0 ? cssHeight / s.refH : 1; + if (sx === 1 && sy === 1) { + drawStroke(ctx, s, isInflight); + return; + } + ctx.save(); + ctx.scale(sx, sy); + drawStroke(ctx, s, isInflight); + ctx.restore(); } // RAF throttle — pointermove 가 60Hz 보다 빠르게 들어와도 redraw 는 frame 당 1회. @@ -374,6 +400,8 @@ inflight = { id: `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, points: [[x, y, normalizePressure(e.pressure)]], + refW: cssWidth, + refH: cssHeight, }; scheduleRedraw(); } @@ -660,9 +688,12 @@
- +