From 33060e93589224220f8734f292f1cd8b901db1cb Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 27 Apr 2026 13:36:27 +0900 Subject: [PATCH] =?UTF-8?q?fix(study):=20stroke=20=EC=A2=8C=ED=91=9C=20?= =?UTF-8?q?=EB=B9=84=EB=A1=80=20=EB=B3=B4=EC=A0=95=20=E2=80=94=20canvas=20?= =?UTF-8?q?dimension=20=EB=B3=80=ED=99=94=20=EC=8B=9C=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스크린샷 비교로 root cause 확정: 큰 창에서 그린 stroke 가 작은 창에서 보면 캔버스 전체 차지하는 비례 (반대도 마찬가지). stroke 좌표가 cssWidth/cssHeight 절대 px 로 저장되어 cssWidth 변경 시 시각적 위치/비율 깨짐. 사용자 보고 "펜/지우개 누르면 해당 부분 확대" = button click → reactive cascade → toolbar flex-wrap 임계 또는 다른 layout shift → cssWidth 일시 변경 → stroke 좌표 비례 깨짐. Fix A: stroke 별 reference dimension - Stroke type 에 refW / refH (그렸을 시점의 cssWidth/cssHeight) 추가. - inflight 생성 시 refW=cssWidth, refH=cssHeight 저장. - redraw 의 drawStrokeScaled() 가 ctx.scale(cssWidth/refW, cssHeight/refH) 적용. stroke 좌표는 그대로 두고 transform 만 stroke 별. R3 의 Path2D 캐시 그대로 재활용. - legacy stroke (refW 없음) 은 1배 (load 시점의 cssWidth 기준). - serializableStrokes 에 refW/refH 포함 — 다른 환경에서 load 시 비례 복원. Fix B: toolbar layout shift trigger 차단 - flex-wrap 제거 → overflow-x-auto. 자릿수 변화 (99→100) 등으로 wrap 발생 시 ResizeObserver 가 cssHeight 변경 → 비례 깨짐의 trigger 였음. - stroke 카운터에 tabular-nums + shrink-0. 자릿수 변화 시 텍스트 width 일정. 새로고침 / 창 이동 시 정상 복귀하던 이유 = 그 시점에 cssWidth 가 새로 결정되며 모든 stroke 가 같은 기준. button click 시 일시적 layout shift 가 trigger 였던 것. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/components/HandwriteCanvas.svelte | 53 +++++++++++++++---- 1 file changed, 42 insertions(+), 11 deletions(-) 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 @@
- +