fix(study): stroke 좌표 비례 보정 — canvas dimension 변화 시 위치 보존
스크린샷 비교로 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Stroke, 'id' | 'points'>[] {
|
||||
return strokes.map((s) => ({ id: s.id, points: s.points }));
|
||||
// Path2D 등 런타임 캐시 (`_` prefix) 제외하고 직렬화. refW/refH 는 그렸을 시점의
|
||||
// cssWidth/cssHeight — 다른 환경 (창 크기 다른 데스크톱, 모바일 등) 에서 load 시
|
||||
// 비례 보정에 사용. 없으면 load 시점의 cssWidth/cssHeight 가 기준이 됨.
|
||||
function serializableStrokes(): Pick<Stroke, 'id' | 'points' | 'refW' | 'refH'>[] {
|
||||
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 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full" style="touch-action: manipulation;">
|
||||
<!-- 툴바 -->
|
||||
<!-- 툴바: flex-wrap 제거 — 자릿수 변화 (stroke 99→100) 등으로 wrap 발생 시
|
||||
toolbar height 증가 → 캔버스 height 감소 → ResizeObserver 발동 → cssWidth/
|
||||
cssHeight 변경 → stroke 비례 깨짐 (확대된 것처럼 보이는 회귀의 trigger 였음).
|
||||
overflow-x-auto 로 좁은 화면에서는 가로 스크롤 허용. -->
|
||||
<div
|
||||
class="flex items-center gap-1 px-2 py-1 border-b border-default bg-surface shrink-0 flex-wrap"
|
||||
class="flex items-center gap-1 px-2 py-1 border-b border-default bg-surface shrink-0 overflow-x-auto"
|
||||
style="touch-action: manipulation; user-select: none; -webkit-user-select: none;"
|
||||
>
|
||||
<button
|
||||
@@ -714,7 +745,7 @@
|
||||
<IconButton icon={Redo2} size="sm" aria-label="다시 실행" onclick={clickThenBlur(redo)} disabled={undoStack.length === 0} style={BTN_STYLE} tabindex={-1} onmousedown={blockBtnFocus} onpointerdown={blockBtnFocus} />
|
||||
<IconButton icon={Trash2} size="sm" aria-label="모두 지우기" onclick={clickThenBlur(clearAll)} disabled={strokes.length === 0} style={BTN_STYLE} tabindex={-1} onmousedown={blockBtnFocus} onpointerdown={blockBtnFocus} />
|
||||
|
||||
<span class="text-xs text-dim ml-2">stroke {strokes.length}</span>
|
||||
<span class="text-xs text-dim ml-2 tabular-nums shrink-0">stroke {strokes.length}</span>
|
||||
<div class="flex-1"></div>
|
||||
{#if snapshotErr}
|
||||
<span class="text-xs text-error mr-2">{snapshotErr}</span>
|
||||
|
||||
Reference in New Issue
Block a user