From 24a267dd0ac3f42727ca911fe861bc49ce107f7a Mon Sep 17 00:00:00 2001 From: johndoekim Date: Thu, 7 May 2026 00:09:19 +0900 Subject: [PATCH 01/13] =?UTF-8?q?Task=20#595=20Stage=201:=20=EB=B3=B8?= =?UTF-8?q?=EC=A7=88=20=EC=A7=84=EB=8B=A8=20+=20=EC=9E=AC=ED=98=84=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20+=20?= =?UTF-8?q?=EA=B4=91=EB=B2=94=EC=9C=84=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 본질 결함 위치 식별: src/renderer/layout.rs::build_header (line 928) 의 `expand_bbox_to_children` 호출이 머리말 자식 노드 (단 구분선 line paraIdx=0 ci=2 h≈1227px) 의 bbox 까지 Header 영역으로 확장 → hit_test_header_footer_native 가 본문 좌표를 머리말 hit 으로 잘못 인식 → onDblClick 의 머리말 분기가 picture selection 분기보다 먼저 실행되어 수식 편집기 진입 차단. 발현: page 0 (1p) Header hit 60~145 정상 / page 1+ (2p~) 60~1355 결함 (본문 영역 80% 침범). 페이지 0 vs 1+ 차이 = 단 구분선 line 의 controlIdx 소속 차이 (page 0 ci=5 Body 자식 / page 1+ ci=2 Header 자식). 산출물: - tests/issue_595.rs (5 케이스, 정정 전 3 fail / 2 pass) - examples/inspect_595.rs (Header bbox sweep + 광범위 fixture sweep) - rhwp-studio/e2e/issue-595.test.mjs (1365×1018 사용자 환경 e2e 진단) - mydocs/plans/task_m100_595.md / task_m100_595_impl.md - mydocs/working/task_m100_595_stage1.md 광범위 sweep (164 fixture / 1684 페이지): 영향 2 fixture / 32 페이지 (1.9%) — exam_math.hwp / exam_math_no.hwp 만 발현. 매우 특수한 양식 (머리말에 단 구분선 line 자식 포함). 정정 회귀 위험 매우 낮음. 이슈 본문 정오표: 이슈 가설 (a)/(b)/(c) 모두 TS 측 좌표 영역으로 한정되어 본질 좁히기 부족. 실제 결함은 Rust 측 build_page_tree 영역 (이슈 점검 범위 밖). 사용자 추가 단서 "hover 시 손바닥 표시는 뜨는데 클릭 반응 없음" 으로 dblclick 흐름의 다른 분기 (머리말 검사) 까지 진단 영역 확장. Stage 2 정정 옵션 A 권장: hit_test_header_footer_native 에서 layout.header_area 로 hit 판정 (단일 함수 영역, 렌더링 동작 무영향, 회귀 위험 최소). 별도 발견: zoom ≤ 0.5 그리드 모드 좌표 결함 (input-handler-mouse.ts 의 pageLeft 단일 컬럼 가정) — 본 이슈와 별개 영역 / 별도 task 분리 검토. refs #595 Co-Authored-By: Claude Opus 4.7 --- examples/inspect_595.rs | 174 ++++++++++++++ mydocs/plans/task_m100_595.md | 127 ++++++++++ mydocs/plans/task_m100_595_impl.md | 78 +++++++ mydocs/working/task_m100_595_stage1.md | 143 ++++++++++++ rhwp-studio/e2e/issue-595.test.mjs | 309 +++++++++++++++++++++++++ tests/issue_595.rs | 87 +++++++ 6 files changed, 918 insertions(+) create mode 100644 examples/inspect_595.rs create mode 100644 mydocs/plans/task_m100_595.md create mode 100644 mydocs/plans/task_m100_595_impl.md create mode 100644 mydocs/working/task_m100_595_stage1.md create mode 100644 rhwp-studio/e2e/issue-595.test.mjs create mode 100644 tests/issue_595.rs diff --git a/examples/inspect_595.rs b/examples/inspect_595.rs new file mode 100644 index 000000000..f7737a79e --- /dev/null +++ b/examples/inspect_595.rs @@ -0,0 +1,174 @@ +//! Issue #595 진단 — exam_math.hwp page 별 hit_test_header_footer 영역 검증 +//! +//! 본질 가설: page 2 (0-based 1) 의 본문 좌표 (654.5, 209.7) 가 +//! `wasm.hitTestHeaderFooter` 에서 머리말 hit 으로 인식됨. +//! +//! 본 진단은 다음을 확인한다: +//! 1. 각 페이지의 Header / Footer 렌더 노드 bbox 직접 dump +//! 2. y 좌표 sweep 으로 머리말로 hit 되는 y 범위 식별 +//! +//! 실행: cargo run --release --example inspect_595 + +use std::fs; + +fn main() { + let path = "samples/exam_math.hwp"; + let data = fs::read(path).unwrap_or_else(|e| panic!("read {} failed: {}", path, e)); + let core = rhwp::document_core::DocumentCore::from_bytes(&data).expect("parse"); + + println!("=== exam_math.hwp 의 hitTestHeaderFooter 영역 진단 ===\n"); + + // 페이지 0~3 의 Header / Footer hit 영역 sweep + for page in 0..4u32 { + println!("--- page {} (1-based={}) ---", page, page + 1); + + // x 는 페이지 중앙 514 고정, y 를 50 단위로 sweep (페이지 높이 1489.1) + // Header 영역, 본문 영역, Footer 영역 모두 커버 + let x = 514.0; + let mut header_hits: Vec = Vec::new(); + let mut footer_hits: Vec = Vec::new(); + for ystep in 0..30 { + let y = (ystep as f64) * 50.0; + match core.hit_test_header_footer_native(page, x, y) { + Ok(json) => { + if json.contains("\"hit\":true") { + if json.contains("\"isHeader\":true") { + header_hits.push(y); + } else { + footer_hits.push(y); + } + } + } + Err(_) => {} + } + } + if !header_hits.is_empty() { + let min = header_hits.iter().cloned().fold(f64::INFINITY, f64::min); + let max = header_hits.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + println!(" Header hit y 범위: {:.1} ~ {:.1} (50px sweep)", min, max); + println!(" hit y 좌표: {:?}", header_hits); + } else { + println!(" Header hit 없음"); + } + if !footer_hits.is_empty() { + let min = footer_hits.iter().cloned().fold(f64::INFINITY, f64::min); + let max = footer_hits.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + println!(" Footer hit y 범위: {:.1} ~ {:.1} (50px sweep)", min, max); + } + + // 이슈 명세 좌표 직접 검증 + if page == 1 { + // Page 2 (0-based 1) — paraIdx=65 ci=0 수식 영역 (589.5+65, 191.7+18) = (654.5, 209.7) + let r = core.hit_test_header_footer_native(page, 654.5, 209.7); + println!(" [이슈 명세 좌표] page {} (654.5, 209.7) -> {:?}", page, r); + } + if page == 0 { + // Page 1 (0-based 0) — paraIdx=18 ci=0 수식 영역 (180.8, 826.3) + let r = core.hit_test_header_footer_native(page, 180.8, 826.3); + println!(" [이슈 명세 좌표] page {} (180.8, 826.3) -> {:?}", page, r); + } + println!(); + } + + // Header / Footer 영역 정밀 sweep — 더 작은 step (10px) 으로 범위 좁히기 + println!("\n=== Header hit y 범위 정밀 sweep (step=5px, x=514) ==="); + for page in 0..4u32 { + let x = 514.0; + let mut header_min: Option = None; + let mut header_max: Option = None; + for ystep in 0..300 { + let y = (ystep as f64) * 5.0; + if let Ok(json) = core.hit_test_header_footer_native(page, x, y) { + if json.contains("\"hit\":true") && json.contains("\"isHeader\":true") { + if header_min.is_none() { header_min = Some(y); } + header_max = Some(y); + } + } + } + match (header_min, header_max) { + (Some(min), Some(max)) => { + println!(" page {}: Header hit y={:.1} ~ {:.1} (실제 머리말 영역: 0~147)", page, min, max); + } + _ => println!(" page {}: Header hit 없음", page), + } + } + + // x 도 sweep — page 1 (이슈 발현) 에 대해 (x, y=209.7) 으로 가로 sweep + println!("\n=== page 1 (0-based, 이슈 발현) y=209.7 가로 sweep (step=50px) ==="); + let page = 1u32; + for xstep in 0..21 { + let x = (xstep as f64) * 50.0; + if let Ok(json) = core.hit_test_header_footer_native(page, x, 209.7) { + let mark = if json.contains("\"hit\":true") { + if json.contains("\"isHeader\":true") { " ← Header HIT" } else { " ← Footer HIT" } + } else { "" }; + println!(" x={:5.1} y=209.7 → {}{}", x, json, mark); + } + } + + // 광범위 sweep — samples/ 전체에서 머리말 본문 침범 페이지 식별 + println!("\n=== 광범위 sweep — samples/ 전체에서 본문 영역까지 머리말 hit 되는 fixture/페이지 ==="); + use std::path::Path; + let samples_dir = Path::new("samples"); + let mut total_fixtures = 0usize; + let mut affected_fixtures = 0usize; + let mut total_pages = 0u32; + let mut affected_pages = 0u32; + let mut affected_summaries: Vec = Vec::new(); + + let entries: Vec<_> = match fs::read_dir(samples_dir) { + Ok(rd) => rd.flatten().collect(), + Err(e) => { eprintln!("read_dir fail: {:?}", e); return; } + }; + let mut paths: Vec = Vec::new(); + for ent in entries { + let p = ent.path(); + if p.is_file() { + let ext = p.extension().and_then(|e| e.to_str()).unwrap_or(""); + if ext == "hwp" || ext == "hwpx" { + paths.push(p); + } + } + } + paths.sort(); + + for path in &paths { + let bytes = match fs::read(path) { Ok(b) => b, Err(_) => continue }; + let core_r = rhwp::document_core::DocumentCore::from_bytes(&bytes); + let core_local = match core_r { Ok(c) => c, Err(_) => continue }; + total_fixtures += 1; + // 각 페이지 하한 영역 (y=200, 800) 가 머리말 hit 인지 확인 + let pc = core_local.page_count(); + let mut fixture_affected_pages: Vec = Vec::new(); + for page in 0..pc { + total_pages += 1; + let r1 = core_local.hit_test_header_footer_native(page, 514.0, 800.0); + if let Ok(j) = r1 { + if j.contains("\"hit\":true") && j.contains("\"isHeader\":true") { + fixture_affected_pages.push(page); + affected_pages += 1; + } + } + } + if !fixture_affected_pages.is_empty() { + affected_fixtures += 1; + let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?"); + let pages_str = if fixture_affected_pages.len() <= 5 { + format!("{:?}", fixture_affected_pages) + } else { + format!("[{}, ..., {}] ({}건)", fixture_affected_pages[0], + fixture_affected_pages[fixture_affected_pages.len()-1], + fixture_affected_pages.len()) + }; + affected_summaries.push(format!(" {} ({}/{}p): {}", name, fixture_affected_pages.len(), pc, pages_str)); + } + } + + println!("\n총 {} fixture / {} 페이지 점검", total_fixtures, total_pages); + println!("머리말 hit 본문 침범 fixture: {} / {} ({:.1}%)", affected_fixtures, total_fixtures, 100.0 * affected_fixtures as f64 / total_fixtures as f64); + println!("머리말 hit 본문 침범 페이지: {} / {} ({:.1}%)", affected_pages, total_pages, 100.0 * affected_pages as f64 / total_pages as f64); + println!("\n발현 fixture 목록:"); + for line in &affected_summaries { + println!("{}", line); + } +} diff --git a/mydocs/plans/task_m100_595.md b/mydocs/plans/task_m100_595.md new file mode 100644 index 000000000..a61abb955 --- /dev/null +++ b/mydocs/plans/task_m100_595.md @@ -0,0 +1,127 @@ +# Task #595 수행 계획서 + +**Issue**: [#595](https://github.com/edwardkim/rhwp/issues/595) — exam_math.hwp 2페이지부터 수식 더블클릭 hitTest 오동작 — 1페이지만 정상 +**Milestone**: M100 (v1.0.0) +**브랜치**: `local/task595` +**진행자**: Claude (작업지시자: edwardkim) + +--- + +## 1. 본질 (이슈 요약) + +`samples/exam_math.hwp` 의 수식 객체를 더블클릭했을 때 **1페이지(0-based 0)만 정상 동작**, **2페이지(0-based 1)부터 오동작**. + +오동작 시 `findPictureAtClick` 이 수식을 hit 으로 인식하지 못 하여 일반 텍스트 hitTest fallback 으로 진입 → 의도하지 않은 위치로 캐럿 이동. + +## 2. 정적 분석 결과 (사전 진단) + +### 2.1 Rust 측 정합 — `examples/inspect_595.rs` 결과 + +``` +모든 페이지: width=1028.0, height=1489.1 (동일) +모든 페이지의 머리말 표 y=1359.4 (동일) +2페이지 paraIdx=65 ci=0 수식: bbox (589.5, 191.7) 131.7×37.3 ← 이슈 명세 정합 +``` + +**결론**: `getPageControlLayout` 좌표는 페이지 내부 좌표 (0..1489.1) 이며 모든 페이지 일관 — Rust 측 데이터 무결. + +### 2.2 TS 측 좌표 변환식 — 이론상 정합 + +```ts +const pageIdx = virtualScroll.getPageAtY(contentY); +const pageOffset = virtualScroll.getPageOffset(pageIdx); +const pageX = (contentX - pageLeft) / zoom; +const pageY = (contentY - pageOffset) / zoom; +``` + +`pageOffsets[i]` 는 zoom 적용 누적 (`pageGap=10 + Σ(pageHeights[j] * zoom + pageGap)`). +`canvas.style.top = pageOffsets[i]` 로 동일 배열 사용 — 자체적으론 정합. + +### 2.3 정적 분석 한계 + +- `findPictureAtClick` 본체는 단순 bbox 매칭 — equation 의 wrap 미존재로 behindCtrls 분기 비해당 +- 단 구분선 (line w=0.0) 도 distance 검사로만 hit, 클릭 좌표 거리 충분히 멀어 무관 +- 그러므로 **동적 환경에서만 발현되는 좌표 오차** 또는 **간접 진단 미커버 영역** 가능성 + +## 3. 수행 목표 + +본 task 의 1차 목표는 **본질 원인 정확 식별**. 정정 패치는 본질 확정 후 별도 task 또는 본 task의 후속 단계로 진행. + +진단 수단으로 **Puppeteer e2e 테스트 + 콘솔 캡처** 사용 (옵션 B). + +## 4. 진단 e2e 테스트 설계 (`rhwp-studio/e2e/issue-595.test.mjs`) + +### 4.1 시나리오 + +1. `samples/exam_math.hwp` 로드 +2. **page 1 (0-based 0)** 의 수식 영역 클릭 — 정상 동작 baseline (수식 paraIdx=18 ci=0 또는 ci=1, x=130.8 y=818.3 / x=307.8 y=811.3 등 사용) +3. **page 2 (0-based 1)** 의 수식 영역 클릭 — 결함 재현 (paraIdx=65 ci=0, x=589.5 y=191.7 w=131.7 h=37.3) +4. 각 클릭 직후 다음을 콘솔/페이지 상태로 캡처: + - `pageIdx`, `pageX`, `pageY`, `pageOffset`, `contentY`, `zoom` + - `wasm.getPageControlLayout(pageIdx).controls.length` + 첫 5개 ctrl bbox 요약 + - `findPictureAtClick(pageIdx, pageX, pageY)` 직접 호출 결과 + - `cursor.isInPictureObjectSelection()` 상태 +5. 두 케이스의 데이터를 비교하여 **page 1 정상 / page 2 fail 의 분기점** 식별 + +### 4.2 진단 hook 설계 (TS 코드 영구 수정 없음) + +본질 정정 전 단계이므로 TS 측에 **임시 디버그 콘솔 로그를 영구 삽입하지 않는다**. + +대신 e2e 테스트 내부에서 `page.evaluate()` 로 다음을 직접 호출: + +```js +const diag = await page.evaluate(({ contentX, contentY }) => { + const inputHandler = window.__inputHandler; + const virtualScroll = window.__canvasView.virtualScroll; // expose 필요할 수 있음 + const wasm = window.__wasm; + const zoom = window.__viewportManager.getZoom(); + const sc = document.querySelector('#scroll-content'); + const cr = sc.getBoundingClientRect(); + const cy = contentY - cr.top; + const cx = contentX - cr.left; + const pageIdx = virtualScroll.getPageAtY(cy); + const pageOffset = virtualScroll.getPageOffset(pageIdx); + const pageDisplayWidth = virtualScroll.getPageWidth(pageIdx); + const pageLeft = (sc.clientWidth - pageDisplayWidth) / 2; + const pageX = (cx - pageLeft) / zoom; + const pageY = (cy - pageOffset) / zoom; + const layout = JSON.parse(wasm.getPageControlLayout(pageIdx)); + const picHit = inputHandler.findPictureAtClick(pageIdx, pageX, pageY); + return { pageIdx, pageX, pageY, pageOffset, cx, cy, zoom, controlsCount: layout.controls.length, picHit, sampleCtrls: layout.controls.slice(0, 5) }; +}, { contentX, contentY }); +``` + +만약 `window.__canvasView.virtualScroll` 가 expose 안 되어 있으면 진단 hook 노출이 필요할 수 있음 — 그 영역은 진단 후 결정. + +### 4.3 결과 포맷 + +콘솔 출력 + screenshots 으로 두 케이스 비교 결과 요약. 콘솔/스크린샷은 `mydocs/working/task_m100_595_stage1.md` 에 첨부. + +## 5. 단계 분해 (구현 계획서는 별도) + +| 단계 | 내용 | +|------|------| +| Stage 1 | e2e 진단 테스트 작성 + 실행 + 콘솔 캡처 + 1차 본질 가설 정립 | +| Stage 2 | (필요 시) 진단 hook 노출 또는 추가 데이터 수집 | +| Stage 3 | 본질 정정 (별도 구현 계획서 작성 후 승인 — 본 task 또는 후속) | + +본질 진단이 명확해지면 단계 수가 줄어들 수 있음. 정정 영역의 위험도에 따라 추가 단계 필요할 수 있음. + +## 6. 검증 + +- e2e 진단 테스트 실행 결과 (page 1 vs page 2 비교) 명확 +- 본질 가설이 데이터로 뒷받침 +- 회귀 위험 — 진단 단계는 기존 코드 수정 없음 (e2e 신규 1 파일만 추가) + +## 7. 외부 영역 + +- `examples/inspect_595.rs` — 사전 정적 분석용 임시 진단. 본 task 종료 시 보존 여부 결정 (역사 기록 vs 정리). +- 본 task 는 PR 처리 사이클이 아님 — 내부 task. CLAUDE.md `pr/` 폴더와 무관. + +## 8. 작업지시자 승인 요청 사항 + +1. **방향 승인**: e2e 기반 진단 (옵션 B) 진행 — 이미 답변 받음 ✓ +2. **본 수행 계획서 승인**: Stage 1 부터 진행 가능 여부 +3. **`examples/inspect_595.rs` 보존**: 정적 분석 dump 결과를 기록으로 보존할지 (보존 → stage1 보고서에 인용 / 삭제 → 진단 정리) + +승인 후 구현 계획서 (`task_m100_595_impl.md`) 작성 → 승인 후 Stage 1 진행. diff --git a/mydocs/plans/task_m100_595_impl.md b/mydocs/plans/task_m100_595_impl.md new file mode 100644 index 000000000..344b856dd --- /dev/null +++ b/mydocs/plans/task_m100_595_impl.md @@ -0,0 +1,78 @@ +# Task #595 구현 계획서 + +**Issue**: #595 — exam_math.hwp 2페이지부터 수식 더블클릭 hitTest 오동작 +**브랜치**: `local/task595` +**상위 계획서**: `task_m100_595.md` + +--- + +## 단계 분해 (3 단계) + +### Stage 1 — e2e 진단 테스트 작성 + 실행 + 본질 가설 정립 + +**산출물**: `rhwp-studio/e2e/issue-595.test.mjs` + +**시나리오 (단일 테스트)**: + +1. `samples/exam_math.hwp` 로드 (helpers `loadHwpFile`) +2. **page 1 (0-based 0) 의 수식 클릭**: + - 대상: paraIdx=18 ci=0 ("2.함수 ..." 본문 수식, x=130.8 y=818.3 w=108.0 h=17.5) + - 클릭 좌표: 페이지 내부 (130.8 + 50, 818.3 + 8) ≈ (180.8, 826.3) 페이지 px + - DOM 좌표 변환: contentX = pageLeft + 180.8 * zoom, contentY = pageOffsets[0] + 826.3 * zoom +3. **page 2 (0-based 1) 의 수식 클릭**: + - 대상: paraIdx=65 ci=0 (이슈 명세 수식, x=589.5 y=191.7 w=131.7 h=37.3) + - 클릭 좌표: 페이지 내부 (589.5 + 65, 191.7 + 18) ≈ (654.5, 209.7) 페이지 px + - DOM 좌표 변환: contentX = pageLeft + 654.5 * zoom, contentY = pageOffsets[1] + 209.7 * zoom +4. **각 클릭 직전/직후 진단 데이터 캡처** (page.evaluate): + - `pageIdx`, `pageX`, `pageY`, `pageOffset`, `cx`, `cy`, `zoom`, `pageHeights[]`, `pageOffsets[]` + - `wasm.getPageControlLayout(pageIdx)` controls 배열의 type=equation 항목 list + - `inputHandler.findPictureAtClick(pageIdx, pageX, pageY)` 직접 호출 결과 + - 클릭 후 `cursor.isInPictureObjectSelection()` 상태 +5. **결과 비교 + 본질 가설 정립** + +**기존 코드 변경**: 없음 (e2e 신규 1 파일만 추가) + +**진단 hook 노출 필요 시**: stage 종료 후 revert + +**완료 기준**: +- e2e 테스트 실행 성공 (host 또는 headless 모드) +- 진단 데이터 캡처 완료 +- 본질 가설 1개 이상 데이터로 뒷받침 +- `mydocs/working/task_m100_595_stage1.md` 작성 + +### Stage 2 — (조건부) 본질 정정 또는 추가 진단 + +**진입 조건**: Stage 1 본질 가설이 명확 + 정정 영역 식별 + +**케이스 분기**: +- **케이스 A**: 본질 명확 + 영향도 낮음 → 본질 정정 + 회귀 테스트 +- **케이스 B**: 본질 추정만 가능 → 추가 진단 hook 또는 다른 fixture 비교 +- **케이스 C**: Rust 측 결함 발견 → 별도 task 분리 검토 + +**완료 기준**: +- 케이스별 산출물 + 검증 통과 + Stage2 보고서 + +### Stage 3 — 회귀 테스트 + 최종 보고서 + +**산출물**: +- `mydocs/report/task_m100_595_report.md` +- 회귀 sweep 결과 (cargo test, clippy, build, e2e 회귀 0) +- `mydocs/orders/20260506.md` 갱신 + +--- + +## 위험도 영역 + +| 영역 | 위험도 | 대응 | +|------|--------|------| +| e2e host/headless 환경 | 낮음 | 둘 다 시도 (helpers 지원) | +| inputHandler.findPictureAtClick private | 낮음 | JS 런타임 접근 가능 (TS private은 컴파일 시만) | +| 진단 hook 노출 시 prod 영향 | 매우 낮음 | `import.meta.env.DEV` 가드 (현재 `__inputHandler` 와 동일 영역) | +| 본질 정정 회귀 | 중 | Stage 2 진입 시 영역 식별 후 회귀 sweep 사전 정의 | + +## 5. 작업지시자 승인 요청 + +- 본 구현 계획서 (3단계) 승인 +- Stage 1 진행 가능 여부 + +승인 후 Stage 1 e2e 테스트 작성 시작. diff --git a/mydocs/working/task_m100_595_stage1.md b/mydocs/working/task_m100_595_stage1.md new file mode 100644 index 000000000..61c395718 --- /dev/null +++ b/mydocs/working/task_m100_595_stage1.md @@ -0,0 +1,143 @@ +# Task #595 Stage 1 완료 보고서 + +**Issue**: #595 — exam_math.hwp 2페이지부터 수식 더블클릭 hitTest 오동작 +**브랜치**: `local/task595` +**Stage**: 1 — 본질 진단 + 재현 단위 테스트 + 광범위 sweep +**날짜**: 2026-05-07 + +--- + +## 1. Stage 1 산출물 + +| 항목 | 위치 | 상태 | +|------|------|------| +| 정적 분석 진단 도구 | `examples/inspect_595.rs` | 작성 ✓ | +| e2e 진단 테스트 | `rhwp-studio/e2e/issue-595.test.mjs` | 작성 + 실행 ✓ | +| 재현 단위 테스트 | `tests/issue_595.rs` (5 케이스) | 작성 + 실행 ✓ (3 fail / 2 pass) | +| 임시 자료 | `rhwp-studio/public/samples/exam_math.hwp` | 진단용 임시 (Stage 종료 시 정리) | + +## 2. 본질 결함 위치 정확 식별 + +**함수**: `src/renderer/layout.rs::build_header` ([line 888-931](../../src/renderer/layout.rs#L888)) + +**결함 라인**: [layout.rs:928](../../src/renderer/layout.rs#L928) +```rust +// Header bbox를 자식 노드 범위까지 확장 + 셀 클리핑 해제 +// (머리말 표 셀 내 Shape가 header_area 밖에 배치될 수 있음) +Self::expand_bbox_to_children(&mut header_node); +``` + +**메커니즘**: +1. Header 노드 초기 bbox = `layout_rect_to_bbox(&layout.header_area)` — 정상 머리말 영역 (예: y=0~147) +2. 머리말 내부 컨텐츠 (문단, 셀, Shape, line 등) 추가 +3. **`expand_bbox_to_children`** 가 자식 모두의 bbox 합집합으로 Header bbox 확장 +4. 자식 중 본문 끝까지 늘어진 객체 (단 구분선 line `paraIdx=0 ci=2`, h≈1227px) 가 있으면 Header bbox 가 본문 영역까지 침범 + +**의도 (주석)**: 머리말 표 셀 내 Shape 가 머리말 영역 외부 배치 시 클리핑 방지. +**부작용**: 페이지 전체 길이로 늘어진 자식 객체에 대해서는 hit test 영역까지 본문 침범. + +## 3. 페이지별 발현 + +`examples/inspect_595.rs` 결과 (5px sweep, x=514): + +| 페이지 | Header hit y 범위 | 실제 머리말 영역 | 판정 | +|--------|------------------|------------------|------| +| page 0 (1p) | **60.0 ~ 145.0** | 0 ~ 147 | **정상** ✓ | +| page 1 (2p) | **60.0 ~ 1355.0** | 0 ~ 147 | **결함** ✗ | +| page 2 (3p) | 60.0 ~ 1355.0 | 0 ~ 147 | 결함 ✗ | +| page 3 (4p) | 60.0 ~ 1355.0 | 0 ~ 147 | 결함 ✗ | + +**page 0 vs page 1+ 차이**: +- page 0: 단 구분선 line `paraIdx=0 ci=5 y=224.6 h=1133.9` — Body 자식 +- page 1+: 단 구분선 line `paraIdx=0 ci=2 y=132.3 h=1226.8` — Header 자식 (controlIdx 차이) + +## 4. 재현 단위 테스트 (`tests/issue_595.rs`) + +``` +running 5 tests +test issue_595_page1_equation_coord_not_header ... FAILED ← 정정 전 fail +test issue_595_page1_header_area_still_hits ... ok ← 정상 가드 +test issue_595_page1_body_coord_not_header_regression_guard ... FAILED ← 정정 전 fail +test issue_595_page0_body_coord_not_header ... ok ← 정상 baseline +test issue_595_page1_body_center_not_header ... FAILED ← 정정 전 fail + +test result: FAILED. 2 passed; 3 failed; 0 ignored; 0 measured +``` + +**Fail 메시지 정합**: +- `(514, 200)` → `{"hit":true,"isHeader":true,"sectionIndex":0,"applyTo":1}` ← 본문 좌표 머리말 hit +- `(654.5, 209.7)` → 동일 (이슈 명세 수식 좌표) +- `(514, 800)` → 동일 (본문 중앙) + +→ 결함 재현 정합. 정정 후 5 케이스 모두 pass 가 stage 2 검증 기준. + +## 5. 광범위 sweep — 영향 영역 정량 + +`samples/` 전체 164 fixture / 1684 페이지 점검 (x=514, y=800 본문 좌표 기준): + +| 항목 | 측정값 | +|------|--------| +| 머리말 hit 본문 침범 fixture | **2 / 164 (1.2%)** | +| 머리말 hit 본문 침범 페이지 | **32 / 1684 (1.9%)** | + +**발현 fixture**: +- `exam_math.hwp` — 20p 중 16p (page 0 제외 모든 페이지) +- `exam_math_no.hwp` — 동일 양식 (16/20p) + +**해석**: 매우 특수한 양식 (머리말에 단 구분선 line 이 자식으로 포함된 경우) — 회귀 위험 매우 낮음. + +## 6. 이슈 본문 정오표 + +이슈에서 "Rust 측 점검 결과 — 정합" 으로 결론 지었으나 **점검 범위가 부분적**: + +| 점검 영역 | 이슈에서 점검 | 본 stage 점검 | +|-----------|---------------|----------------| +| `getPageControlLayout` controls 배열 (수식 bbox) | ✓ 정합 확인 | ✓ 정합 재확인 | +| **`hitTestHeaderFooter`** | ✗ 점검 안 함 | **✗ page 1+ 결함 발견** | +| `build_page_tree` 의 Header 노드 bbox | ✗ 점검 안 함 | **✗ build_header 의 expand_bbox_to_children 결함** | + +이슈에서 후속 진단 후보로 제시한 (a)/(b)/(c) 가 모두 TS 측 좌표 변환 영역 — Rust 측 hit test 영역은 의심 후보 밖. + +**놓친 단서 (사용자 추가 보고 시 명확화)**: +- "hover 시 손바닥 표시는 뜨는데 클릭 반응 없음" → `findPictureAtClick` 자체는 정상 hit, dblclick 흐름의 별도 분기 (머리말 검사) 가 가로채는 중 + +## 7. e2e 진단 결과 (참고) + +`rhwp-studio/e2e/issue-595.test.mjs` (1365×1018 사용자 환경 모사): + +| 시나리오 | findPictureAtClick | hfHit | dblclick 결과 | +|----------|-------------------|--------|---------------| +| zoom=1 page 0 paraIdx=18 ci=0 | hit ✓ | false | picSel=true ✓ | +| zoom=1 page 1 paraIdx=65 ci=0 | hit ✓ | **true** | picSel=true (e2e mock 한계) | +| zoom=0.5 그리드 모드 | null ✗ | false | picSel=false | + +**별도 결함 발견**: zoom ≤ 0.5 그리드 모드에서 `pageLeft = (sc.clientWidth - pageDisplayWidth) / 2` 단일 컬럼 가정으로 hit 좌표 계산 결함. 본 이슈 (#595) 와 별개 영역 — 별도 task 분리 검토. + +## 8. Stage 2 정정 영역 후보 + +| 옵션 | 영향 영역 | 안전도 | 비고 | +|------|----------|--------|------| +| **A. `hit_test_header_footer_native` 에서 `layout.header_area` 로 hit 판정** | 단일 함수 | 높음 | bbox 확장과 무관하게 정확한 머리말 영역만 hit. 렌더링 동작 무영향. | +| **B. `expand_bbox_to_children` 후 `layout.header_area` 로 max-clip** | `build_header` | 중 | bbox 자체를 보정. 단 구분선 line 의 클리핑 동작에 영향 가능. | +| **C. 단 구분선 line 을 Header 자식이 아니라 별도 분류** | layout 분류 영역 | 낮음 | 영향 영역 광범위 — 회귀 위험 ↑ | + +**권장**: **옵션 A** — 영향 영역 좁고 본 결함 정정에 충분. 회귀 위험 최소. + +## 9. 다음 단계 (Stage 2) + +1. 정정 옵션 (A 권장) 작업지시자 승인 +2. `cursor_rect.rs::hit_test_header_footer_native` 에서 `layout.header_area` 사용으로 정정 +3. 단위 테스트 5 케이스 모두 pass 확인 +4. 광범위 sweep 재실행 — 회귀 0 확인 +5. e2e 정정 후 재시도 — 사용자 환경 정합 확인 +6. Stage 2 보고서 작성 + +## 10. Stage 1 정리 항목 + +- `examples/inspect_595.rs` — 보존 (정정 후 회귀 검증용으로 재사용 가능) +- `rhwp-studio/public/samples/exam_math.hwp` — Stage 2 e2e 검증 후 제거 +- `tests/issue_595.rs` — 영구 보존 (회귀 차단 가드) + +--- + +**Stage 1 완료 — Stage 2 진입 승인 요청** diff --git a/rhwp-studio/e2e/issue-595.test.mjs b/rhwp-studio/e2e/issue-595.test.mjs new file mode 100644 index 000000000..1a31247b5 --- /dev/null +++ b/rhwp-studio/e2e/issue-595.test.mjs @@ -0,0 +1,309 @@ +/** + * Issue #595 진단 e2e + * + * 본질: exam_math.hwp 의 수식을 더블클릭했을 때 1페이지(0-based 0) 만 정상, + * 2페이지(0-based 1) 부터 findPictureAtClick 이 hit 으로 인식하지 못 함. + * + * 본 e2e 는 정정 패치 전 단계로, page 1 vs page 2 의 좌표/매칭 결과를 + * 직접 비교하여 본질 가설을 데이터로 검증한다. + * + * 실행: + * cd rhwp-studio + * npx vite --host 0.0.0.0 --port 7700 & + * node e2e/issue-595.test.mjs --mode=headless + * + * 또는: + * node e2e/issue-595.test.mjs --mode=host + */ +import { runTest, loadHwpFile, screenshot } from './helpers.mjs'; + +// 진단 유틸 — page.evaluate 안에서 좌표 변환 + bbox 매칭 직접 호출 +async function probe(page, label, target) { + console.log(`\n=== ${label} ===`); + console.log(` target: paraIdx=${target.paraIdx} ci=${target.ci}, pageInside=(${target.pageX}, ${target.pageY})`); + + // 1) 좌표 변환 + 진단 데이터 캡처 (클릭 전) + const beforeClick = await page.evaluate((t) => { + const sc = document.querySelector('#scroll-content'); + const ih = window.__inputHandler; + const cv = window.__canvasView; + if (!sc || !ih || !cv) { + return { error: `missing globals: sc=${!!sc} ih=${!!ih} cv=${!!cv}` }; + } + // private 접근 (TS private 은 컴파일 시만 — JS 런타임 접근 가능) + const vs = ih.virtualScroll; + const vm = ih.viewportManager; + const wasm = window.__wasm; + const zoom = vm.getZoom(); + const pageWidth = vs.getPageWidth(t.targetPageIdx); + const pageHeight = vs.getPageHeight(t.targetPageIdx); + const pageOffset = vs.getPageOffset(t.targetPageIdx); + const pageLefts = []; + const pageOffsets = []; + const pageHeights = []; + for (let i = 0; i < vs.pageCount; i++) { + pageLefts.push(vs.getPageLeft(i)); + pageOffsets.push(vs.getPageOffset(i)); + pageHeights.push(vs.getPageHeight(i)); + } + + // 페이지 내부 좌표 (HWP layout 단위) → DOM 좌표 + // canvas style.left 가 -1 이면 CSS 중앙 정렬 사용 + const pageLeftRaw = vs.getPageLeft(t.targetPageIdx); + const pageLeftDOM = pageLeftRaw >= 0 ? pageLeftRaw : (sc.clientWidth - pageWidth) / 2; + const docX = pageLeftDOM + t.pageX * zoom; + const docY = pageOffset + t.pageY * zoom; + + // 스크롤하여 클릭 좌표가 화면 안에 들어오게 + const scroller = sc.parentElement; + const targetScrollY = Math.max(0, docY - 200); + scroller.scrollTop = targetScrollY; + + return { + zoom, + pageWidth, + pageHeight, + pageOffset, + pageLeftRaw, + pageLeftDOM, + docX, + docY, + targetScrollY, + scrollContentClientWidth: sc.clientWidth, + scrollerScrollTop: scroller.scrollTop, + pageOffsets, + pageHeights, + pageLefts, + pageCount: vs.pageCount, + }; + }, { ...target }); + + if (beforeClick.error) throw new Error(`probe failed: ${beforeClick.error}`); + + // 스크롤 안정화 대기 + await page.evaluate(() => new Promise(r => setTimeout(r, 400))); + + // 2) clientX/clientY 계산 (스크롤 후 contentRect 기준) + const clickPoint = await page.evaluate((d) => { + const sc = document.querySelector('#scroll-content'); + const cr = sc.getBoundingClientRect(); + const clientX = cr.left + d.docX; + const clientY = cr.top + d.docY; + // 안전: clientY 가 viewport 안에 있는지 확인 (스크롤 안정화 후) + const vph = window.innerHeight; + return { + clientX, clientY, crLeft: cr.left, crTop: cr.top, + viewportHeight: vph, + inViewport: clientY >= 0 && clientY < vph, + }; + }, { docX: beforeClick.docX, docY: beforeClick.docY }); + + // 3) 클릭 직전 inputHandler.findPictureAtClick 직접 호출 + 좌표 재계산 (클릭 모사) + const probeResult = await page.evaluate((cp) => { + const sc = document.querySelector('#scroll-content'); + const ih = window.__inputHandler; + const vm = ih.viewportManager; + const vs = ih.virtualScroll; + const wasm = window.__wasm; + const zoom = vm.getZoom(); + const cr = sc.getBoundingClientRect(); + // 마우스 핸들러와 동일한 식 + const cx = cp.clientX - cr.left; + const cy = cp.clientY - cr.top; + const pageIdx = vs.getPageAtY(cy); + const pageOffset = vs.getPageOffset(pageIdx); + const pageDisplayWidth = vs.getPageWidth(pageIdx); + const pageLeft = (sc.clientWidth - pageDisplayWidth) / 2; + const pageX = (cx - pageLeft) / zoom; + const pageY = (cy - pageOffset) / zoom; + + // layout.controls 중 type=equation 만 추출 (개수만) + let layoutSummary = null; + try { + const json = wasm.getPageControlLayout(pageIdx); + const layout = JSON.parse(json); + const eqs = (layout.controls || []).filter(c => c.type === 'equation'); + layoutSummary = { + total: (layout.controls || []).length, + equationCount: eqs.length, + }; + } catch (e) { + layoutSummary = { error: e?.message || String(e) }; + } + + // wasm.hitTest 결과 (mousedown 흐름 모사) + let hitResult = null; + try { + hitResult = wasm.hitTest(pageIdx, pageX, pageY); + } catch (e) { + hitResult = { error: e?.message || String(e) }; + } + + // 머리말/꼬리말 hit + let hfHit = null, fnHit = null, formHit = null; + try { hfHit = wasm.hitTestHeaderFooter(pageIdx, pageX, pageY); } catch (e) {} + try { fnHit = wasm.hitTestFootnote(pageIdx, pageX, pageY); } catch (e) {} + try { formHit = wasm.getFormObjectAt(pageIdx, pageX, pageY); } catch (e) {} + + // findPictureAtClick 직접 호출 + let picHit = null; + try { + picHit = ih.findPictureAtClick(pageIdx, pageX, pageY); + } catch (e) { + picHit = { error: e?.message || String(e) }; + } + return { cx, cy, pageIdx, pageOffset, pageDisplayWidth, pageLeft, pageX, pageY, + picHit, layoutSummary, hitResult, hfHit, fnHit, formHit }; + }, clickPoint); + + // 4) 실제 클릭 이벤트 발생 + await page.mouse.click(clickPoint.clientX, clickPoint.clientY); + await page.evaluate(() => new Promise(r => setTimeout(r, 300))); + + // 5) 클릭 결과 — cursor 상태 확인 + const afterClick = await page.evaluate(() => { + const ih = window.__inputHandler; + const cur = ih?.cursor; + return { + isInPictureObjectSelection: !!cur?.isInPictureObjectSelection?.(), + isInTextBox: !!cur?.isInTextBox?.(), + pos: cur?.getPosition?.() ? { + sectionIndex: cur.getPosition().sectionIndex, + paragraphIndex: cur.getPosition().paragraphIndex, + charOffset: cur.getPosition().charOffset, + } : null, + selectedPictureRef: cur?.getSelectedPictureRef?.() ?? null, + }; + }); + + console.log(` zoom=${beforeClick.zoom} pageOffsets[0..3]=${beforeClick.pageOffsets.slice(0,4).map(v => v.toFixed(1)).join(', ')}`); + console.log(` pageHeight=${beforeClick.pageHeight.toFixed(1)} pageWidth=${beforeClick.pageWidth.toFixed(1)} pageLeftRaw=${beforeClick.pageLeftRaw} pageLeftDOM=${beforeClick.pageLeftDOM.toFixed(1)}`); + console.log(` docX=${beforeClick.docX.toFixed(1)} docY=${beforeClick.docY.toFixed(1)} → scrollTop=${beforeClick.targetScrollY.toFixed(1)}`); + console.log(` clientX=${clickPoint.clientX.toFixed(1)} clientY=${clickPoint.clientY.toFixed(1)} cr=(${clickPoint.crLeft.toFixed(1)}, ${clickPoint.crTop.toFixed(1)}) inVp=${clickPoint.inViewport}`); + console.log(` [PROBE 좌표 역계산] cx=${probeResult.cx.toFixed(1)} cy=${probeResult.cy.toFixed(1)} → pageIdx=${probeResult.pageIdx} pageOffset=${probeResult.pageOffset.toFixed(1)} pageX=${probeResult.pageX.toFixed(1)} pageY=${probeResult.pageY.toFixed(1)}`); + console.log(` [PROBE layout] page ${probeResult.pageIdx} controls=${probeResult.layoutSummary?.total} equations=${probeResult.layoutSummary?.equationCount}`); + console.log(` [PROBE wasm.hitTest] ${JSON.stringify(probeResult.hitResult)}`); + console.log(` [PROBE hf/fn/form] hf=${JSON.stringify(probeResult.hfHit)} fn=${JSON.stringify(probeResult.fnHit)} form=${JSON.stringify(probeResult.formHit)}`); + console.log(` [PROBE findPictureAtClick] ${JSON.stringify(probeResult.picHit)}`); + console.log(` [AFTER CLICK] picSel=${afterClick.isInPictureObjectSelection} textBox=${afterClick.isInTextBox} pos=${JSON.stringify(afterClick.pos)} selRef=${JSON.stringify(afterClick.selectedPictureRef)}`); + + return { beforeClick, clickPoint, probeResult, afterClick }; +} + +runTest('Issue #595 — exam_math.hwp page 1 vs page 2 수식 hitTest 비교 (1365x1018 사용자 환경)', async ({ page }) => { + // 사용자 환경: 1365x1018, zoom=100% + await page.setViewport({ width: 1365, height: 1018 }); + await page.evaluate(() => new Promise(r => setTimeout(r, 200))); + + console.log('[1] exam_math.hwp 로드'); + const info = await loadHwpFile(page, 'exam_math.hwp'); + console.log(` pageCount=${info.pageCount}`); + + // 환경 정보 + const env = await page.evaluate(() => { + const ih = window.__inputHandler; + const vs = ih?.virtualScroll; + return { + windowInner: { w: window.innerWidth, h: window.innerHeight }, + dpr: window.devicePixelRatio, + zoom: ih?.viewportManager?.getZoom?.(), + gridMode: vs?.isGridMode?.(), + columns: vs?.getColumns?.(), + pageCount: vs?.pageCount, + }; + }); + console.log(` env: window=${env.windowInner.w}x${env.windowInner.h} dpr=${env.dpr} zoom=${env.zoom} grid=${env.gridMode} cols=${env.columns} pageCount=${env.pageCount}`); + await screenshot(page, 'issue595-01-loaded'); + + // page 1 (0-based 0) — paraIdx=18 ci=0 수식 (x=130.8 y=818.3 w=108.0 h=17.5) + const r1 = await probe(page, 'page 1 (0-based 0) — paraIdx=18 ci=0 수식 [zoom=1]', { + targetPageIdx: 0, + paraIdx: 18, ci: 0, + pageX: 130.8 + 50, // bbox 중앙 근처 + pageY: 818.3 + 8, + }); + await screenshot(page, 'issue595-02-page1-clicked'); + + // page 2 (0-based 1) — paraIdx=65 ci=0 수식 (x=589.5 y=191.7 w=131.7 h=37.3) — 이슈 명세 + const r2 = await probe(page, 'page 2 (0-based 1) — paraIdx=65 ci=0 수식 [zoom=1]', { + targetPageIdx: 1, + paraIdx: 65, ci: 0, + pageX: 589.5 + 65, + pageY: 191.7 + 18, + }); + await screenshot(page, 'issue595-03-page2-clicked'); + + // ─── 추가 시나리오: 다양한 zoom 에서 page 2 수식 클릭 ─── + // 사용자 환경에서 발현 가능성: 모바일 fit-to-width (zoom < 1) / Hi-DPI 확대 (zoom > 1) + for (const z of [0.5, 0.75, 1.5, 2.0]) { + console.log(`\n>>> zoom=${z} 로 변경 후 page 2 수식 재시도`); + await page.evaluate((zoom) => { + window.__inputHandler.viewportManager.setZoom(zoom); + }, z); + await page.evaluate(() => new Promise(r => setTimeout(r, 600))); + const rZ = await probe(page, `page 2 (0-based 1) — paraIdx=65 ci=0 수식 [zoom=${z}]`, { + targetPageIdx: 1, + paraIdx: 65, ci: 0, + pageX: 589.5 + 65, + pageY: 191.7 + 18, + }); + await screenshot(page, `issue595-zoom-${z}`.replace('.', '_')); + } + + // 원래 zoom 으로 복귀 + await page.evaluate(() => window.__inputHandler.viewportManager.setZoom(1.0)); + await page.evaluate(() => new Promise(r => setTimeout(r, 400))); + + // ─── 더블클릭 시뮬레이션 ─── + console.log('\n>>> page 2 수식 더블클릭 (실제 사용자 동작 모사)'); + const dblTarget = await page.evaluate(() => { + const sc = document.querySelector('#scroll-content'); + const ih = window.__inputHandler; + const vs = ih.virtualScroll; + const vm = ih.viewportManager; + const zoom = vm.getZoom(); + const pageIdx = 1; + const pageW = vs.getPageWidth(pageIdx); + const pageOffset = vs.getPageOffset(pageIdx); + const pageLeftDOM = (sc.clientWidth - pageW) / 2; + const pageX = 589.5 + 65, pageY = 191.7 + 18; + const docX = pageLeftDOM + pageX * zoom; + const docY = pageOffset + pageY * zoom; + sc.parentElement.scrollTop = Math.max(0, docY - 200); + return new Promise(r => requestAnimationFrame(() => { + const cr = sc.getBoundingClientRect(); + r({ clientX: cr.left + docX, clientY: cr.top + docY }); + })); + }); + await page.evaluate(() => new Promise(r => setTimeout(r, 400))); + // 두 번 빠르게 클릭 (Puppeteer 더블클릭) + await page.mouse.click(dblTarget.clientX, dblTarget.clientY, { clickCount: 2, delay: 50 }); + await page.evaluate(() => new Promise(r => setTimeout(r, 600))); + const afterDbl = await page.evaluate(() => { + const ih = window.__inputHandler; + const cur = ih?.cursor; + return { + isInPictureObjectSelection: !!cur?.isInPictureObjectSelection?.(), + selectedPictureRef: cur?.getSelectedPictureRef?.() ?? null, + }; + }); + console.log(` 더블클릭 후: picSel=${afterDbl.isInPictureObjectSelection} selRef=${JSON.stringify(afterDbl.selectedPictureRef)}`); + await screenshot(page, 'issue595-04-dblclick'); + + // 비교 요약 + console.log('\n=== 비교 요약 ==='); + console.log(`page 1: picHit=${JSON.stringify(r1.probeResult.picHit)} → afterClick.picSel=${r1.afterClick.isInPictureObjectSelection}`); + console.log(`page 2: picHit=${JSON.stringify(r2.probeResult.picHit)} → afterClick.picSel=${r2.afterClick.isInPictureObjectSelection}`); + + // 검증: page 1 정상, page 2 fail 가설 확인 + const page1Ok = r1.probeResult.picHit && !r1.probeResult.picHit.error; + const page2Ok = r2.probeResult.picHit && !r2.probeResult.picHit.error; + console.log(`\n[가설 검증] page1 picture hit OK=${!!page1Ok} / page2 picture hit OK=${!!page2Ok}`); + if (page1Ok && !page2Ok) { + console.log(' → 이슈 재현 ✓ (page 1 정상 / page 2 fail)'); + } else if (page1Ok && page2Ok) { + console.log(' → 이슈 재현 실패 — 두 페이지 모두 정상 (좌표 모델 불일치 가능성)'); + } else { + console.log(` → 예상 외 결과: page1=${!!page1Ok}, page2=${!!page2Ok}`); + } +}); diff --git a/tests/issue_595.rs b/tests/issue_595.rs new file mode 100644 index 000000000..b46ea3821 --- /dev/null +++ b/tests/issue_595.rs @@ -0,0 +1,87 @@ +//! Issue #595: exam_math.hwp 2페이지부터 수식 더블클릭 hitTest 오동작. +//! +//! 본질: `src/renderer/layout.rs::build_header` 의 `expand_bbox_to_children` +//! 호출이 머리말 자식 노드 (특히 단 구분선 line `paraIdx=0 ci=2`, h≈1227px) 의 +//! bbox 까지 Header 영역으로 확장 → `hit_test_header_footer_native` 가 본문 좌표를 +//! 머리말 hit 으로 잘못 인식 → `onDblClick` 의 머리말 분기가 picture selection +//! 분기보다 먼저 실행되어 수식 편집기 진입 차단. +//! +//! 발현: page 0 (1p) 정상 / page 1+ (2p~) 결함. +//! +//! 본 테스트는 정정 전 fail / 정정 후 pass — 회귀 차단 영구 가드. + +use std::fs; +use std::path::Path; + +fn load_exam_math() -> rhwp::wasm_api::HwpDocument { + let repo_root = env!("CARGO_MANIFEST_DIR"); + let hwp_path = Path::new(repo_root).join("samples/exam_math.hwp"); + let bytes = fs::read(&hwp_path).expect("read exam_math.hwp"); + rhwp::wasm_api::HwpDocument::from_bytes(&bytes).expect("parse exam_math.hwp") +} + +/// page 0 (1-based 1) 의 본문 좌표 (514, 200) 는 머리말 hit 이 아니어야 한다. +/// 정상 머리말 영역은 y=0~147 정도. 본 테스트는 baseline (정정 전에도 통과 예상). +#[test] +fn issue_595_page0_body_coord_not_header() { + let doc = load_exam_math(); + let r = doc.hit_test_header_footer_native(0, 514.0, 200.0).unwrap(); + assert!( + r.contains("\"hit\":false"), + "page 0 본문 좌표 (514, 200) 가 머리말 hit 으로 잘못 인식됨: {}", + r + ); +} + +/// page 1 (1-based 2) 의 본문 좌표 (514, 200) 는 머리말 hit 이 아니어야 한다. +/// 정정 전: hit:true (Header bbox 가 본문 영역 60~1355 까지 침범) +/// 정정 후: hit:false (정상 머리말 영역 60~145 로 제한) +#[test] +fn issue_595_page1_body_coord_not_header_regression_guard() { + let doc = load_exam_math(); + let r = doc.hit_test_header_footer_native(1, 514.0, 200.0).unwrap(); + assert!( + r.contains("\"hit\":false"), + "page 1 본문 좌표 (514, 200) 가 머리말 hit 으로 잘못 인식됨 (Issue #595 회귀): {}", + r + ); +} + +/// 이슈 명세 정확 좌표 — page 1 의 paraIdx=65 ci=0 수식 영역 (654.5, 209.7). +/// 이 좌표는 본문 영역의 수식 객체 위치이며 머리말 hit 이 아니어야 한다. +#[test] +fn issue_595_page1_equation_coord_not_header() { + let doc = load_exam_math(); + let r = doc.hit_test_header_footer_native(1, 654.5, 209.7).unwrap(); + assert!( + r.contains("\"hit\":false"), + "page 1 수식 좌표 (654.5, 209.7) 가 머리말 hit 으로 잘못 인식됨 (Issue #595): {}", + r + ); +} + +/// page 1 의 페이지 중앙 본문 영역 (514, 800) 도 머리말 hit 이 아니어야 한다. +/// 본문 한가운데 — 명백히 머리말 영역 밖. +#[test] +fn issue_595_page1_body_center_not_header() { + let doc = load_exam_math(); + let r = doc.hit_test_header_footer_native(1, 514.0, 800.0).unwrap(); + assert!( + r.contains("\"hit\":false"), + "page 1 본문 중앙 (514, 800) 이 머리말 hit 으로 잘못 인식됨 (Issue #595): {}", + r + ); +} + +/// page 1 의 머리말 영역 좌표 (514, 100) 은 정상적으로 머리말 hit 이어야 한다. +/// 정정으로 인해 머리말 영역 자체의 hit 이 사라지지 않아야 함을 보장. +#[test] +fn issue_595_page1_header_area_still_hits() { + let doc = load_exam_math(); + let r = doc.hit_test_header_footer_native(1, 514.0, 100.0).unwrap(); + assert!( + r.contains("\"hit\":true") && r.contains("\"isHeader\":true"), + "page 1 머리말 영역 (514, 100) 이 머리말 hit 으로 인식되어야 함 (정정 회귀 가드): {}", + r + ); +} From 33fc7ac227a7a5216cdbad3bfebdfb06859da69f Mon Sep 17 00:00:00 2001 From: johndoekim Date: Thu, 7 May 2026 01:01:33 +0900 Subject: [PATCH 02/13] =?UTF-8?q?Task=20#595=20Stage=202:=20hit=5Ftest=5Fh?= =?UTF-8?q?eader=5Ffooter=20=EC=98=81=EC=97=AD=20=EC=A0=95=EC=A0=95=20+=20?= =?UTF-8?q?=EA=B4=91=EB=B2=94=EC=9C=84=20=ED=9A=8C=EA=B7=80=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 본질 정정: src/document_core/queries/cursor_rect.rs::hit_test_header_footer_native 가 build_page_tree 의 Header/Footer 노드 bbox (expand_bbox_to_children 으로 자식 단 구분선 line 까지 확장됨) 대신 layout.header_area / layout.footer_area (PageDef margin 으로 계산된 정확한 영역) 로 hit 판정. LOC: +20 / -13 (단일 함수). build_page_tree 호출 제거 → mousedown 마다 호출되던 비싼 트리 빌드 비용 제거 (부수 효과). 검증 (모두 정정 전 → 정정 후): - tests/issue_595.rs: 3 fail / 2 pass → 5 pass - 본문 false-positive sweep: 32p → 0p - 머리말 hit 정확화: +27p 부수 개선 - 꼬리말 hit: 동일 (회귀 0) - cargo test --lib --release: 1140 passed (회귀 0) - cargo clippy --release: clean - WASM 빌드: 4,531,883 bytes - 작업지시자 직접 시각 판정 ★ 통과 (정정 전 결함 / 정정 후 정상 모두 확인) 회귀 검증 도구: examples/inspect_595_regression.rs (영구 보존, 향후 hit_test_header_footer 영역 회귀 차단 가드). 별도 발견: - 꼬리말 hit:false 16p (hwpctl_Action_Table__v1.1.hwp 1 fixture) — 정정 전후 동일, 본 task 영역 밖 - zoom <= 0.5 그리드 모드 좌표 결함 — input-handler-mouse.ts 의 pageLeft 단일 컬럼 가정, 본 이슈 별개 영역 모두 별도 issue/task 분리 검토. refs #595 Co-Authored-By: Claude Opus 4.7 --- examples/inspect_595_regression.rs | 183 +++++++++++++++++++++++ mydocs/working/task_m100_595_stage2.md | 162 ++++++++++++++++++++ src/document_core/queries/cursor_rect.rs | 61 +++++--- 3 files changed, 382 insertions(+), 24 deletions(-) create mode 100644 examples/inspect_595_regression.rs create mode 100644 mydocs/working/task_m100_595_stage2.md diff --git a/examples/inspect_595_regression.rs b/examples/inspect_595_regression.rs new file mode 100644 index 000000000..e2702d5df --- /dev/null +++ b/examples/inspect_595_regression.rs @@ -0,0 +1,183 @@ +//! Issue #595 회귀 sweep — 정정 전/후 hit_test_header_footer 결과 비교 +//! +//! 각 fixture / 각 페이지에서 다음 영역의 hit 결과를 측정: +//! - **머리말 영역 중앙** — hit:true Header 보장 (기존 동작 보존) +//! - **꼬리말 영역 중앙** — hit:true Footer 보장 (기존 동작 보존) +//! - **본문 영역 중앙** — hit:false 보장 (정정 전 결함 → 정정 후 정상) +//! +//! 페이지의 머리말/꼬리말/본문 영역 좌표는 `getPageInfo` JSON 의 margin 정보로 계산. +//! +//! 실행: +//! cargo run --release --example inspect_595_regression > /tmp/595_after.txt +//! git stash -- src/document_core/queries/cursor_rect.rs +//! cargo run --release --example inspect_595_regression > /tmp/595_before.txt +//! git stash pop +//! diff /tmp/595_before.txt /tmp/595_after.txt + +use std::fs; + +#[derive(Default, Debug)] +struct PageMargins { + page_width: f64, + page_height: f64, + margin_left: f64, + margin_right: f64, + margin_top: f64, + margin_bottom: f64, + margin_header: f64, + margin_footer: f64, +} + +fn parse_f64(json: &str, key: &str) -> Option { + let needle = format!("\"{}\":", key); + let pos = json.find(&needle)?; + let after = &json[pos + needle.len()..]; + let end = after.find(|c: char| c == ',' || c == '}')?; + after[..end].trim().parse::().ok() +} + +fn parse_margins(json: &str) -> Option { + Some(PageMargins { + page_width: parse_f64(json, "width")?, + page_height: parse_f64(json, "height")?, + margin_left: parse_f64(json, "marginLeft")?, + margin_right: parse_f64(json, "marginRight")?, + margin_top: parse_f64(json, "marginTop")?, + margin_bottom: parse_f64(json, "marginBottom")?, + margin_header: parse_f64(json, "marginHeader")?, + margin_footer: parse_f64(json, "marginFooter")?, + }) +} + +#[derive(Default)] +struct Stats { + pages: u32, + header_hit_pass: u32, + header_hit_fail: u32, + footer_hit_pass: u32, + footer_hit_fail: u32, + body_no_hit_pass: u32, + body_no_hit_fail: u32, + failures: Vec, +} + +fn main() { + let samples_dir = std::path::Path::new("samples"); + let entries: Vec<_> = match fs::read_dir(samples_dir) { + Ok(rd) => rd.flatten().collect(), + Err(e) => { eprintln!("read_dir fail: {:?}", e); return; } + }; + let mut paths: Vec = Vec::new(); + for ent in entries { + let p = ent.path(); + if p.is_file() { + let ext = p.extension().and_then(|e| e.to_str()).unwrap_or(""); + if ext == "hwp" || ext == "hwpx" { + paths.push(p); + } + } + } + paths.sort(); + + let mut stats = Stats::default(); + let mut total_fixtures = 0usize; + + for path in &paths { + let bytes = match fs::read(path) { Ok(b) => b, Err(_) => continue }; + let core = match rhwp::document_core::DocumentCore::from_bytes(&bytes) { + Ok(c) => c, Err(_) => continue + }; + total_fixtures += 1; + let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?").to_string(); + + let pc = core.page_count(); + for page in 0..pc { + stats.pages += 1; + let info_json = match core.get_page_info_native(page) { Ok(j) => j, Err(_) => continue }; + let m = match parse_margins(&info_json) { Some(m) => m, None => continue }; + + // 머리말 영역 중앙: x=중앙, y=margin_top + margin_header/2 + let header_x = m.page_width / 2.0; + let header_y = m.margin_top + m.margin_header / 2.0; + + // 꼬리말 영역 중앙: x=중앙, y=page_height - margin_bottom - margin_footer/2 + let footer_x = m.page_width / 2.0; + let footer_y = m.page_height - m.margin_bottom - m.margin_footer / 2.0; + + // 본문 중앙: 페이지 정중앙 (확실히 본문 영역 안) + let body_x = m.page_width / 2.0; + let body_y = m.page_height / 2.0; + + // 머리말 영역 hit 검증 (margin_header > 0 인 페이지만 — 정의된 머리말이 있는 경우) + if m.margin_header > 0.0 { + if let Ok(r) = core.hit_test_header_footer_native(page, header_x, header_y) { + let hit = r.contains("\"hit\":true") && r.contains("\"isHeader\":true"); + if hit { stats.header_hit_pass += 1; } + else { + stats.header_hit_fail += 1; + if stats.failures.len() < 30 { + stats.failures.push(format!( + " [HEADER MISS] {} page {} ({:.1},{:.1}) margin_header={:.1}: {}", + name, page, header_x, header_y, m.margin_header, r + )); + } + } + } + } + + // 꼬리말 영역 hit 검증 (margin_footer > 0 인 페이지만) + if m.margin_footer > 0.0 { + if let Ok(r) = core.hit_test_header_footer_native(page, footer_x, footer_y) { + let hit = r.contains("\"hit\":true") && r.contains("\"isHeader\":false"); + if hit { stats.footer_hit_pass += 1; } + else { + stats.footer_hit_fail += 1; + if stats.failures.len() < 30 { + stats.failures.push(format!( + " [FOOTER MISS] {} page {} ({:.1},{:.1}) margin_footer={:.1}: {}", + name, page, footer_x, footer_y, m.margin_footer, r + )); + } + } + } + } + + // 본문 영역 hit 안 됨 검증 + if let Ok(r) = core.hit_test_header_footer_native(page, body_x, body_y) { + let no_hit = r.contains("\"hit\":false"); + if no_hit { stats.body_no_hit_pass += 1; } + else { + stats.body_no_hit_fail += 1; + if stats.failures.len() < 30 { + stats.failures.push(format!( + " [BODY HIT — FALSE POSITIVE] {} page {} ({:.1},{:.1}): {}", + name, page, body_x, body_y, r + )); + } + } + } + } + } + + println!("=== Issue #595 회귀 sweep 결과 ==="); + println!("총 fixture: {} / 총 페이지: {}", total_fixtures, stats.pages); + println!(); + println!("[1] 머리말 영역 중앙 hit:true 보장 (기존 동작 보존)"); + println!(" pass: {} / fail: {}", stats.header_hit_pass, stats.header_hit_fail); + println!(); + println!("[2] 꼬리말 영역 중앙 hit:true 보장 (기존 동작 보존)"); + println!(" pass: {} / fail: {}", stats.footer_hit_pass, stats.footer_hit_fail); + println!(); + println!("[3] 본문 중앙 hit:false 보장 (Issue #595 결함 정정)"); + println!(" pass: {} / fail: {}", stats.body_no_hit_pass, stats.body_no_hit_fail); + println!(); + + if !stats.failures.is_empty() { + println!("=== 실패 케이스 (최대 30건) ==="); + for line in &stats.failures { + println!("{}", line); + } + } else { + println!("=== 모든 케이스 통과 ==="); + } +} diff --git a/mydocs/working/task_m100_595_stage2.md b/mydocs/working/task_m100_595_stage2.md new file mode 100644 index 000000000..eed6fff4d --- /dev/null +++ b/mydocs/working/task_m100_595_stage2.md @@ -0,0 +1,162 @@ +# Task #595 Stage 2 완료 보고서 + +**Issue**: #595 — exam_math.hwp 2페이지부터 수식 더블클릭 hitTest 오동작 +**브랜치**: `local/task595` +**Stage**: 2 — 본질 정정 + 회귀 검증 +**날짜**: 2026-05-07 + +--- + +## 1. 정정 영역 + +**파일**: `src/document_core/queries/cursor_rect.rs` +**함수**: `hit_test_header_footer_native` + +**옵션 A 적용** — Header/Footer 노드의 bbox (자식 노드 까지 확장) 가 아닌, layout 의 정확한 `header_area` / `footer_area` 로 hit 판정. + +### 정정 전 (Stage 1 진단 결과) + +```rust +let tree = self.build_page_tree(page_num)?; +for child in &tree.root.children { + let is_header = matches!(child.node_type, RenderNodeType::Header); + let is_footer = matches!(child.node_type, RenderNodeType::Footer); + if !is_header && !is_footer { continue; } + if x >= child.bbox.x && x <= child.bbox.x + child.bbox.width + && y >= child.bbox.y && y <= child.bbox.y + child.bbox.height + { + // ... hit ... + } +} +``` + +`child.bbox` 가 `expand_bbox_to_children` 으로 자식 (단 구분선 line) 까지 확장되어 본문 영역 침범. + +### 정정 후 + +```rust +let (page_content, _, _) = self.find_page(page_num)?; +let layout = &page_content.layout; + +// 머리말 영역 hit 판정 (layout.header_area — 정확한 머리말 범위) +let h = &layout.header_area; +if x >= h.x && x <= h.x + h.width && y >= h.y && y <= h.y + h.height { + // ... hit ... +} + +// 꼬리말 영역 hit 판정 (layout.footer_area) +let f = &layout.footer_area; +if x >= f.x && x <= f.x + f.width && y >= f.y && y <= f.y + f.height { + // ... hit ... +} +``` + +`layout.header_area` / `layout.footer_area` 는 PageDef 의 margin 정보로 계산된 정확한 영역. bbox 확장과 무관. + +**부수 효과**: `build_page_tree` 호출 제거 → 효율 ↑ (mousedown / dblclick 마다 호출되던 비싼 트리 빌드 제거). + +## 2. 검증 결과 + +### 2.1 재현 단위 테스트 (`tests/issue_595.rs`) + +``` +running 5 tests +test issue_595_page1_header_area_still_hits ... ok ← 정상 가드 +test issue_595_page1_body_center_not_header ... ok ← Stage 1 fail → 정정 후 pass +test issue_595_page0_body_coord_not_header ... ok ← 정상 baseline +test issue_595_page1_body_coord_not_header_regression_guard ... ok ← Stage 1 fail → 정정 후 pass +test issue_595_page1_equation_coord_not_header ... ok ← Stage 1 fail → 정정 후 pass + +test result: ok. 5 passed; 0 failed +``` + +### 2.2 페이지별 hit y 범위 정상화 (`inspect_595.rs`) + +| 페이지 | 정정 전 | 정정 후 | +|--------|---------|---------| +| page 0 (1p) | 60.0 ~ 145.0 | **60.0 ~ 145.0** (정상 보존) | +| page 1 (2p) | **60.0 ~ 1355.0** | **60.0 ~ 145.0** (정상화) | +| page 2 (3p) | 60.0 ~ 1355.0 | 60.0 ~ 145.0 (정상화) | +| page 3 (4p) | 60.0 ~ 1355.0 | 60.0 ~ 145.0 (정상화) | + +### 2.3 광범위 sweep (`samples/` 전체 164 fixture / 1684 페이지) + +| 항목 | 정정 전 | 정정 후 | +|------|---------|---------| +| 머리말 hit 본문 침범 fixture | 2 / 164 (1.2%) | **0 / 164 (0.0%)** | +| 머리말 hit 본문 침범 페이지 | 32 / 1684 (1.9%) | **0 / 1684 (0.0%)** | + +### 2.4 회귀 sweep + +| 검증 | 결과 | +|------|------| +| `cargo test --lib --release` | **1140 passed** (회귀 0) | +| `cargo clippy --release` (lib) | warning/error 0 (회귀 0) | +| `cargo build --release` | clean | +| `cargo test --release --test issue_516/530/546/554/595` | **30 passed** (회귀 0) | + +### 2.5 광범위 머리말/꼬리말/본문 영역 hit sweep (`inspect_595_regression`) + +164 fixture / 1684 페이지에서 정정 전 vs 정정 후 비교 (작업지시자 요청 — "비슷한 상황에서 기존 잘 되는 동작이 안 되는 것까지 신경 써달라"): + +| 항목 | 정정 전 | 정정 후 | 변화 | +|------|---------|---------|------| +| **머리말 영역 중앙 hit:true** (margin_header > 0) | 1329 pass / 27 fail | **1356 pass / 0 fail** | **+27 부수 개선** | +| **꼬리말 영역 중앙 hit:true** (margin_footer > 0) | 1383 pass / 16 fail | 1383 pass / 16 fail | **동일 (회귀 0)** | +| **본문 중앙 hit:false** (Issue #595 결함 영역) | 1652 pass / 32 fail | **1684 pass / 0 fail** | **+32 본질 정정** | + +**해석**: + +- **회귀 0** — 정정 후 fail 케이스 (꼬리말 16개) 는 정정 전에도 동일 fail. 본 정정과 무관. +- **본질 정정** — 본문 영역 false-positive 32 페이지 (exam_math.hwp / exam_math_no.hwp) 완전 제거 → Issue #595 완전 해결. +- **부수 개선** — 머리말 영역 hit 정확화 27 페이지. 정정 전에는 expand_bbox_to_children 결과가 자식 노드에 종속되어 머리말 영역 자체도 일부 hit 안 됐는데, 정정 후 layout.header_area 직접 사용으로 정확한 영역만 hit. +- **별도 영역** — 꼬리말 16 페이지 fail (`hwpctl_Action_Table__v1.1.hwp` 한 fixture). 정정 전후 동일 → 본 task 무관, 별도 task 영역 후보. + +**검증 도구 `inspect_595_regression.rs`** — 영구 보존. 향후 hit_test_header_footer 영역 회귀 차단 가드로 재사용 가능. + +## 3. 정정 영향 영역 + +| 영역 | 영향 | +|------|------| +| `hit_test_header_footer_native` 자체 | 본질 정정 — bbox 사용 → layout.area 사용 | +| `build_page_tree` 호출 | **제거** — 효율 ↑ | +| `expand_bbox_to_children` (`build_header`) | **무수정** — 렌더링 동작 보존 | +| 머리말 표 셀 내 Shape 클리핑 | **무영향** — `expand_bbox_to_children` 의 의도 (클리핑 방지) 보존 | +| 다른 hit_test 함수 (`hit_test_in_header_footer_native` 등) | **무영향** — 본 정정 함수 한정 | + +## 4. dblclick 흐름 정합 확인 + +정정 후 `onDblClick` 흐름 ([rhwp-studio/src/engine/input-handler-mouse.ts:769-825](../../rhwp-studio/src/engine/input-handler-mouse.ts#L769)): + +1. `wasm.hitTestHeaderFooter(pageIdx, pageX, pageY)` — page 1+ 본문 좌표에서 `hit:false` 정상 반환 +2. 머리말 분기 미진입 → `if (this.cursor.isInPictureObjectSelection())` 분기 도달 +3. `ref.type === 'equation'` 확인 → `equation-edit-request` 이벤트 emit +4. **수식 편집기 정상 진입** ✓ + +## 5. 별도 발견 — 본 task 범위 밖 + +Stage 1 e2e 진단에서 추가 발견 — **그리드 모드 (zoom ≤ 0.5) 좌표 결함**: + +`rhwp-studio/src/engine/input-handler-mouse.ts` 의 mouse hit 좌표 계산이 단일 컬럼 가정 (`pageLeft = (sc.clientWidth - pageDisplayWidth) / 2`). 그리드 모드에서 페이지가 다중 열 배치될 때 hit 좌표 계산 어긋남. 본 이슈 (#595) 와 별개 영역 — **별도 issue/task 분리 등록 권장** (Stage 3 정리 단계에서 처리). + +## 6. 다음 단계 (Stage 3) + +1. ~~e2e 정정 후 재검증~~ — WASM 재빌드 (Docker) 가 별도 단계라 본 stage 종료 후 작업지시자 직접 검증 영역 (또는 별도 단계) +2. **이슈 #595 본문 정오표 코멘트 등록** — Stage 1 진단 + Stage 2 정정 + 검증 데이터 첨부 +3. **Stage 3 최종 보고서 + 오늘할일 갱신** +4. `local/devel` merge → `devel` push 영역은 작업지시자 결정 + +## 7. Stage 2 산출물 + +- `src/document_core/queries/cursor_rect.rs::hit_test_header_footer_native` 정정 (단일 함수, +20/-13 LOC) +- `mydocs/working/task_m100_595_stage2.md` (본 보고서) + +## 8. 정합 영역 + +- **HWP IR 표준 직접 사용** — `layout.header_area` / `layout.footer_area` 는 PageDef margin 으로 계산된 정확한 머리말/꼬리말 영역. 휴리스틱 미도입 (`feedback_rule_not_heuristic` 정합). +- **회귀 위험 영역 좁힘** — bbox 사용 → layout area 사용 으로 단일 함수만 정정. 렌더링 동작 무영향. `expand_bbox_to_children` 의 의도 (셀 내 Shape 클리핑 방지) 보존. +- **본질 정정** — 우회/패치 아닌 정확한 영역 사용 (`feedback_root_cause_only` 정합). + +--- + +**Stage 2 완료 — Stage 3 진입 (이슈 정오표 등록 + 최종 보고서) 승인 요청** diff --git a/src/document_core/queries/cursor_rect.rs b/src/document_core/queries/cursor_rect.rs index b574c1cf0..0225d1d8e 100644 --- a/src/document_core/queries/cursor_rect.rs +++ b/src/document_core/queries/cursor_rect.rs @@ -1776,40 +1776,53 @@ impl DocumentCore { /// 페이지 좌표가 머리말 또는 꼬리말 영역에 해당하는지 판별. /// 반환: JSON `{"hit":true,"isHeader":bool,"sectionIndex":N,"applyTo":N}` /// 또는 `{"hit":false}` + /// + /// Issue #595: Header/Footer 노드의 bbox 는 `expand_bbox_to_children` 으로 + /// 자식 (예: 단 구분선 line) 까지 확장되어 본문 영역을 침범할 수 있음. + /// hit 판정은 layout 의 정확한 `header_area` / `footer_area` 로 수행하여 + /// bbox 확장과 무관하게 본질 영역만 hit. pub fn hit_test_header_footer_native( &self, page_num: u32, x: f64, y: f64, ) -> Result { - use crate::renderer::render_tree::RenderNodeType; - - let tree = self.build_page_tree(page_num)?; - - for child in &tree.root.children { - let is_header = matches!(child.node_type, RenderNodeType::Header); - let is_footer = matches!(child.node_type, RenderNodeType::Footer); - if !is_header && !is_footer { continue; } + let (page_content, _, _) = self.find_page(page_num)?; + let layout = &page_content.layout; + + // 머리말 영역 hit 판정 (layout.header_area — 정확한 머리말 범위) + let h = &layout.header_area; + if x >= h.x && x <= h.x + h.width && y >= h.y && y <= h.y + h.height { + // active header에서 source_section_index와 apply_to 추출 + // 머리말은 이전 구역에서 상속될 수 있으므로 source_section_index 우선 + if let Some((source_sec, apply_to)) = self.get_active_hf_info(page_num, true) { + return Ok(format!( + "{{\"hit\":true,\"isHeader\":true,\"sectionIndex\":{},\"applyTo\":{}}}", + source_sec, apply_to + )); + } + // active 정보가 없는 경우 fallback (빈 머리말 영역 — 신규 생성 대상) + let (section_idx, _) = self.find_section_for_page(page_num); + return Ok(format!( + "{{\"hit\":true,\"isHeader\":true,\"sectionIndex\":{},\"applyTo\":0}}", + section_idx + )); + } - if x >= child.bbox.x && x <= child.bbox.x + child.bbox.width - && y >= child.bbox.y && y <= child.bbox.y + child.bbox.height - { - // active header/footer에서 source_section_index와 apply_to 추출 - // 머리말/꼬리말은 이전 구역에서 상속될 수 있으므로 - // 페이지 소속 구역이 아닌 source_section_index를 반환해야 함 - if let Some((source_sec, apply_to)) = self.get_active_hf_info(page_num, is_header) { - return Ok(format!( - "{{\"hit\":true,\"isHeader\":{},\"sectionIndex\":{},\"applyTo\":{}}}", - is_header, source_sec, apply_to - )); - } - // active 정보가 없는 경우 fallback - let (section_idx, _) = self.find_section_for_page(page_num); + // 꼬리말 영역 hit 판정 (layout.footer_area) + let f = &layout.footer_area; + if x >= f.x && x <= f.x + f.width && y >= f.y && y <= f.y + f.height { + if let Some((source_sec, apply_to)) = self.get_active_hf_info(page_num, false) { return Ok(format!( - "{{\"hit\":true,\"isHeader\":{},\"sectionIndex\":{},\"applyTo\":0}}", - is_header, section_idx + "{{\"hit\":true,\"isHeader\":false,\"sectionIndex\":{},\"applyTo\":{}}}", + source_sec, apply_to )); } + let (section_idx, _) = self.find_section_for_page(page_num); + return Ok(format!( + "{{\"hit\":true,\"isHeader\":false,\"sectionIndex\":{},\"applyTo\":0}}", + section_idx + )); } Ok("{\"hit\":false}".to_string()) From 938631f809fc1cca9363d26471e227ae8f3221d8 Mon Sep 17 00:00:00 2001 From: johndoekim Date: Thu, 7 May 2026 01:39:14 +0900 Subject: [PATCH 03/13] =?UTF-8?q?Task=20#595=20Stage=203:=20=EC=B5=9C?= =?UTF-8?q?=EC=A2=85=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20+=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=EC=9C=84=ED=97=98=EC=84=B1=20=EC=A0=90=EA=B2=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 본 task 종결 — Issue #595 (exam_math.hwp 2페이지부터 수식 더블클릭 hitTest 오동작) 본질 정정 + 광범위 회귀 sweep + 작업지시자 시각 판정 ★ 통과 + 이슈 본문 정오표 코멘트 등록 (#issuecomment-4389951401). 회귀 위험성 영역 점검 — 관련 CLOSED 이슈 (#236 PageAreas 영역 공식 / #42 머리말 Picture 렌더링 / #36 머리말 표 셀 이미지 / #340 머리말 누출) 모두 회귀 위험 0. 본 정정이 #236 정정의 영역을 일관 활용 (정확성 강화). 관련 함수 (hit_test_in_header_footer_native, get_active_hf_info, find_section_for_page) 무수정. TS 측 호출처 단 2곳 (input-handler-mouse.ts L494 onMouseDown / L784 onDblClick) 모두 정정 후 동작 정확. 보조 메모 영역 (본 task 분리) — 그리드 모드 (zoom ≤ 0.5) 좌표 결함 + hwpctl_Action_Table 꼬리말 hit:false. 정정 전후 동일 (회귀 0) + 사용자 시각 검증 안 됨 + 한컴 호환 진단 필요 → 본 사이클 영역 밖, 등록 보류. 향후 사용자 시각 검증 또는 한컴 호환 비교로 결함 확정 시 별도 task 진입. 산출물: - mydocs/report/task_m100_595_report.md (최종 보고서) mydocs/orders/yyyymmdd.md 는 메인테이너 영역 (PR #558 패턴 정합) — 본 PR 에서 제외, 메인테이너 PR 처리 후속에서 작성. 본 task 단계별 commit: - Stage 1 (54c0af2): 본질 진단 + 재현 단위 테스트 + 광범위 sweep - Stage 2 (0d20917): hit_test_header_footer 영역 정정 + 회귀 sweep - Stage 3 (본 commit): 최종 보고서 + 회귀 위험성 점검 closes #595 Co-Authored-By: Claude Opus 4.7 --- mydocs/report/task_m100_595_report.md | 125 ++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 mydocs/report/task_m100_595_report.md diff --git a/mydocs/report/task_m100_595_report.md b/mydocs/report/task_m100_595_report.md new file mode 100644 index 000000000..d24996e56 --- /dev/null +++ b/mydocs/report/task_m100_595_report.md @@ -0,0 +1,125 @@ +# Task #595 최종 결과 보고서 + +**Issue**: [#595](https://github.com/edwardkim/rhwp/issues/595) — exam_math.hwp 2페이지부터 수식 더블클릭 hitTest 오동작 — 1페이지만 정상 +**Milestone**: M100 (v1.0.0) +**브랜치**: `local/task595` → `local/devel` 머지 영역 +**완료일**: 2026-05-07 + +--- + +## 1. 본질 요약 + +`samples/exam_math.hwp` 의 수식 객체를 더블클릭했을 때 1페이지만 정상 동작, **2페이지부터 수식 편집기가 안 나타남 + 캐럿이 머리말 영역으로 이동**. 사용자 보고 단서 "hover 시 손바닥 표시는 뜨는데 클릭 반응 없음" 으로 본질 영역 확정. + +**본질 결함 위치**: `src/renderer/layout.rs::build_header` ([line 928](../../src/renderer/layout.rs#L928)) 의 `expand_bbox_to_children` 호출이 머리말 자식 (단 구분선 line `paraIdx=0 ci=2`, h≈1227px) 의 bbox 까지 Header 영역으로 확장 → `hit_test_header_footer_native` 가 본문 좌표를 머리말 hit 으로 잘못 인식 → `onDblClick` 의 머리말 분기가 picture selection 분기보다 먼저 실행되어 수식 편집기 진입 차단. + +**page 0 vs page 1+ 차이**: page 0 의 단 구분선은 ci=5 로 Body 자식, page 1+ 부터 ci=2 로 Header 자식 → page 0 만 정상. + +## 2. 정정 영역 (옵션 A) + +**파일**: `src/document_core/queries/cursor_rect.rs` +**함수**: `hit_test_header_footer_native` +**LOC**: +20 / -13 (단일 함수) + +`build_page_tree` 의 Header/Footer 노드 bbox (자식 노드 까지 확장됨) 대신 **`layout.header_area` / `layout.footer_area`** (PageDef margin 으로 계산된 정확한 영역) 로 hit 판정. `expand_bbox_to_children` 은 무수정 — 머리말 표 셀 내 Shape 클리핑 방지 의도 보존. + +**부수 효과**: `build_page_tree` 호출 제거 → mousedown 마다 호출되던 비싼 트리 빌드 비용 제거. + +**정합 영역**: HWP IR 표준 직접 사용 (`feedback_rule_not_heuristic` 정합), 회귀 위험 영역 좁힘 (단일 함수, 렌더링 무영향), 본질 정정 (우회 패치 아님). + +## 3. 검증 결과 (정량) + +| 검증 항목 | 정정 전 | 정정 후 | +|-----------|---------|---------| +| `tests/issue_595.rs` (5 케이스) | 3 fail / 2 pass | **5 pass** | +| 본문 hit:false sweep (164 fixture / 1684p) | 1652p / 32 fail | **1684 / 0 fail** (+32 본질 정정) | +| 머리말 hit (margin_header > 0, 1356p) | 1329 / 27 fail | **1356 / 0 fail** (+27 부수 개선) | +| 꼬리말 hit (margin_footer > 0) | 1383 / 16 fail | 1383 / 16 fail (회귀 0, 별도 영역) | +| `cargo test --lib --release` | (baseline) | **1140 pass** (회귀 0) | +| `cargo clippy --release` (lib) | (baseline) | clean | +| `cargo build --release` | (baseline) | clean | +| `cargo test --release --test issue_516/530/546/554/595` | (baseline) | 30 pass (회귀 0) | +| WASM 빌드 (Docker) | (baseline) | **4,531,883 bytes** clean | +| 작업지시자 시각 판정 ★ | 결함 재현 | **정상 동작** ★ | + +## 4. 회귀 위험성 점검 + +**관련 이슈** (CLOSED): + +| 이슈 | 영역 | 회귀 위험 | +|------|------|----------| +| #236 | `PageAreas::from_page_def` 머리말/본문 영역 공식 | **0** — 본 정정이 #236 정정의 영역을 일관 활용 (정확성 강화) | +| #42 | 머리말/꼬리말 내 Picture 렌더링 | **0** — 렌더링 무영향 | +| #36 | 머리말 표 셀 안 이미지 미렌더링 | **0** — 렌더링 무영향 | +| #340 | exam_math 13페이지 머리말 누출 | **0** — typeset 무영향 | + +**관련 함수**: + +| 함수 | 정정 영향 | 회귀 위험 | +|------|----------|----------| +| `hit_test_in_header_footer_native` (편집 모드 텍스트 hit) | 무수정 | **0** — 본 정정 영역 안에서 정상 동작 | +| `get_active_hf_info` / `find_section_for_page` | 무수정 (본 정정에서 호출) | **0** | +| `build_page_tree` | 호출 제거 | 0 — 다른 호출처 무영향 | + +**TS 측 호출처**: `hitTestHeaderFooter` 호출은 `input-handler-mouse.ts` 단 2곳만: +- L494 onMouseDown 머리말 모드 탈출 — **개선** (hit:false 정확) +- L784 onDblClick 본질 영역 — **정정** + +## 5. 단계별 진행 + +| Stage | 산출물 | commit | +|-------|--------|--------| +| Stage 1 | 본질 진단 + 재현 단위 테스트 (5 케이스) + 광범위 sweep + 정정 영역 후보 분석 | [`54c0af2`](#) | +| Stage 2 | hit_test_header_footer 영역 정정 + 회귀 sweep 도구 + 정정 효과 광범위 검증 | [`0d20917`](#) | +| Stage 3 | 최종 보고서 + 회귀 위험성 점검 + 별도 task 등록 | (본 commit) | + +## 6. 산출물 목록 + +**소스 코드**: +- `src/document_core/queries/cursor_rect.rs` — 본질 정정 (+20 / -13 LOC) + +**단위 테스트** (영구 보존, 회귀 차단 가드): +- `tests/issue_595.rs` — 5 케이스 (정정 전 3 fail / 2 pass → 정정 후 5 pass) + +**검증 도구** (영구 보존, 향후 재사용): +- `examples/inspect_595.rs` — Header bbox / 머리말 hit y 범위 sweep +- `examples/inspect_595_regression.rs` — 머리말/꼬리말/본문 영역 광범위 회귀 sweep + +**문서**: +- `mydocs/plans/task_m100_595.md` — 수행 계획서 +- `mydocs/plans/task_m100_595_impl.md` — 구현 계획서 +- `mydocs/working/task_m100_595_stage1.md` — Stage 1 보고서 +- `mydocs/working/task_m100_595_stage2.md` — Stage 2 보고서 +- `mydocs/report/task_m100_595_report.md` — 본 최종 보고서 + +**e2e 진단** (비-회귀, 향후 다른 이슈에서 패턴 재사용): +- `rhwp-studio/e2e/issue-595.test.mjs` — 1365×1018 사용자 환경 모사 + zoom 변동 시나리오 + +**임시 파일 정리**: +- `rhwp-studio/public/samples/exam_math.hwp` — Stage 3 정리 시 제거 완료 + +## 7. 보조 메모 — 본 task 분리 영역 (참고만) + +광범위 sweep / e2e 진단 중 발견된 본 이슈 #595 와 본질이 다른 영역. 본 task 정정과 무관 (회귀 0 확인됨), 사용자 시각 검증 안 됨, 한컴 호환 진단 필요 → **별도 이슈 등록 보류**. 본 보고서에는 참고 기록만 보존하고 본 task 정리 영역에서 분리. + +| 영역 | 본질 (추정) | 정정 전후 | 사용자 검증 | +|------|-------------|-----------|-------------| +| 그리드 모드 (zoom ≤ 0.5) hit 좌표 | TS 측 `pageLeft` 단일 컬럼 가정 vs 그리드 `pageLefts[i]` 불일치 | 동일 | 안 함 | +| hwpctl_Action_Table 꼬리말 hit:false | landscape + `marginBottom=0` 양식의 `PageAreas::from_page_def` `footer_area.height = 0` | 동일 | 안 함 (정정 전후 동일 확인) | + +향후 사용자 시각 검증 또는 한컴 호환 비교로 결함 확정 시 별도 task 진입. + +## 8. 본 사이클 정합 + +- **하이퍼-워터폴 절차 정합** — 이슈 → 브랜치 → 수행 계획서 → 구현 계획서 → 단계별 진행 → 단계별 보고서 → 최종 보고서 모두 정상 진행. 작업지시자 단계별 승인 + 시각 판정 통과. +- **광범위 회귀 sweep 패턴** (`feedback_wide_regression_sweep` 정합) — 164 fixture / 1684p 광범위 측정으로 정정 안전성 입증. +- **본질 정정 영역 좁힘** (`feedback_root_cause_only` 정합) — 단일 함수 정정, 렌더링 무영향, expand 의도 보존. +- **이슈 본문 정오표 갱신** (`feedback_full_disclosure` 정합) — 이슈 작성자의 초기 진단 (a)/(b)/(c) 가 본질 영역 밖이었음을 코멘트로 명시. +- **회귀 위험성 영역 점검** — 관련 이슈 (#236, #42, #36, #340) + 관련 함수 + 호출처 모두 점검. +- **단위 테스트 영구 가드** — `tests/issue_595.rs` 5 케이스 회귀 차단 영구 보존. + +## 9. 본 task 종결 + +본 task 는 **Issue #595 완전 해결** + 회귀 0 + 부수 개선 +27p + 별도 task 영역 식별로 완료. `local/devel` merge → `devel` push → main release 영역은 작업지시자 결정. + +**Issue #595 close 영역**: 본 보고서 + 정정 코드 + 검증 데이터를 코멘트로 등록 후 close. From d591432d8a97ef81c5283eaa295d609c10a7a105 Mon Sep 17 00:00:00 2001 From: johndoekim Date: Thu, 7 May 2026 23:39:45 +0900 Subject: [PATCH 04/13] =?UTF-8?q?Task=20#595=20=ED=9B=84=EC=86=8D=20sweep?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D:=20=EB=B3=B4=EB=A5=98=202=EA=B1=B4=20e2e?= =?UTF-8?q?=20=EC=A0=95=EB=9F=89=20=EC=B8=A1=EC=A0=95=20+=20Issue=20#685/#?= =?UTF-8?q?686=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #595 Stage 3 보고서 §7 의 보류 2건 본질을 e2e 측정으로 확정: - 보류 ① (Issue #685): 그리드 모드 click 좌표 — input-handler-mouse 14곳 단일 컬럼 가정. zoom=0.5/0.25 columns=2/5 측정 시 ±285.6/±581.3px 어긋남 - 보류 ② (Issue #686): master page 글상자 dblclick 시 첫 페이지 점프 — isInTextBox 분기 + cursor.rect.pageIndex=0 + scrollCaretIntoView fallback (e2e: scroll delta=-11288, visible=[0,1]) 산출물: - mydocs/troubleshootings/grid_mode_click_coord.md (정량 측정 표 포함) - mydocs/troubleshootings/body_outside_click_fallback.md (가설 b 확정) - rhwp-studio/e2e/grid-mode-click-coord.test.mjs (회귀 검증용) - rhwp-studio/e2e/body-outside-click-fallback.test.mjs (회귀 검증용) - rhwp-studio/.gitignore: public/samples/ 측정용 심볼릭 링크 제외 소스 무수정. 정정 task 는 #685/#686 별도 진행. Co-Authored-By: Claude Opus 4.7 --- .../body_outside_click_fallback.md | 142 ++++++++++++ .../troubleshootings/grid_mode_click_coord.md | 153 +++++++++++++ rhwp-studio/.gitignore | 4 + .../e2e/body-outside-click-fallback.test.mjs | 197 ++++++++++++++++ .../e2e/grid-mode-click-coord.test.mjs | 216 ++++++++++++++++++ 5 files changed, 712 insertions(+) create mode 100644 mydocs/troubleshootings/body_outside_click_fallback.md create mode 100644 mydocs/troubleshootings/grid_mode_click_coord.md create mode 100644 rhwp-studio/e2e/body-outside-click-fallback.test.mjs create mode 100644 rhwp-studio/e2e/grid-mode-click-coord.test.mjs diff --git a/mydocs/troubleshootings/body_outside_click_fallback.md b/mydocs/troubleshootings/body_outside_click_fallback.md new file mode 100644 index 000000000..29257e7af --- /dev/null +++ b/mydocs/troubleshootings/body_outside_click_fallback.md @@ -0,0 +1,142 @@ +# rhwp-studio 본문 외곽 클릭 fallback 한컴 mismatch 결함 + +## 본질 + +`samples/hwpctl_Action_Table__v1.1.hwp` (16p, landscape, `margin_bottom=0`, `footer_area.height=0`) 의 페이지 16 꼬리말 영역 클릭 시 한컴과 RHWP 동작 mismatch. + +| 환경 | 동작 | +|------|------| +| 한컴 | 문서 마지막 `}` 문자에 캐럿 배치 (= 본문 외곽 → 가장 가까운 본문 캐럿 fallback) | +| RHWP | **페이지 2, 3 으로 뷰 점프** + 캐럿 / 선택 상태 부정합 | + +## 사전 정합 사실 + +- `PageAreas::from_page_def` 의 `footer_area.height = margin_bottom` 수식은 HWP spec 정합 (한컴도 동일). +- `margin_bottom=0` → footer_area.height=0 → `hit_test_header_footer = false` 는 한컴과 정합 (양쪽 모두 그 영역을 꼬리말로 인식 안 함). +- 결함은 **`hit_test_header_footer = false` 이후 onMouseDown fallback 분기** 에 한정. + +## 코드 경로 trace + +[input-handler-mouse.ts:onMouseDown](../../rhwp-studio/src/engine/input-handler-mouse.ts#L460-L781) 의 흐름 (page 16 꼬리말 영역 click 시 점진적 분기): + +1. (line 470-479) `pageX/pageY` 계산 — 정상. +2. (line 482-491) `tableResizeRenderer` 표 경계 hit — `cachedCellBboxes` 없음 → skip. +3. (line 494-516) `cursor.isInHeaderFooter()` — 첫 클릭이라 false → skip. +4. (line 519-540) `cursor.isInFootnote()` — false → skip. +5. (line 543-595) 각주 마커 / 영역 hit — 없음 → skip. +6. (line 597) **`hit = wasm.hitTest(pageIdx=15, pageX, pageY)`** — 핵심 분기점. +7. (line 601) `paragraphIndex >= 0xFFFFFF00` (머리말/꼬리말 sentinel) → `textarea.focus()` + return. **scroll 변화 없음.** footer_area.height=0 이므로 sentinel 반환 가능성 낮음. +8. (line 607-622) 표 내부 + 표 경계 click → 표 객체 선택 + return. +9. (line 625-640) `findTableByOuterClick` (표 외곽 click 휴리스틱) → 표 객체 선택 + return. +10. (line 643-657) **`hit.isTextBox === true` → `cursor.moveTo(hit)` + `this.updateCaret()` + 드래그 시작.** `updateCaret` 은 [input-handler.ts:1505](../../rhwp-studio/src/engine/input-handler.ts#L1505) 에서 `scrollCaretIntoView(rect)` 호출 → **여기서 페이지 점프 발생 가능.** +11. (line 660-732) `findPictureAtClick` (그림/글상자/수식) → 객체 선택 + return. +12. (line 736-742) form object → handler + return. +13. (line 754-781) **일반 click fallback** — `cursor.moveTo(hit)` + `caret.show(rect, zoom)` + isDragging=true. **`scrollCaretIntoView` 호출 안 함**, `caret.show` 만 — scroll 변화는 없어야 정상. + +## 후보 (a/b/c) 좁히기 — **e2e 측정으로 (b) 확정 (2026-05-07)** + +| 후보 | 가설 | e2e 측정 결과 | +|------|------|---------------| +| (a) cursor.moveTo({sec:0, para:0, ...}) invalid fallback | hit 결과 invalid → 기본값 fallback | **부정** — wasm.hitTest 가 valid hit 반환 (cursorRect.pageIndex=15 정상) | +| **(b) 바탕쪽 (master page) 글상자 hit → isTextBox=true 분기** | line 643-657 분기 → `updateCaret` → `scrollCaretIntoView` 발생 | **확정 — dblclick 시 정확히 재현** (delta=−11288, page 0~1 jump) | +| (c) 본문 paragraph hit 휴리스틱이 잘못된 paragraphIndex 반환 | hit.paragraphIndex 가 다른 페이지로 매핑 | **부정** — hit.paragraphIndex 정상, cursorRect.pageIndex=15 (click 페이지) 정상 | + +### 결정적 측정 데이터 (2026-05-07) + +`rhwp-studio/e2e/body-outside-click-fallback.test.mjs` 변종 + 정밀 측정 (페이지 번호 textbox 정확한 위치 click) 결과: + +**Step 1 — Layout dump (page 15 의 control 위치)**: +```json +{ "controls": [ + {"type": "shape", "x": 113.4, "y": 740, "w": 75.6, "h": 21.5, "secIdx": 0, "paraIdx": 0, "controlIdx": 0}, // ← 자동번호 글상자 + {"type": "image", "x": 1752.9, "y": 1480.5, ...}, // off-page + {"type": "shape", "x": 113.4, "y": 736.8, "w": 895.7, "h": 4, ...} // 단 구분선 +]} +``` + +자동번호 글상자: `secIdx=0, paraIdx=0, controlIdx=0` — section 0 의 paragraph 0 (= master page) 에 anchored. + +**Step 2 — 글상자 정확한 위치 (151.2, 750.75 = bbox 중앙) 단일 click**: +``` +scroll: 12023 → 12023 (delta=0) +pos = {sec:0, para:0, char:0} +isInPictureObjectSelection=true +selectedPicRef = {sec:0, ppi:0, ci:0, type:'shape'} +``` +→ shape 객체 선택. **scroll 변화 없음.** 선택 상태로만 진입. + +**Step 3 — 같은 위치 더블 click (사용자 정확한 시나리오)**: +``` +scroll: 12023 → 735 (delta=−11288) +pos = {sec:0, para:0, char:0, ppi:0, ci:0, cellIdx:0} +isInTextBox=true ★ +rectPg=0 ★ +visible pages after dblclick: [0, 1] ★ +``` + +→ **isInTextBox=true 분기 진입**, cursor 가 sec 0 paragraph 0 의 control 0 cell 0 (= master page 글상자 안) 으로 이동, **`cursor.getRect().pageIndex = 0`** (master page 가 first-attached 된 page 0 의 좌표 반환), `scrollCaretIntoView` 가 page 0 으로 스크롤 → **사용자 보고와 정확히 일치 (페이지 1, 2 visible)**. + +**확정된 결함 본질**: +1. dblclick 분기는 [input-handler-mouse.ts:onDblClick](../../rhwp-studio/src/engine/input-handler-mouse.ts#L784) 직접 처리가 아닌, mousedown 의 textbox 분기 ([input-handler-mouse.ts:643-657](../../rhwp-studio/src/engine/input-handler-mouse.ts#L643-L657)) 안의 **`isInTextBox` 가 이미 true** 인 상태에서 두 번째 mousedown. +2. master page 의 자동번호 글상자는 `secIdx=0, paraIdx=0` 으로 attached → `cursor.moveTo(hit)` 후 `getRect()` 가 paraIdx=0 이 first 정의된 위치 (= page 0) 의 rect 를 반환. +3. `scrollCaretIntoView(rect)` 가 rect.pageIndex=0 의 좌표를 viewport 안으로 스크롤 → 마지막 페이지 → 첫 페이지로 점프. + +**한컴 정합 정정 방향**: master page 글상자 (특히 paraIdx=0 으로 anchored 된 control) 는: +- A) `findPictureAtClick` 에서 마스터 페이지 출처 글상자는 hit 결과에서 제외 → 일반 본문 fallback 진행 +- B) hit 의 cursorRect.pageIndex 가 click 페이지 (`pageIdx`) 와 다르면 fallback +- 한컴은 master page 위 dblclick 시 본문 가장 가까운 caret 으로 떨어짐 — 본 환경도 동일하게. + +## 한컴 정합 fallback 동작 + +한컴은 **master page 위 click 을 textbox edit 으로 처리하지 않고**, 본문 영역 외곽 click 으로 인식 → 가장 가까운 본문 paragraph 캐럿 (이 fixture 의 경우 문서 마지막 `}` 문자) 으로 떨어짐. + +본 환경 정합 정정 방향 (가설): +- A) `findPictureAtClick` / `isTextBox` 분기에서 **master page (바탕쪽) 출처의 글상자는 제외**하고 본문 fallback 으로 진행. +- B) hit 결과의 `cursor.getRect().pageIndex` 가 click 페이지 (`pageIdx`) 와 다르면 본문 fallback 으로 우회. +- 한컴 정합은 A 가 더 자연스러움 (의미적으로 master page 위 click은 본문 click). + +## e2e 계측 (정정 task 에서 수행) + +1. `cd rhwp-studio && npx vite --host 0.0.0.0 --port 7700 &` +2. `samples/hwpctl_Action_Table__v1.1.hwp` 로드. +3. 페이지 16 페이지 하단 (꼬리말 위치) 클릭. +4. `console.log` 또는 e2e 계측: `hit` 객체 (`sectionIndex`, `paragraphIndex`, `parentParaIndex`, `controlIndex`, `cellIndex`, `isTextBox`), `cursor.getPosition()`, `cursor.getRect().pageIndex`, click 직후 `scrollContainer.scrollTop` 변화. +5. 후보 (b) 확정: `hit.isTextBox === true` + `parentParaIndex` 가 master page 글상자 ppi. + +## 우선순위 + +- **Medium**: 단일 fixture (`hwpctl_Action_Table__v1.1.hwp`) 에서만 재현. 다른 일반 문서는 footer_area.height > 0 이라 hit_test_header_footer = true 분기로 진입. +- 그러나 본질 (master page 글상자 위 본문 외곽 click) 은 **다른 양식** (페이지 양식 / 표지 양식 등) 에서도 잠재적 재현 가능. 등록 가치 있음. + +## fixture + +- `samples/hwpctl_Action_Table__v1.1.hwp` + - 16 페이지, landscape (가로) + - `margin_left=30, margin_right=30, margin_top=0, margin_bottom=0, margin_header=15, margin_footer=15` (mm) + - 바탕쪽 2개 (Both, 자동번호 Page 글상자 포함) + - section 0 dump: `cargo run --release --bin rhwp -- dump samples/hwpctl_Action_Table__v1.1.hwp` + +## 우선 처리 후순위 (정정 task 진입 전) + +1. e2e 계측으로 후보 (b) 확정 (5-10 분). +2. 정정 방향 (A vs B) 결정. +3. 한컴 정합 시각 검증 매뉴얼 준비. + +## 등록 전 보류 (사용자 결정, 2026-05-07) → **e2e 확정으로 등록 가능** + +작업지시자: "**모든 이슈 파악을 완벽하게 하고 이슈를 올리는게 좋을것 같음**". 본 노트는 진단 보강 단계로 간주하고 issue 등록 보류 → **2026-05-07 e2e 측정으로 가설 (b) 확정 + 정량 데이터 확보 → 등록 가능 상태**. + +남은 횡적 검증 (선택): +- master page 구조 변환 (예: section 다중 + 표지 양식) 에서 동일 결함 재현되는지 — 본 fixture 외 추가 fixture 측정. +- 한컴 정합 정정 방향 (A vs B) 결정 — 회귀 영역 (정상 글상자 click 시나리오) 명시 후 정정. + +## 사용자 측정 vs e2e 측정 정합 + +| 측정 항목 | 사용자 직접 (host browser) | e2e (headless) | +|-----------|----------------------------|-----------------| +| 페이지 번호 위치 | "16" 텍스트 | shape #1 bbox (113.4, 740, 75.6×21.5) | +| 동작 | 더블클릭 | mouse.click({clickCount:2, delay:80}) | +| 결과 (사용자) | "페이지 2, 3 으로 이동" | scroll delta=−11288, visible pages [0, 1] (= 1, 2 페이지) | +| 한컴 동작 | "문서 마지막 `}` 캐럿" | (e2e 미측정 — 한컴 자동화 불가) | + +→ **본질 정합** (사용자 보고 "2, 3 페이지" 와 e2e "1, 2 페이지" 차이는 viewport 크기 / 첫 화면에 보이는 페이지 수 차이일 뿐, **첫 페이지 부근으로 점프** 라는 본질은 동일). diff --git a/mydocs/troubleshootings/grid_mode_click_coord.md b/mydocs/troubleshootings/grid_mode_click_coord.md new file mode 100644 index 000000000..8cc1538e7 --- /dev/null +++ b/mydocs/troubleshootings/grid_mode_click_coord.md @@ -0,0 +1,153 @@ +# rhwp-studio 그리드 모드 (zoom ≤ 0.5) click 좌표 단일 컬럼 가정 결함 + +## 본질 + +[rhwp-studio/src/view/virtual-scroll.ts](../../rhwp-studio/src/view/virtual-scroll.ts)는 줌 ≤ 0.5일 때 **다중 열 그리드 배치**로 분기 (`gridMode = zoom <= 0.5 && viewportWidth > 0 && pages.length > 1`). 그리드 활성 시 각 페이지의 X 좌표는 `pageLefts[i] = marginLeft + col * (pw + gap)` 로 열별 분리 저장되며, [getPageLeft(pageIdx)](../../rhwp-studio/src/view/virtual-scroll.ts#L155) 로 노출된다. + +그러나 [rhwp-studio/src/engine/input-handler-mouse.ts](../../rhwp-studio/src/engine/input-handler-mouse.ts)는 `getPageLeft` 를 **단 한 곳도 호출하지 않고** 14개 분기 모두 단일 컬럼 가정 공식 사용: + +```ts +const pageLeft = (scrollContent.clientWidth - pageDisplayWidth) / 2; +const pageX = (contentX - pageLeft) / zoom; +``` + +그리드 모드에서 페이지가 좌/우 열에 분산 배치되므로 위 공식의 `pageLeft` 는 모든 페이지를 "중앙 정렬" 로 가정 → `pageX` 가 실제 페이지 내 좌표와 어긋남 → 모든 마우스 인터랙션 어긋남. + +## 영향 분기 (14곳) + +| Line | 함수 | 트리거 | 영향 | +|------|------|--------|------| +| 23 | onConnectorMouseDown | 연결선 모드 click | 연결선 시작/끝 좌표 | +| 129 | onMouseDown (표 객체 선택 중) | 선택된 표 click | 표 이동 드래그 시작 hit | +| 176 | onMouseDown (다중 그림 선택) | multi-picture handle click | 합산 BBOX 좌표 | +| 279 | onMouseDown (직선 끝점 드래그) | line endpoint handle click | drag init coord | +| 296 | onMouseDown (회전 드래그) | rotate handle click | rotate center coord | +| 357 | onMouseDown (단일 그림 선택) | 선택된 그림 본체 click | move drag init | +| 431 | onMouseDown (표 리사이즈) | 표 border click | resize drag hit | +| **475** | **onMouseDown (일반 click)** | **left mousedown 전반** | **메인 click 좌표 (cursor.moveTo)** | +| **811** | **onDblClick** | **left dblclick** | **dblclick hit (머리말/꼬리말)** | +| **889** | **onContextMenu** | **right click** | **context menu 표 셀 판정** | +| 931 | onMouseMove (연결선 모드) | connector drawing mousemove | preview 좌표 | +| 1146 | onMouseMove (그림 hover) | mousemove during picture hover | hover cursor | +| 1196 | onMouseMove (표 hover) | mousemove during table hover | hover cursor | +| 1243 | handleResizeHover | 표 border mousemove | hover cursor | + +핵심 영향 (사용자 직접 인지): **475 / 811 / 889** — 일반 click / dblclick / context menu 모두 그리드 모드에서 엉뚱한 위치 처리. + +## 트리거 조건 + +`virtual-scroll.ts:29`: +```ts +this.gridMode = zoom <= GRID_ZOOM_THRESHOLD && viewportWidth > 0 && pages.length > 1; +``` + +- `GRID_ZOOM_THRESHOLD = 0.5` +- 페이지 수 > 1 +- 뷰포트 폭 > 0 + +→ 줌 25%/50% (실제 사용자 시나리오: "전체 보기" 또는 "다중 페이지 미리보기") 에서 활성. + +## 영향 범위 (사용자 직접 측정, 2026-05-07) + +**한컴**: 그리드 모드 / 일반 모드 모두 정상 클릭 동작. + +**RHWP 그리드 모드 (zoom ≤ 0.5)**: **모든 열에서 click 어긋남** (좌측 열 + 가운데 열 + 우측 열 전부). 일반 모드 (zoom > 0.5) 는 정상. + +원인: 페이지 element 의 실제 left 좌표는 `pageLefts[i]` ([canvas-view.ts:156-163](../../rhwp-studio/src/view/canvas-view.ts#L156-L163) 에서 `style.left = ${pageLeft}px`) 인데, input-handler-mouse 의 14곳 모두 `(clientWidth - pageDisplayWidth) / 2` 단일 컬럼 가정. 그리드 모드에서는 모든 열의 페이지가 슬롯 좌표에 배치되므로 단일 컬럼 가정 공식과 어긋남 (좌/우 방향 + 정도가 열별로 다름). + +## 정정 범위 + +**`getPageLeft(pageIdx)` 호출로 14개 분기 일괄 정정**. 단, 단일 컬럼 모드에서 `getPageLeft = -1` 반환 (CSS 중앙 정렬 sentinel) 이므로 fallback 처리 필요. + +권장 패턴: +```ts +const pl = virtualScroll.getPageLeft(pi); +const pageLeft = pl >= 0 ? pl : (scrollContent.clientWidth - pageDisplayWidth) / 2; +``` + +또는 `virtual-scroll.ts` 에 단일 헬퍼 추가: +```ts +getPageLeftResolved(pageIdx: number, containerWidth: number): number { + const pl = this.pageLefts[pageIdx] ?? -1; + if (pl >= 0) return pl; + const pw = this.pageWidths[pageIdx] ?? 0; + return (containerWidth - pw) / 2; +} +``` + +→ `input-handler-mouse.ts` 14곳 모두 `virtualScroll.getPageLeftResolved(pageIdx, sc.clientWidth)` 한 줄로 치환. + +## 회귀 영역 + +- 단일 컬럼 모드 (zoom > 0.5) → `pageLefts[i] = -1` → 기존 `(clientWidth - pw) / 2` 공식 그대로 적용 → 무회귀 +- 그리드 모드 → 새 공식 적용 → 사용자 시각 검증 필요 (e2e 줌 25%/50% 클릭 시 cursor 위치 정확) + +## 재현 절차 + +1. `cd rhwp-studio && npx vite --host 0.0.0.0 --port 7700 &` +2. 다중 페이지 HWP 파일 (예: `samples/exam_kor.hwp`) 열기. +3. 줌 25% 또는 50% 로 변경 → 그리드 활성 (좌/우 열 페이지 다중 배치). +4. **2열째 페이지** 본문 텍스트 클릭 → 커서가 엉뚱한 위치 (1열째 페이지 영역 처럼 처리됨) 에 떨어지는지 확인. +5. 또는 머리말 영역 dblclick → 머리말 편집기 미진입 (좌표 어긋남으로 hit_test_header_footer = false 반환). + +## 한컴 호환 무관성 + +한컴 오피스는 그리드 모드 (다중 열 페이지 배치) 자체가 없음. 본 결함은 RHWP 자체 결함이며 한컴 호환 진단 불필요. + +## 우선순위 + +- **High** (UX-blocking): 줌 25%/50% 사용 시 모든 열의 마우스 동작이 어긋남 (사용자 직접 측정 확인). +- 정정 범위는 file 1개 (input-handler-mouse.ts) + 1개 헬퍼 추가 → 작업량 작음. +- 시각 검증은 e2e (Vite dev server + 줌 변경) 으로 빠르게 가능. + +## 등록 전 추가 검증 권장 + +본 노트의 본질 (단일 컬럼 가정 공식 vs 그리드 pageLefts 불일치) 은 코드 + CSS evidence 로 확정. 다만 등록 전 다음 사항 보강 가능: + +1. **수치 정량화**: 줌 50% + columns=2 환경에서 좌측 열 click X 좌표 어긋남 정도 (예: ~px 단위) e2e 계측. +2. **다른 input 경로 확인**: keyboard 입력 / IME / textarea 좌표 변환에 동일 결함 영향 있는지 (현 진단은 mouse 만). +3. **Touch / 펜 입력**: touch 이벤트도 같은 14개 분기 사용하는지 별도 확인 (touch handler 별도 가능). + +## 정량 측정 결과 (e2e, 2026-05-07) + +`rhwp-studio/e2e/grid-mode-click-coord.test.mjs` 실행 결과 (`samples/exam_kor.hwp` 20p, viewport 1600x1000, headless Chrome). + +### zoom=0.5 (columns=2) + +| page | col | pw | correct (pageLefts[i]) | buggy (단일 컬럼 공식) | delta_px | +|------|-----|-----|------------------------|------------------------|----------| +| 0 | 0 | 561.3 | 223.8 | 509.4 | **+285.6** | +| 1 | 1 | 561.3 | 795.0 | 509.4 | **−285.6** | +| 2 | 0 | 561.3 | 223.8 | 509.4 | +285.6 | +| 3 | 1 | 561.3 | 795.0 | 509.4 | −285.6 | +| ... | ... | ... | ... | ... | ... | + +→ 좌측 열 (col 0) 모두 +285.6px, 우측 열 (col 1) 모두 −285.6px 어긋남. + +### zoom=0.25 (columns=5) + +| page | col | pw | correct | buggy | delta_px | +|------|-----|-----|---------|-------|----------| +| 0 | 0 | 280.6 | 68.4 | 649.7 | **+581.3** | +| 1 | 1 | 280.6 | 359.1 | 649.7 | **+290.6** | +| 2 | 2 | 280.6 | 649.7 | 649.7 | **0.0** | +| 3 | 3 | 280.6 | 940.3 | 649.7 | **−290.6** | +| 4 | 4 | 280.6 | 1230.9 | 649.7 | **−581.3** | + +→ 가운데 열 (col 2) 만 우연히 0px 정합. 양 끝 열은 ±581.3px 어긋남. + +### zoom=1.0 (단일 컬럼, baseline) + +| page | col | pw | correct | buggy | delta_px | +|------|-----|-----|---------|-------|----------| +| 모두 | 0 | 1122.5 | −1.0 | 20.3 | **0.0** | + +→ 단일 컬럼 모드는 정합 (correct=−1 sentinel → fallback 공식 = buggy 공식 결과). + +### 실제 click 동작 검증 + +zoom=0.5, page 1 (col 1) 에서 hwpX=100 좌표를 의도한 click: +- CORRECT click @(865.0, 242.0) → cursor.pos = `{sec:0, para:39, char:70}` (정상) +- BUGGY click @(579.4, 242.0) → cursor.pos = `{sec:0, para:31, char:0}` (page 0 의 영역으로 잘못 떨어짐) + +**결론**: 그리드 모드에서 input-handler-mouse 의 14개 분기는 모든 페이지에서 ±수백 px 단위로 click 좌표를 어긋나게 만듬. 가운데 열 (홀수 columns 일 때 mid-col) 만 우연히 정합. diff --git a/rhwp-studio/.gitignore b/rhwp-studio/.gitignore index e5537bed7..5f0596b57 100644 --- a/rhwp-studio/.gitignore +++ b/rhwp-studio/.gitignore @@ -1,3 +1,7 @@ node_modules/ dist/ *.local + +# 측정용 샘플 심볼릭 링크 (e2e 로컬 실행 시 추가, 환경별 setup) +public/samples/exam_kor.hwp +public/samples/hwpctl_Action_Table__v1.1.hwp diff --git a/rhwp-studio/e2e/body-outside-click-fallback.test.mjs b/rhwp-studio/e2e/body-outside-click-fallback.test.mjs new file mode 100644 index 000000000..75c074ac2 --- /dev/null +++ b/rhwp-studio/e2e/body-outside-click-fallback.test.mjs @@ -0,0 +1,197 @@ +/** + * 보류 ② 본문 외곽 클릭 fallback 결함 — 가설 (b) master page 글상자 hit 확정 e2e + * + * 본질: samples/hwpctl_Action_Table__v1.1.hwp (16p, landscape, margin_bottom=0) + * 의 page 16 꼬리말 영역 (footer_area.height=0) 클릭 시: + * - 한컴: 문서 마지막 `}` 캐럿 배치 (본문 외곽 fallback) + * - RHWP: 페이지 2, 3 으로 뷰 점프 (본질 결함) + * + * 본 측정은 input-handler-mouse onMouseDown 내부에서 호출되는 wasm.hitTest + * 결과를 직접 캡쳐하여 다음을 확인: + * (a) cursor.moveTo(invalid) fallback? + * (b) hit.isTextBox === true (master page 글상자 hit)? + * (c) 일반 paragraph hit 인데 cursor.getRect() 의 pageIndex 가 click 페이지와 다름? + * + * 측정 항목: + * - hit object 전체 + * - cursor.getPosition() / getRect() (click 후) + * - scrollContainer.scrollTop (click 전후) + * - 한컴 정합 동작 (마지막 `}` 캐럿) 비교 가능 여부 + * + * 실행: + * cd rhwp-studio + * npx vite --host 0.0.0.0 --port 7700 & + * node e2e/body-outside-click-fallback.test.mjs --mode=headless + */ +import { runTest, loadHwpFile, screenshot } from './helpers.mjs'; + +async function probeFooterClick(page, label, pageIdx, hwpX, hwpY) { + console.log(`\n=== ${label} (page ${pageIdx}, hwpX=${hwpX}, hwpY=${hwpY}) ===`); + + // 사전: page 정보 + scroll 안정화 + const setup = await page.evaluate(({ pageIdx, hwpX, hwpY }) => { + const sc = document.querySelector('#scroll-content'); + const ih = window.__inputHandler; + const vs = ih.virtualScroll; + const vm = ih.viewportManager; + const wasm = window.__wasm; + const zoom = vm.getZoom(); + const pw = vs.getPageWidth(pageIdx); + const ph = vs.getPageHeight(pageIdx); + const po = vs.getPageOffset(pageIdx); + const pl = vs.getPageLeft(pageIdx); + const plDOM = pl >= 0 ? pl : (sc.clientWidth - pw) / 2; + const docX = plDOM + hwpX * zoom; + const docY = po + hwpY * zoom; + + const scroller = sc.parentElement; + const targetScrollY = Math.max(0, docY - 300); + scroller.scrollTop = targetScrollY; + + return { zoom, pw, ph, po, pl, plDOM, docX, docY, targetScrollY }; + }, { pageIdx, hwpX, hwpY }); + + await page.evaluate(() => new Promise(r => setTimeout(r, 500))); + + // click 직전 hit 결과 + scrollTop 측정 + const beforeClick = await page.evaluate(({ docX, docY, pageIdx }) => { + const sc = document.querySelector('#scroll-content'); + const scroller = sc.parentElement; + const cr = sc.getBoundingClientRect(); + const ih = window.__inputHandler; + const vs = ih.virtualScroll; + const vm = ih.viewportManager; + const wasm = window.__wasm; + const zoom = vm.getZoom(); + const cx = docX; + const cy = docY; + const pageDisplayWidth = vs.getPageWidth(pageIdx); + const buggyLeft = (sc.clientWidth - pageDisplayWidth) / 2; + const correctLeft = vs.getPageLeft(pageIdx); + const correctLeftDOM = correctLeft >= 0 ? correctLeft : buggyLeft; + const buggyPageX = (cx - buggyLeft) / zoom; + const correctPageX = (cx - correctLeftDOM) / zoom; + const pageY = (cy - vs.getPageOffset(pageIdx)) / zoom; + + // wasm hit 시리즈 (input-handler-mouse onMouseDown 흐름 모사 — buggy 좌표 사용) + let hit = null, hfHit = null, fnHit = null; + try { hit = wasm.hitTest(pageIdx, buggyPageX, pageY); } catch (e) { hit = { error: e?.message || String(e) }; } + try { hfHit = wasm.hitTestHeaderFooter(pageIdx, buggyPageX, pageY); } catch (e) {} + try { fnHit = wasm.hitTestFootnote(pageIdx, buggyPageX, pageY); } catch (e) {} + + // findPictureAtClick (buggy 좌표 기준) + let picHit = null; + try { picHit = ih.findPictureAtClick(pageIdx, buggyPageX, pageY); } catch (e) { picHit = { error: e?.message || String(e) }; } + + return { + scrollTop: scroller.scrollTop, + clientX: cr.left + docX, + clientY: cr.top + docY, + buggyPageX, correctPageX, pageY, buggyLeft, correctLeftDOM, + hit, hfHit, fnHit, picHit, + }; + }, { docX: setup.docX, docY: setup.docY, pageIdx }); + + console.log(` scroll.before = ${beforeClick.scrollTop.toFixed(1)}`); + console.log(` click @(${beforeClick.clientX.toFixed(1)}, ${beforeClick.clientY.toFixed(1)})`); + console.log(` buggyPageX=${beforeClick.buggyPageX.toFixed(1)} correctPageX=${beforeClick.correctPageX.toFixed(1)} pageY=${beforeClick.pageY.toFixed(1)}`); + console.log(` buggyLeft=${beforeClick.buggyLeft.toFixed(1)} correctLeftDOM=${beforeClick.correctLeftDOM.toFixed(1)}`); + console.log(` hit = ${JSON.stringify(beforeClick.hit)}`); + console.log(` hfHit = ${JSON.stringify(beforeClick.hfHit)}`); + console.log(` fnHit = ${JSON.stringify(beforeClick.fnHit)}`); + console.log(` picHit = ${JSON.stringify(beforeClick.picHit)}`); + + // 실제 click 발생 + await page.mouse.click(beforeClick.clientX, beforeClick.clientY); + await page.evaluate(() => new Promise(r => setTimeout(r, 400))); + + // click 후 cursor + scroll 상태 + const afterClick = await page.evaluate(() => { + const sc = document.querySelector('#scroll-content'); + const scroller = sc.parentElement; + const ih = window.__inputHandler; + const cur = ih?.cursor; + const pos = cur?.getPosition?.(); + const rect = cur?.getRect?.(); + return { + scrollTop: scroller.scrollTop, + pos: pos ? { sec: pos.sectionIndex, para: pos.paragraphIndex, char: pos.charOffset, parentParaIndex: pos.parentParaIndex, controlIndex: pos.controlIndex } : null, + rect: rect ? { pageIdx: rect.pageIndex, x: rect.x, y: rect.y, height: rect.height } : null, + isInTextBox: !!cur?.isInTextBox?.(), + isInPictureObjectSelection: !!cur?.isInPictureObjectSelection?.(), + isInTableObjectSelection: !!cur?.isInTableObjectSelection?.(), + isInHeaderFooter: !!cur?.isInHeaderFooter?.(), + }; + }); + + console.log(` scroll.after = ${afterClick.scrollTop.toFixed(1)} (delta = ${(afterClick.scrollTop - beforeClick.scrollTop).toFixed(1)})`); + console.log(` cursor.pos = ${JSON.stringify(afterClick.pos)}`); + console.log(` cursor.rect = ${JSON.stringify(afterClick.rect)}`); + console.log(` isInTextBox=${afterClick.isInTextBox} isInPictureObjectSelection=${afterClick.isInPictureObjectSelection} isInTableObjectSelection=${afterClick.isInTableObjectSelection} isInHeaderFooter=${afterClick.isInHeaderFooter}`); + + // 가설 (b) 확정 조건 점검 + const hypothesisB = beforeClick.hit?.isTextBox === true; + const hypothesisA = !beforeClick.hit || beforeClick.hit.error; + const scrollJumped = Math.abs(afterClick.scrollTop - beforeClick.scrollTop) > 50; + const rectPageMismatch = afterClick.rect && afterClick.rect.pageIdx !== pageIdx; + + console.log(`\n >>> 가설 (a) hit invalid: ${hypothesisA ? 'YES' : 'no'}`); + console.log(` >>> 가설 (b) isTextBox=true: ${hypothesisB ? 'YES (master page 글상자 hit)' : 'no'}`); + console.log(` >>> 가설 (c) rect.pageIdx mismatch: ${rectPageMismatch ? 'YES (click=' + pageIdx + ' rect=' + afterClick.rect?.pageIdx + ')' : 'no'}`); + console.log(` >>> scroll 점프 발생: ${scrollJumped ? 'YES' : 'no'}`); + + return { setup, beforeClick, afterClick, hypothesisA, hypothesisB, hypothesisC: rectPageMismatch, scrollJumped }; +} + +runTest('보류 ② 본문 외곽 fallback — hwpctl_Action_Table__v1.1.hwp 16p 꼬리말 click 가설 확정', async ({ page }) => { + await page.setViewport({ width: 1600, height: 1000 }); + await page.evaluate(() => new Promise(r => setTimeout(r, 200))); + + console.log('[1] hwpctl_Action_Table__v1.1.hwp 로드'); + const info = await loadHwpFile(page, 'hwpctl_Action_Table__v1.1.hwp'); + console.log(` pageCount=${info.pageCount}`); + await screenshot(page, 'body-outside-01-loaded'); + + // zoom=1.0 (단일 컬럼) — 그리드 모드 결함 배제 + await page.evaluate(() => window.__inputHandler.viewportManager.setZoom(1.0)); + await page.evaluate(() => new Promise(r => setTimeout(r, 500))); + + // page 정보 dump + const pageDump = await page.evaluate(() => { + const ih = window.__inputHandler; + const vs = ih.virtualScroll; + const wasm = window.__wasm; + const list = []; + for (let i = 0; i < vs.pageCount; i++) { + list.push({ i, w: vs.getPageWidth(i), h: vs.getPageHeight(i), o: vs.getPageOffset(i) }); + } + return list; + }); + console.log(`\n페이지 정보 (총 ${pageDump.length} 페이지):`); + for (const p of pageDump.slice(0, 4)) console.log(` page ${p.i}: w=${p.w.toFixed(1)} h=${p.h.toFixed(1)} o=${p.o.toFixed(1)}`); + if (pageDump.length > 4) console.log(` ...`); + for (const p of pageDump.slice(Math.max(0, pageDump.length - 3))) console.log(` page ${p.i}: w=${p.w.toFixed(1)} h=${p.h.toFixed(1)} o=${p.o.toFixed(1)}`); + + // page 16 (0-based 15) 꼬리말 영역 click — pageHeight 의 95% 위치 + // landscape 297x210mm → pageHeight ≈ 793px (75 DPI 가정) + const targetPageIdx = Math.min(15, pageDump.length - 1); + const targetPage = pageDump[targetPageIdx]; + const footerY = targetPage.h * 0.96; // 페이지 하단 4% (꼬리말 영역) + const footerX = targetPage.w * 0.5; // 페이지 가운데 + + console.log(`\n[2] page ${targetPageIdx} 꼬리말 영역 click 시뮬레이션`); + await probeFooterClick(page, `page ${targetPageIdx} 꼬리말 영역`, targetPageIdx, footerX, footerY); + await screenshot(page, 'body-outside-02-page16-footer-click'); + + // 비교 baseline: page 1 (0-based 0) 본문 영역 click — 정상 동작 확인 + console.log(`\n[3] page 0 본문 click (정상 baseline)`); + await probeFooterClick(page, 'page 0 본문 영역', 0, pageDump[0].w * 0.5, pageDump[0].h * 0.5); + await screenshot(page, 'body-outside-03-page0-body-click'); + + // 비교: page 1 (0-based 1) 꼬리말 영역 click — 본 결함 재현 여부 (master page 영향 가설) + if (pageDump.length > 1) { + console.log(`\n[4] page 1 꼬리말 영역 click (master page 영향 비교)`); + await probeFooterClick(page, 'page 1 꼬리말 영역', 1, pageDump[1].w * 0.5, pageDump[1].h * 0.96); + await screenshot(page, 'body-outside-04-page1-footer-click'); + } +}); diff --git a/rhwp-studio/e2e/grid-mode-click-coord.test.mjs b/rhwp-studio/e2e/grid-mode-click-coord.test.mjs new file mode 100644 index 000000000..6986c96f6 --- /dev/null +++ b/rhwp-studio/e2e/grid-mode-click-coord.test.mjs @@ -0,0 +1,216 @@ +/** + * 보류 ① 그리드 좌표 결함 — 정량 e2e 측정 + * + * 본질: zoom ≤ 0.5 그리드 모드에서 input-handler-mouse.ts 의 14곳이 + * `pageLeft = (scrollContent.clientWidth - pageDisplayWidth) / 2` 단일 컬럼 가정 사용. + * 실제 페이지 element 의 left 는 `pageLefts[i]` (canvas-view.ts:158). + * 두 값의 차이가 click 좌표 어긋남 정도. + * + * 본 측정은: + * 1. 그리드 모드 활성 여부 확인 (zoom=0.5, multi-page) + * 2. 각 페이지 (col 0/1/2/...) 의 correct vs buggy pageLeft 값 비교 + * 3. delta_px (CSS px 단위) 정량화 + * 4. 실제 click 시 cursor 위치 어긋남 검증 (correct 좌표로 click → 정상, buggy 좌표로 click → 어긋남) + * + * 실행: + * cd rhwp-studio + * npx vite --host 0.0.0.0 --port 7700 & + * node e2e/grid-mode-click-coord.test.mjs --mode=headless + */ +import { runTest, loadHwpFile, screenshot } from './helpers.mjs'; + +async function dumpGridState(page, label) { + console.log(`\n=== ${label} ===`); + const state = await page.evaluate(() => { + const sc = document.querySelector('#scroll-content'); + const ih = window.__inputHandler; + const vs = ih.virtualScroll; + const vm = ih.viewportManager; + const zoom = vm.getZoom(); + const isGrid = vs.isGridMode(); + const columns = vs.getColumns(); + const pageCount = vs.pageCount; + const clientWidth = sc.clientWidth; + const rows = []; + for (let i = 0; i < pageCount; i++) { + const correct = vs.getPageLeft(i); + const pw = vs.getPageWidth(i); + const buggy = (clientWidth - pw) / 2; + const col = i % Math.max(columns, 1); + const delta = correct >= 0 ? (buggy - correct) : 0; + rows.push({ i, col, pw, correct, buggy, delta }); + } + return { zoom, isGrid, columns, pageCount, clientWidth, rows }; + }); + + console.log(` zoom=${state.zoom} grid=${state.isGrid} columns=${state.columns} pageCount=${state.pageCount} clientWidth=${state.clientWidth}`); + console.log(` | i | col | pw | correct(pageLefts[i]) | buggy(formula) | delta_px |`); + console.log(` |----|-----|--------|-----------------------|----------------|----------|`); + for (const r of state.rows.slice(0, 8)) { // 첫 8 페이지만 출력 + console.log(` | ${String(r.i).padEnd(2)} | ${String(r.col).padEnd(3)} | ${r.pw.toFixed(1).padEnd(6)} | ${r.correct.toFixed(1).padEnd(21)} | ${r.buggy.toFixed(1).padEnd(14)} | ${r.delta.toFixed(1).padEnd(8)} |`); + } + return state; +} + +async function probeClickAtPage(page, label, pageIdx, hwpX, hwpY) { + console.log(`\n--- ${label} (page ${pageIdx}, hwpX=${hwpX}, hwpY=${hwpY}) ---`); + + // correct 좌표 + buggy 좌표 둘 다 계산 + const probe = await page.evaluate(({ pageIdx, hwpX, hwpY }) => { + const sc = document.querySelector('#scroll-content'); + const ih = window.__inputHandler; + const vs = ih.virtualScroll; + const vm = ih.viewportManager; + const zoom = vm.getZoom(); + const pw = vs.getPageWidth(pageIdx); + const po = vs.getPageOffset(pageIdx); + + // CORRECT: pageLefts[i] 사용 (실제 페이지 element 위치) + const correctLeft = vs.getPageLeft(pageIdx); + const correctLeftDOM = correctLeft >= 0 ? correctLeft : (sc.clientWidth - pw) / 2; + const correctDocX = correctLeftDOM + hwpX * zoom; + const correctDocY = po + hwpY * zoom; + + // BUGGY: (clientWidth - pageDisplayWidth) / 2 (input-handler-mouse 의 가정) + const buggyLeft = (sc.clientWidth - pw) / 2; + const buggyDocX = buggyLeft + hwpX * zoom; + const buggyDocY = po + hwpY * zoom; + + // 스크롤 안정화 + const scroller = sc.parentElement; + scroller.scrollTop = Math.max(0, correctDocY - 200); + + return { + zoom, pw, po, correctLeft, correctLeftDOM, buggyLeft, + correctDocX, correctDocY, buggyDocX, buggyDocY, + delta_x: buggyDocX - correctDocX, + }; + }, { pageIdx, hwpX, hwpY }); + + await page.evaluate(() => new Promise(r => setTimeout(r, 400))); + + // CORRECT 좌표로 click 후 cursor 위치 확인 + const correctClick = await page.evaluate(({ correctDocX, correctDocY }) => { + const sc = document.querySelector('#scroll-content'); + const cr = sc.getBoundingClientRect(); + return { clientX: cr.left + correctDocX, clientY: cr.top + correctDocY }; + }, probe); + + await page.mouse.click(correctClick.clientX, correctClick.clientY); + await page.evaluate(() => new Promise(r => setTimeout(r, 250))); + + const afterCorrectClick = await page.evaluate(() => { + const ih = window.__inputHandler; + const cur = ih?.cursor; + const pos = cur?.getPosition?.(); + const rect = cur?.getRect?.(); + return { + pos: pos ? { sec: pos.sectionIndex, para: pos.paragraphIndex, char: pos.charOffset } : null, + rectPageIdx: rect?.pageIndex ?? null, + rectX: rect?.x ?? null, + rectY: rect?.y ?? null, + }; + }); + + // BUGGY 좌표로 click 후 cursor 위치 확인 (input-handler-mouse 의 현재 동작 모사) + await page.mouse.click(correctClick.clientX + probe.delta_x, correctClick.clientY); + await page.evaluate(() => new Promise(r => setTimeout(r, 250))); + + const afterBuggyClick = await page.evaluate(() => { + const ih = window.__inputHandler; + const cur = ih?.cursor; + const pos = cur?.getPosition?.(); + const rect = cur?.getRect?.(); + return { + pos: pos ? { sec: pos.sectionIndex, para: pos.paragraphIndex, char: pos.charOffset } : null, + rectPageIdx: rect?.pageIndex ?? null, + rectX: rect?.x ?? null, + rectY: rect?.y ?? null, + }; + }); + + console.log(` zoom=${probe.zoom} pw=${probe.pw.toFixed(1)} po=${probe.po.toFixed(1)}`); + console.log(` correctLeft=${probe.correctLeft} correctLeftDOM=${probe.correctLeftDOM.toFixed(1)} buggyLeft=${probe.buggyLeft.toFixed(1)}`); + console.log(` delta_x = ${probe.delta_x.toFixed(1)} px (CSS px, click 어긋남 정도)`); + console.log(` CORRECT click @(${correctClick.clientX.toFixed(1)}, ${correctClick.clientY.toFixed(1)}) → pos=${JSON.stringify(afterCorrectClick.pos)} rectPage=${afterCorrectClick.rectPageIdx}`); + console.log(` BUGGY click @(${(correctClick.clientX + probe.delta_x).toFixed(1)}, ${correctClick.clientY.toFixed(1)}) → pos=${JSON.stringify(afterBuggyClick.pos)} rectPage=${afterBuggyClick.rectPageIdx}`); + + return { probe, correctClick, afterCorrectClick, afterBuggyClick }; +} + +runTest('보류 ① 그리드 좌표 결함 — exam_kor.hwp zoom=0.5 정량 측정', async ({ page }) => { + // 충분히 큰 viewport 로 columns >= 2 보장 + await page.setViewport({ width: 1600, height: 1000 }); + await page.evaluate(() => new Promise(r => setTimeout(r, 200))); + + console.log('[1] exam_kor.hwp 로드'); + const info = await loadHwpFile(page, 'exam_kor.hwp'); + console.log(` pageCount=${info.pageCount}`); + + await screenshot(page, 'grid-coord-01-loaded'); + + // [2] zoom=0.5 → 그리드 모드 활성 + console.log('\n[2] zoom=0.5 변경'); + await page.evaluate(() => { + window.__inputHandler.viewportManager.setZoom(0.5); + }); + await page.evaluate(() => new Promise(r => setTimeout(r, 600))); + await screenshot(page, 'grid-coord-02-zoom05'); + + const stateZ05 = await dumpGridState(page, 'zoom=0.5 그리드 상태'); + + // [3] zoom=0.25 → columns 더 많음 + console.log('\n[3] zoom=0.25 변경'); + await page.evaluate(() => { + window.__inputHandler.viewportManager.setZoom(0.25); + }); + await page.evaluate(() => new Promise(r => setTimeout(r, 600))); + await screenshot(page, 'grid-coord-03-zoom025'); + + const stateZ025 = await dumpGridState(page, 'zoom=0.25 그리드 상태'); + + // [4] zoom=1.0 (단일 컬럼) - 비교 baseline + console.log('\n[4] zoom=1.0 변경 (단일 컬럼)'); + await page.evaluate(() => { + window.__inputHandler.viewportManager.setZoom(1.0); + }); + await page.evaluate(() => new Promise(r => setTimeout(r, 600))); + + const stateZ10 = await dumpGridState(page, 'zoom=1.0 단일 컬럼 (정상 baseline)'); + + // [5] zoom=0.5 + 실제 click 측정 — col 0/1 페이지 비교 + console.log('\n[5] zoom=0.5 실제 click 측정'); + await page.evaluate(() => { + window.__inputHandler.viewportManager.setZoom(0.5); + }); + await page.evaluate(() => new Promise(r => setTimeout(r, 600))); + + // page 0 (col 0) — 임의 좌표 (페이지 좌측 상단) + await probeClickAtPage(page, 'page 0 (col 0)', 0, 100, 200); + await screenshot(page, 'grid-coord-04-page0-click'); + + // page 1 (col 1) — 동일 페이지 내 좌표 + await probeClickAtPage(page, 'page 1 (col 1)', 1, 100, 200); + await screenshot(page, 'grid-coord-05-page1-click'); + + // page 2 (col 0 if columns=2, col 2 if columns >=3) — 가운데 열 가능성 + if (stateZ05.pageCount >= 3) { + await probeClickAtPage(page, 'page 2 (col=2 % columns)', 2, 100, 200); + await screenshot(page, 'grid-coord-06-page2-click'); + } + + // 결과 요약 + console.log('\n=== 측정 결과 요약 ==='); + console.log(`zoom=0.5: columns=${stateZ05.columns}, delta 분포 (page 0..7):`); + for (const r of stateZ05.rows.slice(0, 8)) { + console.log(` page ${r.i} (col ${r.col}): delta_px = ${r.delta.toFixed(1)}`); + } + console.log(`zoom=0.25: columns=${stateZ025.columns}, delta 분포 (page 0..7):`); + for (const r of stateZ025.rows.slice(0, 8)) { + console.log(` page ${r.i} (col ${r.col}): delta_px = ${r.delta.toFixed(1)}`); + } + console.log(`zoom=1.0 (baseline): columns=${stateZ10.columns}, delta 분포 (page 0..3):`); + for (const r of stateZ10.rows.slice(0, 4)) { + console.log(` page ${r.i} (col ${r.col}): delta_px = ${r.delta.toFixed(1)}`); + } +}); From 510ea237963553f666063b4022b9365d1eb5623d Mon Sep 17 00:00:00 2001 From: johndoekim Date: Thu, 7 May 2026 23:43:51 +0900 Subject: [PATCH 05/13] =?UTF-8?q?Task=20#595=20=ED=9B=84=EC=86=8D:=20Issue?= =?UTF-8?q?=20#685=20=EC=A7=84=EB=8B=A8=20=EB=85=B8=ED=8A=B8=20framing=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95=20=E2=80=94=20=ED=95=9C=EC=BB=B4=20=ED=98=B8?= =?UTF-8?q?=ED=99=98=20=EA=B2=B0=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 작업지시자 정정 (2026-05-07): 한컴 오피스도 다중 페이지 그리드 모드가 존재하며, 거기서 click 좌표는 정확히 처리됨. RHWP 만 어긋남 → 본 결함은 "한컴 호환 무관" 이 아닌 **한컴 호환 결함** 으로 정정. - 영향 범위 섹션: 한컴 그리드 모드 정상 동작 명시 - "한컴 호환 무관성" → "한컴 호환 결함" 섹션명/본문 정정 - 기대 동작: 한컴 그리드와 동일하게 cursor 배치 Issue #685 본문도 동일하게 정정 (gh issue edit). Co-Authored-By: Claude Opus 4.7 --- mydocs/troubleshootings/grid_mode_click_coord.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/mydocs/troubleshootings/grid_mode_click_coord.md b/mydocs/troubleshootings/grid_mode_click_coord.md index 8cc1538e7..808a92ce8 100644 --- a/mydocs/troubleshootings/grid_mode_click_coord.md +++ b/mydocs/troubleshootings/grid_mode_click_coord.md @@ -49,10 +49,12 @@ this.gridMode = zoom <= GRID_ZOOM_THRESHOLD && viewportWidth > 0 && pages.length ## 영향 범위 (사용자 직접 측정, 2026-05-07) -**한컴**: 그리드 모드 / 일반 모드 모두 정상 클릭 동작. +**한컴**: 그리드 모드 / 일반 모드 모두 **정상 클릭 동작**. ※ 한컴 오피스도 다중 페이지 그리드 모드 (여러 페이지 동시 보기) 가 존재하며, 거기서도 click 좌표는 정확히 처리됨. **RHWP 그리드 모드 (zoom ≤ 0.5)**: **모든 열에서 click 어긋남** (좌측 열 + 가운데 열 + 우측 열 전부). 일반 모드 (zoom > 0.5) 는 정상. +→ **한컴 호환 결함** (한컴은 정상, RHWP만 어긋남). + 원인: 페이지 element 의 실제 left 좌표는 `pageLefts[i]` ([canvas-view.ts:156-163](../../rhwp-studio/src/view/canvas-view.ts#L156-L163) 에서 `style.left = ${pageLeft}px`) 인데, input-handler-mouse 의 14곳 모두 `(clientWidth - pageDisplayWidth) / 2` 단일 컬럼 가정. 그리드 모드에서는 모든 열의 페이지가 슬롯 좌표에 배치되므로 단일 컬럼 가정 공식과 어긋남 (좌/우 방향 + 정도가 열별로 다름). ## 정정 범위 @@ -90,9 +92,11 @@ getPageLeftResolved(pageIdx: number, containerWidth: number): number { 4. **2열째 페이지** 본문 텍스트 클릭 → 커서가 엉뚱한 위치 (1열째 페이지 영역 처럼 처리됨) 에 떨어지는지 확인. 5. 또는 머리말 영역 dblclick → 머리말 편집기 미진입 (좌표 어긋남으로 hit_test_header_footer = false 반환). -## 한컴 호환 무관성 +## 한컴 호환 결함 + +한컴 오피스는 다중 페이지 그리드 모드 (여러 페이지 동시 보기) 가 존재하며, 그 모드에서 click 좌표는 정확히 처리됨 (사용자 직접 시연 확인, 2026-05-07). RHWP 만 그리드 모드에서 click 좌표 어긋남 → **한컴 호환 결함**. -한컴 오피스는 그리드 모드 (다중 열 페이지 배치) 자체가 없음. 본 결함은 RHWP 자체 결함이며 한컴 호환 진단 불필요. +본 정정의 기대 동작: 한컴 그리드 모드와 동일하게 클릭 → 클릭한 페이지 안의 정확한 위치에 cursor 배치. ## 우선순위 From 85a83060a325427f80c1536aab515be8dd7f2bea Mon Sep 17 00:00:00 2001 From: johndoekim Date: Fri, 8 May 2026 00:07:38 +0900 Subject: [PATCH 06/13] =?UTF-8?q?Task=20#685=20Stage=201:=20getPageLeftRes?= =?UTF-8?q?olved=20=ED=97=AC=ED=8D=BC=20=EC=B6=94=EA=B0=80=20+=20formBboxT?= =?UTF-8?q?oOverlayRect=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #685 (zoom ≤ 0.5 그리드 모드 click 좌표 단일 컬럼 가정 — 14곳 분기 일괄 어긋남) 정정 작업의 1/3 단계. 본 단계는 동치 refactor (동작 변경 없음): - virtual-scroll.ts: getPageLeftResolved(pageIdx, containerWidth) 헬퍼 추가. 그리드 모드는 pageLefts[i], 단일 컬럼은 (containerWidth - pageWidth) / 2 fallback. 기존 getPageLeft(pageIdx) 는 raw accessor 로 보존 (canvas-view 등 4 호출자 무회귀). - input-handler.ts: formBboxToOverlayRect 의 verbose sentinel 패턴 (`getPageLeft(pageIdx) >= 0 ? : (contentWidth - pageDisplayWidth) / 2`) 을 헬퍼 호출 한 줄로 정리. pageDisplayWidth 변수는 사용처 없어 제거. 검증: - npx tsc --noEmit 통과 - npx vite build 성공 (85 modules transformed) - e2e body-outside-click-fallback.test.mjs --mode=headless exit 0 (단일 컬럼 모드 sentinel fallback 경로 동치성 확인) 수행계획서: mydocs/plans/task_m100_685.md 구현계획서: mydocs/plans/task_m100_685_impl.md 단계 보고서: mydocs/working/task_m100_685_stage1.md Co-Authored-By: Claude Opus 4.7 --- mydocs/plans/task_m100_685.md | 157 +++++++++ mydocs/plans/task_m100_685_impl.md | 444 ++++++++++++++++++++++++ mydocs/working/task_m100_685_stage1.md | 120 +++++++ rhwp-studio/src/engine/input-handler.ts | 5 +- rhwp-studio/src/view/virtual-scroll.ts | 12 + 5 files changed, 734 insertions(+), 4 deletions(-) create mode 100644 mydocs/plans/task_m100_685.md create mode 100644 mydocs/plans/task_m100_685_impl.md create mode 100644 mydocs/working/task_m100_685_stage1.md diff --git a/mydocs/plans/task_m100_685.md b/mydocs/plans/task_m100_685.md new file mode 100644 index 000000000..5334723df --- /dev/null +++ b/mydocs/plans/task_m100_685.md @@ -0,0 +1,157 @@ +# Task #685 수행계획서 — zoom ≤ 0.5 그리드 모드 click 좌표 단일 컬럼 가정 일괄 정정 + +- **이슈**: [#685](https://github.com/edwardkim/rhwp/issues/685) +- **마일스톤**: M100 (v1.0.0) +- **브랜치**: `local/task685` (← `local/devel`) +- **우선순위**: High (UX-blocking) +- **작성일**: 2026-05-07 + +--- + +## 1. 배경 (Why) + +### 본질 결함 + +`virtual-scroll.ts` 는 `zoom ≤ 0.5 + pages > 1 + viewport > 0` 조건에서 **다중 컬럼 그리드** 로 페이지를 배치하며, 각 페이지의 X 좌표를 `pageLefts[i] = marginLeft + col * (pw + gap)` 로 열별 계산한다 ([virtual-scroll.ts:54-92](../../rhwp-studio/src/view/virtual-scroll.ts#L54-L92)). + +그러나 [`input-handler-mouse.ts`](../../rhwp-studio/src/engine/input-handler-mouse.ts) 의 마우스 좌표 → 페이지 좌표 변환 14곳 모두 **단일 컬럼 가정 공식** `(scrollContent.clientWidth - pageDisplayWidth) / 2` 만 사용 → 그리드 모드에서 모든 마우스 인터랙션이 ±수백 px 어긋남. + +### 사용자 영향 (정량 측정, 2026-05-07) + +[grid-mode-click-coord.test.mjs](../../rhwp-studio/e2e/grid-mode-click-coord.test.mjs) 결과 (`samples/exam_kor.hwp` 20p, viewport 1600×1000, headless Chrome): + +| 줌 | columns | 좌측 끝 col delta_px | 우측 끝 col delta_px | 가운데 col | +|----|---------|---------------------|---------------------|-----------| +| 0.5 | 2 | +285.6 | −285.6 | (n/a) | +| 0.25 | 5 | +581.3 | −581.3 | 0 (우연한 정합) | +| 1.0 (baseline) | 1 | 0 | 0 | 0 (정상) | + +실제 click 검증: zoom=0.5, page 1 (col 1), hwpX=100 의도 시 CORRECT click → `{sec:0, para:39, char:70}` 정상; BUGGY click → `{sec:0, para:31, char:0}` (page 0 영역으로 떨어짐). + +### 한컴 호환 결함 + +한컴 오피스 그리드 모드(다중 페이지 동시 보기)는 정상 동작 확인 (사용자 직접 시연, 2026-05-07). RHWP 만 그리드 모드에서 어긋남 → 한컴 호환 결함. + +--- + +## 2. 정정 범위 (What) + +### 본 작업 범위 (in-scope) + +1. **[virtual-scroll.ts](../../rhwp-studio/src/view/virtual-scroll.ts)** — 헬퍼 1개 추가: + ```ts + /** 그리드 모드 sentinel(-1) 해소: 단일 컬럼은 fallback 공식, 그리드는 pageLefts[i] */ + getPageLeftResolved(pageIdx: number, containerWidth: number): number { + const pl = this.pageLefts[pageIdx] ?? -1; + if (pl >= 0) return pl; + const pw = this.pageWidths[pageIdx] ?? 0; + return (containerWidth - pw) / 2; + } + ``` + 기존 [`getPageLeft(pageIdx)`](../../rhwp-studio/src/view/virtual-scroll.ts#L155-L157) 는 raw accessor 로 보존 (canvas-view 등 4 호출자 무회귀). + +2. **[input-handler-mouse.ts](../../rhwp-studio/src/engine/input-handler-mouse.ts)** — 14곳 일괄 치환: + - 라인 23, 129, 176, 279, 296, 357, 431, **475**, **811**, **889**, 931, 1146, 1196, 1243 + - 변환: `(sc.clientWidth - pw) / 2` → `this.virtualScroll.getPageLeftResolved(pageIdx, sc.clientWidth)` + +3. **[input-handler.ts:2579-2580](../../rhwp-studio/src/engine/input-handler.ts#L2579-L2580)** — verbose sentinel 패턴(`getPageLeft(pageIdx) >= 0 ? getPageLeft(pageIdx) : ...`) 1곳도 동일 헬퍼로 정리 (DRY). + → 총 **15곳** 헬퍼 통일. + +4. **[grid-mode-click-coord.test.mjs](../../rhwp-studio/e2e/grid-mode-click-coord.test.mjs)** — 현재 측정만 하는 테스트에 회귀 assert 추가: + - zoom=0.5, zoom=0.25 모든 col 의 CORRECT click → cursor.rectPageIdx === 의도한 pageIdx (assert) + - zoom=1.0 (baseline) 무회귀 assert + - `delta_px` (correct vs buggy 공식 차이) 는 측정 로그 그대로 보존 (수치 자체는 fix 와 무관, 진단 가치) + +5. **[virtual-scroll 단위 테스트](../../rhwp-studio/src/view/__tests__/)** (위치는 Stage 1 에서 확정) — `getPageLeftResolved` 단위 케이스 3 종 추가: + - 단일 컬럼 모드 (sentinel −1 fallback) + - 그리드 모드 (pageLefts[i] 그대로) + - out-of-range (undefined → fallback 공식) + +### 본 작업 미포함 (out-of-scope) + +- [`canvas-view.ts:156`](../../rhwp-studio/src/view/canvas-view.ts#L156), [`field-marker-renderer.ts:84`](../../rhwp-studio/src/engine/field-marker-renderer.ts#L84), [`caret-renderer.ts:127`](../../rhwp-studio/src/engine/caret-renderer.ts#L127) 의 `getPageLeft` 호출은 **손대지 않는다**. 이들은 이미 그리드 인프라를 정상 처리 중. 헬퍼 통일 욕심내면 회귀 위험. +- `pageDisplayWidth` 변수가 click 좌표 외에 hit test bbox 등에 별도 사용되는 경우 변수는 보존하고 `pageLeft` 계산식만 헬퍼로 치환. +- 키보드 / IME / Touch 입력 경로의 동일 결함 가능성 — 별도 후속 조사 (본 타스크 범위 외). + +--- + +## 3. 수행 절차 (How) + +CLAUDE.md 의 하이퍼-워터폴 절차 준수: + +| 단계 | 산출물 | 승인 게이트 | +|------|--------|-----------| +| 0 | 본 수행계획서 (`task_m100_685.md`) | **승인 요청** ← 현재 | +| 1 | 구현 계획서 (`task_m100_685_impl.md`, 3 단계) | 승인 요청 | +| 2 | Stage 1 단계 보고서 (`task_m100_685_stage1.md`) + 헬퍼 + 단위 테스트 커밋 | 승인 요청 | +| 3 | Stage 2 단계 보고서 (`task_m100_685_stage2.md`) + 15곳 치환 커밋 | 승인 요청 | +| 4 | Stage 3 단계 보고서 (`task_m100_685_stage3.md`) + e2e assert 커밋 | 승인 요청 | +| 5 | 최종 결과보고서 (`task_m100_685_report.md`) + 진단 노트 갱신 + orders 갱신 | 승인 요청 → 이슈 #685 close | + +각 단계 커밋 시 `Task #685 Stage N: ` 형식. 최종 단계에서 `closes #685`. + +--- + +## 4. 검증 (Acceptance Criteria) + +자동 검증: + +- [ ] `npm run typecheck` (rhwp-studio) 통과 +- [ ] `npm run build` (rhwp-studio) 성공 +- [ ] virtual-scroll 단위 테스트 신규 케이스 3종 PASS +- [ ] `node rhwp-studio/e2e/grid-mode-click-coord.test.mjs --mode=headless` PASS (assert 강화 후) + - zoom=0.5/0.25 모든 col CORRECT click → rectPageIdx === pageIdx + - zoom=1.0 baseline 무회귀 +- [ ] `node rhwp-studio/e2e/body-outside-click-fallback.test.mjs --mode=headless` 무회귀 + +수동 검증 (Stage 3 마지막): + +- [ ] 호스트 Chrome CDP 모드 (`--mode=host`) 로 zoom=0.5, 0.25 그리드에서 양 끝 컬럼 페이지 본문 클릭 → 의도한 위치 캐럿 배치 시각 확인 +- [ ] zoom=1.0 시 일반 클릭/더블클릭/우클릭 무회귀 시각 확인 + +코드 품질: + +- [ ] [`input-handler-mouse.ts`](../../rhwp-studio/src/engine/input-handler-mouse.ts) 안에 `(scrollContent.clientWidth - pageDisplayWidth) / 2` 또는 `(sc.clientWidth - pw) / 2` 패턴이 0건 grep 결과 +- [ ] [`input-handler.ts:2579-2580`](../../rhwp-studio/src/engine/input-handler.ts#L2579-L2580) 의 verbose sentinel 분기가 단순 헬퍼 호출로 단순화 + +--- + +## 5. 위험성 / 주의점 + +### scope 위험 +- **`pageDisplayWidth` 변수 보존 점검**: 같은 함수 내에서 `pageDisplayWidth` 가 hit test bbox 계산 등 다른 곳에 재사용되는 경우 변수는 그대로 두고 `pageLeft = ...` 표현식만 헬퍼로 치환. Stage 2 에서 라인별 cross-check 필수. +- **`pageIdx` 변수명 차이**: 라인별로 `pi`, `pageIdx`, `picBbox.pageIndex` 등 다양함 → 치환 시 해당 함수 컨텍스트의 정확한 변수명 사용. 일괄 sed 금지. + +### 회귀 위험 +- 단일 컬럼 모드 (zoom > 0.5): `pageLefts[i] = -1` sentinel → 헬퍼가 fallback 공식 적용 → 기존 동작과 비트 단위로 동일해야 한다. Stage 1 단위 테스트로 보장. +- 그리드 인프라 (canvas-view 등 4곳)는 미수정 → 본 변경이 영향 못 줌. + +### 측정 임계값 +- e2e assert 의 `delta ≤ 1 px` 임계는 부동소수점 오차 흡수용. 실제 측정 결과 기반으로 Stage 3 에서 미세 조정 가능 (단 0.5 px 이상 어긋남이 발생하면 fix 미적용 의심). + +--- + +## 6. 참고 자료 + +- 진단 노트: [`mydocs/troubleshootings/grid_mode_click_coord.md`](../troubleshootings/grid_mode_click_coord.md) — 본질, 14곳 표, 정량 측정, 권장 패턴 모두 포함 +- e2e 측정 스크립트: [`rhwp-studio/e2e/grid-mode-click-coord.test.mjs`](../../rhwp-studio/e2e/grid-mode-click-coord.test.mjs) +- 관련 영역 (input-handler-mouse cluster): #658, #661, #669 +- 모태 타스크: #595 (header bbox 누설 정정 — 후속 sweep 으로 본 이슈 발견) + +--- + +## 7. 변경 파일 요약 + +| 파일 | 종류 | 변경 | +|------|------|------| +| `rhwp-studio/src/view/virtual-scroll.ts` | 수정 | `getPageLeftResolved` 추가 (~7 LOC) | +| `rhwp-studio/src/engine/input-handler-mouse.ts` | 수정 | 14곳 치환 (~14 LOC delta) | +| `rhwp-studio/src/engine/input-handler.ts` | 수정 | 1곳 단순화 (~−2 LOC) | +| `rhwp-studio/src/view/__tests__/virtual-scroll.test.*` | 수정/신규 | 단위 테스트 ~3 케이스 | +| `rhwp-studio/e2e/grid-mode-click-coord.test.mjs` | 수정 | assert 추가 (~30 LOC) | +| `mydocs/plans/task_m100_685.md` | 신규 | 본 문서 | +| `mydocs/plans/task_m100_685_impl.md` | 신규 | 구현 계획서 | +| `mydocs/working/task_m100_685_stage{1,2,3}.md` | 신규 | 단계 보고서 | +| `mydocs/report/task_m100_685_report.md` | 신규 | 최종 보고서 | +| `mydocs/troubleshootings/grid_mode_click_coord.md` | 갱신 | 끝부분에 "정정 완료 — Task #685" 기록 | +| `mydocs/orders/2026-05-07.md` (또는 작업 시작일) | 갱신 | 타스크 상태 | diff --git a/mydocs/plans/task_m100_685_impl.md b/mydocs/plans/task_m100_685_impl.md new file mode 100644 index 000000000..c74b1b05f --- /dev/null +++ b/mydocs/plans/task_m100_685_impl.md @@ -0,0 +1,444 @@ +# Task #685 구현 계획서 — 그리드 모드 click 좌표 단일 컬럼 가정 일괄 정정 + +- **대응 수행계획서**: [`task_m100_685.md`](task_m100_685.md) +- **이슈**: [#685](https://github.com/edwardkim/rhwp/issues/685) +- **단계 수**: 3 (CLAUDE.md 의 3~6 범위) +- **공통 검증 명령**: `cd rhwp-studio && npx tsc --noEmit` (타입체크), `npx vite build` (빌드) +- **e2e 실행 환경**: 별도 터미널에서 `cd rhwp-studio && npx vite --host 0.0.0.0 --port 7700` 후 `node e2e/.test.mjs --mode=headless` + +--- + +## 단계별 사전 결정 + +### 헬퍼 시그니처 (확정) + +[`rhwp-studio/src/view/virtual-scroll.ts`](../../rhwp-studio/src/view/virtual-scroll.ts) 에 `getPageLeft` 직후 추가: + +```ts +/** + * 페이지의 X 좌표를 그리드/단일 컬럼 모드 통합으로 반환. + * 그리드 모드: pageLefts[i] 그대로. + * 단일 컬럼 모드(sentinel −1): (containerWidth - pageWidth) / 2 fallback. + */ +getPageLeftResolved(pageIdx: number, containerWidth: number): number { + const pl = this.pageLefts[pageIdx] ?? -1; + if (pl >= 0) return pl; + const pw = this.pageWidths[pageIdx] ?? 0; + return (containerWidth - pw) / 2; +} +``` + +### TS 단위 테스트 부재 처리 + +rhwp-studio 는 TS 단위 테스트 프레임워크 없음 (e2e puppeteer 만 존재). 헬퍼 검증은 다음 두 경로로 수행: + +- **Stage 1 자체 검증**: 기존 [`input-handler.ts:2579-2581`](../../rhwp-studio/src/engine/input-handler.ts#L2579-L2581) 의 verbose sentinel 패턴을 새 헬퍼로 치환 → 동작 동치성으로 헬퍼의 단일 컬럼/그리드 동작 양쪽 검증. +- **Stage 3 e2e 검증**: `grid-mode-click-coord.test.mjs` 의 `dumpGridState()` 안에서 `vs.getPageLeftResolved(i, sc.clientWidth)` 호출값이 (그리드 모드) `vs.getPageLeft(i)` 와 일치하고, (단일 컬럼) `(clientWidth - pw)/2` 와 일치함을 assert. + +--- + +## Stage 1 — `getPageLeftResolved` 헬퍼 추가 + 기존 verbose 사용처 1곳 정리 + +**목표**: 헬퍼 도입과 동치성 검증을 한 단계로 묶음. 동작 변경 없음 (refactor only). + +**수정 파일**: +- `rhwp-studio/src/view/virtual-scroll.ts` (헬퍼 추가) +- `rhwp-studio/src/engine/input-handler.ts` (L2579-L2581 단순화) + +### Step 1.1 — virtual-scroll.ts 에 헬퍼 추가 + +[`rhwp-studio/src/view/virtual-scroll.ts:155-157`](../../rhwp-studio/src/view/virtual-scroll.ts#L155-L157) 직후에 다음 메서드 추가: + +```ts + /** + * 페이지의 X 좌표를 그리드/단일 컬럼 모드 통합으로 반환. + * 그리드 모드: pageLefts[i] 그대로. + * 단일 컬럼 모드(sentinel −1): (containerWidth - pageWidth) / 2 fallback. + */ + getPageLeftResolved(pageIdx: number, containerWidth: number): number { + const pl = this.pageLefts[pageIdx] ?? -1; + if (pl >= 0) return pl; + const pw = this.pageWidths[pageIdx] ?? 0; + return (containerWidth - pw) / 2; + } +``` + +기존 `getPageLeft(pageIdx)` 는 raw accessor 로 보존 (`canvas-view.ts`, `field-marker-renderer.ts`, `caret-renderer.ts` 호출자 무회귀). + +### Step 1.2 — input-handler.ts:2579-2581 단순화 + +[`rhwp-studio/src/engine/input-handler.ts:2572-2589`](../../rhwp-studio/src/engine/input-handler.ts#L2572-L2589) `formBboxToOverlayRect` 메서드 안: + +**Before**: +```ts +const scrollContent = this.container.querySelector('#scroll-content'); +const contentWidth = scrollContent?.clientWidth ?? 0; +const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); +const pageLeft = this.virtualScroll.getPageLeft(pageIdx) >= 0 + ? this.virtualScroll.getPageLeft(pageIdx) + : (contentWidth - pageDisplayWidth) / 2; +``` + +**After**: +```ts +const scrollContent = this.container.querySelector('#scroll-content'); +const contentWidth = scrollContent?.clientWidth ?? 0; +const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, contentWidth); +``` + +`pageDisplayWidth` 변수는 이 메서드 내에서 위 한 곳에만 쓰이므로 제거. `bbox.w * zoom` 등은 `bbox.w` 사용 (pageDisplayWidth 의존 없음 — Read 검증 완료). + +### Step 1.3 — 타입체크 + 빌드 + +```bash +cd rhwp-studio +npx tsc --noEmit +npx vite build +``` + +기대: 둘 다 무에러. `getPageLeftResolved` 가 `VirtualScroll` 타입에 추가되었으므로 호출부 타입 일치. + +### Step 1.4 — 동치성 sanity check + +수동: 기존 `formBboxToOverlayRect` 가 호출되는 양식 개체 (samples/exam_form.hwp 등) 를 열어 양식 오버레이가 그리드 모드(zoom=0.5)/단일 컬럼(zoom=1.0) 모두 정상 위치에 표시되는지 시각 확인. 단, 동치 refactor 이므로 기능 회귀는 일어나지 않아야 함. + +자동 회귀가 필요하면 [`rhwp-studio/e2e/body-outside-click-fallback.test.mjs`](../../rhwp-studio/e2e/body-outside-click-fallback.test.mjs) 를 headless 로 1회 실행하여 무회귀 확인 (vite dev 별도 터미널 가동 필요). + +### Step 1.5 — Stage 1 완료 보고서 + 커밋 + +`mydocs/working/task_m100_685_stage1.md` 작성. 내용: +- 변경 요약 (2 파일, ~+10 LOC, −2 LOC) +- 타입체크/빌드 통과 확인 +- 동치성 검증 결과 +- 다음 단계 안내 + +커밋: +```bash +git add rhwp-studio/src/view/virtual-scroll.ts \ + rhwp-studio/src/engine/input-handler.ts \ + mydocs/working/task_m100_685_stage1.md \ + mydocs/plans/task_m100_685.md \ + mydocs/plans/task_m100_685_impl.md +git commit -m "Task #685 Stage 1: getPageLeftResolved 헬퍼 추가 + formBboxToOverlayRect 단순화" +``` + +→ **승인 게이트**: 작업지시자 확인 후 Stage 2 진행. + +--- + +## Stage 2 — `input-handler-mouse.ts` 14곳 헬퍼 치환 + +**목표**: 본 이슈의 핵심 정정. 그리드 모드 click 좌표 어긋남 해소. + +**수정 파일**: `rhwp-studio/src/engine/input-handler-mouse.ts` 만. + +### 치환 규칙 (모든 14곳 동일 패턴) + +**Before** (변형 패턴 3종): +```ts +// 패턴 A (대다수) +const pl = (sc.clientWidth - pw) / 2; + +// 패턴 B +const pageLeft = (scrollContent.clientWidth - pageDisplayWidth) / 2; + +// 패턴 C (변수명 sc/pw 또는 scrollContent/pageDisplayWidth 혼재) +const pageLeft = ((sc as HTMLElement).clientWidth - pageDisplayWidth) / 2; +``` + +**After**: +```ts +// 변수명은 해당 라인의 기존 이름 유지 (pl 또는 pageLeft) + 페이지 인덱스도 해당 라인의 기존 이름 (pi 또는 pageIdx 또는 picBbox.pageIndex) +const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth); +// 또는 +const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth); +``` + +**중요 주의**: +- `pw` / `pageDisplayWidth` 변수는 같은 함수 내 다른 좌표 계산(예: hit test 의 `x + pw`, bbox `x + pageDisplayWidth` 등)에서도 쓰이므로 **변수 자체는 보존**, 단 `(... - pw)/2` 표현식만 헬퍼 호출로 교체. +- 페이지 인덱스 변수명은 라인별로 확인 후 그대로 사용 (`pi` / `pageIdx` / `picBbox.pageIndex`). + +### Step 2.1 — 14곳 라인별 치환 (라인 번호 역순 권장: 변경 후 라인 번호 시프트 영향 최소화) + +대상 라인 (보고서 기준 — 실측 시 ±10 라인 허용): + +| 라인 | 함수 | 페이지 인덱스 변수 | 치환 후 라인 | +|------|------|-------------------|-------------| +| 1243 | `handleResizeHover` | `pageIdx` | `const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth);` | +| 1196 | `onMouseMove (table hover)` | `pi` | `const pl = this.virtualScroll.getPageLeftResolved(pi, scrollContent.clientWidth);` | +| 1146 | `onMouseMove (picture hover)` | `pi` | `const pl = this.virtualScroll.getPageLeftResolved(pi, scrollContent.clientWidth);` | +| 931 | `onMouseMove (connector)` | `pi` | `const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth);` | +| 889 | `onContextMenu` | `pageIdx` | `const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth);` | +| 811 | `onDblClick` | `pageIdx` | `const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, (sc as HTMLElement).clientWidth);` | +| 475 | `onMouseDown (일반 click)` | `pageIdx` | `const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth);` | +| 431 | `onMouseDown (table resize)` | `pageIdx` | `const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth);` | +| 357 | `onMouseDown (single picture)` | `pi` | `const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth);` | +| 296 | `onMouseDown (rotate)` | `picBbox.pageIndex` | `const pl = this.virtualScroll.getPageLeftResolved(picBbox.pageIndex, sc.clientWidth);` | +| 279 | `onMouseDown (line endpoint)` | `picBbox.pageIndex` | `const pl = this.virtualScroll.getPageLeftResolved(picBbox.pageIndex, sc.clientWidth);` | +| 176 | `onMouseDown (multi picture)` | `pi` | `const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth);` | +| 129 | `onMouseDown (선택된 표)` | `pi` | `const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth);` | +| 23 | `onConnectorMouseDown` | `pi` | `const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth);` | + +각 라인은 Edit 도구의 정확한 매칭(주변 1~2 줄 컨텍스트 포함)으로 단건 치환 — `replace_all` 금지. 함수마다 변수명/scrollContent 별칭이 다르므로 일괄 sed 위험. + +### Step 2.2 — `pageDisplayWidth` 잔여 사용처 점검 + +각 함수에서 `pageDisplayWidth` 변수가 click 좌표 외에도 hit test bbox 등에 쓰이면 보존. 안 쓰이면 unused-var 경고 회피를 위해 함께 제거. + +```bash +grep -n "pageDisplayWidth\|const pw =" rhwp-studio/src/engine/input-handler-mouse.ts | head -40 +``` + +→ Stage 2 작업 시 확인. 보통 같은 함수 안에서 `bbox.x + pageDisplayWidth` 같은 hit test 영역 결정에 사용되므로 보존 권장. + +### Step 2.3 — 정정 누락 sweep + +```bash +grep -nE "\(.*clientWidth\s*-\s*\w*[Pp]age\w*[Ww]idth\w*\s*\)\s*/\s*2" rhwp-studio/src/engine/input-handler-mouse.ts +grep -nE "\(sc\.clientWidth\s*-\s*pw\)\s*/\s*2" rhwp-studio/src/engine/input-handler-mouse.ts +``` + +기대 출력: 둘 다 0 건. 1건 이상이면 Stage 2 미완 — 해당 라인 추가 치환. + +### Step 2.4 — 타입체크 + 빌드 + +```bash +cd rhwp-studio +npx tsc --noEmit +npx vite build +``` + +기대: 둘 다 무에러. + +### Step 2.5 — 기존 e2e 무회귀 점검 + +별도 터미널에서 vite dev server 가동 후: + +```bash +cd rhwp-studio +node e2e/body-outside-click-fallback.test.mjs --mode=headless +``` + +기대: 모든 PASS, FAIL 0. + +가능하면 [`text-flow.test.mjs`](../../rhwp-studio/e2e/text-flow.test.mjs) 도 추가 회귀 확인. + +### Step 2.6 — Stage 2 완료 보고서 + 커밋 + +`mydocs/working/task_m100_685_stage2.md` 작성. 내용: +- 14곳 라인별 치환 결과 표 (Before / After 일치 확인) +- 잔여 `(clientWidth - pw)/2` 패턴 grep 결과 0건 확인 +- 타입체크/빌드 통과 +- e2e 무회귀 결과 +- Stage 3 (assert 강화) 안내 + +커밋: +```bash +git add rhwp-studio/src/engine/input-handler-mouse.ts \ + mydocs/working/task_m100_685_stage2.md +git commit -m "Task #685 Stage 2: input-handler-mouse 14곳 헬퍼 치환 — 그리드 모드 click 좌표 정정" +``` + +→ **승인 게이트**: 작업지시자 확인 후 Stage 3 진행. + +--- + +## Stage 3 — e2e assert 강화 + 시각 검증 + +**목표**: 본 정정이 실제 그리드 모드 click 동작을 정상화했음을 자동 회귀로 증명. 단일 컬럼 모드 무회귀 보장. + +**수정 파일**: `rhwp-studio/e2e/grid-mode-click-coord.test.mjs` 만. + +### Step 3.1 — `dumpGridState` 에 헬퍼 동치성 assert 추가 + +기존 `dumpGridState` (라인 22-53) 의 `page.evaluate` 안에서 각 row 계산 시 `helperResolved` 필드 추가: + +```ts +const correct = vs.getPageLeft(i); +const pw = vs.getPageWidth(i); +const buggy = (clientWidth - pw) / 2; +const helperResolved = vs.getPageLeftResolved(i, clientWidth); // ← 추가 +const col = i % Math.max(columns, 1); +const delta = correct >= 0 ? (buggy - correct) : 0; +const helperDelta = correct >= 0 ? (helperResolved - correct) : (helperResolved - buggy); +rows.push({ i, col, pw, correct, buggy, helperResolved, helperDelta, delta }); +``` + +함수 종료 직전 (return state 직전) 에 도입한 `assert` 사용: + +```ts +import { runTest, loadHwpFile, screenshot, assert } from './helpers.mjs'; +// ... +// 헬퍼 동치성 — 모든 페이지에 대해 helperDelta 가 0 (sub-pixel 오차 흡수 위해 |x| < 0.01) +const maxHelperDelta = Math.max(...state.rows.map(r => Math.abs(r.helperDelta))); +assert(maxHelperDelta < 0.01, + `[${label}] getPageLeftResolved == 기대값 (max|delta|=${maxHelperDelta.toFixed(4)}px)`); +``` + +기대: 모든 모드에서 PASS (그리드: helperResolved == pageLefts[i], 단일 컬럼: helperResolved == buggy fallback 공식). + +### Step 3.2 — `probeClickAtPage` 에 click→cursor 정합 assert 추가 + +기존 `probeClickAtPage` (라인 55-139) 마지막 console.log 뒤에: + +```ts +// 본 fix 이후, CORRECT click → 의도한 페이지에 cursor 배치 +assert( + afterCorrectClick.rectPageIdx === pageIdx, + `[${label}] CORRECT click → cursor.rectPageIdx=${afterCorrectClick.rectPageIdx} (기대 ${pageIdx})` +); +// 추가: cursor.pos 가 null 이 아니어야 함 (페이지 영역 내 정상 hit) +assert( + afterCorrectClick.pos !== null, + `[${label}] CORRECT click → cursor.pos !== null` +); +``` + +기대: zoom=0.5 page 0(col 0), page 1(col 1), page 2 모두 PASS. + +### Step 3.3 — zoom=1.0 (단일 컬럼) baseline 검증 + +기존 `[4] zoom=1.0` 블록 (라인 173-179) 직후에: + +```ts +// 단일 컬럼 baseline — 모든 페이지 helperResolved == buggy 공식 (sentinel fallback 동치) +const z10MaxDelta = Math.max(...stateZ10.rows.slice(0, 8).map(r => Math.abs((r.helperResolved - r.buggy)))); +assert(z10MaxDelta < 0.01, `zoom=1.0 단일 컬럼: helperResolved == fallback 공식 (max|delta|=${z10MaxDelta.toFixed(4)}px)`); +``` + +기대: 단일 컬럼 모드에서 헬퍼와 fallback 공식 비트 단위 일치. + +### Step 3.4 — zoom=1.0 click 정합도 추가 검증 + +`[4] zoom=1.0` 블록 뒤에 (또는 새 블록): + +```ts +// zoom=1.0 click baseline — 단일 컬럼에서 page 0 click 시 cursor 정합 +console.log('\n[4b] zoom=1.0 click baseline'); +await probeClickAtPage(page, 'page 0 (single col)', 0, 100, 200); +``` + +`probeClickAtPage` 의 assert 가 자동 실행되어 단일 컬럼 모드 click 무회귀 확인. + +### Step 3.5 — 보고서 그룹화 + +`runTest` 가 자동으로 `e2e-output/grid-mode-click-coord-report.html` 생성. PASS/FAIL 카운트가 보고서에 집계되도록 `assert` 호출만 추가하면 충분. + +### Step 3.6 — e2e 실행 + 모든 PASS 확인 + +```bash +# 별도 터미널 +cd rhwp-studio && npx vite --host 0.0.0.0 --port 7700 + +# 본 터미널 +cd rhwp-studio && node e2e/grid-mode-click-coord.test.mjs --mode=headless +``` + +기대 결과: +- 모든 `assert` PASS (FAIL 0) +- `process.exitCode == 0` 종료 +- 콘솔에 zoom=0.5/0.25 grid 모드 + zoom=1.0 baseline 모두 PASS 로그 + +### Step 3.7 — 호스트 Chrome CDP 모드 시각 검증 + +```bash +# 호스트 Chrome (또는 동등 환경) 에 원격 디버깅 활성 후 +cd rhwp-studio && node e2e/grid-mode-click-coord.test.mjs --mode=host +``` + +수동 시각 확인 (1회): +- zoom=0.5 시 page 1 (col 1) 클릭 → 캐럿이 page 1 의 의도한 위치에 정확히 배치 +- zoom=0.25 시 양 끝 컬럼 (col 0, col 4) 클릭 → 캐럿이 클릭한 페이지 안에 배치 +- zoom=1.0 일반 클릭 무회귀 + +### Step 3.8 — 진단 노트 끝부분에 정정 완료 기록 추가 + +`mydocs/troubleshootings/grid_mode_click_coord.md` 끝부분 (라인 157 뒤) 에 추가: + +```markdown +## 정정 완료 (2026-05-08, Task #685) + +본 결함은 Task #685 에서 정정됨: +- `virtualScroll.getPageLeftResolved(pageIdx, containerWidth)` 헬퍼 도입 +- `input-handler-mouse.ts` 14곳 + `input-handler.ts` 1곳 헬퍼 일괄 치환 (총 15곳) +- e2e (`grid-mode-click-coord.test.mjs`) 회귀 assert 추가 — zoom=0.5/0.25 모든 col CORRECT click → 의도한 페이지에 캐럿 배치 확인 + +회귀 영역 확인됨: 단일 컬럼 (zoom > 0.5) 무회귀, 그리드 모드 양 끝 컬럼 정합. +``` + +### Step 3.9 — Stage 3 완료 보고서 + 커밋 + +`mydocs/working/task_m100_685_stage3.md` 작성. 내용: +- e2e assert 추가 위치 / 검증 항목 표 +- headless 실행 결과 (모든 PASS 카운트) +- host 모드 시각 확인 결과 (스크린샷 경로) +- 진단 노트 갱신 내용 + +커밋: +```bash +git add rhwp-studio/e2e/grid-mode-click-coord.test.mjs \ + mydocs/troubleshootings/grid_mode_click_coord.md \ + mydocs/working/task_m100_685_stage3.md +git commit -m "Task #685 Stage 3: 그리드 모드 click 좌표 회귀 assert 강화 + 시각 검증" +``` + +→ **승인 게이트**: 작업지시자 확인 후 최종 보고서 단계. + +--- + +## 최종 단계 — 결과 보고서 + 이슈 close + +**목표**: 타스크 종결. + +### 산출물 + +1. `mydocs/report/task_m100_685_report.md` — 최종 결과보고서: + - 본질 진단 요약 (이슈 #685 정량 측정 인용) + - 정정 결과 (15곳 치환 + 헬퍼 1개 + e2e 강화) + - 검증 결과 (typecheck/build/e2e/시각 모두 PASS) + - 회귀 영역 확인 + - 후속 조사 제안 (키보드/IME/Touch 동일 결함 가능성) + +2. `mydocs/orders/2026-05-07.md` (또는 작업 시작일 파일) 갱신 — 타스크 #685 상태 [완료] 표시. + +### 커밋 + +```bash +git add mydocs/report/task_m100_685_report.md \ + mydocs/orders/2026-05-07.md +git commit -m "Task #685: 최종 결과보고서 + orders 갱신 (closes #685)" +``` + +→ **승인 게이트**: 작업지시자 확인 후 GitHub 이슈 close 진행 (`gh issue close 685` 또는 `closes #685` 커밋이 merge 되면 자동 close). + +### 머저 절차 (작업지시자 권한) + +```bash +# 작업지시자가 직접 수행 +git checkout local/devel +git merge local/task685 --no-ff -m "Merge local/devel: Task #685 — 그리드 모드 click 좌표 일괄 정정" +# (원격 push 는 devel branch 통해서 별도 시점) +``` + +본 구현 절차 안에서는 머저를 수행하지 않음 (작업지시자 결정). + +--- + +## 회귀 위험 요약 (모든 Stage 공통) + +| 영역 | 위험 | 완화 | +|------|------|------| +| 단일 컬럼 click 좌표 | 헬퍼 fallback 공식이 기존과 비트 단위 일치하지 않으면 회귀 | Stage 1 sanity check (formBboxToOverlayRect 동치) + Stage 3 zoom=1.0 baseline assert | +| `pageDisplayWidth` 변수 잔여 사용 | 변수 제거 시 hit test 깨짐 | Step 2.2 cross-check, 변수 보존 우선 | +| 변수명 mismatch | `pi`/`pageIdx`/`picBbox.pageIndex` 혼용 | 일괄 치환 금지, 라인별 단건 Edit | +| e2e 임계값 | 부동소수점 오차로 false fail | 0.01 px 임계로 sub-pixel 흡수 | +| canvas-view/field-marker/caret renderer 4곳 | 헬퍼 미사용 → 만약 그리드 모드 동작이 깨지는 일이 있으면 본 작업과 무관 회귀 | 본 작업 미수정 — 별도 후속 | + +--- + +## 단계 완료 기준 종합 + +- [ ] Stage 1 통과: 헬퍼 추가 + verbose 패턴 정리, 타입체크/빌드 PASS, 동치성 시각 sanity OK, 보고서 + 커밋 +- [ ] Stage 2 통과: 14곳 치환, grep sweep 0건, 타입체크/빌드 PASS, 기존 e2e 무회귀, 보고서 + 커밋 +- [ ] Stage 3 통과: e2e assert 강화 후 모든 PASS, host 모드 시각 확인, 진단 노트 갱신, 보고서 + 커밋 +- [ ] 최종: 결과보고서 + orders 갱신 + 이슈 #685 close 절차 진행 diff --git a/mydocs/working/task_m100_685_stage1.md b/mydocs/working/task_m100_685_stage1.md new file mode 100644 index 000000000..fa2b7aa39 --- /dev/null +++ b/mydocs/working/task_m100_685_stage1.md @@ -0,0 +1,120 @@ +# Task #685 Stage 1 단계 보고서 — `getPageLeftResolved` 헬퍼 추가 + verbose 패턴 정리 + +- **이슈**: [#685](https://github.com/edwardkim/rhwp/issues/685) +- **수행계획서**: [task_m100_685.md](../plans/task_m100_685.md) +- **구현 계획서**: [task_m100_685_impl.md](../plans/task_m100_685_impl.md) +- **단계 위치**: 3 단계 중 1/3 +- **변경 성격**: 동치 refactor (동작 변경 없음) +- **작성일**: 2026-05-08 + +--- + +## 변경 요약 + +| 파일 | 변경 | 비고 | +|------|------|------| +| `rhwp-studio/src/view/virtual-scroll.ts` | +12 LOC | `getPageLeftResolved(pageIdx, containerWidth)` 헬퍼 추가 | +| `rhwp-studio/src/engine/input-handler.ts` | -3 LOC, +1 LOC | `formBboxToOverlayRect` 내 verbose sentinel 패턴을 헬퍼 호출로 단순화 | + +총 코드 변경: ~+10 LOC. 기능적 동작 변경 없음 (헬퍼 동치성 검증). + +--- + +## 1. virtual-scroll.ts: 헬퍼 추가 + +[`rhwp-studio/src/view/virtual-scroll.ts`](../../rhwp-studio/src/view/virtual-scroll.ts) 의 `getPageLeft(pageIdx)` 직후 다음 메서드 추가: + +```ts +/** + * 페이지의 X 좌표를 그리드/단일 컬럼 모드 통합으로 반환. + * 그리드 모드: pageLefts[i] 그대로. + * 단일 컬럼 모드(sentinel −1): (containerWidth - pageWidth) / 2 fallback. + */ +getPageLeftResolved(pageIdx: number, containerWidth: number): number { + const pl = this.pageLefts[pageIdx] ?? -1; + if (pl >= 0) return pl; + const pw = this.pageWidths[pageIdx] ?? 0; + return (containerWidth - pw) / 2; +} +``` + +기존 [`getPageLeft(pageIdx)`](../../rhwp-studio/src/view/virtual-scroll.ts#L155-L157) 는 그대로 보존 — `canvas-view.ts`, `field-marker-renderer.ts`, `caret-renderer.ts` 의 4 호출자 무회귀. + +## 2. input-handler.ts: formBboxToOverlayRect 단순화 + +[`rhwp-studio/src/engine/input-handler.ts`](../../rhwp-studio/src/engine/input-handler.ts) 의 `formBboxToOverlayRect` 메서드 (양식 개체 bbox → scroll-content 절대 좌표 변환): + +**Before**: +```ts +const scrollContent = this.container.querySelector('#scroll-content'); +const contentWidth = scrollContent?.clientWidth ?? 0; +const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); +const pageLeft = this.virtualScroll.getPageLeft(pageIdx) >= 0 + ? this.virtualScroll.getPageLeft(pageIdx) + : (contentWidth - pageDisplayWidth) / 2; +``` + +**After**: +```ts +const scrollContent = this.container.querySelector('#scroll-content'); +const contentWidth = scrollContent?.clientWidth ?? 0; +const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, contentWidth); +``` + +`pageDisplayWidth` 변수는 이 메서드 내 다른 사용처가 없어 제거 (`bbox.w * zoom` 가 width 직접 사용 — Read 검증). + +→ 헬퍼의 두 모드(sentinel/grid) 동작 동치성을 자연스럽게 검증. + +--- + +## 검증 결과 + +### 1. typecheck + +``` +$ cd rhwp-studio && npx tsc --noEmit +(무에러 — exit 0) +``` + +### 2. vite build + +``` +$ npx vite build +✓ 85 modules transformed. +✓ built in 725ms +PWA v1.2.0 +mode generateSW +precache 52 entries (23195.46 KiB) +``` + +### 3. 동치성 e2e 무회귀 sanity (`body-outside-click-fallback.test.mjs`, headless) + +``` +$ node e2e/body-outside-click-fallback.test.mjs --mode=headless +exit=0 +``` + +핵심 측정 결과 (samples/exam_kor.hwp 16p, viewport 기본): +- page 15 꼬리말 영역: `buggyPageX=561.3, correctPageX=561.3` (zoom=1.0 단일 컬럼 → 두 공식 동치) +- page 0 본문 / page 1 꼬리말 click 모두 가설 (a)/(b)/(c) 모두 negative — 회귀 없음. + +→ 본 변경은 양식 오버레이 좌표 산출에서 **동작 비트 단위로 동일** 함을 확인 (단일 컬럼 모드 sentinel fallback 경로). + +--- + +## 회귀 위험 점검 + +| 영역 | 위험 | 결과 | +|------|------|------| +| `formBboxToOverlayRect` 단일 컬럼 모드 | 헬퍼 fallback 식이 기존과 다르면 양식 오버레이 위치 어긋남 | OK — 식 동일 (`(contentWidth - pageWidth) / 2`) | +| `formBboxToOverlayRect` 그리드 모드 | 헬퍼 grid 경로가 `getPageLeft(pageIdx) >= 0` 분기와 다르면 변경 발생 | OK — 양쪽 모두 `pageLefts[i]` 반환 | +| `pageDisplayWidth` 변수 제거 | 다른 곳에서 변수 참조 시 컴파일 실패 | OK — typecheck 통과 | +| 기존 `getPageLeft` 호출자 4곳 (canvas-view 등) | 헬퍼 추가가 기존 메서드 변경 시 영향 | OK — 기존 메서드 미변경 | + +--- + +## 다음 단계 + +Stage 2 — `input-handler-mouse.ts` 14곳 헬퍼 일괄 치환 (구현계획서 Stage 2 절차 따름). + +승인 요청 → 승인 시 Stage 2 진행. diff --git a/rhwp-studio/src/engine/input-handler.ts b/rhwp-studio/src/engine/input-handler.ts index 1dfcbc373..6b38833f9 100644 --- a/rhwp-studio/src/engine/input-handler.ts +++ b/rhwp-studio/src/engine/input-handler.ts @@ -2575,10 +2575,7 @@ export class InputHandler { const pageOffset = this.virtualScroll.getPageOffset(pageIdx); const scrollContent = this.container.querySelector('#scroll-content'); const contentWidth = scrollContent?.clientWidth ?? 0; - const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); - const pageLeft = this.virtualScroll.getPageLeft(pageIdx) >= 0 - ? this.virtualScroll.getPageLeft(pageIdx) - : (contentWidth - pageDisplayWidth) / 2; + const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, contentWidth); return { left: pageLeft + bbox.x * zoom, diff --git a/rhwp-studio/src/view/virtual-scroll.ts b/rhwp-studio/src/view/virtual-scroll.ts index 75a4107dc..c59b5b71f 100644 --- a/rhwp-studio/src/view/virtual-scroll.ts +++ b/rhwp-studio/src/view/virtual-scroll.ts @@ -156,6 +156,18 @@ export class VirtualScroll { return this.pageLefts[pageIdx] ?? -1; } + /** + * 페이지의 X 좌표를 그리드/단일 컬럼 모드 통합으로 반환. + * 그리드 모드: pageLefts[i] 그대로. + * 단일 컬럼 모드(sentinel −1): (containerWidth - pageWidth) / 2 fallback. + */ + getPageLeftResolved(pageIdx: number, containerWidth: number): number { + const pl = this.pageLefts[pageIdx] ?? -1; + if (pl >= 0) return pl; + const pw = this.pageWidths[pageIdx] ?? 0; + return (containerWidth - pw) / 2; + } + getMaxPageWidth(): number { return this.maxPageWidth; } From c9617e01b2ee4ba6f3026eeab116f76f9dd9ee58 Mon Sep 17 00:00:00 2001 From: johndoekim Date: Fri, 8 May 2026 00:15:08 +0900 Subject: [PATCH 07/13] =?UTF-8?q?Task=20#685=20Stage=202:=20input-handler-?= =?UTF-8?q?mouse=2014=EA=B3=B3=20=ED=97=AC=ED=8D=BC=20=EC=B9=98=ED=99=98?= =?UTF-8?q?=20=E2=80=94=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EB=AA=A8=EB=93=9C?= =?UTF-8?q?=20click=20=EC=A2=8C=ED=91=9C=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #685 의 본질 정정. zoom ≤ 0.5 그리드 모드에서 페이지 X 좌표를 열별로 분리 저장(pageLefts[i] = marginLeft + col*(pw+gap))함에도 input-handler-mouse 의 14개 분기 모두 단일 컬럼 가정 공식 `(clientWidth - pageWidth) / 2` 만 사용하여 ±수백 px click 좌표 어긋남. Stage 1 에서 도입한 getPageLeftResolved(pageIdx, containerWidth) 헬퍼로 14곳을 일괄 치환: 영향 분기 (14곳, 핵심 사용자 영향 굵게): L23 onClick (연결선 모드) L129 onClick (선택된 표 이동) L176 onClick (다중 그림 선택 BBOX) L279 onClick (직선 끝점 핸들, picBbox.pageIndex) L296 onClick (회전 핸들, picBbox.pageIndex) L357 onClick (단일 그림 본체) L431 onClick (셀 선택 표 리사이즈) **L475 onClick (일반 click main path)** **L811 onDblClick (머리말/꼬리말 진입, sc as HTMLElement)** **L889 onContextMenu** L931 onMouseMove (연결선 미리보기) L1146 onMouseMove (그림 hover) L1196 onMouseMove (표 hover) L1243 handleResizeHover (표 경계선 hover) 치환 방식 (6 회 Edit): - picBbox.pageIndex 2곳 (L279, L296) 컨텍스트 단건 Edit - (sc as HTMLElement) 캐스트 1곳 (L811) 단일 Edit - 나머지 11곳: 패턴별 replace_all 3 회 (4-space, 2-space prefix 분리) `pw` / `pageDisplayWidth` 변수 선언은 hit test bbox 등 다른 용도 가능성으로 보존 — 표현식만 헬퍼 호출로 교체. 검증: - grep sweep: `clientWidth - X) / 2` 패턴 0 건 잔여 - getPageLeftResolved 호출 수 14 (정확) - npx tsc --noEmit 통과 - npx vite build 성공 - e2e body-outside-click-fallback.test.mjs --mode=headless exit 0 (단일 컬럼 모드 click 좌표 무회귀 — 헬퍼 sentinel fallback 경로 정상) 본 정정의 본질 효과(그리드 모드 click 좌표 정합)는 Stage 3 에서 grid-mode-click-coord.test.mjs assert 강화로 자동 회귀화 예정. Co-Authored-By: Claude Opus 4.7 --- mydocs/working/task_m100_685_stage2.md | 121 ++++++++++++++++++ rhwp-studio/src/engine/input-handler-mouse.ts | 28 ++-- 2 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 mydocs/working/task_m100_685_stage2.md diff --git a/mydocs/working/task_m100_685_stage2.md b/mydocs/working/task_m100_685_stage2.md new file mode 100644 index 000000000..ac7d40df9 --- /dev/null +++ b/mydocs/working/task_m100_685_stage2.md @@ -0,0 +1,121 @@ +# Task #685 Stage 2 단계 보고서 — `input-handler-mouse.ts` 14곳 헬퍼 일괄 치환 + +- **이슈**: [#685](https://github.com/edwardkim/rhwp/issues/685) +- **수행계획서**: [task_m100_685.md](../plans/task_m100_685.md) +- **구현 계획서**: [task_m100_685_impl.md](../plans/task_m100_685_impl.md) +- **단계 위치**: 3 단계 중 2/3 +- **변경 성격**: 본질 정정 (그리드 모드 click 좌표 정합) +- **작성일**: 2026-05-08 + +--- + +## 변경 요약 + +| 파일 | 변경 | +|------|------| +| `rhwp-studio/src/engine/input-handler-mouse.ts` | 14 라인 수정 (insertion 14, deletion 14 — 1:1 표현식 치환) | + +총 코드 변경: 14 LOC. 변수 선언 (`pw`, `pageDisplayWidth`) 및 페이지 인덱스 변수 (`pi`/`pageIdx`/`picBbox.pageIndex`) 모두 보존, `(... - X)/2` 표현식만 헬퍼 호출로 교체. + +--- + +## 1. 14곳 라인별 치환 결과 + +| 라인(전) | 함수 | 페이지 idx 변수 | sc/pw 변수 | 치환 후 | +|---------|------|-----------------|------------|---------| +| 23 | onClick (연결선 모드) | `pi` | `sc` / `pw` | `getPageLeftResolved(pi, sc.clientWidth)` | +| 129 | onClick (선택된 표 이동) | `pi` | `sc` / `pw` | `getPageLeftResolved(pi, sc.clientWidth)` | +| 176 | onClick (다중 그림 선택 BBOX) | `pi` | `sc` / `pw` | `getPageLeftResolved(pi, sc.clientWidth)` | +| 279 | onClick (직선 끝점 핸들) | `picBbox.pageIndex` | `sc` / `pw` | `getPageLeftResolved(picBbox.pageIndex, sc.clientWidth)` | +| 296 | onClick (회전 핸들) | `picBbox.pageIndex` | `sc` / `pw` | `getPageLeftResolved(picBbox.pageIndex, sc.clientWidth)` | +| 357 | onClick (단일 그림 본체) | `pi` | `sc` / `pw` | `getPageLeftResolved(pi, sc.clientWidth)` | +| 431 | onClick (셀 선택 표 리사이즈) | `pageIdx` | `scrollContent` / `pageDisplayWidth` | `getPageLeftResolved(pageIdx, scrollContent.clientWidth)` | +| 475 | onClick (일반 click main path) | `pageIdx` | `scrollContent` / `pageDisplayWidth` | `getPageLeftResolved(pageIdx, scrollContent.clientWidth)` | +| 811 | onDblClick (머리말/꼬리말 진입) | `pageIdx` | `(sc as HTMLElement)` / `pageDisplayWidth` | `getPageLeftResolved(pageIdx, (sc as HTMLElement).clientWidth)` | +| 889 | onContextMenu | `pageIdx` | `scrollContent` / `pageDisplayWidth` | `getPageLeftResolved(pageIdx, scrollContent.clientWidth)` | +| 931 | onMouseMove (연결선 미리보기) | `pi` | `sc` / `pw` | `getPageLeftResolved(pi, sc.clientWidth)` | +| 1146 | onMouseMove (그림 hover) | `pi` | `scrollContent` / `pw` | `getPageLeftResolved(pi, scrollContent.clientWidth)` | +| 1196 | onMouseMove (표 hover) | `pi` | `scrollContent` / `pw` | `getPageLeftResolved(pi, scrollContent.clientWidth)` | +| 1243 | handleResizeHover (표 경계선 hover) | `pageIdx` | `scrollContent` / `pageDisplayWidth` | `getPageLeftResolved(pageIdx, scrollContent.clientWidth)` | + +치환 방식: 6 회 Edit 호출 (그룹별 `replace_all` + 컨텍스트 단건 Edit). + +``` +Edit 1: L279 (line endpoint, picBbox.pageIndex) — 컨텍스트 단건 +Edit 2: L296 (rotate, picBbox.pageIndex) — 컨텍스트 단건 +Edit 3: L811 (sc as HTMLElement) — 단일 occurrence +Edit 4: replace_all "const pl = (sc.clientWidth - pw) / 2;" (4-space prefix) → 5곳 일괄 (L23, 129, 176, 357, 931 — 모두 pi) +Edit 5: replace_all "const pl = (scrollContent.clientWidth - pw) / 2;" → 2곳 일괄 (L1146, 1196 — 모두 pi) +Edit 6: replace_all "const pageLeft = (scrollContent.clientWidth - pageDisplayWidth) / 2;" → 4곳 일괄 (L431, 475, 889, 1243 — 모두 pageIdx; 4-space + 2-space prefix 두 번 호출) +``` + +각 시점의 `pw`/`pageDisplayWidth` 변수 선언은 보존 (hit test bbox 계산 등 다른 용도 잠재 사용). + +--- + +## 검증 결과 + +### 1. 잔여 buggy 패턴 sweep + +``` +$ grep -nE "clientWidth\s*-\s*\w+\)\s*/\s*2" src/engine/input-handler-mouse.ts +(0 건 — exit=1 grep 매칭 없음) +``` + +### 2. 헬퍼 호출 수 카운트 + +``` +$ grep -c "getPageLeftResolved" src/engine/input-handler-mouse.ts +14 +``` + +→ 정확히 14곳 모두 헬퍼 호출로 교체됨. + +### 3. typecheck + +``` +$ npx tsc --noEmit +(무에러) +``` + +### 4. vite build + +``` +$ npx vite build +✓ 85 modules transformed. +✓ built in (단축, 정상) +PWA v1.2.0 — generateSW 정상 +``` + +### 5. e2e 무회귀 — `body-outside-click-fallback.test.mjs --mode=headless` + +``` +$ exit=0 +- ERROR/FAIL/에러: 0 매칭 +- 가설 (a) hit invalid: no +- 가설 (b) isTextBox=true: no +- 가설 (c) rect.pageIdx mismatch: no +- scroll 점프: no +``` + +→ 단일 컬럼 모드에서 click 좌표 산출 무회귀 (Stage 1 의 동치성과 일관 — 헬퍼 sentinel fallback 경로 정상). + +--- + +## 회귀 위험 점검 + +| 영역 | 위험 | 결과 | +|------|------|------| +| 단일 컬럼 모드 click 좌표 (zoom > 0.5) | 헬퍼 fallback 식이 기존과 다르면 회귀 | OK — body-outside-click-fallback e2e 무회귀 | +| `pw` / `pageDisplayWidth` 변수 잔여 사용처 | 변수 제거 시 hit test bbox 깨짐 | OK — 변수 보존 | +| 페이지 인덱스 변수 mismatch | `pi` vs `picBbox.pageIndex` 잘못 적용 시 좌표 오인 | OK — Edit 단건 컨텍스트로 명시적 분리 (L279/296) | +| L811 의 `sc as HTMLElement` 캐스트 보존 | 캐스트 누락 시 `clientWidth` 접근 타입 에러 | OK — typecheck 통과 | +| Stage 3 그리드 e2e | 본 변경의 본질 검증 (zoom=0.5/0.25 click → cursor) | 다음 단계 | + +--- + +## 다음 단계 + +Stage 3 — `grid-mode-click-coord.test.mjs` 의 측정 로깅에 회귀 assert 추가 + 호스트 모드 시각 검증. 본 정정의 본질 효과 (그리드 모드 click 좌표 정합) 자동 회귀화. + +승인 요청 → 승인 시 Stage 3 진행. diff --git a/rhwp-studio/src/engine/input-handler-mouse.ts b/rhwp-studio/src/engine/input-handler-mouse.ts index 2467f9093..fd7cc1f35 100644 --- a/rhwp-studio/src/engine/input-handler-mouse.ts +++ b/rhwp-studio/src/engine/input-handler-mouse.ts @@ -20,7 +20,7 @@ export function onClick(this: any, e: MouseEvent): void { const pi = this.virtualScroll.getPageAtY(cy); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); - const pl = (sc.clientWidth - pw) / 2; + const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth); const pageX = (cx - pl) / zoom; const pageY = (cy - po) / zoom; @@ -126,7 +126,7 @@ export function onClick(this: any, e: MouseEvent): void { const pi = this.virtualScroll.getPageAtY(cy); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); - const pl = (sc.clientWidth - pw) / 2; + const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth); const px = (cx - pl) / zoom; const py = (cy - po) / zoom; try { @@ -173,7 +173,7 @@ export function onClick(this: any, e: MouseEvent): void { const pi = this.virtualScroll.getPageAtY(cy); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); - const pl = (sc.clientWidth - pw) / 2; + const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth); const px = (cx - pl) / zoom; const py = (cy - po) / zoom; // 합산 BBOX 계산 @@ -276,7 +276,7 @@ export function onClick(this: any, e: MouseEvent): void { const zoom = this.viewportManager.getZoom(); const po = this.virtualScroll.getPageOffset(picBbox.pageIndex); const pw = this.virtualScroll.getPageWidth(picBbox.pageIndex); - const pl = (sc.clientWidth - pw) / 2; + const pl = this.virtualScroll.getPageLeftResolved(picBbox.pageIndex, sc.clientWidth); this.isLineEndpointDragging = true; this.lineEndpointState = { ref: { sec: ref.sec, ppi: ref.ppi, ci: ref.ci, type: ref.type }, @@ -293,7 +293,7 @@ export function onClick(this: any, e: MouseEvent): void { const zoom = this.viewportManager.getZoom(); const po = this.virtualScroll.getPageOffset(picBbox.pageIndex); const pw = this.virtualScroll.getPageWidth(picBbox.pageIndex); - const pl = (sc.clientWidth - pw) / 2; + const pl = this.virtualScroll.getPageLeftResolved(picBbox.pageIndex, sc.clientWidth); // 도형 중심 (scroll-content 좌표) const objCx = pl + (picBbox.x + picBbox.w / 2) * zoom; const objCy = po + (picBbox.y + picBbox.h / 2) * zoom; @@ -354,7 +354,7 @@ export function onClick(this: any, e: MouseEvent): void { const pi = this.virtualScroll.getPageAtY(cy); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); - const pl = (sc.clientWidth - pw) / 2; + const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth); const px = (cx - pl) / zoom; const py = (cy - po) / zoom; if (!e.shiftKey && pi === picBbox.pageIndex && @@ -428,7 +428,7 @@ export function onClick(this: any, e: MouseEvent): void { const pageIdx = this.virtualScroll.getPageAtY(contentY); const pageOffset = this.virtualScroll.getPageOffset(pageIdx); const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); - const pageLeft = (scrollContent.clientWidth - pageDisplayWidth) / 2; + const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth); const pageX = (contentX - pageLeft) / zoom; const pageY = (contentY - pageOffset) / zoom; const pageBboxes = bboxes.filter((b: any) => b.pageIndex === pageIdx); @@ -472,7 +472,7 @@ export function onClick(this: any, e: MouseEvent): void { // CSS 중앙 정렬 보정 (left:50%; transform:translateX(-50%)) const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); - const pageLeft = (scrollContent.clientWidth - pageDisplayWidth) / 2; + const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth); // 페이지 내 좌표 (줌 역산) const pageX = (contentX - pageLeft) / zoom; @@ -808,7 +808,7 @@ export function onDblClick(this: any, e: MouseEvent): void { if (pageIdx >= 0) { const pageOffset = this.virtualScroll.getPageOffset(pageIdx); const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); - const pageLeft = ((sc as HTMLElement).clientWidth - pageDisplayWidth) / 2; + const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, (sc as HTMLElement).clientWidth); const pageX = (contentX - pageLeft) / zoom; const pageY = (contentY - pageOffset) / zoom; const hfHit = this.wasm.hitTestHeaderFooter(pageIdx, pageX, pageY); @@ -886,7 +886,7 @@ export function onContextMenu(this: any, e: MouseEvent): void { const pageIdx = this.virtualScroll.getPageAtY(contentY); const pageOffset = this.virtualScroll.getPageOffset(pageIdx); const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); - const pageLeft = (scrollContent.clientWidth - pageDisplayWidth) / 2; + const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth); const pageX = (contentX - pageLeft) / zoom; const pageY = (contentY - pageOffset) / zoom; @@ -928,7 +928,7 @@ export function onMouseMove(this: any, e: MouseEvent): void { const pi = this.virtualScroll.getPageAtY(cy); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); - const pl = (sc.clientWidth - pw) / 2; + const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth); const pageX = (cx - pl) / zoom; const pageY = (cy - po) / zoom; _connector.showConnectionPointOverlay.call(this, pi, pageX, pageY); @@ -1143,7 +1143,7 @@ export function onMouseMove(this: any, e: MouseEvent): void { const pi = this.virtualScroll.getPageAtY(y); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); - const pl = (scrollContent.clientWidth - pw) / 2; + const pl = this.virtualScroll.getPageLeftResolved(pi, scrollContent.clientWidth); const px = (x - pl) / zoom; const py = (y - po) / zoom; if (pi === picBbox.pageIndex && @@ -1193,7 +1193,7 @@ export function onMouseMove(this: any, e: MouseEvent): void { const pi = this.virtualScroll.getPageAtY(y); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); - const pl = (scrollContent.clientWidth - pw) / 2; + const pl = this.virtualScroll.getPageLeftResolved(pi, scrollContent.clientWidth); const px = (x - pl) / zoom; const py = (y - po) / zoom; try { @@ -1240,7 +1240,7 @@ export function handleResizeHover(this: any, e: MouseEvent): void { const pageIdx = this.virtualScroll.getPageAtY(contentY); const pageOffset = this.virtualScroll.getPageOffset(pageIdx); const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); - const pageLeft = (scrollContent.clientWidth - pageDisplayWidth) / 2; + const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth); const pageX = (contentX - pageLeft) / zoom; const pageY = (contentY - pageOffset) / zoom; From 89735d44a134ec00c1243647f927899a8b0c444a Mon Sep 17 00:00:00 2001 From: johndoekim Date: Fri, 8 May 2026 00:24:43 +0900 Subject: [PATCH 08/13] =?UTF-8?q?Task=20#685=20Stage=203:=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=20=EB=AA=A8=EB=93=9C=20click=20=EC=A2=8C?= =?UTF-8?q?=ED=91=9C=20e2e=20assert=20=EA=B0=95=ED=99=94=20+=20=ED=9B=84?= =?UTF-8?q?=EC=86=8D=20=EA=B2=B0=ED=95=A8=20#689=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #685 정정 작업 3/3 단계. e2e grid-mode-click-coord.test.mjs 의 측정 로깅에 회귀 assert 추가하여 Task #685 (pageLeft 공식 정정) 의 효과를 자동 회귀화. assert 추가: - dumpGridState: getPageLeftResolved(i, clientWidth) == 기대값 (그리드 모드 pageLefts[i] 또는 단일 컬럼 fallback 공식, 모든 페이지 max|delta| < 0.01 px) - probeClickAtPage: CORRECT click → cursor.pos !== null (전 케이스), CORRECT click → cursor.rectPageIdx === pageIdx (last col 만 strict, non-last col 은 후속 결함 #689 로 SKIP 안내) 추가 probe: - zoom=0.25 last col (page 4) — 5-column 그리드 정합 검증 - zoom=1.0 page 0 — 단일 컬럼 baseline 무회귀 검증 검증 결과 (--mode=headless, exit 0): PASS=11 / FAIL=0 / SKIP=2 (의도된 non-last col 스킵) Stage 3 진행 중 발견된 후속 결함 (Issue #689 등록): virtual-scroll.ts getPageAtY(docY) 가 Y 좌표만 보고 row 의 last page idx 만 반환 → non-last col 페이지 click 시 row 의 last col 페이지로 잘못 처리됨. Task #685 의 pageLeft 정정과 별개의 결함 영역으로, scope 엄격 준수 결정에 따라 별도 타스크 (#689) 에서 진행. Task #685 의 정정은 last col 케이스 (zoom=1.0 col 0, zoom=0.5 col 1, zoom=0.25 col 4 등) 에서 완전 정합 확인. 진단노트 (grid_mode_click_coord.md) 끝부분에 "정정 완료 — 부분 정정" + #689 후속 안내 기록 추가. Co-Authored-By: Claude Opus 4.7 --- .../troubleshootings/grid_mode_click_coord.md | 20 +++ mydocs/working/task_m100_685_stage3.md | 126 ++++++++++++++++++ .../e2e/grid-mode-click-coord.test.mjs | 54 +++++++- 3 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 mydocs/working/task_m100_685_stage3.md diff --git a/mydocs/troubleshootings/grid_mode_click_coord.md b/mydocs/troubleshootings/grid_mode_click_coord.md index 808a92ce8..ded743882 100644 --- a/mydocs/troubleshootings/grid_mode_click_coord.md +++ b/mydocs/troubleshootings/grid_mode_click_coord.md @@ -155,3 +155,23 @@ zoom=0.5, page 1 (col 1) 에서 hwpX=100 좌표를 의도한 click: - BUGGY click @(579.4, 242.0) → cursor.pos = `{sec:0, para:31, char:0}` (page 0 의 영역으로 잘못 떨어짐) **결론**: 그리드 모드에서 input-handler-mouse 의 14개 분기는 모든 페이지에서 ±수백 px 단위로 click 좌표를 어긋나게 만듬. 가운데 열 (홀수 columns 일 때 mid-col) 만 우연히 정합. + +## 정정 완료 (2026-05-08, Task #685) — 부분 정정 + +본 결함의 1차 원인 (`(clientWidth - pageWidth) / 2` 단일 컬럼 가정 공식) 은 Task #685 에서 정정됨: + +- `virtualScroll.getPageLeftResolved(pageIdx, containerWidth)` 헬퍼 도입 — 그리드 모드는 `pageLefts[i]`, 단일 컬럼은 `(containerWidth - pageWidth) / 2` fallback (sentinel −1 해소). +- `input-handler-mouse.ts` 14곳 + `input-handler.ts` (`formBboxToOverlayRect`) 1곳 헬퍼 일괄 치환 (총 15곳). +- e2e (`grid-mode-click-coord.test.mjs`) 회귀 assert 추가 — `getPageLeftResolved` 동치성 + last-col CORRECT click → cursor.rectPageIdx 정합 검증. + +### 후속 결함 (Issue #689) + +Stage 3 e2e assert 강화로 추가 결함이 노출됨: [`virtual-scroll.ts` `getPageAtY(docY)`](../../rhwp-studio/src/view/virtual-scroll.ts#L133-L140) 가 Y 좌표만 보고 row 의 last page idx 만 반환 → non-last col 페이지 click 시 row 의 last col 페이지로 잘못 처리됨. + +| zoom | columns | last col 정합 | non-last col 정합 | +|------|---------|--------------|-------------------| +| 1.0 | 1 | ✅ (col 0 = last) | n/a | +| 0.5 | 2 | ✅ col 1 | ❌ col 0 | +| 0.25 | 5 | ✅ col 4 | ❌ col 0~3 | + +본 후속 결함은 [Issue #689](https://github.com/edwardkim/rhwp/issues/689) 으로 분리 등록 (`getPageAtPoint(docX, docY)` 헬퍼 도입 + 14곳 `getPageAtY` 호출 일괄 치환 방향). Task #685 의 정정은 last-col 케이스에서 정상 동작하며, non-last col 정정은 #689 에서 진행. diff --git a/mydocs/working/task_m100_685_stage3.md b/mydocs/working/task_m100_685_stage3.md new file mode 100644 index 000000000..ddf3990c8 --- /dev/null +++ b/mydocs/working/task_m100_685_stage3.md @@ -0,0 +1,126 @@ +# Task #685 Stage 3 단계 보고서 — e2e 회귀 assert 강화 + 후속 결함 분리 등록 + +- **이슈**: [#685](https://github.com/edwardkim/rhwp/issues/685) +- **수행계획서**: [task_m100_685.md](../plans/task_m100_685.md) +- **구현 계획서**: [task_m100_685_impl.md](../plans/task_m100_685_impl.md) +- **단계 위치**: 3 단계 중 3/3 +- **변경 성격**: e2e 회귀 자동화 + scope 발견사항 분리 +- **작성일**: 2026-05-08 + +--- + +## 변경 요약 + +| 파일 | 변경 | +|------|------| +| `rhwp-studio/e2e/grid-mode-click-coord.test.mjs` | +30 LOC: import assert, dumpGridState 에 helperResolved 동치성 assert, probeClickAtPage 에 last-col only 정합 assert, zoom=0.25 last-col probe + zoom=1.0 baseline probe 추가 | +| `mydocs/troubleshootings/grid_mode_click_coord.md` | +20 LOC: "정정 완료 — 부분 정정" + 후속 결함 (#689) 안내 | + +--- + +## 1. e2e assert 추가 + +### dumpGridState — 헬퍼 동치성 검증 + +각 페이지마다 `getPageLeftResolved(i, clientWidth)` 호출값을 측정하고, 기대값(`pageLefts[i] ?? buggy`)과 비교. 모든 페이지에서 |delta| < 0.01 px 임을 assert. + +```js +const expectedHelper = correct >= 0 ? correct : buggy; +const helperDelta = helperResolved - expectedHelper; +// ... +assert(maxHelperDelta < 0.01, `[${label}] getPageLeftResolved == 기대값 ...`); +``` + +### probeClickAtPage — last-col only 정합 검증 + +CORRECT click @(correctDocX, correctDocY) → `cursor.rectPageIdx === pageIdx` 를 assert. 단, **last-col 케이스에서만 strict** 하게 검증. non-last col 은 후속 결함 (Issue #689) 으로 항상 row 의 last page 로 떨어지므로 SKIP 로깅. + +```js +if (probe.isLastCol) { + assert(afterCorrectClick.rectPageIdx === pageIdx, `[${label}] CORRECT click → cursor.rectPageIdx=...`); +} else { + console.log(` SKIP: [${label}] non-last col rectPageIdx strict assert ... — Issue #689 후속`); +} +``` + +### 추가 probe + +- **`[3b] zoom=0.25 last col (page 4)`** — 5-column 그리드의 마지막 컬럼 click 정합 검증 +- **`[4b] zoom=1.0 page 0`** — 단일 컬럼 baseline click 무회귀 검증 + +--- + +## 2. 검증 결과 + +### e2e 실행 — `grid-mode-click-coord.test.mjs --mode=headless` + +``` +$ node e2e/grid-mode-click-coord.test.mjs --mode=headless +exit=0 + +PASS: 11 FAIL: 0 SKIP: 2 (의도된 non-last col) +``` + +세부 항목: + +| 검증 항목 | 결과 | +|-----------|------| +| `[zoom=0.5 그리드 상태]` getPageLeftResolved == 기대값 (max delta=0.00 px) | PASS | +| `[zoom=0.25 그리드 상태]` getPageLeftResolved == 기대값 (max delta=0.00 px) | PASS | +| `[zoom=1.0 단일 컬럼 baseline]` getPageLeftResolved == 기대값 (max delta=0.00 px) | PASS | +| `[page 4 (zoom=0.25 last col)]` cursor.pos !== null + rectPageIdx=4 | PASS | +| `[page 0 (zoom=1.0 single col)]` cursor.pos !== null + rectPageIdx=0 | PASS | +| `[page 0 (col 0)]` cursor.pos !== null | PASS | +| `[page 0 (col 0)]` rectPageIdx strict (col=0/columns=2) | SKIP (#689) | +| `[page 1 (col 1)]` cursor.pos !== null + rectPageIdx=1 | PASS | +| `[page 2 (col=2 % columns)]` cursor.pos !== null | PASS | +| `[page 2 (col 0/columns=2)]` rectPageIdx strict | SKIP (#689) | + +→ Task #685 의 본질 효과 (last col 에서 `pageLefts[i]` 적용 → cursor.rectPageIdx 정합) 완전 검증. + +--- + +## 3. Stage 3 진행 중 발견된 후속 결함 — Issue #689 등록 + +본 Stage 의 assert 강화로 새로운 결함이 노출됨: + +[`virtual-scroll.ts:133-140 getPageAtY`](../../rhwp-studio/src/view/virtual-scroll.ts#L133-L140) 는 Y 좌표만 보고 페이지 인덱스를 결정. 그리드 모드에서 한 row 의 모든 페이지가 동일한 `pageOffsets[i] = rowTop` 을 가지므로 **항상 row 의 last page idx 만 반환**. + +| 의도 페이지 | col | rectPageIdx 결과 | 정합 여부 | +|------------|-----|------------------|----------| +| 0 (zoom=0.5) | 0 | 1 | ❌ | +| 1 (zoom=0.5) | 1 (last) | 1 | ✅ | +| 2 (zoom=0.5) | 0 | 3 | ❌ | +| 4 (zoom=0.25) | 4 (last) | 4 | ✅ | + +→ **Task #685 의 pageLeft 정정만으로는 last-col 케이스만 해결됨**. non-last col 정정은 별도 결함 영역 (`getPageAtY` 의 X 무시) 이므로 [Issue #689](https://github.com/edwardkim/rhwp/issues/689) 으로 분리 등록. + +### 작업지시자 결정 (2026-05-08) + +scope 엄격 준수 — Task #685 의 정정 범위는 본문 명시 그대로 (pageLeft 공식 14곳) 유지. 후속 결함 (#689) 은 별도 타스크에서 진행. + +--- + +## 4. 시각 검증 (작업지시자 검토 단계로 위임) + +`--mode=host` (호스트 Chrome CDP) 로 시각 검증 1회는 작업지시자가 환경에서 직접 실행하시기를 권장. 본 단계의 자동 검증은 headless 모드로 PASS 확인 완료. + +--- + +## 5. 완료 기준 점검 + +- [x] dumpGridState 헬퍼 동치성 assert 추가 (3 모드 × 페이지수 만큼 검증) +- [x] probeClickAtPage 정합 assert 추가 (last-col strict, non-last skip with 안내) +- [x] zoom=0.25 last col probe 추가 +- [x] zoom=1.0 baseline probe 추가 +- [x] e2e headless PASS=11 / FAIL=0 / SKIP=2 (의도) +- [x] 진단노트 (`grid_mode_click_coord.md`) "정정 완료 — 부분 정정" + #689 안내 추가 +- [x] Issue #689 등록 ([https://github.com/edwardkim/rhwp/issues/689](https://github.com/edwardkim/rhwp/issues/689)) + +--- + +## 다음 단계 + +최종 결과보고서 (`mydocs/report/task_m100_685_report.md`) 작성 + `mydocs/orders/2026-05-07.md` (또는 작업 시작일) 갱신 → 승인 후 이슈 #685 close 절차. + +승인 요청 → 승인 시 최종 단계 진행. diff --git a/rhwp-studio/e2e/grid-mode-click-coord.test.mjs b/rhwp-studio/e2e/grid-mode-click-coord.test.mjs index 6986c96f6..f34b78ddd 100644 --- a/rhwp-studio/e2e/grid-mode-click-coord.test.mjs +++ b/rhwp-studio/e2e/grid-mode-click-coord.test.mjs @@ -17,7 +17,7 @@ * npx vite --host 0.0.0.0 --port 7700 & * node e2e/grid-mode-click-coord.test.mjs --mode=headless */ -import { runTest, loadHwpFile, screenshot } from './helpers.mjs'; +import { runTest, loadHwpFile, screenshot, assert } from './helpers.mjs'; async function dumpGridState(page, label) { console.log(`\n=== ${label} ===`); @@ -36,19 +36,30 @@ async function dumpGridState(page, label) { const correct = vs.getPageLeft(i); const pw = vs.getPageWidth(i); const buggy = (clientWidth - pw) / 2; + const helperResolved = vs.getPageLeftResolved(i, clientWidth); const col = i % Math.max(columns, 1); const delta = correct >= 0 ? (buggy - correct) : 0; - rows.push({ i, col, pw, correct, buggy, delta }); + // 헬퍼 기대값: 그리드 모드는 pageLefts[i], 단일 컬럼은 buggy 공식과 동치 + const expectedHelper = correct >= 0 ? correct : buggy; + const helperDelta = helperResolved - expectedHelper; + rows.push({ i, col, pw, correct, buggy, helperResolved, helperDelta, delta }); } return { zoom, isGrid, columns, pageCount, clientWidth, rows }; }); console.log(` zoom=${state.zoom} grid=${state.isGrid} columns=${state.columns} pageCount=${state.pageCount} clientWidth=${state.clientWidth}`); - console.log(` | i | col | pw | correct(pageLefts[i]) | buggy(formula) | delta_px |`); - console.log(` |----|-----|--------|-----------------------|----------------|----------|`); + console.log(` | i | col | pw | correct(pageLefts[i]) | buggy(formula) | helper | delta_px |`); + console.log(` |----|-----|--------|-----------------------|----------------|--------|----------|`); for (const r of state.rows.slice(0, 8)) { // 첫 8 페이지만 출력 - console.log(` | ${String(r.i).padEnd(2)} | ${String(r.col).padEnd(3)} | ${r.pw.toFixed(1).padEnd(6)} | ${r.correct.toFixed(1).padEnd(21)} | ${r.buggy.toFixed(1).padEnd(14)} | ${r.delta.toFixed(1).padEnd(8)} |`); + console.log(` | ${String(r.i).padEnd(2)} | ${String(r.col).padEnd(3)} | ${r.pw.toFixed(1).padEnd(6)} | ${r.correct.toFixed(1).padEnd(21)} | ${r.buggy.toFixed(1).padEnd(14)} | ${r.helperResolved.toFixed(1).padEnd(6)} | ${r.delta.toFixed(1).padEnd(8)} |`); } + + // 헬퍼 동치성 assert: 모든 페이지에 대해 |helperDelta| < 0.01 + const maxHelperDelta = Math.max(...state.rows.map(r => Math.abs(r.helperDelta))); + assert( + maxHelperDelta < 0.01, + `[${label}] getPageLeftResolved == 기대값 (max|helperDelta|=${maxHelperDelta.toFixed(4)}px)` + ); return state; } @@ -64,6 +75,9 @@ async function probeClickAtPage(page, label, pageIdx, hwpX, hwpY) { const zoom = vm.getZoom(); const pw = vs.getPageWidth(pageIdx); const po = vs.getPageOffset(pageIdx); + const columns = typeof vs.getColumns === 'function' ? vs.getColumns() : 1; + const col = pageIdx % Math.max(columns, 1); + const isLastCol = col === columns - 1; // CORRECT: pageLefts[i] 사용 (실제 페이지 element 위치) const correctLeft = vs.getPageLeft(pageIdx); @@ -84,6 +98,7 @@ async function probeClickAtPage(page, label, pageIdx, hwpX, hwpY) { zoom, pw, po, correctLeft, correctLeftDOM, buggyLeft, correctDocX, correctDocY, buggyDocX, buggyDocY, delta_x: buggyDocX - correctDocX, + columns, col, isLastCol, }; }, { pageIdx, hwpX, hwpY }); @@ -135,6 +150,25 @@ async function probeClickAtPage(page, label, pageIdx, hwpX, hwpY) { console.log(` CORRECT click @(${correctClick.clientX.toFixed(1)}, ${correctClick.clientY.toFixed(1)}) → pos=${JSON.stringify(afterCorrectClick.pos)} rectPage=${afterCorrectClick.rectPageIdx}`); console.log(` BUGGY click @(${(correctClick.clientX + probe.delta_x).toFixed(1)}, ${correctClick.clientY.toFixed(1)}) → pos=${JSON.stringify(afterBuggyClick.pos)} rectPage=${afterBuggyClick.rectPageIdx}`); + // fix 검증: CORRECT click → cursor.pos 정상 (모든 col) + assert( + afterCorrectClick.pos !== null, + `[${label}] CORRECT click → cursor.pos !== null` + ); + + // rectPageIdx assert 는 last-col 케이스에서만 strict 하게 검증. + // non-last col 은 getPageAtY 가 X 무시하고 row 의 last page idx 만 반환하는 별개 결함 (Issue #689) + // 으로 인해 항상 마지막 col 페이지로 cursor 가 떨어짐. Task #685 의 pageLeft 정정만으로는 해결되지 않는 + // 후속 결함이며, 본 e2e 는 #685 정정 효과 (last col 에서 정확한 pageLefts[i] 적용) 만 검증. + if (probe.isLastCol) { + assert( + afterCorrectClick.rectPageIdx === pageIdx, + `[${label}] CORRECT click → cursor.rectPageIdx=${afterCorrectClick.rectPageIdx} (기대 ${pageIdx}, last col=${probe.col}/columns=${probe.columns})` + ); + } else { + console.log(` SKIP: [${label}] non-last col rectPageIdx strict assert (col=${probe.col}/columns=${probe.columns}) — getPageAtY X-무시 결함, Issue #689 후속`); + } + return { probe, correctClick, afterCorrectClick, afterBuggyClick }; } @@ -169,6 +203,12 @@ runTest('보류 ① 그리드 좌표 결함 — exam_kor.hwp zoom=0.5 정량 측 const stateZ025 = await dumpGridState(page, 'zoom=0.25 그리드 상태'); + // [3b] zoom=0.25 last col (col=4) click 검증 — pageLeft 정정 효과 + if (stateZ025.columns >= 2 && stateZ025.pageCount > stateZ025.columns - 1) { + const lastColPage = stateZ025.columns - 1; + await probeClickAtPage(page, `page ${lastColPage} (zoom=0.25 last col)`, lastColPage, 100, 200); + } + // [4] zoom=1.0 (단일 컬럼) - 비교 baseline console.log('\n[4] zoom=1.0 변경 (단일 컬럼)'); await page.evaluate(() => { @@ -178,6 +218,10 @@ runTest('보류 ① 그리드 좌표 결함 — exam_kor.hwp zoom=0.5 정량 측 const stateZ10 = await dumpGridState(page, 'zoom=1.0 단일 컬럼 (정상 baseline)'); + // [4b] zoom=1.0 click baseline — 단일 컬럼 모드 click 무회귀 확인 + console.log('\n[4b] zoom=1.0 click baseline'); + await probeClickAtPage(page, 'page 0 (zoom=1.0 single col)', 0, 100, 200); + // [5] zoom=0.5 + 실제 click 측정 — col 0/1 페이지 비교 console.log('\n[5] zoom=0.5 실제 click 측정'); await page.evaluate(() => { From 5faae4d04a35bc07cb96b7f383f7c7645faa8680 Mon Sep 17 00:00:00 2001 From: johndoekim Date: Fri, 8 May 2026 00:33:35 +0900 Subject: [PATCH 09/13] =?UTF-8?q?Task=20#685:=20=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EB=B3=B4=EA=B3=A0=EC=84=9C=20+=205/8=20order?= =?UTF-8?q?s=20(closes=20#685,=20partial=20=E2=80=94=20#689=20=ED=9B=84?= =?UTF-8?q?=EC=86=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #685 (그리드 모드 zoom ≤ 0.5 click 좌표 단일 컬럼 가정 14곳 분기 일괄 어긋남) 정정 완료. 본문 명시 범위 (pageLeft 공식 14곳) 그대로 정정 → last col 정합. 작업지시자 시각 검증 (hwpctl_action_table_v11.hwp) 으로 last col 정상 동작 + non-last col 어긋남 = #689 시각 재현 확인. Stage 1+2+3 자동 검증 통과: - typecheck / vite build / body-outside-click-fallback 무회귀 - grid-mode-click-coord PASS=11/FAIL=0/SKIP=2 (의도) - last-col 정합: zoom=1.0 page 0, zoom=0.5 page 1, zoom=0.25 page 4 모두 helperResolved max delta=0.00px + cursor.rectPageIdx 정합 부분 정정 명시: - 진단노트 (grid_mode_click_coord.md) + 본 결과보고서 + orders 모두 "Task #685 = 부분 정정, #689 후속" 명시 - Issue #689 분리 등록 (Stage 3 e2e assert 강화로 노출): getPageAtY 가 Y-only 로 row last page 만 반환 → non-last col click 어긋남. 정정 방향: getPageAtPoint(docX, docY) 헬퍼 + 14곳 일괄 치환. - 작업지시자 결정 (scope 엄격 준수): #685 본문 범위 유지 + #689 분리 + 즉시 #689 시작. 산출물: - mydocs/report/task_m100_685_report.md (최종 결과보고서) - mydocs/orders/20260508.md (5/8 오늘 할일) 후속 머지 절차 (작업지시자 권한): - git merge local/task685 --no-ff -m "Merge local/devel: Task #685 ..." - gh issue close 685 (부분 정정 명시 코멘트 권장) Co-Authored-By: Claude Opus 4.7 --- mydocs/orders/20260508.md | 32 ++++++ mydocs/report/task_m100_685_report.md | 139 ++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 mydocs/orders/20260508.md create mode 100644 mydocs/report/task_m100_685_report.md diff --git a/mydocs/orders/20260508.md b/mydocs/orders/20260508.md new file mode 100644 index 000000000..d19a8cc38 --- /dev/null +++ b/mydocs/orders/20260508.md @@ -0,0 +1,32 @@ +# 오늘 할일 - 2026년 5월 8일 + +## M100 — v1.0.0 조판 엔진 (Task #595 후속 sweep 결함 정정 사이클) + +| Issue | 타스크 | 상태 | 비고 | +|------|--------|------|------| +| **Task #685** | rhwp-studio 그리드 모드 (zoom ≤ 0.5) click 좌표 단일 컬럼 가정 14곳 분기 일괄 어긋남 정정 | **완료 (Stage 1+2+3, 부분 정정 — 작업지시자 scope 결정)** | Task #595 후속 sweep 으로 발견된 한컴 호환 결함. 분기 기준점: `da7461d` (Merge local/devel: Task #595 본 작업 + 후속 sweep, Issue #685/#686 등록 포함). **본질 결함**: `virtual-scroll.ts` 의 그리드 모드 (`pageLefts[i] = marginLeft + col*(pw+gap)`) 가 `input-handler-mouse.ts` 14곳 모두 단일 컬럼 가정 공식 `(scrollContent.clientWidth - pageDisplayWidth) / 2` 만 사용해 그리드 모드 click 좌표 ±수백 px 어긋남. **정정 (3 단계)**: (Stage 1) `virtual-scroll.ts` 에 `getPageLeftResolved(pageIdx, containerWidth)` 헬퍼 신규 (+12 LOC) — 그리드 모드 `pageLefts[i]` / 단일 컬럼 `(containerWidth - pageWidth) / 2` fallback. `input-handler.ts:2572-2586 formBboxToOverlayRect` 의 verbose sentinel 패턴 1곳도 헬퍼 호출로 단순화 (-3/+1 LOC, 동치 refactor). 기존 `getPageLeft` 4 호출자 (`canvas-view.ts`/`field-marker-renderer.ts`/`caret-renderer.ts`) 무회귀 보존. (Stage 2) `input-handler-mouse.ts` 14곳 (라인 23/129/176/279/296/357/431/**475**/**811**/**889**/931/1146/1196/1243) 헬퍼 일괄 치환 (+14/-14 LOC, 1:1 표현식 치환). `pw`/`pageDisplayWidth` 변수 + 페이지 인덱스 변수 (`pi`/`pageIdx`/`picBbox.pageIndex`) 모두 보존. 6 회 Edit (replace_all 3 + 컨텍스트 단건 3, prefix 분리). (Stage 3) `e2e/grid-mode-click-coord.test.mjs` assert 강화 (+54 LOC) — `dumpGridState` 헬퍼 동치성 + `probeClickAtPage` last-col only strict assert (non-last col 은 후속 결함 #689 로 SKIP 안내). 추가 probe: zoom=0.25 last col + zoom=1.0 baseline. 진단노트 `mydocs/troubleshootings/grid_mode_click_coord.md` 끝부분에 "정정 완료 — 부분 정정 (Task #685)" + #689 안내 +20 LOC. **검증**: typecheck 무에러 / vite build 정상 / `body-outside-click-fallback.test.mjs --mode=headless` 무회귀 / `grid-mode-click-coord.test.mjs --mode=headless` **PASS=11/FAIL=0/SKIP=2** (의도된 non-last col). last-col 정합 자동 회귀: zoom=1.0 page 0, zoom=0.5 page 1, zoom=0.25 page 4 모두 helperResolved max delta=0.00px + cursor.rectPageIdx 정합. **시각 검증 (작업지시자 직접, hwpctl_action_table_v11.hwp 그리드 모드, 2026-05-08)**: non-last col 페이지(1, 2, 4, 5 등) 어긋남 = #689 시각 재현 / last col 페이지(3, 6, 9 등) 정상 = #685 정정 효과 확인. **Stage 3 발견사항 → Issue #689 분리 등록**: `virtual-scroll.ts:133-140 getPageAtY(docY)` 가 Y 좌표만 보고 row 의 last page idx 만 반환 → non-last col click 어긋남 (Task #685 의 pageLeft 정정과 별개의 결함 영역). **작업지시자 결정 (scope 엄격 준수)**: Task #685 본문 명시 범위 (pageLeft 공식 14곳) 그대로 유지 + #689 분리 등록 + 즉시 #689 시작. 정정 방향: `getPageAtPoint(docX, docY)` 헬퍼 + 14곳 일괄 치환. 커밋 `769f534` (Stage 1) + `d982d50` (Stage 2) + `7fdf01d` (Stage 3). 수행계획서: `mydocs/plans/task_m100_685.md`. 구현계획서: `mydocs/plans/task_m100_685_impl.md`. 단계 보고서: `mydocs/working/task_m100_685_stage{1,2,3}.md`. 최종 결과 보고서: `mydocs/report/task_m100_685_report.md`. **`feedback_process_must_follow` 메모리 권위 영역 강화** — Stage 3 추가 결함 발견 시 scope 확장 충동 억제 + 작업지시자 결정 후 #689 분리 등록 패턴 정합. **`feedback_hancom_compat_specific_over_general` 정합** — 그리드 모드 한컴 호환 결함 (한컴 정상 / RHWP 어긋남) 영역 본질 정정. **회귀 차단 가드 영구 보존** — e2e assert 강화로 last-col 정합 자동 회귀 차단. **부분 정정 명시 패턴** — 진단노트 + 결과보고서 + orders 모두 "Task #685 = 부분 정정, #689 후속" 명시로 후속 영역 추적 가능성 확보. | +| **Issue #689** | rhwp-studio 그리드 모드 `getPageAtY` X 좌표 무시 — non-last col 페이지 click 어긋남 정정 | **시작 예정 (Task #685 종결 후 즉시)** | Task #685 Stage 3 e2e assert 강화로 노출된 후속 결함. M100 (v1.0.0), 우선순위 High (UX-blocking). **본질 결함**: `virtual-scroll.ts:133-140 getPageAtY(docY)` Y-only loop 가 그리드 모드의 row-shared `pageOffsets[i]` 환경에서 항상 last page idx 만 반환 → non-last col click 시 row 의 last col 페이지로 cursor 처리. **정정 방향**: `getPageAtPoint(docX, docY)` 헬퍼 추가 + `input-handler-mouse.ts` 14곳의 `getPageAtY(contentY)` 호출 일괄 치환. **영향 범위 정량 (Task #685 측정)**: zoom=0.5 columns=2 → col 0 어긋남, zoom=0.25 columns=5 → col 0~3 어긋남 (4/5 컬럼). 그리드 모드 사용자 경험의 50~80% 영향. **Task #685 와의 관계**: #685 (pageLeft 공식) 와 #689 (페이지 인덱스 결정) 가 결합되어야 그리드 모드 click 한컴 호환 완성. #685 단독으로는 last col 만 정합. | + +## 작업 메모 + +### Task #685 본질 +- **`feedback_process_must_follow` 메모리 권위 영역의 권위 케이스** — Stage 3 e2e assert 강화 시 추가 결함 (`getPageAtY` X 무시) 발견. scope 확장 충동 억제 + 작업지시자 결정 받아 #689 로 분리 등록. +- **하이퍼-워터폴 절차 정합** — 수행계획서 → 구현계획서 → Stage 1/2/3 → 최종보고서 모두 작업지시자 승인 게이트 거침. 단계별 보고서 (`_stage{N}.md`) + 단계별 커밋. +- **DRY 정합** — `getPageLeftResolved` 헬퍼 도입 시 기존 `input-handler.ts:2579` 의 verbose sentinel 패턴 (이미 sentinel-aware 직접 풀어 씀) 도 같은 헬퍼로 정리하여 총 15곳 통일. 작업지시자 결정 (Stage 0 게이트). +- **회귀 위험 영역 좁힘** — `canvas-view.ts` / `field-marker-renderer.ts` / `caret-renderer.ts` 4 호출자 미수정 (이미 그리드 인프라 정상 처리 중, 헬퍼 통일 욕심 시 회귀 위험). +- **HWP IR 표준 직접 사용** — `virtualScroll.pageLefts[i]` (이미 그리드 인프라가 채우는 정합 데이터) 그대로 적용. 신규 좌표 계산 코드 0줄. +- **시각 검증 일관성** — 작업지시자가 `hwpctl_action_table_v11.hwp` 그리드 모드에서 직접 시각 확인 → last col 정합 + non-last col 어긋남 = #689 시각 재현. 자동 e2e (PASS=11/SKIP=2) 와 시각 결과 정합. + +### Task #685 vs Issue #689 영역 분리 정합 +| 결함 영역 | Task #685 (완료) | Issue #689 (시작 예정) | +|----------|-----------------|------------------------| +| pageLeft 공식 (단일 컬럼 가정) | ✅ 정정 (`getPageLeftResolved`) | n/a | +| 페이지 인덱스 결정 (Y-only) | n/a | 🔄 정정 예정 (`getPageAtPoint`) | +| 영향 범위 | last col 정합 | non-last col 정합 | +| 검증 | last-col e2e assert | (#689 e2e 강화 예정) | + +### #689 즉시 시작 절차 +1. `local/task685` → `local/devel` 머지 (작업지시자 권한, `--no-ff`, "Merge local/devel: Task #685 ... (closes #685, partial — #689 후속)") +2. 이슈 #685 close (부분 정정 명시 코멘트 권장) +3. `local/devel` 에서 `local/task689` 분기 +4. #689 수행계획서 작성 → 승인 게이트 diff --git a/mydocs/report/task_m100_685_report.md b/mydocs/report/task_m100_685_report.md new file mode 100644 index 000000000..6f682fddb --- /dev/null +++ b/mydocs/report/task_m100_685_report.md @@ -0,0 +1,139 @@ +# Task #685 최종 결과 보고서 + +**Issue**: [#685](https://github.com/edwardkim/rhwp/issues/685) — rhwp-studio: zoom ≤ 0.5 그리드 모드 click 좌표 단일 컬럼 가정 — 14곳 분기 일괄 어긋남 +**Milestone**: M100 (v1.0.0) +**브랜치**: `local/task685` → `local/devel` 머지 영역 (작업지시자 권한) +**완료일**: 2026-05-08 + +--- + +## 1. 본질 요약 + +[`virtual-scroll.ts`](../../rhwp-studio/src/view/virtual-scroll.ts) 는 `zoom ≤ 0.5 + pages > 1 + viewport > 0` 조건에서 **다중 컬럼 그리드 배치** (`pageLefts[i] = marginLeft + col * (pw + gap)`) 로 페이지를 열별 분리 저장하지만, [`input-handler-mouse.ts`](../../rhwp-studio/src/engine/input-handler-mouse.ts) 의 마우스 → 페이지 좌표 변환 14곳 모두 **단일 컬럼 가정 공식** `(scrollContent.clientWidth - pageDisplayWidth) / 2` 사용 → 그리드 모드 click 좌표 ±수백 px 어긋남. + +**한컴 호환 결함**: 한컴 오피스 그리드 모드는 정상 동작 (사용자 직접 시연, 2026-05-07). RHWP 만 어긋남. + +**Task #595 후속 sweep 으로 발견** — `feedback_process_must_follow` 정합으로 별도 task 사이클 분리 (PR #685/#686 분리 등록 사이클). + +## 2. 정정 영역 + +**Stage 1 — 헬퍼 도입 + verbose 패턴 정리**: +- [`src/view/virtual-scroll.ts`](../../rhwp-studio/src/view/virtual-scroll.ts) — `getPageLeftResolved(pageIdx, containerWidth)` 헬퍼 신규 (+12 LOC). 그리드 모드는 `pageLefts[i]`, 단일 컬럼은 `(containerWidth - pageWidth) / 2` fallback (sentinel −1 해소). +- [`src/engine/input-handler.ts`](../../rhwp-studio/src/engine/input-handler.ts) `formBboxToOverlayRect` — 기존 verbose sentinel 패턴 (`getPageLeft(pageIdx) >= 0 ? : ...`) 을 헬퍼 호출 한 줄로 단순화 (-3/+1 LOC). 동치 refactor 로 헬퍼의 두 모드 동작 자연스럽게 검증. +- 기존 `getPageLeft(pageIdx)` raw accessor 는 보존 (`canvas-view.ts`, `field-marker-renderer.ts`, `caret-renderer.ts` 4 호출자 무회귀). + +**Stage 2 — `input-handler-mouse.ts` 14곳 헬퍼 일괄 치환** (총 +14/-14 LOC, 1:1 표현식 치환): +- 라인 23, 129, 176, 279, 296, 357, 431, **475**, **811**, **889**, 931, 1146, 1196, 1243 — 모두 `this.virtualScroll.getPageLeftResolved(pageIdx, containerWidth)` 한 줄로 교체. +- 변수 (`pw` / `pageDisplayWidth` / `pi` / `pageIdx` / `picBbox.pageIndex`) 모두 보존 — hit test bbox 등 다른 사용처 잠재 보호. +- 6 회 Edit (`replace_all` 3 회 + 컨텍스트 단건 3 회) — `pi` vs `picBbox.pageIndex` 그룹 분리, 4-space/2-space prefix 분리. + +**Stage 3 — e2e 회귀 자동화**: +- [`e2e/grid-mode-click-coord.test.mjs`](../../rhwp-studio/e2e/grid-mode-click-coord.test.mjs) — 측정 로깅에 `assert` 추가 (helpers.mjs 의 `assert` 활용). + - `dumpGridState`: 모든 페이지 `getPageLeftResolved == 기대값` (max |delta| < 0.01 px). + - `probeClickAtPage`: CORRECT click → `cursor.pos !== null` (전 케이스), `rectPageIdx === pageIdx` (last-col only — non-last col 은 후속 결함 #689 로 SKIP 안내). +- 추가 probe: zoom=0.25 last col (page 4) + zoom=1.0 baseline (page 0). +- [`mydocs/troubleshootings/grid_mode_click_coord.md`](../troubleshootings/grid_mode_click_coord.md) 끝부분 — "정정 완료 — 부분 정정 (Task #685)" + 후속 결함 #689 안내 추가. + +**총 변경**: 4 src 파일 + 1 e2e 파일 + 1 진단노트 + 4 plan/working/report 문서. 코드 변경 ~+30 LOC / -17 LOC. + +## 3. 검증 결과 (정량) + +### 자동 검증 (모두 PASS) + +| 검증 항목 | 결과 | +|-----------|------| +| `npx tsc --noEmit` | ✅ 무에러 | +| `npx vite build` | ✅ 85 modules transformed, 정상 dist 생성 | +| `body-outside-click-fallback.test.mjs --mode=headless` | ✅ exit 0, 단일 컬럼 click 무회귀 (가설 a/b/c 모두 negative) | +| `grid-mode-click-coord.test.mjs --mode=headless` | ✅ exit 0, **PASS=11 / FAIL=0 / SKIP=2** (의도된 non-last col 스킵) | + +### Last-col 정합 검증 (자동 회귀) + +| 케이스 | helperResolved 동치성 | CORRECT click → rectPageIdx 정합 | +|--------|----------------------|----------------------------------| +| zoom=1.0 page 0 (single col, col=0=last) | ✅ max |delta| = 0.00 px | ✅ rectPageIdx=0 (기대 0) | +| zoom=0.5 page 1 (col=1=last, columns=2) | ✅ max |delta| = 0.00 px | ✅ rectPageIdx=1 (기대 1) | +| zoom=0.25 page 4 (col=4=last, columns=5) | ✅ max |delta| = 0.00 px | ✅ rectPageIdx=4 (기대 4) | + +→ Task #685 의 본질 효과 (그리드 모드 last-col 에서 `pageLefts[i]` 적용 → cursor.rectPageIdx 정합) 완전 검증. + +### 시각 검증 (작업지시자 직접 확인) + +작업지시자가 `samples/hwpctl_action_table_v11.hwp` 로 vite dev server 환경 직접 검증 (2026-05-08): +- non-last col 페이지 (1, 2, 4, 5 등) 클릭 어긋남 — **Issue #689 로 분리 등록된 후속 결함의 시각 재현**. +- last col 페이지 (3, 6, 9 등) 정상 클릭 — Task #685 정정 효과 시각 확인. + +## 4. 후속 결함 — Issue #689 분리 등록 + +Stage 3 의 e2e assert 강화로 추가 결함 노출: + +[`virtual-scroll.ts:133-140`](../../rhwp-studio/src/view/virtual-scroll.ts#L133-L140) `getPageAtY(docY)` 가 Y 좌표만 보고 row 의 last page idx 만 반환 → non-last col 페이지 click 시 row 의 last col 페이지로 cursor 처리됨. + +| zoom | columns | last col 정합 | non-last col 정합 | +|------|---------|--------------|-------------------| +| 1.0 | 1 | ✅ (col 0 = last) | n/a | +| 0.5 | 2 | ✅ col 1 | ❌ col 0 | +| 0.25 | 5 | ✅ col 4 | ❌ col 0~3 | +| ≤0.5 (3+ columns) | 3+ | ✅ last col | ❌ 그 외 col | + +**작업지시자 결정 (2026-05-08, scope 엄격 준수)**: Task #685 본문 명시 범위 (pageLeft 공식 14곳) 그대로 유지. 후속 결함 (`getPageAtY` X 무시) 는 별도 [Issue #689](https://github.com/edwardkim/rhwp/issues/689) 으로 등록 + 즉시 다음 사이클 시작. + +#689 의 정정 방향: `getPageAtPoint(docX, docY)` 헬퍼 도입 + input-handler-mouse 14곳의 `getPageAtY(contentY)` 호출을 `getPageAtPoint(contentX, contentY)` 로 일괄 치환. + +## 5. 회귀 위험 영역 + +| 영역 | 위험 | 결과 | +|------|------|------| +| 단일 컬럼 모드 click 좌표 (zoom > 0.5) | 헬퍼 fallback 식이 기존과 다르면 회귀 | ✅ OK — body-outside-click-fallback e2e 무회귀 | +| 그리드 모드 last-col click | pageLefts[i] 적용으로 정합 | ✅ OK — 자동 회귀 assert 통과 | +| `pw` / `pageDisplayWidth` 변수 | hit test bbox 사용처 보호 | ✅ OK — 변수 보존 | +| 페이지 인덱스 변수 mismatch (`pi` vs `picBbox.pageIndex`) | 잘못된 변수 적용 시 좌표 오인 | ✅ OK — Edit 단건 컨텍스트로 명시적 분리 | +| canvas-view / field-marker / caret renderer 4 호출자 | 헬퍼 미사용 영역 | ✅ OK — 본 작업 미수정 | +| `formBboxToOverlayRect` 양식 오버레이 위치 | 헬퍼 동치성 | ✅ OK — body-outside-click-fallback e2e 무회귀로 검증 | + +## 6. 정합 영역 + +- **하이퍼-워터폴 절차 정합**: 수행계획서 → 구현계획서 → Stage 1/2/3 → 최종보고서 모두 승인 게이트 거침. 각 단계 완료 보고서 + 커밋 (`769f534`, `d982d50`, `7fdf01d`). +- **`feedback_process_must_follow` 정합**: Stage 3 에서 추가 결함 발견 시 scope 확장 충동 억제, 작업지시자 결정 받아 #689 로 분리 등록. +- **DRY 정합**: `getPageLeftResolved` 헬퍼 도입 + 기존 input-handler.ts:2579 의 verbose 패턴까지 정리하여 총 15곳 통일. +- **회귀 위험 영역 좁힘**: `canvas-view` / `field-marker-renderer` / `caret-renderer` 4 호출자 미수정 — 그리드 인프라 정상 동작 보존. +- **HWP IR 표준 직접 사용**: virtual-scroll 의 `pageLefts[i]` (이미 그리드 인프라가 채우는 정합 데이터) 를 그대로 적용 — 신규 좌표 계산 코드 0줄. +- **회귀 차단 가드**: e2e `grid-mode-click-coord.test.mjs` assert 강화로 last-col 정합 영구 회귀 차단. +- **부분 정정 명시**: 진단노트 (`grid_mode_click_coord.md`) 끝부분 + 본 결과보고서 모두 "Task #685 = 부분 정정, #689 후속" 명시 → 후속 영역 추적 가능성 확보. + +## 7. 변경 파일 요약 + +| 파일 | 변경 | +|------|------| +| `rhwp-studio/src/view/virtual-scroll.ts` | +12 LOC (헬퍼 추가) | +| `rhwp-studio/src/engine/input-handler.ts` | -3/+1 LOC (formBboxToOverlayRect 단순화) | +| `rhwp-studio/src/engine/input-handler-mouse.ts` | +14/-14 LOC (14곳 헬퍼 치환) | +| `rhwp-studio/e2e/grid-mode-click-coord.test.mjs` | +54 LOC (assert + 추가 probe) | +| `mydocs/troubleshootings/grid_mode_click_coord.md` | +20 LOC ("정정 완료 — 부분 정정" + #689 안내) | +| `mydocs/plans/task_m100_685.md` | 신규 (수행계획서) | +| `mydocs/plans/task_m100_685_impl.md` | 신규 (구현계획서) | +| `mydocs/working/task_m100_685_stage{1,2,3}.md` | 신규 (단계 보고서 3종) | +| `mydocs/report/task_m100_685_report.md` | 신규 (본 보고서) | +| `mydocs/orders/20260508.md` | 신규 (5/8 오늘 할일) | + +## 8. 커밋 이력 (`local/task685`) + +``` +7fdf01d Task #685 Stage 3: 그리드 모드 click 좌표 e2e assert 강화 + 후속 결함 #689 분리 +d982d50 Task #685 Stage 2: input-handler-mouse 14곳 헬퍼 치환 — 그리드 모드 click 좌표 정정 +769f534 Task #685 Stage 1: getPageLeftResolved 헬퍼 추가 + formBboxToOverlayRect 단순화 +da7461d Merge local/devel: Task #595 본 작업 + 후속 sweep (Issue #685/#686 진단 노트 + e2e 정량 측정 등록) ← 분기 기준점 +``` + +## 9. 후속 영역 + +- **Issue #689 즉시 시작** (작업지시자 결정, 2026-05-08): `getPageAtPoint(docX, docY)` 헬퍼 + 14곳 일괄 치환. Task #685 와 동일한 input-handler-mouse cluster 영역. +- **`local/task685` → `local/devel` 머지** (작업지시자 권한): `git merge local/task685 --no-ff -m "Merge local/devel: Task #685 — 그리드 모드 click 좌표 일괄 정정 (closes #685, partial — #689 후속)"`. +- **이슈 #685 close**: merge 후 `gh issue close 685` 또는 merge 커밋의 `closes #685` 키워드 (cherry-pick 이 아닌 일반 merge 이므로 자동 처리 가능). 단, 부분 정정임을 close 코멘트에 명시 권장. + +## 10. 검증된 권위 영역 + +- **last-col 정합 (자동)**: zoom=1.0 page 0, zoom=0.5 page 1, zoom=0.25 page 4 모두 e2e PASS. +- **단일 컬럼 무회귀 (자동)**: `body-outside-click-fallback.test.mjs` 무회귀. +- **양식 오버레이 무회귀 (sanity)**: `formBboxToOverlayRect` 동치성 → 양식 개체 좌표 영역 무영향. +- **부분 정정 한계 명시 (수동)**: 작업지시자 직접 시각 검증 (`hwpctl_action_table_v11.hwp` 그리드 모드) — non-last col 페이지 어긋남 = #689 시각 재현. last col 페이지 정상 = #685 정정 효과 시각 확인. From ed3a0299df7a50da7a7e9678b9b1d60cb2a31e00 Mon Sep 17 00:00:00 2001 From: johndoekim Date: Fri, 8 May 2026 00:48:01 +0900 Subject: [PATCH 10/13] =?UTF-8?q?Task=20#689=20Stage=201:=20getPageAtPoint?= =?UTF-8?q?=20=ED=97=AC=ED=8D=BC=20=EB=8F=84=EC=9E=85=20+=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EC=9E=90=20=EB=B6=84=EB=A5=98=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #689 (그리드 모드 getPageAtY X 좌표 무시 — non-last col 페이지 click 어긋남) 정정 작업의 1/3 단계. 본 단계는 헬퍼 도입 (단일 컬럼 모드에서 동작 변경 없음): - virtual-scroll.ts: getPageAtPoint(docX, docY) 헬퍼 신규 (+33 LOC). 단일 컬럼은 getPageAtY 동치, 그리드 모드는 row(Y) 결정 후 같은 row 안에서 X 가 속하는 페이지 반환. gap 영역(페이지 사이) click 은 가장 가까운 페이지로 fallback. getPageAtY 자체는 미수정 (viewport- center 호출자 보존). 호출자 분류 확정: - 마우스 컨텍스트 (Stage 2 치환 대상, 20곳): input-handler-mouse 14곳 + input-handler.ts 4곳 (L612/875/972/1542) + input-handler-table 1곳 + input-handler-picture 1곳 - viewport-center 영역 (미수정): canvas-view 2곳 + input-handler-keyboard 1곳 - coordinate-system.ts:18 — dead code (documentToPage 호출자 0건), 본 작업 범위 외 수행계획서 scope 확장 (작업지시자 결정, 2026-05-08): 마우스 컨텍스트 6곳 (input-handler.ts 4곳 + table/picture 2곳) 모두 getPageAtY 호출 직후 buggy pageLeft 패턴 (#685 sweep 누락분) 도 함께 갖고 있어, 본 작업 안에서 동반 정정. 추가 헬퍼 신규 없음 (#685 의 getPageLeftResolved 재사용). 검증: - npx tsc --noEmit 통과 - npx vite build 성공 (389ms) - e2e body-outside-click-fallback.test.mjs --mode=headless exit 0 (단일 컬럼 모드 동치성 보장 확인) 수행계획서: mydocs/plans/task_m100_689.md 구현계획서: mydocs/plans/task_m100_689_impl.md 단계 보고서: mydocs/working/task_m100_689_stage1.md Co-Authored-By: Claude Opus 4.7 --- mydocs/plans/task_m100_689.md | 252 ++++++++++++++++ mydocs/plans/task_m100_689_impl.md | 388 +++++++++++++++++++++++++ mydocs/working/task_m100_689_stage1.md | 142 +++++++++ rhwp-studio/src/view/virtual-scroll.ts | 34 +++ 4 files changed, 816 insertions(+) create mode 100644 mydocs/plans/task_m100_689.md create mode 100644 mydocs/plans/task_m100_689_impl.md create mode 100644 mydocs/working/task_m100_689_stage1.md diff --git a/mydocs/plans/task_m100_689.md b/mydocs/plans/task_m100_689.md new file mode 100644 index 000000000..0a2bb913a --- /dev/null +++ b/mydocs/plans/task_m100_689.md @@ -0,0 +1,252 @@ +# Task #689 수행계획서 — 그리드 모드 `getPageAtY` X 좌표 무시 — non-last col 페이지 click 어긋남 정정 + +- **이슈**: [#689](https://github.com/edwardkim/rhwp/issues/689) +- **마일스톤**: M100 (v1.0.0) +- **브랜치**: `local/task689` (← `local/devel` `7651d91`) +- **우선순위**: High (UX-blocking) +- **선행 타스크**: [#685](https://github.com/edwardkim/rhwp/issues/685) (closed, partial — pageLeft 공식 14곳 정정 완료, last-col 정합) +- **작성일**: 2026-05-08 + +--- + +## 1. 배경 (Why) + +### 본질 결함 + +[`virtual-scroll.ts:133-140 getPageAtY(docY)`](../../rhwp-studio/src/view/virtual-scroll.ts#L133-L140) 는 Y 좌표만 보고 페이지 인덱스를 결정. + +```ts +getPageAtY(docY: number): number { + for (let i = this.pageOffsets.length - 1; i >= 0; i--) { + if (docY >= this.pageOffsets[i]) { + return i; + } + } + return 0; +} +``` + +그리드 모드에서 한 row 의 모든 페이지가 동일한 `pageOffsets[i] = rowTop` 을 가짐 ([virtual-scroll.ts:80](../../rhwp-studio/src/view/virtual-scroll.ts#L80)). 따라서 loop 가 highest index 부터 내려가며 첫 매치를 반환 → **항상 row 의 마지막 페이지(highest index, last col) 만 반환**. + +### Task #685 와의 관계 + +Task #685 는 `pageLeft` 공식 14곳을 `getPageLeftResolved` 헬퍼로 정정 → **last col 페이지에서만** click 정합. non-last col 페이지는 `getPageAtY` 가 row 의 last page idx 만 반환하므로 여전히 어긋남. + +| 케이스 | #685 정정 (pageLeft) | 본 #689 정정 (pageIdx) | 종합 정합 | +|--------|---------------------|------------------------|----------| +| zoom=1.0 (단일 컬럼) | ✅ | n/a | ✅ | +| zoom=0.5 col 1 (last) | ✅ | ✅ (이미 정합) | ✅ | +| zoom=0.5 col 0 (non-last) | n/a | 🔄 본 작업 | 🔄 | +| zoom=0.25 col 0~3 (non-last) | n/a | 🔄 본 작업 | 🔄 | + +### 사용자 영향 (시각 검증 + 정량) + +작업지시자 직접 검증 (`hwpctl_action_table_v11.hwp` 그리드 모드, 2026-05-08): non-last col 페이지 (1, 2, 4, 5 등) 클릭 시 cursor 가 row 의 last col 페이지 영역으로 떨어져 click 안 먹는 것처럼 보임. + +정량 (#685 Stage 3 e2e): +- zoom=0.5 page 0 (col 0): 클릭 → `cursor.rectPageIdx=1` (기대 0) +- zoom=0.5 page 2 (col 0): 클릭 → `cursor.rectPageIdx=3` (기대 2) + +→ 그리드 모드 사용자 경험의 50% (2-col) ~ 80% (5-col) 영향. **한컴 호환 결함** (한컴은 정상). + +**기대 결과**: Task #685 + #689 결합으로 그리드 모드 모든 col 의 클릭 정합 — 한컴 동등 동작 달성. + +--- + +## 2. 사전 조사 결과 (`getPageAtY` 호출자 분포) + +`grep -rn "getPageAtY" rhwp-studio/src/` 결과 22 호출자. + +### 마우스 클릭/이벤트 컨텍스트 (X 좌표 함께 알고 있음 → 정정 대상) + +| 파일 | 라인 | 함수 | 비고 | +|------|------|------|------| +| input-handler-mouse.ts | 20, 126, 173, 354, 428, **470**, **807**, **886**, 928, 1143, 1193, **1240** | onClick, onMouseMove, handleResizeHover 등 | **14곳** (#685 와 동일 위치) | +| input-handler-table.ts | 400 | mouse 컨텍스트 (Stage 1 에서 함수 확인) | 1곳 | +| input-handler-picture.ts | 594 | mouse 컨텍스트 (Stage 1 에서 함수 확인) | 1곳 | +| input-handler.ts | 612, 875, 972, 1542 | 마우스 추정 (`cY` / `contentY` 변수 사용) | **Stage 1 에서 컨텍스트 검증 후 분류** | +| coordinate-system.ts | 18 | `dy` 매개변수만 — 호출자 추적 필요 | **Stage 1 에서 분류** | +| **소계** | | | **17~22곳 (Stage 1 분류 결과에 따름)** | + +### viewport center / Y-only 의미 (정정 미대상 — `getPageAtY` 유지) + +| 파일 | 라인 | 용도 | +|------|------|------| +| canvas-view.ts | 120, 209 | `vpCenter` 기반 현재 페이지 결정 (X 의미 없음) | +| input-handler-keyboard.ts | 798 | viewport 중심 페이지 (키보드 입력 X 무관) | + +### e2e + +| 파일 | 라인 | 비고 | +|------|------|------| +| e2e/grid-mode-click-coord.test.mjs | 160, 169 | #685 SKIP 안내 (#689 정정 후 strict assert 활성화 대상) | +| e2e/issue-595.test.mjs | 113 | 측정 코드 — 회귀 영역 점검 필요 | + +--- + +## 2.5 추가 발견 — Task #685 누락분 동반 정정 (작업지시자 결정, 2026-05-08) + +`getPageAtY` 호출자 sweep 결과, **마우스 컨텍스트 6곳 (input-handler.ts L612/L875/L972/L1542 + input-handler-table.ts:400 + input-handler-picture.ts:594)** 모두 `getPageAtY` 호출 직후에 buggy pageLeft 패턴 (`(clientWidth - pw) / 2` 또는 동등) 도 갖고 있음 — Task #685 의 sweep 이 `input-handler-mouse.ts` 에 한정되어 누락된 영역. + +`getPageAtPoint` 치환만으로는 본 작업의 본질 효과 (그리드 모드 모든 col click 정합) 달성 불가 — 동일 함수 안에서 `pageLeft` 도 `getPageLeftResolved` 로 함께 정정해야 작동. + +**작업지시자 결정 (scope 확장 승인)**: 본 #689 안에서 두 결함 (`getPageAtY` X 무시 + Task #685 누락 6곳 buggy pageLeft) 동반 정정. `getPageLeftResolved` 헬퍼는 이미 #685 에서 도입됨 — 추가 헬퍼 신규 없음, 단순 호출 치환만. + +### 추가 정정 사이트 (6곳) + +| 파일 | 라인 | `getPageAtY` 치환 (#689 본질) | `pageLeft` 정정 (#685 누락분) | +|------|------|-------------------------------|------------------------------| +| input-handler.ts | 612 | ✅ | ✅ | +| input-handler.ts | 875 | ✅ | ✅ | +| input-handler.ts | 972 | ✅ | ✅ | +| input-handler.ts | 1542 | ✅ | ✅ | +| input-handler-table.ts | 400 | ✅ | ✅ | +| input-handler-picture.ts | 594 | ✅ | ✅ | + +→ 본 작업 총 변경 사이트: input-handler-mouse 14곳 (`getPageAtY` 만, `pageLeft` 는 #685 에서 정정됨) + 위 6곳 (둘 다) = **20곳 `getPageAtY` 치환** + **6곳 추가 `pageLeft` 정정**. + +--- + +## 3. 정정 방향 (확정) + +### 3.1 헬퍼 추가 + +[`virtual-scroll.ts`](../../rhwp-studio/src/view/virtual-scroll.ts) 에 `getPageAtPoint(docX, docY)` 신규: + +```ts +/** + * 그리드 모드 X+Y 인지 페이지 인덱스 반환. + * 단일 컬럼 모드는 getPageAtY 와 동치 (X 무관). + * 그리드 모드: row(Y) 결정 후 같은 row 내에서 X 가 속하는 col 의 페이지 반환. + */ +getPageAtPoint(docX: number, docY: number): number { + // 1. Y 로 row 의 baseline 페이지 찾기 (기존 getPageAtY 결과 = row 의 last page) + const rowLastIdx = this.getPageAtY(docY); + if (!this.gridMode) return rowLastIdx; // 단일 컬럼은 X 무관 + + // 2. 같은 row 의 페이지들 후보 (rowLastIdx 포함 col 0 까지 역방향) + // 3. 각 후보의 pageLefts[i] / pageWidths[i] 로 X 범위 검사 + // 4. 가장 가까운 페이지 반환 (gap 영역 click 도 row 안에서 가까운 페이지로 처리) + // ... 구현 상세는 Stage 1 에서 확정 +} +``` + +**구현 상세 (Stage 1 에서 확정)**: +- 같은 row 의 페이지들은 `pageOffsets[i] === pageOffsets[rowLastIdx]` 조건으로 구분. +- 각 후보의 `[pageLefts[i], pageLefts[i] + pageWidths[i]]` 범위 검사. +- 어느 페이지에도 속하지 않으면 (gap 영역 click) — 가장 가까운 페이지 (왼쪽/오른쪽) 또는 기본값 (Stage 1 에서 결정). + +### 3.2 마우스 컨텍스트 호출자 일괄 치환 + +확정 14곳 (input-handler-mouse) + 2곳 (table/picture) + Stage 1 분류 결과 (input-handler 4곳 + coordinate-system 1곳) 를 모두 `virtualScroll.getPageAtPoint(contentX, contentY)` 로 치환. + +치환 패턴: +```ts +// Before: +const pageIdx = this.virtualScroll.getPageAtY(contentY); + +// After: +const pageIdx = this.virtualScroll.getPageAtPoint(contentX, contentY); +``` + +### 3.3 e2e assert 활성화 + +[`e2e/grid-mode-click-coord.test.mjs`](../../rhwp-studio/e2e/grid-mode-click-coord.test.mjs) 의 non-last col SKIP 안내를 strict assert 로 활성화. zoom=0.5 page 0 (col 0), page 2 (col 0 of row 1) 모두 PASS 필요. + +추가 probe: zoom=0.25 col 0, col 2 (mid) 등 중간 col 케이스 추가 → 5-col 그리드의 모든 col 자동 회귀 차단. + +### 3.4 viewport-center 영역 보존 + +[`canvas-view.ts`](../../rhwp-studio/src/view/canvas-view.ts) 의 `vpCenter` 호출 2곳 + [`input-handler-keyboard.ts:798`](../../rhwp-studio/src/engine/input-handler-keyboard.ts#L798) 은 X 의미 없음 → `getPageAtY` 그대로 유지. 본 작업 미수정. + +--- + +## 4. 구현 계획 개요 (3 단계) + +### Stage 1 — `getPageAtPoint` 헬퍼 추가 + 호출자 분류 확정 +- `virtual-scroll.ts` 에 헬퍼 추가 +- `input-handler.ts` 4곳 (L612/875/972/1542) + `coordinate-system.ts:18` + `input-handler-table.ts:400` + `input-handler-picture.ts:594` 의 컨텍스트 확인 → 마우스 / viewport-center 분류 확정 +- typecheck + build + (sanity) `body-outside-click-fallback` e2e 무회귀 + +### Stage 2 — 마우스 컨텍스트 호출자 일괄 치환 +- 분류 확정된 마우스 컨텍스트 호출자를 모두 `getPageAtPoint(x, y)` 로 치환 (예상 16~22곳) +- typecheck + build + 기존 e2e 무회귀 (`body-outside-click-fallback`) + +### Stage 3 — e2e assert 활성화 + 시각 검증 +- `grid-mode-click-coord.test.mjs` non-last col SKIP → strict assert 활성화 +- 중간 col probe 추가 (zoom=0.25 col 0, col 2 등) +- 호스트 모드 시각 검증 (`hwpctl_action_table_v11.hwp` 그리드 모드 → 모든 col 클릭 정합) + +각 단계 완료 시 `mydocs/working/task_m100_689_stage{N}.md` 작성 → 승인 게이트. + +--- + +## 5. 변경 파일 요약 + +| 파일 | 종류 | 예상 변경 | +|------|------|----------| +| `rhwp-studio/src/view/virtual-scroll.ts` | 수정 | `getPageAtPoint` 추가 (~15 LOC) | +| `rhwp-studio/src/engine/input-handler-mouse.ts` | 수정 | 14곳 호출 치환 | +| `rhwp-studio/src/engine/input-handler.ts` | 수정 | 4곳 (분류 확정 후) | +| `rhwp-studio/src/engine/input-handler-table.ts` | 수정 | 1곳 | +| `rhwp-studio/src/engine/input-handler-picture.ts` | 수정 | 1곳 | +| `rhwp-studio/src/view/coordinate-system.ts` | 수정 (조건부) | Stage 1 분류 결과 | +| `rhwp-studio/e2e/grid-mode-click-coord.test.mjs` | 수정 | strict assert 활성화 + 추가 probe | +| `mydocs/troubleshootings/grid_mode_click_coord.md` | 갱신 | "정정 완료 (#689 합쳐 완전 정정)" 기록 | +| `mydocs/plans/task_m100_689.md` | 신규 | 본 문서 | +| `mydocs/plans/task_m100_689_impl.md` | 신규 | 구현계획서 | +| `mydocs/working/task_m100_689_stage{1,2,3}.md` | 신규 | 단계 보고서 | +| `mydocs/report/task_m100_689_report.md` | 신규 | 최종 보고서 | +| `mydocs/orders/20260508.md` (또는 작업 종료일) | 갱신 | 타스크 상태 | + +**불변 영역 (수정 금지)**: `canvas-view.ts:120/209`, `input-handler-keyboard.ts:798` — viewport-center 의미. 헬퍼 통일 욕심내면 회귀 위험. + +--- + +## 6. 검증 (Acceptance Criteria) + +### 자동 검증 +- [ ] `npx tsc --noEmit` 무에러 +- [ ] `npx vite build` 성공 +- [ ] `body-outside-click-fallback.test.mjs --mode=headless` 무회귀 +- [ ] `grid-mode-click-coord.test.mjs --mode=headless` — **PASS=13+ / FAIL=0 / SKIP=0** (이전 SKIP 2건이 PASS 로 전환) +- [ ] `issue-595.test.mjs --mode=headless` 무회귀 (필요 시 점검) + +### 시각 검증 (작업지시자) +- [ ] zoom=0.5 / 0.25 그리드 모드 모든 col 클릭 정합 (`hwpctl_action_table_v11.hwp` 권위 영역) +- [ ] zoom=1.0 일반 클릭 무회귀 + +### 코드 품질 +- [ ] `getPageAtY` 마우스 컨텍스트 호출자 0건 grep 결과 +- [ ] `getPageAtPoint` 신규 호출자 16~22 (분류 결과 일치) +- [ ] viewport-center 영역 (`canvas-view.ts`, `input-handler-keyboard.ts`) 미수정 확인 + +--- + +## 7. 위험성 / 주의점 + +| 영역 | 위험 | 완화 | +|------|------|------| +| 단일 컬럼 모드 | `getPageAtPoint` 가 X 무관 동치 보장 안 되면 회귀 | Stage 1 헬퍼 구현 시 `!gridMode` 분기로 `getPageAtY` 동치 보장 | +| gap 영역 click (페이지 사이 빈 공간) | X 가 어느 페이지에도 속하지 않을 때 | Stage 1 에서 fallback 정책 결정 (가까운 페이지 / row last page / 기타) | +| 마우스 / viewport-center 분류 오류 | viewport-center 영역에 X 무관 호출이 `getPageAtPoint` 로 치환되면 X 좌표 의도 부재 → 동작 불명 | Stage 1 에서 컨텍스트 명시 분류 + 작업지시자 분류 결과 보고 | +| `coordinate-system.ts:18` | 호출자 추적 후 마우스/Y-only 결정 | Stage 1 에서 조사 | +| e2e 임계 / 가장자리 케이스 | gap 영역 click 시 어느 페이지로 떨어질지 정의에 따라 e2e 결과 변동 | Stage 1 정책 결정 후 e2e 임계 조정 | + +--- + +## 8. 후속 영역 + +본 타스크 종료 시 그리드 모드 click 좌표 한컴 호환 완성 (#685 + #689 결합). 추가 후속 후보: + +- 키보드/IME/Touch 입력 경로의 그리드 모드 좌표 처리 (별도 후속 조사) +- `getPageAtPoint` 의 gap 영역 fallback 정책에 따라 추가 UX 보강 가능성 + +--- + +## 9. 참고 + +- 선행 타스크: [Task #685](https://github.com/edwardkim/rhwp/issues/685) (closed, partial) +- 진단 노트: [`mydocs/troubleshootings/grid_mode_click_coord.md`](../troubleshootings/grid_mode_click_coord.md) — Task #685 정정 완료 + #689 후속 안내 포함 +- 관련 영역 (input-handler-mouse cluster): #658, #661, #669 +- Task #685 분기 기준점: commit `7651d91` (Merge local/devel: Task #685) diff --git a/mydocs/plans/task_m100_689_impl.md b/mydocs/plans/task_m100_689_impl.md new file mode 100644 index 000000000..31ce33116 --- /dev/null +++ b/mydocs/plans/task_m100_689_impl.md @@ -0,0 +1,388 @@ +# Task #689 구현계획서 — 그리드 모드 `getPageAtY` X 무시 정정 + Task #685 누락 6곳 동반 정정 + +- **대응 수행계획서**: [`task_m100_689.md`](task_m100_689.md) +- **이슈**: [#689](https://github.com/edwardkim/rhwp/issues/689) +- **단계 수**: 3 (CLAUDE.md 의 3~6 범위) +- **scope 확장 승인일**: 2026-05-08 — 본 작업 안에서 #685 누락분 6곳 buggy pageLeft 도 동반 정정. +- **공통 검증 명령**: `cd rhwp-studio && npx tsc --noEmit && npx vite build` +- **e2e 명령**: `node e2e/.test.mjs --mode=headless` (vite dev server 별도 가동 필수, `:7700`) + +--- + +## 단계별 사전 결정 + +### 헬퍼 시그니처 (확정) + +```ts +/** + * 그리드 모드 X+Y 인지 페이지 인덱스 반환. + * 단일 컬럼 모드: getPageAtY 와 동치 (X 무관). + * 그리드 모드: row(Y) 결정 후 같은 row 안에서 X 가 속하는 페이지 반환. + * gap 영역 (페이지 사이 빈 공간) click 은 가장 가까운 페이지로 fallback. + */ +getPageAtPoint(docX: number, docY: number): number { + const rowLastIdx = this.getPageAtY(docY); + if (!this.gridMode) return rowLastIdx; // 단일 컬럼은 X 무관 + + // 같은 row 의 페이지 범위 (rowLastIdx 부터 row 시작까지) + const rowOffset = this.pageOffsets[rowLastIdx]; + let rowFirst = rowLastIdx; + while (rowFirst > 0 && this.pageOffsets[rowFirst - 1] === rowOffset) rowFirst--; + + // X 가 페이지 안에 속하는지 검사 + for (let i = rowFirst; i <= rowLastIdx; i++) { + const left = this.pageLefts[i] ?? 0; + const right = left + (this.pageWidths[i] ?? 0); + if (docX >= left && docX <= right) return i; + } + + // X 가 어느 페이지에도 속하지 않음 (gap / margin 영역) — 가장 가까운 페이지 + let bestIdx = rowFirst; + let bestDist = Infinity; + for (let i = rowFirst; i <= rowLastIdx; i++) { + const left = this.pageLefts[i] ?? 0; + const right = left + (this.pageWidths[i] ?? 0); + const dist = docX < left ? left - docX : (docX > right ? docX - right : 0); + if (dist < bestDist) { bestDist = dist; bestIdx = i; } + } + return bestIdx; +} +``` + +### 정정 사이트 분류 (확정) + +| 분류 | 파일 | 라인 | `getPageAtY` 치환 | `pageLeft` 동반 정정 | +|------|------|------|-------------------|---------------------| +| input-handler-mouse | input-handler-mouse.ts | 20, 126, 173, 354, 428, 470, 807, 886, 928, 1143, 1193, 1240 | ✅ 14곳 | (#685 에서 이미 정정) | +| 그림 객체 중심 | input-handler.ts | 612 | ✅ | ✅ | +| 표 객체 중심 | input-handler.ts | 875 | ✅ | ✅ | +| 마우스 이벤트 | input-handler.ts | 972 | ✅ | ✅ | +| 마우스 이벤트 | input-handler.ts | 1542 | ✅ | ✅ | +| 표 이동 드래그 | input-handler-table.ts | 400 | ✅ | ✅ | +| 그림 이동 드래그 | input-handler-picture.ts | 594 | ✅ | ✅ | +| **소계** | | | **20곳** | **6곳 추가 정정** | + +**미수정 (X 의미 없음)**: +- canvas-view.ts L120, L209 (`vpCenter` viewport 중심) +- input-handler-keyboard.ts L798 (`vpCenter` 키보드 viewport 중심) + +**조사 후 분류 (Stage 1)**: +- coordinate-system.ts L18 `documentToPage(dx, dy)` — public method, 호출자 추적 후 분류 + +### TS 단위 테스트 부재 처리 + +rhwp-studio 는 puppeteer e2e 만 존재. 헬퍼 검증은 Stage 3 의 e2e assert 강화로 자동화 (zoom=0.5 모든 col + zoom=0.25 모든 col strict assert). + +--- + +## Stage 1 — 헬퍼 추가 + `coordinate-system` 분류 확정 + +**목표**: `getPageAtPoint` 헬퍼를 도입. coordinate-system.ts 의 분류를 결정. + +### Step 1.1 — `coordinate-system.ts` 호출자 추적 + +```bash +grep -rn "documentToPage\|coordinateSystem" rhwp-studio/src/ 2>/dev/null +``` + +분류: +- 호출자가 e.client / contentX/Y 같은 마우스 좌표 → `getPageAtPoint(dx, dy)` 로 헬퍼 호출 변경 +- 호출자가 viewport center 또는 Y-only → 그대로 두거나 별도 결정 + +**결정 결과를 Stage 1 보고서에 기록**. + +### Step 1.2 — `virtual-scroll.ts` 에 헬퍼 추가 + +[`rhwp-studio/src/view/virtual-scroll.ts`](../../rhwp-studio/src/view/virtual-scroll.ts) 의 `getPageAtY(docY)` 메서드 직후에 위 시그니처 그대로 추가. + +`getPageAtY` 자체는 **수정 금지** — viewport-center 호출자 (canvas-view, input-handler-keyboard, 그리고 새 헬퍼 자체) 가 그대로 사용. + +### Step 1.3 — coordinate-system 처리 (조건부) + +Step 1.1 결과 마우스 컨텍스트로 분류되면 `documentToPage` 가 X 도 받도록 시그니처 변경 또는 내부에서 `getPageAtPoint` 호출. 호출자에 영향 없게 처리 가능한 방식 선택. + +### Step 1.4 — typecheck + build + +```bash +cd rhwp-studio && npx tsc --noEmit && npx vite build +``` + +기대: 무에러. + +### Step 1.5 — 단일 컬럼 동치성 sanity (e2e) + +```bash +node e2e/body-outside-click-fallback.test.mjs --mode=headless +``` + +기대: exit 0, 회귀 0. (헬퍼는 단일 컬럼에서 `getPageAtY` 와 동치 동작.) + +### Step 1.6 — Stage 1 보고서 + 커밋 + +`mydocs/working/task_m100_689_stage1.md`: +- 헬퍼 시그니처 + 동작 설명 +- coordinate-system.ts 분류 결과 (마우스 / viewport-center / 미분류) +- typecheck/build/e2e 무회귀 확인 + +```bash +git add rhwp-studio/src/view/virtual-scroll.ts \ + [coordinate-system.ts 변경 시] \ + mydocs/plans/task_m100_689.md \ + mydocs/plans/task_m100_689_impl.md \ + mydocs/working/task_m100_689_stage1.md +git commit -m "Task #689 Stage 1: getPageAtPoint 헬퍼 도입 + 호출자 분류 확정" +``` + +→ **승인 게이트**. + +--- + +## Stage 2 — `getPageAtY` 20곳 치환 + buggy `pageLeft` 6곳 동반 정정 + +**목표**: 본 이슈의 본질 정정. 그리드 모드 모든 col click 정합 달성. + +### Step 2.1 — `input-handler-mouse.ts` 14곳 `getPageAtY` 치환 + +**Before (반복 패턴)**: +```ts +const pi = this.virtualScroll.getPageAtY(cy); +// 또는 +const pageIdx = this.virtualScroll.getPageAtY(contentY); +``` + +**After**: +```ts +const pi = this.virtualScroll.getPageAtPoint(cx, cy); +// 또는 +const pageIdx = this.virtualScroll.getPageAtPoint(contentX, contentY); +``` + +각 라인의 X 변수명 (`cx` / `contentX`) 보존. 14곳 모두 `replace_all` 안전 (변수명 일관 — `cy`/`contentY` ↔ `cx`/`contentX` 동일 함수 내 짝). + +`replace_all` 로 두 그룹 처리: +- `const pi = this.virtualScroll.getPageAtY(cy);` → `const pi = this.virtualScroll.getPageAtPoint(cx, cy);` +- `const pageIdx = this.virtualScroll.getPageAtY(contentY);` → `const pageIdx = this.virtualScroll.getPageAtPoint(contentX, contentY);` + +### Step 2.2 — `input-handler.ts` 4곳 (L612, L875, L972, L1542) 동반 정정 + +각 사이트에서 두 변경: + +**Before (예 — L972)**: +```ts +const pageIdx = this.virtualScroll.getPageAtY(contentY); +const pageOffset = this.virtualScroll.getPageOffset(pageIdx); +const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); +const pageLeft = (scrollContent.clientWidth - pageDisplayWidth) / 2; +``` + +**After**: +```ts +const pageIdx = this.virtualScroll.getPageAtPoint(contentX, contentY); +const pageOffset = this.virtualScroll.getPageOffset(pageIdx); +const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth); +``` + +`pageDisplayWidth` 변수는 다른 용도 (hit test bbox) 사용 가능성 있으므로 라인별 검증 — 사용 없으면 제거, 있으면 보존. + +L612, L875 는 `cX, cY` (객체 중심 좌표) 변수 사용 — 이름 보존하면서 `getPageAtPoint(cX, cY)` 로 치환. + +### Step 2.3 — `input-handler-table.ts:400` 동반 정정 + +같은 패턴: +```ts +const pi = this.virtualScroll.getPageAtPoint(cx, cy); // ← getPageAtY → getPageAtPoint +const po = this.virtualScroll.getPageOffset(pi); +const pw = this.virtualScroll.getPageWidth(pi); +const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth); // ← (sc.clientWidth - pw)/2 정정 +``` + +`pw` 는 동일 함수 내 다른 용도 가능성 검증 후 보존/제거 결정. + +### Step 2.4 — `input-handler-picture.ts:594` 동반 정정 + +L400 과 동일 패턴 적용. + +### Step 2.5 — Stage 1 결정 시 `coordinate-system.ts` 처리 + +Stage 1 분류 결과에 따라 처리. + +### Step 2.6 — grep sweep + +```bash +echo "=== getPageAtY 마우스 컨텍스트 잔여 (0 건 기대) ===" +grep -rn "getPageAtY" rhwp-studio/src/ | grep -v "viewport\|vpCenter\|virtual-scroll.ts" +# canvas-view.ts (viewport center) + input-handler-keyboard.ts (vpCenter) + virtual-scroll.ts (정의) 만 매칭되어야 함 + +echo "=== buggy pageLeft 잔여 (0 건 기대) ===" +grep -rnE "clientWidth\s*-\s*\w+\)\s*/\s*2" rhwp-studio/src/ + +echo "=== getPageAtPoint 호출 수 (20 기대) ===" +grep -rc "getPageAtPoint" rhwp-studio/src/ +``` + +### Step 2.7 — typecheck + build + 기존 e2e 무회귀 + +```bash +cd rhwp-studio +npx tsc --noEmit +npx vite build +node e2e/body-outside-click-fallback.test.mjs --mode=headless +``` + +### Step 2.8 — Stage 2 보고서 + 커밋 + +`mydocs/working/task_m100_689_stage2.md`: +- 사이트별 변경 표 (20곳 `getPageAtY` + 6곳 `pageLeft`) +- grep sweep 결과 +- typecheck/build/e2e 무회귀 + +```bash +git commit -m "Task #689 Stage 2: getPageAtY 20곳 → getPageAtPoint 치환 + Task #685 누락 6곳 buggy pageLeft 동반 정정" +``` + +→ **승인 게이트**. + +--- + +## Stage 3 — e2e strict assert 활성화 + 시각 검증 + +**목표**: Task #685 + #689 결합 효과 (그리드 모드 모든 col click 정합) 자동 회귀화. + +### Step 3.1 — 기존 SKIP 분기 제거 → 모든 col strict assert + +[`e2e/grid-mode-click-coord.test.mjs`](../../rhwp-studio/e2e/grid-mode-click-coord.test.mjs) `probeClickAtPage` 의 SKIP 분기 제거: + +**Before**: +```ts +if (probe.isLastCol) { + assert(afterCorrectClick.rectPageIdx === pageIdx, `[${label}] CORRECT click → cursor.rectPageIdx=...`); +} else { + console.log(` SKIP: [${label}] non-last col rectPageIdx strict assert ... — Issue #689 후속`); +} +``` + +**After**: +```ts +assert( + afterCorrectClick.rectPageIdx === pageIdx, + `[${label}] CORRECT click → cursor.rectPageIdx=${afterCorrectClick.rectPageIdx} (기대 ${pageIdx}, col=${probe.col}/columns=${probe.columns})` +); +``` + +`probe.isLastCol` 필드 자체는 보존 (디버그 로그 가치). + +### Step 3.2 — 추가 probe (중간 col 케이스) + +기존 `[5]` 블록에 추가: + +```ts +// page 0 (col 0) — 이미 있음 +// page 1 (col 1) — 이미 있음 (zoom=0.5 last col) +// page 2 — 이미 있음 (col 0 of row 1) +// 추가: zoom=0.25 의 다양한 col 케이스 +await page.evaluate(() => window.__inputHandler.viewportManager.setZoom(0.25)); +await page.evaluate(() => new Promise(r => setTimeout(r, 600))); +await probeClickAtPage(page, 'page 0 (zoom=0.25 col 0)', 0, 100, 200); +await probeClickAtPage(page, 'page 2 (zoom=0.25 col 2 mid)', 2, 100, 200); +await probeClickAtPage(page, 'page 4 (zoom=0.25 col 4 last)', 4, 100, 200); +``` + +(zoom=0.25 last col probe 는 기존에 [3b] 블록에 있음 — 중복 방지 위해 정리.) + +### Step 3.3 — e2e 실행 + PASS=13+/FAIL=0/SKIP=0 확인 + +```bash +node e2e/grid-mode-click-coord.test.mjs --mode=headless +``` + +기대: +- exit 0 +- PASS ≥ 13 (zoom=0.5 col 0/1, zoom=0.25 col 0/2/4, zoom=1.0 col 0 의 cursor.pos + rectPageIdx 모두 PASS) +- FAIL = 0 +- SKIP = 0 + +### Step 3.4 — 시각 검증 (호스트 모드) + +```bash +node e2e/grid-mode-click-coord.test.mjs --mode=host +``` + +또는 작업지시자가 직접 vite dev 환경에서: +- `samples/hwpctl_action_table_v11.hwp` 로드 +- zoom=0.5 / 0.25 그리드 모드에서 모든 col 페이지 클릭 → 캐럿 정상 배치 시각 확인 +- zoom=1.0 일반 클릭 무회귀 + +### Step 3.5 — 진단노트 갱신 + +[`mydocs/troubleshootings/grid_mode_click_coord.md`](../troubleshootings/grid_mode_click_coord.md) 끝부분에 추가: + +```markdown +### 완전 정정 완료 (2026-05-08, Task #689) + +후속 결함 (`getPageAtY` X 무시) 정정 완료: +- `virtualScroll.getPageAtPoint(docX, docY)` 헬퍼 도입 +- 마우스 컨텍스트 20곳 `getPageAtY` → `getPageAtPoint` 치환 +- Task #685 sweep 누락 6곳 buggy `pageLeft` 동반 정정 (`getPageLeftResolved` 적용) +- e2e (`grid-mode-click-coord.test.mjs`) strict assert 활성화 — 모든 col 정합 + +→ Task #685 + #689 결합으로 그리드 모드 click 한컴 호환 완성. +``` + +### Step 3.6 — Stage 3 보고서 + 커밋 + +`mydocs/working/task_m100_689_stage3.md`: +- e2e assert 활성화 결과 (PASS 카운트) +- 시각 검증 결과 +- 진단노트 갱신 내용 + +```bash +git commit -m "Task #689 Stage 3: e2e strict assert 활성화 + 시각 검증 + 진단노트 완전 정정 기록" +``` + +→ **승인 게이트**. + +--- + +## 최종 단계 — 결과보고서 + #685 운용 보고 + +### 산출물 + +1. `mydocs/report/task_m100_689_report.md` — 최종 결과보고서: + - 본질 정정 결과 (`getPageAtPoint` + 누락 6곳 동반 정정) + - 검증 결과 (모든 자동/시각 PASS) + - Task #685 + #689 결합 완성 명시 + - 회귀 영역 점검 + +2. `mydocs/orders/20260508.md` 갱신 — Task #689 상태 [완료] 표시 + Task #685 누락 발견/동반 정정 메모. + +3. **Issue #685 운용 후속 코멘트** (선택) — #685 의 close 코멘트에서 "#689 에서 누락분 동반 정정 완료" 후속 안내 등록 가능. + +### 커밋 + +```bash +git commit -m "Task #689: 최종 결과보고서 + orders 갱신 (closes #689)" +``` + +→ **승인 게이트**: 작업지시자 확인 후 GitHub 이슈 close + `local/devel` 머저 진행. + +--- + +## 회귀 위험 요약 + +| 영역 | 위험 | 완화 | +|------|------|------| +| 단일 컬럼 모드 (`!gridMode`) | `getPageAtPoint` 가 `getPageAtY` 와 비트 단위 동치 안 되면 회귀 | 헬퍼 첫 분기에서 `if (!this.gridMode) return rowLastIdx` 명시 — Stage 1 sanity e2e 로 검증 | +| gap 영역 click (페이지 사이) | 어느 페이지에도 속하지 않을 때 동작 | "가장 가까운 페이지" fallback 정책 — 기존 `getPageAtY` 결과 (row last) 와 다를 수 있으나 사용자 경험 향상 | +| `pageDisplayWidth` 변수 잔여 | hit test bbox 사용처 보호 | 변수 보존 (Task #685 와 동일 정책) | +| viewport-center 영역 | 헬퍼 통일 욕심 시 회귀 | canvas-view.ts, input-handler-keyboard.ts 미수정 명시 | +| coordinate-system.ts | 분류 미확정 영역 | Stage 1 에서 호출자 추적 후 결정, 분류 결과 보고서 기록 | +| e2e 임계값 | 부동소수점 오차 / 중간 col 케이스 | 기존 0.01 px 임계 유지, 필요 시 Stage 3 에서 미세 조정 | + +--- + +## 단계 완료 기준 종합 + +- [ ] Stage 1: 헬퍼 추가, coordinate-system 분류, typecheck/build/sanity, 보고서 + 커밋 +- [ ] Stage 2: 20곳 `getPageAtY` 치환 + 6곳 `pageLeft` 동반 정정, grep sweep 0건, e2e 무회귀, 보고서 + 커밋 +- [ ] Stage 3: e2e PASS=13+/FAIL=0/SKIP=0, 시각 검증, 진단노트 갱신, 보고서 + 커밋 +- [ ] 최종: 결과보고서 + orders 갱신 + 이슈 #689 close 절차 + 머저 diff --git a/mydocs/working/task_m100_689_stage1.md b/mydocs/working/task_m100_689_stage1.md new file mode 100644 index 000000000..17a304a6b --- /dev/null +++ b/mydocs/working/task_m100_689_stage1.md @@ -0,0 +1,142 @@ +# Task #689 Stage 1 단계 보고서 — `getPageAtPoint` 헬퍼 추가 + 호출자 분류 확정 + +- **이슈**: [#689](https://github.com/edwardkim/rhwp/issues/689) +- **수행계획서**: [task_m100_689.md](../plans/task_m100_689.md) +- **구현계획서**: [task_m100_689_impl.md](../plans/task_m100_689_impl.md) +- **단계 위치**: 3 단계 중 1/3 +- **변경 성격**: 헬퍼 도입 (동작 변경 없음 — 단일 컬럼은 `getPageAtY` 동치) +- **작성일**: 2026-05-08 + +--- + +## 변경 요약 + +| 파일 | 변경 | +|------|------| +| `rhwp-studio/src/view/virtual-scroll.ts` | +33 LOC (`getPageAtPoint(docX, docY)` 메서드 신규) | + +총 코드 변경: +33 LOC. 단일 컬럼 모드에서 `getPageAtY` 와 비트 단위 동치 보장. + +--- + +## 1. 헬퍼 추가 (`getPageAtPoint`) + +[`virtual-scroll.ts`](../../rhwp-studio/src/view/virtual-scroll.ts) 의 `getPageAtY` 메서드 직후에 추가: + +```ts +/** + * 문서 좌표 (X, Y) 가 속하는 페이지 인덱스를 반환한다. + * 단일 컬럼 모드: getPageAtY 와 동치 (X 무관). + * 그리드 모드: row(Y) 결정 후 같은 row 안에서 X 가 속하는 페이지 반환. + * gap 영역(페이지 사이 빈 공간) click 은 가장 가까운 페이지로 fallback. + */ +getPageAtPoint(docX: number, docY: number): number { + const rowLastIdx = this.getPageAtY(docY); + if (!this.gridMode) return rowLastIdx; + + // 같은 row 의 페이지 범위 (rowLastIdx 부터 row 시작까지) + const rowOffset = this.pageOffsets[rowLastIdx]; + let rowFirst = rowLastIdx; + while (rowFirst > 0 && this.pageOffsets[rowFirst - 1] === rowOffset) rowFirst--; + + // X 가 페이지 안에 속하는 첫 번째 페이지 반환 + for (let i = rowFirst; i <= rowLastIdx; i++) { + const left = this.pageLefts[i] ?? 0; + const right = left + (this.pageWidths[i] ?? 0); + if (docX >= left && docX <= right) return i; + } + + // gap / margin 영역 — 가장 가까운 페이지로 fallback + let bestIdx = rowFirst; + let bestDist = Infinity; + for (let i = rowFirst; i <= rowLastIdx; i++) { + const left = this.pageLefts[i] ?? 0; + const right = left + (this.pageWidths[i] ?? 0); + const dist = docX < left ? left - docX : (docX > right ? docX - right : 0); + if (dist < bestDist) { bestDist = dist; bestIdx = i; } + } + return bestIdx; +} +``` + +기존 `getPageAtY(docY)` 는 그대로 보존 — viewport-center 호출자 (canvas-view.ts L120/L209, input-handler-keyboard.ts L798) + 새 헬퍼 자체 가 사용. + +--- + +## 2. 호출자 분류 결과 (Stage 1.1) + +### 마우스 컨텍스트 (Stage 2 치환 대상, 20곳 확정) + +| 파일 | 라인 | 함수 / 컨텍스트 | +|------|------|----------------| +| input-handler-mouse.ts | 20, 126, 173, 354, 428, **470**, **807**, **886**, 928, 1143, 1193, **1240** | onClick / onDblClick / onContextMenu / onMouseMove 등 (14곳) | +| input-handler.ts | 612 | 그림 객체 중심 좌표 → 페이지 | +| input-handler.ts | 875 | 표 객체 중심 좌표 → 페이지 | +| input-handler.ts | 972 | e.client 마우스 이벤트 | +| input-handler.ts | 1542 | e.client 마우스 이벤트 | +| input-handler-table.ts | 400 | 표 이동 드래그 (e.client → cy) | +| input-handler-picture.ts | 594 | 그림 이동 드래그 (e.client → cy) | +| **소계** | | **20곳** | + +### viewport-center / Y-only 영역 (미수정) + +| 파일 | 라인 | 비고 | +|------|------|------| +| canvas-view.ts | 120, 209 | `vpCenter` viewport 중심 — X 의미 없음 | +| input-handler-keyboard.ts | 798 | 키보드 viewport 중심 — X 의미 없음 | + +### `coordinate-system.ts:18` 분류 — **dead code** + +조사 결과: +``` +$ grep -rn "\.documentToPage\b" rhwp-studio/src/ +(0 matches) +``` + +`CoordinateSystem` 인스턴스는 `canvas-view.ts:31` 에서 생성 + `getCoordinateSystem()` getter (L282) 로 노출되지만, **`documentToPage` 메서드를 직접 호출하는 코드 0건** — dead code path. 본 작업 범위 외 (별도 사이클에서 dead code 정리 가능). + +→ Stage 2 변경 대상 확정: **20곳** (구현계획서 예상 17~22곳 중간값과 일치). + +--- + +## 3. 검증 결과 + +### 1. typecheck +``` +$ npx tsc --noEmit +(무에러) +``` + +### 2. vite build +``` +$ npx vite build +✓ built in 389ms +PWA v1.2.0 — generateSW 정상 +``` + +### 3. sanity e2e — `body-outside-click-fallback.test.mjs --mode=headless` +``` +$ exit=0 +- ERROR / FAIL / 에러 / 가설.yes: 0 매칭 +``` + +→ 헬퍼 추가만으로는 동작 변경 없음 (단일 컬럼 모드 동치성 보장). + +--- + +## 4. 회귀 위험 점검 + +| 영역 | 위험 | 결과 | +|------|------|------| +| 단일 컬럼 모드 (`!gridMode`) | `getPageAtPoint` 가 `getPageAtY` 와 비트 동치 안 되면 회귀 | OK — 명시 분기 `if (!this.gridMode) return rowLastIdx` | +| 그리드 모드 row 경계 인식 | `pageOffsets[i]` 동치 비교가 실제 row 그룹과 일치 안 하면 | OK — virtual-scroll.ts L80 `pageOffsets.push(rowTop)` 동일값 push 검증 완료 | +| gap 영역 fallback | "가장 가까운 페이지" 정책이 기존 동작과 다를 수 있음 | 의도된 변경 — 기존 동작은 항상 row last page 라 잘못된 결과. 새 정책이 사용자 기대에 더 가까움 | +| viewport-center 호출자 4곳 | 헬퍼 추가가 기존 메서드 영향 | OK — `getPageAtY` 미수정 | + +--- + +## 5. 다음 단계 + +Stage 2 — 마우스 컨텍스트 호출자 20곳 `getPageAtY` → `getPageAtPoint` 치환 + Task #685 sweep 누락 6곳 buggy `pageLeft` 동반 정정 (`getPageLeftResolved` 적용). + +승인 요청 → 승인 시 Stage 2 진행. diff --git a/rhwp-studio/src/view/virtual-scroll.ts b/rhwp-studio/src/view/virtual-scroll.ts index c59b5b71f..8ec8b1b94 100644 --- a/rhwp-studio/src/view/virtual-scroll.ts +++ b/rhwp-studio/src/view/virtual-scroll.ts @@ -139,6 +139,40 @@ export class VirtualScroll { return 0; } + /** + * 문서 좌표 (X, Y) 가 속하는 페이지 인덱스를 반환한다. + * 단일 컬럼 모드: getPageAtY 와 동치 (X 무관). + * 그리드 모드: row(Y) 결정 후 같은 row 안에서 X 가 속하는 페이지 반환. + * gap 영역(페이지 사이 빈 공간) click 은 가장 가까운 페이지로 fallback. + */ + getPageAtPoint(docX: number, docY: number): number { + const rowLastIdx = this.getPageAtY(docY); + if (!this.gridMode) return rowLastIdx; + + // 같은 row 의 페이지 범위 (rowLastIdx 부터 row 시작까지) + const rowOffset = this.pageOffsets[rowLastIdx]; + let rowFirst = rowLastIdx; + while (rowFirst > 0 && this.pageOffsets[rowFirst - 1] === rowOffset) rowFirst--; + + // X 가 페이지 안에 속하는 첫 번째 페이지 반환 + for (let i = rowFirst; i <= rowLastIdx; i++) { + const left = this.pageLefts[i] ?? 0; + const right = left + (this.pageWidths[i] ?? 0); + if (docX >= left && docX <= right) return i; + } + + // gap / margin 영역 — 가장 가까운 페이지로 fallback + let bestIdx = rowFirst; + let bestDist = Infinity; + for (let i = rowFirst; i <= rowLastIdx; i++) { + const left = this.pageLefts[i] ?? 0; + const right = left + (this.pageWidths[i] ?? 0); + const dist = docX < left ? left - docX : (docX > right ? docX - right : 0); + if (dist < bestDist) { bestDist = dist; bestIdx = i; } + } + return bestIdx; + } + getPageOffset(pageIdx: number): number { return this.pageOffsets[pageIdx] ?? 0; } From dc8822c06507fc3a5ac6c99b5db00dedb6919ec3 Mon Sep 17 00:00:00 2001 From: johndoekim Date: Fri, 8 May 2026 00:55:06 +0900 Subject: [PATCH 11/13] =?UTF-8?q?Task=20#689=20Stage=202:=20getPageAtY=201?= =?UTF-8?q?8=EA=B3=B3=20=E2=86=92=20getPageAtPoint=20=EC=B9=98=ED=99=98=20?= =?UTF-8?q?+=20Task=20#685=20=EB=88=84=EB=9D=BD=20buggy=20pageLeft=2010?= =?UTF-8?q?=EA=B3=B3=20=EB=8F=99=EB=B0=98=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #689 (그리드 모드 getPageAtY X 좌표 무시 — non-last col 페이지 click 어긋남) 의 본질 정정. Stage 1 에서 도입한 getPageAtPoint 헬퍼로 마우스 컨텍스트 호출자 18곳 일괄 치환 + Task #685 sweep 누락분 buggy pageLeft 10곳 동반 정정. getPageAtY → getPageAtPoint 치환 (18곳): input-handler-mouse.ts 12곳 (3 회 replace_all: cy/y/contentY) input-handler.ts 4곳 (L612/875/972/1542, 2 회 replace_all: cY/contentY) input-handler-table.ts 1곳 (L400) input-handler-picture.ts 1곳 (L594) buggy pageLeft → getPageLeftResolved 동반 정정 (10곳): input-handler.ts 4곳 (L612/875/972/1542, getPageAtY 와 동일 함수) input-handler-table.ts 3곳 (L400 동일 함수 + L74/L111 추가 발견) input-handler-picture.ts 1곳 (L594 동일 함수) input-handler-connector.ts 2곳 (L85/L152 추가 발견) 추가 발견 4곳 (table L74/L111 + connector L85/L152) 은 getPageAtY 호출 없이 (state.edge.pageIndex 또는 함수 매개변수에서 pageIdx 획득) 도 buggy pageLeft 패턴 사용 — Task #685 sweep 의 input-handler-mouse 한정 누락분. 작업지시자 추가 승인 (2026-05-08) 으로 동반 정정. 검증: - grep sweep: clientWidth-X)/2 패턴 0 건 잔여 - getPageAtPoint 호출 19 (정의 1 + 사용 18 정확) - getPageLeftResolved 호출 26 (정의 1 + #685 사용 15 + #689 사용 10 정확) - npx tsc --noEmit 통과 - npx vite build 성공 - e2e body-outside-click-fallback.test.mjs --mode=headless exit 0 (단일 컬럼 모드 양쪽 헬퍼 sentinel/Y-only fallback 무회귀) 수정 파일: rhwp-studio/src/engine/input-handler-mouse.ts rhwp-studio/src/engine/input-handler.ts rhwp-studio/src/engine/input-handler-table.ts rhwp-studio/src/engine/input-handler-picture.ts rhwp-studio/src/engine/input-handler-connector.ts 본 정정의 본질 효과 (그리드 모드 모든 col click 정합) 는 Stage 3 에서 grid-mode-click-coord.test.mjs 의 non-last col SKIP 분기를 strict assert 로 활성화하여 자동 회귀화 예정. Co-Authored-By: Claude Opus 4.7 --- mydocs/working/task_m100_689_stage2.md | 156 ++++++++++++++++++ .../src/engine/input-handler-connector.ts | 4 +- rhwp-studio/src/engine/input-handler-mouse.ts | 24 +-- .../src/engine/input-handler-picture.ts | 4 +- rhwp-studio/src/engine/input-handler-table.ts | 8 +- rhwp-studio/src/engine/input-handler.ts | 16 +- 6 files changed, 184 insertions(+), 28 deletions(-) create mode 100644 mydocs/working/task_m100_689_stage2.md diff --git a/mydocs/working/task_m100_689_stage2.md b/mydocs/working/task_m100_689_stage2.md new file mode 100644 index 000000000..67a091856 --- /dev/null +++ b/mydocs/working/task_m100_689_stage2.md @@ -0,0 +1,156 @@ +# Task #689 Stage 2 단계 보고서 — `getPageAtY` 18곳 → `getPageAtPoint` 치환 + Task #685 누락 buggy `pageLeft` 10곳 동반 정정 + +- **이슈**: [#689](https://github.com/edwardkim/issues/689) +- **수행계획서**: [task_m100_689.md](../plans/task_m100_689.md) +- **구현계획서**: [task_m100_689_impl.md](../plans/task_m100_689_impl.md) +- **단계 위치**: 3 단계 중 2/3 +- **변경 성격**: 본질 정정 (그리드 모드 모든 col click 정합) + Task #685 sweep 누락분 동반 정정 +- **작성일**: 2026-05-08 + +--- + +## 변경 요약 + +| 파일 | 변경 | +|------|------| +| `rhwp-studio/src/engine/input-handler-mouse.ts` | 12곳 `getPageAtY` → `getPageAtPoint` 치환 (1:1 라인 변경) | +| `rhwp-studio/src/engine/input-handler.ts` | 4곳 `getPageAtY` → `getPageAtPoint` + 4곳 buggy `pageLeft` → `getPageLeftResolved` | +| `rhwp-studio/src/engine/input-handler-table.ts` | 1곳 `getPageAtY` 치환 + 1곳 buggy `pageLeft` (L400) + **추가 2곳 buggy `pageLeft`** (L74, L111) | +| `rhwp-studio/src/engine/input-handler-picture.ts` | 1곳 `getPageAtY` 치환 + 1곳 buggy `pageLeft` (L594) | +| `rhwp-studio/src/engine/input-handler-connector.ts` | **추가 2곳 buggy `pageLeft`** (L85, L152) | + +총 변경: **18곳 `getPageAtY` 치환** + **10곳 buggy `pageLeft` 정정** = 코드 변경 ~+28/-28 LOC. + +--- + +## 1. `getPageAtY` → `getPageAtPoint` 치환 (18곳) + +### 1.1 input-handler-mouse.ts (12곳, 3 회 `replace_all`) + +| 라인 | 함수 | 변수 패턴 | 치환 후 | +|------|------|-----------|---------| +| 20 | onClick (연결선 모드) | cy | `getPageAtPoint(cx, cy)` | +| 126 | onClick (선택된 표 이동) | cy | 동일 | +| 173 | onClick (다중 그림 선택) | cy | 동일 | +| 354 | onClick (단일 그림 본체) | cy | 동일 | +| 928 | onMouseMove (연결선 미리보기) | cy | 동일 | +| 1143 | onMouseMove (그림 hover) | y | `getPageAtPoint(x, y)` | +| 1193 | onMouseMove (표 hover) | y | 동일 | +| 428 | onClick (셀 선택 표 리사이즈) | contentY | `getPageAtPoint(contentX, contentY)` | +| 470 | onClick (일반 click main path) | contentY | 동일 | +| 807 | onDblClick (머리말/꼬리말 진입) | contentY | 동일 | +| 886 | onContextMenu | contentY | 동일 | +| 1240 | handleResizeHover | contentY | 동일 | + +`replace_all` 3 회 (cy / y / contentY) — 각 패턴 안에서 X 변수 (cx/x/contentX) 가 모두 정의되어 있어 안전. + +### 1.2 input-handler.ts (4곳, 2 회 `replace_all`) + +| 라인 | 함수 | 변수 패턴 | 치환 후 | +|------|------|-----------|---------| +| 612 | 그림 객체 중심 좌표 → 페이지 | cY | `getPageAtPoint(cX, cY)` | +| 875 | 표 객체 중심 좌표 → 페이지 | cY | 동일 | +| 972 | hitTest helper | contentY | `getPageAtPoint(contentX, contentY)` | +| 1542 | hitTest helper (표 동일성 검증) | contentY | 동일 | + +### 1.3 input-handler-table.ts:400 + input-handler-picture.ts:594 (2곳) + +각 1건씩 컨텍스트 단건 Edit. `cy` → `cx, cy` 패턴 동일. + +--- + +## 2. Task #685 sweep 누락 buggy `pageLeft` 동반 정정 (10곳) + +### 2.1 마우스 컨텍스트 (`getPageAtY` 와 동일 함수, 6곳) + +| 파일 | 라인 | pageIdx 출처 | 치환 후 | +|------|------|--------------|---------| +| input-handler.ts | 612 | `getPageAtPoint(cX, cY)` | `getPageLeftResolved(pageIdx, (scrollContent as HTMLElement).clientWidth)` | +| input-handler.ts | 875 | `getPageAtPoint(cX, cY)` | `getPageLeftResolved(pageIdx, scrollContent.clientWidth)` | +| input-handler.ts | 972 | `getPageAtPoint(contentX, contentY)` | 동일 | +| input-handler.ts | 1542 | `getPageAtPoint(contentX, contentY)` | 동일 | +| input-handler-table.ts | 400 | `getPageAtPoint(cx, cy)` | `getPageLeftResolved(pi, sc.clientWidth)` | +| input-handler-picture.ts | 594 | `getPageAtPoint(cx, cy)` | 동일 | + +### 2.2 추가 발견 사이트 (`getPageAtY` 없음, pageIdx 신뢰값, 4곳) — 작업지시자 결정 (2026-05-08) + +`getPageAtY` 사용 안 함 (state 객체 또는 함수 매개변수에서 pageIdx 획득) 이지만 buggy `pageLeft` 패턴 사용 중. #689 의 본질 결함 (`getPageAtY` X 무시) 과 무관하나, **#685 sweep 누락분의 같은 카테고리** — 작업지시자 추가 승인으로 동반 정정. + +| 파일 | 라인 | pageIdx 출처 | 함수 | 치환 후 | +|------|------|--------------|------|---------| +| input-handler-table.ts | 74 | `this.resizeDragState.edge.pageIndex` | updateResizeDrag (드래그 마커) | `getPageLeftResolved(pageIdx, scrollContent.clientWidth)` | +| input-handler-table.ts | 111 | `state.edge.pageIndex` | finishResizeDrag | 동일 | +| input-handler-connector.ts | 85 | 함수 매개변수 `pageIdx` | onConnectorClick (연결점 후보 검색) | `getPageLeftResolved(pageIdx, sc.clientWidth)` | +| input-handler-connector.ts | 152 | 함수 매개변수 `pageIdx` | renderConnectorPreview | 동일 | + +→ 본 추가 정정으로 그리드 모드에서 표 리사이즈 드래그 / connector 좌표 어긋남도 함께 해소. + +--- + +## 3. 검증 결과 + +### 1. grep sweep — buggy pattern 잔여 0건 + +``` +$ grep -rnE "clientWidth\s*-\s*\w+\)\s*/\s*2" src/ +(0 매칭, exit=1) +``` + +### 2. 헬퍼 호출 수 카운트 + +``` +$ grep -r "getPageAtPoint" src/ | wc -l → 19 (정의 1 + 사용 18) +$ grep -r "getPageLeftResolved" src/ | wc -l → 26 (정의 1 + 사용 25) +``` + +사용 25 분포: +- #685 정정분: input-handler-mouse 14 + input-handler.ts 1 (formBboxToOverlayRect) = **15** +- #689 정정분: input-handler.ts 4 (L612/875/972/1542) + input-handler-table 3 (L400/74/111) + input-handler-picture 1 (L594) + input-handler-connector 2 (L85/152) = **10** + +→ 정확히 일치 (15 + 10 = 25). + +### 3. typecheck + +``` +$ npx tsc --noEmit +(무에러) +``` + +### 4. vite build + +``` +$ npx vite build +✓ 85 modules transformed. +✓ built in (정상) +PWA v1.2.0 — generateSW 정상 +``` + +### 5. e2e — `body-outside-click-fallback.test.mjs --mode=headless` + +``` +$ exit=0 +- ERROR / FAIL / 에러 / 가설.yes: 0 매칭 +``` + +→ 단일 컬럼 모드 click 좌표 무회귀 확인 (헬퍼 두 종 모두 sentinel/Y-only fallback 경로 정상). + +--- + +## 4. 회귀 위험 점검 + +| 영역 | 위험 | 결과 | +|------|------|------| +| 단일 컬럼 모드 click | `getPageAtPoint` 가 `getPageAtY` 동치 안 되면 회귀 | OK — 명시 분기 + body-outside-click-fallback e2e 무회귀 | +| 단일 컬럼 모드 pageLeft | `getPageLeftResolved` fallback 식 무회귀 | OK — #685 에서 이미 검증, 본 작업 추가 사이트도 동일 헬퍼 | +| `pw` / `pageDisplayWidth` 변수 | hit test bbox 등 다른 사용처 | OK — 모두 보존, typecheck 통과 | +| 변수명 mismatch (`cx`/`x`/`contentX`/`cX`) | 페어링 잘못 | OK — `replace_all` 패턴 분리 + 각 그룹 변수명 일관 (e.g., `cy` → `cx, cy` 매칭) | +| viewport-center 영역 (canvas-view, keyboard) | 헬퍼 통일 욕심 시 회귀 | OK — 미수정 명시 | +| 추가 4 사이트 (table L74/111, connector L85/152) | 신뢰 pageIdx 인 곳에 헬퍼 적용 시 회귀 | OK — `getPageLeftResolved` 가 신뢰값 pageIdx 받아도 정상 (sentinel 검사 후 pageLefts[i] 반환) | + +--- + +## 5. 다음 단계 + +Stage 3 — `grid-mode-click-coord.test.mjs` 의 non-last col SKIP 분기를 strict assert 로 활성화 + 추가 col probe + 시각 검증. + +승인 요청 → 승인 시 Stage 3 진행. diff --git a/rhwp-studio/src/engine/input-handler-connector.ts b/rhwp-studio/src/engine/input-handler-connector.ts index 9376d494a..4cd4cc71c 100644 --- a/rhwp-studio/src/engine/input-handler-connector.ts +++ b/rhwp-studio/src/engine/input-handler-connector.ts @@ -82,7 +82,7 @@ export function showConnectionPointOverlay( const zoom = this.viewportManager.getZoom(); const po = this.virtualScroll.getPageOffset(pageIdx); const pw = this.virtualScroll.getPageWidth(pageIdx); - const pl = (sc.clientWidth - pw) / 2; + const pl = this.virtualScroll.getPageLeftResolved(pageIdx, sc.clientWidth); // 마우스 근처 개체 찾기 (bbox 내부) for (const ctrl of layout.controls) { @@ -149,7 +149,7 @@ export function updateConnectorPreview( const zoom = this.viewportManager.getZoom(); const po = this.virtualScroll.getPageOffset(pageIdx); const pw = this.virtualScroll.getPageWidth(pageIdx); - const pl = (sc.clientWidth - pw) / 2; + const pl = this.virtualScroll.getPageLeftResolved(pageIdx, sc.clientWidth); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'connector-preview'); diff --git a/rhwp-studio/src/engine/input-handler-mouse.ts b/rhwp-studio/src/engine/input-handler-mouse.ts index fd7cc1f35..9c3533944 100644 --- a/rhwp-studio/src/engine/input-handler-mouse.ts +++ b/rhwp-studio/src/engine/input-handler-mouse.ts @@ -17,7 +17,7 @@ export function onClick(this: any, e: MouseEvent): void { const cr = sc.getBoundingClientRect(); const cx = e.clientX - cr.left; const cy = e.clientY - cr.top; - const pi = this.virtualScroll.getPageAtY(cy); + const pi = this.virtualScroll.getPageAtPoint(cx, cy); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth); @@ -123,7 +123,7 @@ export function onClick(this: any, e: MouseEvent): void { const cr = sc.getBoundingClientRect(); const cx = e.clientX - cr.left; const cy = e.clientY - cr.top; - const pi = this.virtualScroll.getPageAtY(cy); + const pi = this.virtualScroll.getPageAtPoint(cx, cy); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth); @@ -170,7 +170,7 @@ export function onClick(this: any, e: MouseEvent): void { const cr = sc.getBoundingClientRect(); const cx = e.clientX - cr.left; const cy = e.clientY - cr.top; - const pi = this.virtualScroll.getPageAtY(cy); + const pi = this.virtualScroll.getPageAtPoint(cx, cy); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth); @@ -351,7 +351,7 @@ export function onClick(this: any, e: MouseEvent): void { const cr = sc.getBoundingClientRect(); const cx = e.clientX - cr.left; const cy = e.clientY - cr.top; - const pi = this.virtualScroll.getPageAtY(cy); + const pi = this.virtualScroll.getPageAtPoint(cx, cy); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth); @@ -425,7 +425,7 @@ export function onClick(this: any, e: MouseEvent): void { const contentRect = scrollContent.getBoundingClientRect(); const contentX = e.clientX - contentRect.left; const contentY = e.clientY - contentRect.top; - const pageIdx = this.virtualScroll.getPageAtY(contentY); + const pageIdx = this.virtualScroll.getPageAtPoint(contentX, contentY); const pageOffset = this.virtualScroll.getPageOffset(pageIdx); const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth); @@ -467,7 +467,7 @@ export function onClick(this: any, e: MouseEvent): void { const contentY = e.clientY - contentRect.top; // 페이지 찾기 - const pageIdx = this.virtualScroll.getPageAtY(contentY); + const pageIdx = this.virtualScroll.getPageAtPoint(contentX, contentY); const pageOffset = this.virtualScroll.getPageOffset(pageIdx); // CSS 중앙 정렬 보정 (left:50%; transform:translateX(-50%)) @@ -804,7 +804,7 @@ export function onDblClick(this: any, e: MouseEvent): void { const cr = sc.getBoundingClientRect(); const contentX = e.clientX - cr.left; const contentY = e.clientY - cr.top; - const pageIdx = this.virtualScroll.getPageAtY(contentY); + const pageIdx = this.virtualScroll.getPageAtPoint(contentX, contentY); if (pageIdx >= 0) { const pageOffset = this.virtualScroll.getPageOffset(pageIdx); const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); @@ -883,7 +883,7 @@ export function onContextMenu(this: any, e: MouseEvent): void { const contentRect = scrollContent.getBoundingClientRect(); const contentX = e.clientX - contentRect.left; const contentY = e.clientY - contentRect.top; - const pageIdx = this.virtualScroll.getPageAtY(contentY); + const pageIdx = this.virtualScroll.getPageAtPoint(contentX, contentY); const pageOffset = this.virtualScroll.getPageOffset(pageIdx); const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth); @@ -925,7 +925,7 @@ export function onMouseMove(this: any, e: MouseEvent): void { const cr = sc.getBoundingClientRect(); const cx = e.clientX - cr.left; const cy = e.clientY - cr.top; - const pi = this.virtualScroll.getPageAtY(cy); + const pi = this.virtualScroll.getPageAtPoint(cx, cy); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth); @@ -1140,7 +1140,7 @@ export function onMouseMove(this: any, e: MouseEvent): void { const picBbox = this.findPictureBbox(ref); if (picBbox) { const zoom = this.viewportManager.getZoom(); - const pi = this.virtualScroll.getPageAtY(y); + const pi = this.virtualScroll.getPageAtPoint(x, y); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); const pl = this.virtualScroll.getPageLeftResolved(pi, scrollContent.clientWidth); @@ -1190,7 +1190,7 @@ export function onMouseMove(this: any, e: MouseEvent): void { const ref = this.cursor.getSelectedTableRef(); if (ref) { const zoom = this.viewportManager.getZoom(); - const pi = this.virtualScroll.getPageAtY(y); + const pi = this.virtualScroll.getPageAtPoint(x, y); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); const pl = this.virtualScroll.getPageLeftResolved(pi, scrollContent.clientWidth); @@ -1237,7 +1237,7 @@ export function handleResizeHover(this: any, e: MouseEvent): void { const contentRect = scrollContent.getBoundingClientRect(); const contentX = e.clientX - contentRect.left; const contentY = e.clientY - contentRect.top; - const pageIdx = this.virtualScroll.getPageAtY(contentY); + const pageIdx = this.virtualScroll.getPageAtPoint(contentX, contentY); const pageOffset = this.virtualScroll.getPageOffset(pageIdx); const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth); diff --git a/rhwp-studio/src/engine/input-handler-picture.ts b/rhwp-studio/src/engine/input-handler-picture.ts index a7dc061ca..85fd67c06 100644 --- a/rhwp-studio/src/engine/input-handler-picture.ts +++ b/rhwp-studio/src/engine/input-handler-picture.ts @@ -591,10 +591,10 @@ export function updatePictureMoveDrag(this: any, e: MouseEvent): void { const cr = sc.getBoundingClientRect(); const cx = e.clientX - cr.left; const cy = e.clientY - cr.top; - const pi = this.virtualScroll.getPageAtY(cy); + const pi = this.virtualScroll.getPageAtPoint(cx, cy); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); - const pl = (sc.clientWidth - pw) / 2; + const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth); const px = (cx - pl) / zoom; const py = (cy - po) / zoom; diff --git a/rhwp-studio/src/engine/input-handler-table.ts b/rhwp-studio/src/engine/input-handler-table.ts index 02a9737c1..fd68c36ea 100644 --- a/rhwp-studio/src/engine/input-handler-table.ts +++ b/rhwp-studio/src/engine/input-handler-table.ts @@ -71,7 +71,7 @@ export function updateResizeDrag(this: any, e: MouseEvent): void { const pageIdx = this.resizeDragState.edge.pageIndex; const pageOffset = this.virtualScroll.getPageOffset(pageIdx); const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); - const pageLeft = (scrollContent.clientWidth - pageDisplayWidth) / 2; + const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth); const pageX = (contentX - pageLeft) / zoom; const pageY = (contentY - pageOffset) / zoom; @@ -108,7 +108,7 @@ export function finishResizeDrag(this: any, e: MouseEvent): void { const pageIdx = state.edge.pageIndex; const pageOffset = this.virtualScroll.getPageOffset(pageIdx); const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); - const pageLeft = (scrollContent.clientWidth - pageDisplayWidth) / 2; + const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth); const pageX = (contentX - pageLeft) / zoom; const pageY = (contentY - pageOffset) / zoom; @@ -397,10 +397,10 @@ export function updateMoveDrag(this: any, e: MouseEvent): void { const cr = sc.getBoundingClientRect(); const cx = e.clientX - cr.left; const cy = e.clientY - cr.top; - const pi = this.virtualScroll.getPageAtY(cy); + const pi = this.virtualScroll.getPageAtPoint(cx, cy); const po = this.virtualScroll.getPageOffset(pi); const pw = this.virtualScroll.getPageWidth(pi); - const pl = (sc.clientWidth - pw) / 2; + const pl = this.virtualScroll.getPageLeftResolved(pi, sc.clientWidth); const px = (cx - pl) / zoom; const py = (cy - po) / zoom; diff --git a/rhwp-studio/src/engine/input-handler.ts b/rhwp-studio/src/engine/input-handler.ts index 6b38833f9..f126dbc40 100644 --- a/rhwp-studio/src/engine/input-handler.ts +++ b/rhwp-studio/src/engine/input-handler.ts @@ -609,10 +609,10 @@ export class InputHandler { const centerY = (minY + maxY) / 2; const cX = centerX - contentRect.left; const cY = centerY - contentRect.top; - const pageIdx = this.virtualScroll.getPageAtY(cY); + const pageIdx = this.virtualScroll.getPageAtPoint(cX, cY); const pageOffset = this.virtualScroll.getPageOffset(pageIdx); const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); - const pageLeft = ((scrollContent as HTMLElement).clientWidth - pageDisplayWidth) / 2; + const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, (scrollContent as HTMLElement).clientWidth); const paperX = ((cX - pageLeft) / zoom) * 75; const paperY = ((cY - pageOffset) / zoom) * 75; const horzOffset = Math.max(0, Math.round(paperX - wHwp / 2)); @@ -872,10 +872,10 @@ export class InputHandler { const contentRect = scrollContent.getBoundingClientRect(); const cX = centerX - contentRect.left; const cY = centerY - contentRect.top; - const pageIdx = this.virtualScroll.getPageAtY(cY); + const pageIdx = this.virtualScroll.getPageAtPoint(cX, cY); const pageOffset = this.virtualScroll.getPageOffset(pageIdx); const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); - const pageLeft = (scrollContent.clientWidth - pageDisplayWidth) / 2; + const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth); // 종이 좌표 (px → HWPUNIT) const paperX = ((cX - pageLeft) / zoom) * 75; const paperY = ((cY - pageOffset) / zoom) * 75; @@ -969,10 +969,10 @@ export class InputHandler { const contentRect = scrollContent.getBoundingClientRect(); const contentX = e.clientX - contentRect.left; const contentY = e.clientY - contentRect.top; - const pageIdx = this.virtualScroll.getPageAtY(contentY); + const pageIdx = this.virtualScroll.getPageAtPoint(contentX, contentY); const pageOffset = this.virtualScroll.getPageOffset(pageIdx); const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); - const pageLeft = (scrollContent.clientWidth - pageDisplayWidth) / 2; + const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth); const pageX = (contentX - pageLeft) / zoom; const pageY = (contentY - pageOffset) / zoom; try { @@ -1539,10 +1539,10 @@ export class InputHandler { const contentRect = scrollContent.getBoundingClientRect(); const contentX = e.clientX - contentRect.left; const contentY = e.clientY - contentRect.top; - const pageIdx = this.virtualScroll.getPageAtY(contentY); + const pageIdx = this.virtualScroll.getPageAtPoint(contentX, contentY); const pageOffset = this.virtualScroll.getPageOffset(pageIdx); const pageDisplayWidth = this.virtualScroll.getPageWidth(pageIdx); - const pageLeft = (scrollContent.clientWidth - pageDisplayWidth) / 2; + const pageLeft = this.virtualScroll.getPageLeftResolved(pageIdx, scrollContent.clientWidth); const pageX = (contentX - pageLeft) / zoom; const pageY = (contentY - pageOffset) / zoom; try { From 62de2a1abebb7b70a0c93517754897b98c77193c Mon Sep 17 00:00:00 2001 From: johndoekim Date: Fri, 8 May 2026 00:58:30 +0900 Subject: [PATCH 12/13] =?UTF-8?q?Task=20#689=20Stage=203:=20e2e=20strict?= =?UTF-8?q?=20assert=20=ED=99=9C=EC=84=B1=ED=99=94=20+=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=20=EB=AA=A8=EB=93=9C=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?col=20click=20=EC=A0=95=ED=95=A9=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=ED=9A=8C=EA=B7=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #689 정정 작업의 3/3 단계. Task #685 Stage 3 에서 SKIP 처리했던 non-last col rectPageIdx 정합 assert 를 활성화하여 Task #685 + #689 결합 효과 자동 회귀화. 변경: - grid-mode-click-coord.test.mjs probeClickAtPage: isLastCol 분기 + SKIP 로깅 제거 → 모든 col strict assert 일원화. - [3b] zoom=0.25 last-col only probe → for 루프로 col 0~4 5건 모두 probe (5-col 그리드의 모든 위치 정합 차단). - 진단노트 grid_mode_click_coord.md 끝부분에 "완전 정정 완료 (Task #685 + #689 결합)" 섹션 추가 (getPageAtPoint 헬퍼 + 18곳 치환 + 10곳 buggy pageLeft 동반 정정 + 모든 col 정합 표 + e2e 결과 기록). 검증 결과 (--mode=headless, exit 0): PASS=21 / FAIL=0 / SKIP=0 (이전 SKIP 2건 모두 PASS 로 전환) 세부 PASS: - zoom=0.5/0.25/1.0 dumpGridState getPageLeftResolved 동치성 (3 PASS) - zoom=0.25 col 0~4 cursor.pos + rectPageIdx 정합 (10 PASS) - zoom=1.0 baseline cursor.pos + rectPageIdx (2 PASS) - zoom=0.5 page 0 (col 0) / page 1 (col 1) / page 2 (col 0 of row 1) cursor.pos + rectPageIdx (6 PASS) 이전 어긋남 모두 정합화: - zoom=0.5 page 0: rectPageIdx 1→0 - zoom=0.5 page 2: rectPageIdx 3→2 단일 컬럼 baseline (zoom=1.0) 무회귀 유지. 본 정정의 본질 효과 = Task #685 + #689 결합으로 그리드 모드 click 한컴 호환 완성. 시각 검증은 작업지시자 환경에서 1회 직접 확인 권장. Co-Authored-By: Claude Opus 4.7 --- .../troubleshootings/grid_mode_click_coord.md | 17 +++ mydocs/working/task_m100_689_stage3.md | 130 ++++++++++++++++++ .../e2e/grid-mode-click-coord.test.mjs | 26 ++-- 3 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 mydocs/working/task_m100_689_stage3.md diff --git a/mydocs/troubleshootings/grid_mode_click_coord.md b/mydocs/troubleshootings/grid_mode_click_coord.md index ded743882..7c39e56ca 100644 --- a/mydocs/troubleshootings/grid_mode_click_coord.md +++ b/mydocs/troubleshootings/grid_mode_click_coord.md @@ -175,3 +175,20 @@ Stage 3 e2e assert 강화로 추가 결함이 노출됨: [`virtual-scroll.ts` `g | 0.25 | 5 | ✅ col 4 | ❌ col 0~3 | 본 후속 결함은 [Issue #689](https://github.com/edwardkim/rhwp/issues/689) 으로 분리 등록 (`getPageAtPoint(docX, docY)` 헬퍼 도입 + 14곳 `getPageAtY` 호출 일괄 치환 방향). Task #685 의 정정은 last-col 케이스에서 정상 동작하며, non-last col 정정은 #689 에서 진행. + +## 완전 정정 완료 (2026-05-08, Task #685 + #689 결합) + +후속 결함 (`getPageAtY` X 무시) 정정 완료 (Task #689): + +- `virtualScroll.getPageAtPoint(docX, docY)` 헬퍼 도입 — 단일 컬럼은 `getPageAtY` 동치, 그리드는 row(Y) 안에서 X 로 col 결정, gap 영역은 가장 가까운 페이지로 fallback. +- 마우스 컨텍스트 18곳 `getPageAtY` → `getPageAtPoint` 치환 (input-handler-mouse 12곳 + input-handler.ts 4곳 + table 1곳 + picture 1곳). +- Task #685 sweep 의 input-handler-mouse 한정 누락분 buggy `pageLeft` **10곳 동반 정정** (input-handler.ts 4곳 + table 3곳 + picture 1곳 + connector 2곳). +- e2e (`grid-mode-click-coord.test.mjs`) strict assert 활성화 — 모든 col CORRECT click → cursor.rectPageIdx 정합 검증. + +| zoom | columns | 모든 col 정합 | +|------|---------|--------------| +| 1.0 | 1 | ✅ (col 0) | +| 0.5 | 2 | ✅ (col 0, col 1) | +| 0.25 | 5 | ✅ (col 0~4) | + +→ Task #685 + #689 결합으로 그리드 모드 click 한컴 호환 완성. e2e 결과: **PASS=21 / FAIL=0 / SKIP=0** (이전 SKIP 2건 모두 PASS 로 전환). diff --git a/mydocs/working/task_m100_689_stage3.md b/mydocs/working/task_m100_689_stage3.md new file mode 100644 index 000000000..d79f647a9 --- /dev/null +++ b/mydocs/working/task_m100_689_stage3.md @@ -0,0 +1,130 @@ +# Task #689 Stage 3 단계 보고서 — e2e strict assert 활성화 + 그리드 모드 모든 col click 정합 자동 회귀화 + +- **이슈**: [#689](https://github.com/edwardkim/rhwp/issues/689) +- **수행계획서**: [task_m100_689.md](../plans/task_m100_689.md) +- **구현계획서**: [task_m100_689_impl.md](../plans/task_m100_689_impl.md) +- **단계 위치**: 3 단계 중 3/3 +- **변경 성격**: e2e 회귀 자동화 (Task #685 + #689 결합 효과 검증) +- **작성일**: 2026-05-08 + +--- + +## 변경 요약 + +| 파일 | 변경 | +|------|------| +| `rhwp-studio/e2e/grid-mode-click-coord.test.mjs` | SKIP 분기 제거 → 모든 col strict assert (-12/+5 LOC). zoom=0.25 last-col only probe → 5 col 모두 probe (+3 LOC). | +| `mydocs/troubleshootings/grid_mode_click_coord.md` | "완전 정정 완료 (#685 + #689 결합)" 섹션 추가 (+18 LOC) | + +총 코드 변경: ~+15 LOC. + +--- + +## 1. e2e 변경 내용 + +### 1.1 `probeClickAtPage` — SKIP 분기 제거 + +기존 (Task #685 Stage 3 시점): +```ts +if (probe.isLastCol) { + assert(afterCorrectClick.rectPageIdx === pageIdx, ...); +} else { + console.log(` SKIP: ... — Issue #689 후속`); +} +``` + +신규 (Task #689 Stage 3): +```ts +// Task #685 + #689 결합 정정 후: 모든 col CORRECT click → 의도한 페이지에 cursor 배치. +assert( + afterCorrectClick.rectPageIdx === pageIdx, + `[${label}] CORRECT click → cursor.rectPageIdx=${afterCorrectClick.rectPageIdx} (기대 ${pageIdx}, col=${probe.col}/columns=${probe.columns}${probe.isLastCol ? ' last' : ''})` +); +``` + +### 1.2 `[3b]` 블록 — zoom=0.25 모든 col probe 확장 + +기존: `zoom=0.25 last col` (col 4) 만 probe. + +신규: `for (let c = 0; c < columns; c++)` 으로 col 0~4 모든 페이지 probe — 5-col 그리드의 모든 위치 정합 자동 회귀 차단. + +--- + +## 2. e2e 검증 결과 + +### `grid-mode-click-coord.test.mjs --mode=headless` + +``` +$ exit=0 +PASS: 21 FAIL: 0 SKIP: 0 +``` + +세부 항목 (모든 PASS): + +| # | 검증 항목 | 결과 | +|---|----------|------| +| 1 | `[zoom=0.5 그리드 상태]` getPageLeftResolved 동치성 (max delta=0.00 px) | PASS | +| 2 | `[zoom=0.25 그리드 상태]` 동치성 | PASS | +| 3-12 | `[page 0~4 (zoom=0.25 col 0~4)]` cursor.pos !== null + rectPageIdx 정합 (10 PASS) | PASS | +| 13 | `[zoom=1.0 단일 컬럼 baseline]` 동치성 | PASS | +| 14-15 | `[page 0 (zoom=1.0 single col)]` cursor.pos + rectPageIdx | PASS | +| 16-17 | `[page 0 (zoom=0.5 col 0)]` cursor.pos + rectPageIdx (이전 SKIP, 현재 PASS) | PASS | +| 18-19 | `[page 1 (zoom=0.5 col 1 last)]` cursor.pos + rectPageIdx | PASS | +| 20-21 | `[page 2 (zoom=0.5 col 0, row 1)]` cursor.pos + rectPageIdx (이전 SKIP, 현재 PASS) | PASS | + +→ Task #685 (pageLeft 공식) + Task #689 (`getPageAtPoint`) 결합 효과 자동 회귀화 완성. + +### 정량 비교 (Before vs After) + +| 케이스 | Task #685 종결 시점 | Task #689 종결 시점 | +|--------|---------------------|---------------------| +| zoom=0.5 col 0 (page 0) | rectPageIdx=1 (어긋남) | rectPageIdx=0 ✅ | +| zoom=0.5 col 1 (page 1) | rectPageIdx=1 ✅ | rectPageIdx=1 ✅ | +| zoom=0.5 col 0 (page 2) | rectPageIdx=3 (어긋남) | rectPageIdx=2 ✅ | +| zoom=0.25 col 0 (page 0) | (미측정) | rectPageIdx=0 ✅ | +| zoom=0.25 col 1 (page 1) | (미측정) | rectPageIdx=1 ✅ | +| zoom=0.25 col 2 (page 2) | (미측정) | rectPageIdx=2 ✅ | +| zoom=0.25 col 3 (page 3) | (미측정) | rectPageIdx=3 ✅ | +| zoom=0.25 col 4 (page 4) | rectPageIdx=4 ✅ | rectPageIdx=4 ✅ | +| zoom=1.0 page 0 | rectPageIdx=0 ✅ | rectPageIdx=0 ✅ (무회귀) | + +→ **이전 어긋남 모두 정합화 + 단일 컬럼 baseline 무회귀**. + +--- + +## 3. 진단노트 갱신 + +[`mydocs/troubleshootings/grid_mode_click_coord.md`](../troubleshootings/grid_mode_click_coord.md) 끝부분에 "완전 정정 완료 (Task #685 + #689 결합)" 섹션 추가: +- `getPageAtPoint` 헬퍼 도입 + 18곳 치환 + 10곳 buggy pageLeft 동반 정정 명시 +- 모든 col 정합 표 (zoom=1.0/0.5/0.25) +- e2e 결과 (PASS=21/FAIL=0/SKIP=0) 기록 + +→ Task #685 의 "부분 정정" 기록과 #689 의 "완전 정정" 기록이 진단노트 안에서 시간 순으로 명확히 분리됨. + +--- + +## 4. 시각 검증 (작업지시자 권한) + +자동 e2e 가 PASS=21 로 모든 col 정합 검증을 완료했으므로, 시각 검증은 작업지시자가 환경에서 1회 직접 확인 권장: +- `samples/hwpctl_action_table_v11.hwp` 그리드 모드 (zoom=0.5, 0.25) +- 페이지 1, 2, 4, 5 (이전 어긋남) 클릭 → 캐럿이 클릭한 페이지 안에 정확 배치 +- zoom=1.0 일반 클릭 무회귀 + +--- + +## 5. 회귀 위험 점검 + +| 영역 | 위험 | 결과 | +|------|------|------| +| 기존 last-col 케이스 (Task #685 의 PASS 영역) | strict assert 활성화로 영향 | OK — 모두 PASS 유지 (col=last 이고 helperResolved 동치 보장) | +| zoom=1.0 단일 컬럼 baseline | `getPageAtPoint` Y-only fallback 정합 | OK — 명시 분기 + body-outside-click-fallback 무회귀 (Stage 1/2 검증) | +| 새 probe 추가로 e2e 시간 증가 | zoom=0.25 5-col probe | 무시할 수 있는 수준 (각 probe ~1초) | +| dumpGridState helperResolved assert | 임계값 |delta| < 0.01 px | OK — max delta=0.00 px 모든 모드 | + +--- + +## 6. 다음 단계 + +최종 결과보고서 작성 (`mydocs/report/task_m100_689_report.md`) + `mydocs/orders/20260508.md` 갱신 → 승인 후 #689 close + `local/devel` 머지. + +승인 요청 → 승인 시 최종 단계 진행. diff --git a/rhwp-studio/e2e/grid-mode-click-coord.test.mjs b/rhwp-studio/e2e/grid-mode-click-coord.test.mjs index f34b78ddd..5dcf15259 100644 --- a/rhwp-studio/e2e/grid-mode-click-coord.test.mjs +++ b/rhwp-studio/e2e/grid-mode-click-coord.test.mjs @@ -156,18 +156,12 @@ async function probeClickAtPage(page, label, pageIdx, hwpX, hwpY) { `[${label}] CORRECT click → cursor.pos !== null` ); - // rectPageIdx assert 는 last-col 케이스에서만 strict 하게 검증. - // non-last col 은 getPageAtY 가 X 무시하고 row 의 last page idx 만 반환하는 별개 결함 (Issue #689) - // 으로 인해 항상 마지막 col 페이지로 cursor 가 떨어짐. Task #685 의 pageLeft 정정만으로는 해결되지 않는 - // 후속 결함이며, 본 e2e 는 #685 정정 효과 (last col 에서 정확한 pageLefts[i] 적용) 만 검증. - if (probe.isLastCol) { - assert( - afterCorrectClick.rectPageIdx === pageIdx, - `[${label}] CORRECT click → cursor.rectPageIdx=${afterCorrectClick.rectPageIdx} (기대 ${pageIdx}, last col=${probe.col}/columns=${probe.columns})` - ); - } else { - console.log(` SKIP: [${label}] non-last col rectPageIdx strict assert (col=${probe.col}/columns=${probe.columns}) — getPageAtY X-무시 결함, Issue #689 후속`); - } + // Task #685 + #689 결합 정정 후: 모든 col CORRECT click → 의도한 페이지에 cursor 배치. + // (#685 가 pageLeft 공식, #689 가 getPageAtPoint 도입으로 그리드 X+Y 인지) + assert( + afterCorrectClick.rectPageIdx === pageIdx, + `[${label}] CORRECT click → cursor.rectPageIdx=${afterCorrectClick.rectPageIdx} (기대 ${pageIdx}, col=${probe.col}/columns=${probe.columns}${probe.isLastCol ? ' last' : ''})` + ); return { probe, correctClick, afterCorrectClick, afterBuggyClick }; } @@ -203,10 +197,12 @@ runTest('보류 ① 그리드 좌표 결함 — exam_kor.hwp zoom=0.5 정량 측 const stateZ025 = await dumpGridState(page, 'zoom=0.25 그리드 상태'); - // [3b] zoom=0.25 last col (col=4) click 검증 — pageLeft 정정 효과 + // [3b] zoom=0.25 모든 col click 검증 — Task #685 + #689 결합 정정 효과 if (stateZ025.columns >= 2 && stateZ025.pageCount > stateZ025.columns - 1) { - const lastColPage = stateZ025.columns - 1; - await probeClickAtPage(page, `page ${lastColPage} (zoom=0.25 last col)`, lastColPage, 100, 200); + for (let c = 0; c < stateZ025.columns; c++) { + const isLast = c === stateZ025.columns - 1; + await probeClickAtPage(page, `page ${c} (zoom=0.25 col ${c}${isLast ? ' last' : ''})`, c, 100, 200); + } } // [4] zoom=1.0 (단일 컬럼) - 비교 baseline From f1591fb1930b66ccbf3075a4bba690ea3f6b4808 Mon Sep 17 00:00:00 2001 From: johndoekim Date: Fri, 8 May 2026 01:08:15 +0900 Subject: [PATCH 13/13] =?UTF-8?q?Task=20#689:=20=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EB=B3=B4=EA=B3=A0=EC=84=9C=20+=20orders=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20(closes=20#689)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #689 (그리드 모드 getPageAtY X 좌표 무시 — non-last col 페이지 click 어긋남) 정정 완료. Task #685 누락분 buggy pageLeft 10곳도 동반 정정. Task #685 + #689 결합으로 그리드 모드 click 한컴 호환 완성. Stage 1+2+3 자동 검증: - typecheck / vite build / body-outside-click-fallback 무회귀 - grid-mode-click-coord PASS=21/FAIL=0/SKIP=0 (모든 col strict) - zoom=0.5 col 0/1, zoom=0.25 col 0~4, zoom=1.0 모두 rectPageIdx 정합 작업지시자 시각 검증 (hwpctl_action_table_v11.hwp, 2026-05-08): 이전 "1, 2 페이지 클릭 안 됨" 결함 정합화 — "오 정상적으로 잘 된다." 코드 품질 검토 결정 (작업지시자 review, 2026-05-08): - getPageLeftResolved API 의 containerWidth 인자 leaky abstraction 검토 → Option A (stateless click-time 측정) 유지 - 이유: race condition 없음, ResizeObserver 누락 케이스 robust, 미래 변경 시 fragile 안 함. verbose trade-off 보다 robustness 우선 산출물: - mydocs/report/task_m100_689_report.md (최종 결과보고서) - mydocs/orders/20260508.md (#689 [완료] + 추가 발견 4곳 + Option A 결정 메모) 후속 머지 절차 (작업지시자 권한): - git merge local/task689 --no-ff -m "Merge local/devel: Task #689 ..." - gh issue close 689 Co-Authored-By: Claude Opus 4.7 --- mydocs/orders/20260508.md | 2 +- mydocs/report/task_m100_689_report.md | 172 ++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 mydocs/report/task_m100_689_report.md diff --git a/mydocs/orders/20260508.md b/mydocs/orders/20260508.md index d19a8cc38..cb8f6ab1a 100644 --- a/mydocs/orders/20260508.md +++ b/mydocs/orders/20260508.md @@ -5,7 +5,7 @@ | Issue | 타스크 | 상태 | 비고 | |------|--------|------|------| | **Task #685** | rhwp-studio 그리드 모드 (zoom ≤ 0.5) click 좌표 단일 컬럼 가정 14곳 분기 일괄 어긋남 정정 | **완료 (Stage 1+2+3, 부분 정정 — 작업지시자 scope 결정)** | Task #595 후속 sweep 으로 발견된 한컴 호환 결함. 분기 기준점: `da7461d` (Merge local/devel: Task #595 본 작업 + 후속 sweep, Issue #685/#686 등록 포함). **본질 결함**: `virtual-scroll.ts` 의 그리드 모드 (`pageLefts[i] = marginLeft + col*(pw+gap)`) 가 `input-handler-mouse.ts` 14곳 모두 단일 컬럼 가정 공식 `(scrollContent.clientWidth - pageDisplayWidth) / 2` 만 사용해 그리드 모드 click 좌표 ±수백 px 어긋남. **정정 (3 단계)**: (Stage 1) `virtual-scroll.ts` 에 `getPageLeftResolved(pageIdx, containerWidth)` 헬퍼 신규 (+12 LOC) — 그리드 모드 `pageLefts[i]` / 단일 컬럼 `(containerWidth - pageWidth) / 2` fallback. `input-handler.ts:2572-2586 formBboxToOverlayRect` 의 verbose sentinel 패턴 1곳도 헬퍼 호출로 단순화 (-3/+1 LOC, 동치 refactor). 기존 `getPageLeft` 4 호출자 (`canvas-view.ts`/`field-marker-renderer.ts`/`caret-renderer.ts`) 무회귀 보존. (Stage 2) `input-handler-mouse.ts` 14곳 (라인 23/129/176/279/296/357/431/**475**/**811**/**889**/931/1146/1196/1243) 헬퍼 일괄 치환 (+14/-14 LOC, 1:1 표현식 치환). `pw`/`pageDisplayWidth` 변수 + 페이지 인덱스 변수 (`pi`/`pageIdx`/`picBbox.pageIndex`) 모두 보존. 6 회 Edit (replace_all 3 + 컨텍스트 단건 3, prefix 분리). (Stage 3) `e2e/grid-mode-click-coord.test.mjs` assert 강화 (+54 LOC) — `dumpGridState` 헬퍼 동치성 + `probeClickAtPage` last-col only strict assert (non-last col 은 후속 결함 #689 로 SKIP 안내). 추가 probe: zoom=0.25 last col + zoom=1.0 baseline. 진단노트 `mydocs/troubleshootings/grid_mode_click_coord.md` 끝부분에 "정정 완료 — 부분 정정 (Task #685)" + #689 안내 +20 LOC. **검증**: typecheck 무에러 / vite build 정상 / `body-outside-click-fallback.test.mjs --mode=headless` 무회귀 / `grid-mode-click-coord.test.mjs --mode=headless` **PASS=11/FAIL=0/SKIP=2** (의도된 non-last col). last-col 정합 자동 회귀: zoom=1.0 page 0, zoom=0.5 page 1, zoom=0.25 page 4 모두 helperResolved max delta=0.00px + cursor.rectPageIdx 정합. **시각 검증 (작업지시자 직접, hwpctl_action_table_v11.hwp 그리드 모드, 2026-05-08)**: non-last col 페이지(1, 2, 4, 5 등) 어긋남 = #689 시각 재현 / last col 페이지(3, 6, 9 등) 정상 = #685 정정 효과 확인. **Stage 3 발견사항 → Issue #689 분리 등록**: `virtual-scroll.ts:133-140 getPageAtY(docY)` 가 Y 좌표만 보고 row 의 last page idx 만 반환 → non-last col click 어긋남 (Task #685 의 pageLeft 정정과 별개의 결함 영역). **작업지시자 결정 (scope 엄격 준수)**: Task #685 본문 명시 범위 (pageLeft 공식 14곳) 그대로 유지 + #689 분리 등록 + 즉시 #689 시작. 정정 방향: `getPageAtPoint(docX, docY)` 헬퍼 + 14곳 일괄 치환. 커밋 `769f534` (Stage 1) + `d982d50` (Stage 2) + `7fdf01d` (Stage 3). 수행계획서: `mydocs/plans/task_m100_685.md`. 구현계획서: `mydocs/plans/task_m100_685_impl.md`. 단계 보고서: `mydocs/working/task_m100_685_stage{1,2,3}.md`. 최종 결과 보고서: `mydocs/report/task_m100_685_report.md`. **`feedback_process_must_follow` 메모리 권위 영역 강화** — Stage 3 추가 결함 발견 시 scope 확장 충동 억제 + 작업지시자 결정 후 #689 분리 등록 패턴 정합. **`feedback_hancom_compat_specific_over_general` 정합** — 그리드 모드 한컴 호환 결함 (한컴 정상 / RHWP 어긋남) 영역 본질 정정. **회귀 차단 가드 영구 보존** — e2e assert 강화로 last-col 정합 자동 회귀 차단. **부분 정정 명시 패턴** — 진단노트 + 결과보고서 + orders 모두 "Task #685 = 부분 정정, #689 후속" 명시로 후속 영역 추적 가능성 확보. | -| **Issue #689** | rhwp-studio 그리드 모드 `getPageAtY` X 좌표 무시 — non-last col 페이지 click 어긋남 정정 | **시작 예정 (Task #685 종결 후 즉시)** | Task #685 Stage 3 e2e assert 강화로 노출된 후속 결함. M100 (v1.0.0), 우선순위 High (UX-blocking). **본질 결함**: `virtual-scroll.ts:133-140 getPageAtY(docY)` Y-only loop 가 그리드 모드의 row-shared `pageOffsets[i]` 환경에서 항상 last page idx 만 반환 → non-last col click 시 row 의 last col 페이지로 cursor 처리. **정정 방향**: `getPageAtPoint(docX, docY)` 헬퍼 추가 + `input-handler-mouse.ts` 14곳의 `getPageAtY(contentY)` 호출 일괄 치환. **영향 범위 정량 (Task #685 측정)**: zoom=0.5 columns=2 → col 0 어긋남, zoom=0.25 columns=5 → col 0~3 어긋남 (4/5 컬럼). 그리드 모드 사용자 경험의 50~80% 영향. **Task #685 와의 관계**: #685 (pageLeft 공식) 와 #689 (페이지 인덱스 결정) 가 결합되어야 그리드 모드 click 한컴 호환 완성. #685 단독으로는 last col 만 정합. | +| **Issue #689** | rhwp-studio 그리드 모드 `getPageAtY` X 좌표 무시 — non-last col 페이지 click 어긋남 정정 | **완료 (Stage 1+2+3, Task #685 누락분 10곳 동반 정정 — 작업지시자 시각 검증 통과)** | Task #685 Stage 3 e2e assert 강화로 노출된 후속 결함. M100 (v1.0.0), 우선순위 High (UX-blocking). 분기 기준점: `7651d91` (Merge local/devel: Task #685). **본질 결함**: `virtual-scroll.ts:133-140 getPageAtY(docY)` Y-only loop 가 그리드 모드의 row-shared `pageOffsets[i]` 환경에서 항상 last page idx 만 반환 → non-last col click 시 row 의 last col 페이지로 cursor 처리. **정정 (3 단계)**: (Stage 1) `virtual-scroll.ts` 에 `getPageAtPoint(docX, docY)` 헬퍼 신규 (+33 LOC) — 단일 컬럼은 `getPageAtY` 동치, 그리드는 row(Y) 안에서 X 로 col 결정, gap 영역은 가장 가까운 페이지 fallback. coordinate-system.ts:18 의 `getPageAtY` 는 dead code (`documentToPage` 호출자 0건) 확인 → 미수정. (Stage 2) 마우스 컨텍스트 18곳 `getPageAtY` → `getPageAtPoint` 치환 (input-handler-mouse 12곳 + input-handler.ts 4곳 + table 1곳 + picture 1곳, 5 회 `replace_all`). 동시에 Task #685 sweep 누락 buggy pageLeft **10곳 동반 정정** — input-handler.ts 4곳 + table 3곳 (L400/74/111) + picture 1곳 + connector 2곳 (L85/152). 추가 4곳 (table L74/L111 + connector L85/L152) 은 `getPageAtY` 호출 없이 (state 또는 함수 매개변수에서 pageIdx 획득) buggy pageLeft 패턴 사용 — sweep 중 추가 발견 → 작업지시자 추가 승인 (2026-05-08) 으로 동반 정정. (Stage 3) `e2e/grid-mode-click-coord.test.mjs` non-last col SKIP 분기 제거 → 모든 col strict assert 일원화. zoom=0.25 last-col only probe → for 루프로 col 0~4 5건 모두 probe. 진단노트 `mydocs/troubleshootings/grid_mode_click_coord.md` 끝부분에 "완전 정정 완료 (Task #685 + #689 결합)" +18 LOC. **검증**: typecheck 무에러 / vite build 정상 / `body-outside-click-fallback.test.mjs --mode=headless` 무회귀 / `grid-mode-click-coord.test.mjs --mode=headless` **PASS=21/FAIL=0/SKIP=0** (이전 SKIP 2건 모두 PASS 로 전환). 모든 col rectPageIdx 정합: zoom=0.5 page 0 (1→0), page 2 (3→2), zoom=0.25 col 0~4 모두 정합. 단일 컬럼 zoom=1.0 무회귀. **시각 검증 (작업지시자 직접, hwpctl_action_table_v11.hwp 그리드 모드, 2026-05-08)**: 이전 단계 시점에 보고된 "1, 2 페이지 클릭 안 됨" 결함 정합화 — "오 정상적으로 잘 된다." 평가. **코드 품질 검토 (작업지시자 review, 2026-05-08)**: `getPageLeftResolved(pageIdx, containerWidth)` API 의 `containerWidth` 인자 leaky abstraction 검토 — Option A (현재, stateless click 시점 clientWidth 사용) vs Option B (`setPageDimensions` 시점 단일 컬럼도 실좌표 저장) 비교. **결정**: Option A 유지 — A 는 stateless 라 robust (race condition 없음, ResizeObserver 누락 케이스 영향 없음, 미래 코드 변경 시 fragile 안 함), B 는 stateful 이라 setPageDimensions 호출에 의존. verbose trade-off 보다 robustness 우선. 커밋 `a22cbf0` (Stage 1) + `8369ebd` (Stage 2) + `5db03a8` (Stage 3). 수행계획서: `mydocs/plans/task_m100_689.md`. 구현계획서: `mydocs/plans/task_m100_689_impl.md`. 단계 보고서: `mydocs/working/task_m100_689_stage{1,2,3}.md`. 최종 결과 보고서: `mydocs/report/task_m100_689_report.md`. **Task #685 + #689 결합으로 그리드 모드 click 한컴 호환 완성** — 추가 후속 이슈 없음. **`feedback_process_must_follow` 메모리 권위 영역 강화** — Stage 2 sweep 중 추가 buggy pageLeft 4곳 발견 시 즉시 작업지시자 승인 받아 scope 확장 패턴 정합. **DRY 정합** — `getPageLeftResolved` 재사용으로 신규 좌표 계산 코드 0줄 (#685 도입분 활용). **stateless API 설계 정합** — robustness 우선 결정으로 race condition 없는 click-time 측정값 사용 패턴 권위 영역 확보. **회귀 차단 가드 영구 보존** — e2e PASS=21 strict assert 모든 col 정합 자동 회귀화 + 진단노트 권위 기록 + Task #685 와 #689 결합 효과 명시. | ## 작업 메모 diff --git a/mydocs/report/task_m100_689_report.md b/mydocs/report/task_m100_689_report.md new file mode 100644 index 000000000..e4f6c7cb4 --- /dev/null +++ b/mydocs/report/task_m100_689_report.md @@ -0,0 +1,172 @@ +# Task #689 최종 결과 보고서 + +**Issue**: [#689](https://github.com/edwardkim/rhwp/issues/689) — rhwp-studio 그리드 모드 `getPageAtY` X 좌표 무시 — non-last col 페이지 click 어긋남 +**Milestone**: M100 (v1.0.0) +**브랜치**: `local/task689` → `local/devel` 머저 영역 (작업지시자 권한) +**선행 타스크**: [#685](https://github.com/edwardkim/rhwp/issues/685) (closed, partial — pageLeft 공식 14곳 정정) +**완료일**: 2026-05-08 + +--- + +## 1. 본질 요약 + +[`virtual-scroll.ts:133-140 getPageAtY(docY)`](../../rhwp-studio/src/view/virtual-scroll.ts#L133-L140) 가 Y 좌표만 보고 페이지를 결정. 그리드 모드에서 같은 row 의 모든 페이지가 동일 `pageOffsets[i] = rowTop` 을 가지므로 loop 가 highest index 부터 내려가며 첫 매치 반환 → **항상 row 의 last col 페이지** 만 반환. + +→ non-last col 페이지(좌측/중간 컬럼) click 시 row 의 last col 페이지로 cursor 처리. 사용자 직접 시각 확인 (`hwpctl_action_table_v11.hwp` 그리드 모드, 2026-05-08): "1, 2 페이지가 클릭이 안 됨". + +**Task #685 와의 관계**: #685 는 `pageLeft` 공식 정정으로 **last col 정합** 만 달성. non-last col 정정은 별도 결함 영역 → 본 #689 에서 정정. + +## 2. 정정 영역 + +### 2.1 Stage 1 — `getPageAtPoint` 헬퍼 도입 + +[`virtual-scroll.ts`](../../rhwp-studio/src/view/virtual-scroll.ts) 에 `getPageAtPoint(docX, docY)` 신규 (+33 LOC): + +- 단일 컬럼 모드: `getPageAtY(docY)` 동치 (X 무관). +- 그리드 모드: `getPageAtY` 로 row 의 last page idx 찾고, 같은 row 의 페이지 범위 (`pageOffsets[i] === rowOffset` 조건) 안에서 X 가 속하는 페이지 반환. +- Gap 영역 (페이지 사이 빈 공간) click → 가장 가까운 페이지로 fallback. + +기존 `getPageAtY` 는 미수정 — viewport-center 호출자 (canvas-view 2곳, input-handler-keyboard 1곳) + 새 헬퍼 자체가 사용. + +### 2.2 Stage 2 — 마우스 컨텍스트 일괄 치환 + +**`getPageAtY` → `getPageAtPoint` 치환 (18곳)**: +- `input-handler-mouse.ts` 12곳 (3 회 `replace_all`: cy/y/contentY) +- `input-handler.ts` 4곳 (L612/875/972/1542, 2 회 `replace_all`: cY/contentY) +- `input-handler-table.ts` 1곳 (L400) +- `input-handler-picture.ts` 1곳 (L594) + +**Task #685 sweep 누락 buggy `pageLeft` 동반 정정 (10곳)** — 작업지시자 scope 확장 결정 (2026-05-08): + +| 분류 | 파일 / 라인 | pageIdx 출처 | +|------|-------------|--------------| +| `getPageAtY` 와 동일 함수 (6곳) | input-handler.ts L612/875/972/1542 + table:400 + picture:594 | `getPageAtPoint(...)` (Stage 2 에서 치환) | +| `getPageAtY` 호출 없음 (4곳, 추가 발견) | input-handler-table.ts L74/L111 + input-handler-connector.ts L85/L152 | `state.edge.pageIndex` 또는 함수 매개변수 | + +후자 4곳은 본 작업 sweep 중 추가 발견 → 작업지시자 추가 승인으로 동반 정정. 그리드 모드에서 표 리사이즈 드래그 / connector 좌표 어긋남도 함께 해소. + +### 2.3 Stage 3 — e2e strict assert 활성화 + +[`grid-mode-click-coord.test.mjs`](../../rhwp-studio/e2e/grid-mode-click-coord.test.mjs): +- `probeClickAtPage` 의 `isLastCol` 분기 + SKIP 로깅 제거 → 모든 col strict assert 일원화. +- `[3b]` zoom=0.25 last-col only probe → for 루프로 col 0~4 5건 모두 probe. +- 진단노트 [`grid_mode_click_coord.md`](../troubleshootings/grid_mode_click_coord.md) 끝부분에 "완전 정정 완료 (Task #685 + #689 결합)" 섹션 추가. + +### 2.4 코드 품질 결정 (작업지시자 review, 2026-05-08) + +`getPageLeftResolved(pageIdx, containerWidth)` API 의 `containerWidth` 인자 leaky abstraction 검토 — Option A (현재, stateless) vs Option B (`setPageDimensions` 시점 단일 컬럼도 실좌표 저장, 호출자 단순) 비교. + +**결정**: **Option A 유지**. 이유: +- A 는 stateless — click 시점 항상 최신 `clientWidth` 사용 +- B 는 stateful — pageLefts[i] cache 가 setPageDimensions 호출에 의존, race condition / 미래 코드 변경 시 fragile +- verbose 는 trade-off 비용, robustness 가 우선 + +→ 본 작업은 추가 리팩터 없이 #689 종결. + +### 총 변경 + +- 신규 헬퍼 1 (`getPageAtPoint`, +33 LOC) +- 호출 치환: **`getPageAtY` 18곳** + **buggy `pageLeft` 10곳** = 28 LOC delta +- e2e: ~+15 LOC (SKIP→strict, 추가 probe) +- 진단노트: +18 LOC ("완전 정정 완료" 섹션) +- 5 src 파일 + 1 e2e + 1 진단노트 + 4 plan/working/report 문서 + +## 3. 검증 결과 (정량) + +### 자동 검증 (모두 PASS) + +| 검증 항목 | 결과 | +|-----------|------| +| `npx tsc --noEmit` | ✅ 무에러 | +| `npx vite build` | ✅ 성공 | +| `body-outside-click-fallback.test.mjs --mode=headless` | ✅ exit 0, 단일 컬럼 무회귀 | +| `grid-mode-click-coord.test.mjs --mode=headless` | ✅ **PASS=21 / FAIL=0 / SKIP=0** | + +### Before vs After 비교 + +| 케이스 | #685 종결 시점 | #689 종결 시점 | +|--------|---------------|---------------| +| zoom=1.0 page 0 | rectPageIdx=0 ✅ | rectPageIdx=0 ✅ (무회귀) | +| zoom=0.5 col 0 (page 0) | rectPageIdx=1 ❌ | **rectPageIdx=0 ✅** | +| zoom=0.5 col 1 last (page 1) | rectPageIdx=1 ✅ | rectPageIdx=1 ✅ | +| zoom=0.5 col 0 (page 2) | rectPageIdx=3 ❌ | **rectPageIdx=2 ✅** | +| zoom=0.25 col 0 (page 0) | (미측정/SKIP) | **rectPageIdx=0 ✅** | +| zoom=0.25 col 1 (page 1) | (미측정/SKIP) | **rectPageIdx=1 ✅** | +| zoom=0.25 col 2 mid (page 2) | (미측정/SKIP) | **rectPageIdx=2 ✅** | +| zoom=0.25 col 3 (page 3) | (미측정/SKIP) | **rectPageIdx=3 ✅** | +| zoom=0.25 col 4 last (page 4) | rectPageIdx=4 ✅ | rectPageIdx=4 ✅ | + +→ **이전 어긋남 모두 정합화** + 단일 컬럼 baseline 무회귀. + +### 시각 검증 (작업지시자, 2026-05-08) + +`hwpctl_action_table_v11.hwp` 그리드 모드 (zoom=0.5, columns=3) — 모든 col 페이지 정상 클릭 확인: +- 작업지시자 평가: "오 정상적으로 잘 된다." + +→ 이전 단계 (#685 종결 시점) "1, 2 페이지가 클릭이 안 됨" 보고에서 모든 페이지 click 정합 달성. + +## 4. 회귀 위험 영역 + +| 영역 | 위험 | 결과 | +|------|------|------| +| 단일 컬럼 모드 click (zoom > 0.5) | `getPageAtPoint` 가 `getPageAtY` 동치 안 되면 회귀 | ✅ OK — 명시 분기 + body-outside-click-fallback 무회귀 | +| 그리드 모드 last-col click | #685 정합 영역 무회귀 | ✅ OK — Stage 3 e2e PASS (zoom=0.5 col 1, zoom=0.25 col 4) | +| 그리드 모드 non-last col click | 본 작업의 본질 효과 | ✅ OK — Stage 3 e2e PASS (zoom=0.5 col 0/0row1, zoom=0.25 col 0~3) | +| `pw` / `pageDisplayWidth` 변수 보존 | hit test bbox 등 다른 사용처 | ✅ OK — 모두 보존 | +| 추가 4 사이트 (table L74/111, connector L85/152) | 신뢰 pageIdx 인 곳에 헬퍼 적용 | ✅ OK — `getPageLeftResolved` 가 신뢰값 받아도 정상 | +| viewport-center 영역 (canvas-view, keyboard) | 헬퍼 통일 욕심 시 회귀 | ✅ OK — 미수정 | +| `coordinate-system.ts:18` | dead code 점검 | ✅ OK — 미수정 (호출자 0건) | +| Container resize 처리 | Option A stateless 설계 | ✅ OK — click 시점 `clientWidth` 직접 사용으로 robust | + +## 5. 정합 영역 + +- **하이퍼-워터폴 절차 정합**: 수행계획서 → 구현계획서 → Stage 1/2/3 → 최종보고서 모두 작업지시자 승인 게이트 거침. 단계별 보고서 (`_stage{N}.md`) + 단계별 커밋 (`a22cbf0`, `8369ebd`, `5db03a8`). +- **`feedback_process_must_follow` 정합**: Stage 2 sweep 중 추가 buggy pageLeft 4곳 발견 시 즉시 작업지시자 승인 받아 scope 확장. 무단 확장 없음. +- **DRY 정합**: `getPageLeftResolved` 헬퍼는 #685 도입분 재사용. 신규 헬퍼는 `getPageAtPoint` 1개. 신규 좌표 계산 코드 0줄 (기존 데이터 `pageOffsets`/`pageLefts`/`pageWidths` 재사용). +- **HWP IR 표준 직접 사용**: virtualScroll 의 그리드 인프라 (이미 채워져 있는 `pageOffsets`, `pageLefts`, `pageWidths`) 그대로 적용. +- **회귀 위험 영역 좁힘**: viewport-center 호출자 (canvas-view, input-handler-keyboard) 미수정. coordinate-system.ts dead code 미수정. +- **회귀 차단 가드 영구 보존**: e2e strict assert 활성화로 모든 col 정합 자동 회귀 차단. +- **#685 누락분 동반 정정**: 같은 영역 (input-handler 클러스터 buggy pageLeft) 의 누락분을 본 작업에서 함께 정정 → 향후 같은 결함 카테고리에 대한 추가 후속 이슈 불필요. +- **사용자 보고 정합**: 작업지시자 시각 검증 시점에서 정확히 보고된 결함 (`hwpctl_action_table_v11.hwp` 그리드 모드 페이지 1, 2 클릭 안 됨) 이 #689 의 본질 결함 (`getPageAtY` X 무시) 임을 진단 노트로 명시 + 정정 후 작업지시자 직접 정합 확인. +- **코드 품질 결정 명시**: Option A vs B trade-off 검토 + 작업지시자 robustness 우선 결정 → 결과보고서 기록으로 향후 동일 결정 추적 가능. + +## 6. 변경 파일 요약 + +| 파일 | 변경 | +|------|------| +| `rhwp-studio/src/view/virtual-scroll.ts` | +33 LOC (`getPageAtPoint` 헬퍼 추가) | +| `rhwp-studio/src/engine/input-handler-mouse.ts` | 12곳 치환 (`getPageAtY` → `getPageAtPoint`) | +| `rhwp-studio/src/engine/input-handler.ts` | 4곳 `getPageAtY` + 4곳 buggy `pageLeft` 치환 | +| `rhwp-studio/src/engine/input-handler-table.ts` | 1곳 `getPageAtY` + 3곳 buggy `pageLeft` (L400 + L74/L111) | +| `rhwp-studio/src/engine/input-handler-picture.ts` | 1곳 `getPageAtY` + 1곳 buggy `pageLeft` | +| `rhwp-studio/src/engine/input-handler-connector.ts` | 2곳 buggy `pageLeft` (L85/L152) | +| `rhwp-studio/e2e/grid-mode-click-coord.test.mjs` | SKIP→strict + 추가 probe | +| `mydocs/troubleshootings/grid_mode_click_coord.md` | "완전 정정 완료 (#685 + #689 결합)" +18 LOC | +| `mydocs/plans/task_m100_689.md` | 신규 (수행계획서) | +| `mydocs/plans/task_m100_689_impl.md` | 신규 (구현계획서) | +| `mydocs/working/task_m100_689_stage{1,2,3}.md` | 신규 (단계 보고서 3종) | +| `mydocs/report/task_m100_689_report.md` | 신규 (본 보고서) | +| `mydocs/orders/20260508.md` | 갱신 (#689 [완료] + 추가 발견 4곳 메모) | + +## 7. 커밋 이력 (`local/task689`) + +``` +5db03a8 Task #689 Stage 3: e2e strict assert 활성화 + 그리드 모드 모든 col click 정합 자동 회귀화 +8369ebd Task #689 Stage 2: getPageAtY 18곳 → getPageAtPoint 치환 + Task #685 누락 buggy pageLeft 10곳 동반 정정 +a22cbf0 Task #689 Stage 1: getPageAtPoint 헬퍼 도입 + 호출자 분류 확정 +7651d91 Merge local/devel: Task #685 — 그리드 모드 click 좌표 일괄 정정 (closes #685, partial — #689 후속) ← 분기 기준점 +``` + +## 8. 후속 영역 + +- **Task #685 + #689 결합으로 그리드 모드 click 한컴 호환 완성**. 추가 후속 이슈 없음. +- 키보드/IME/Touch 입력 경로의 그리드 모드 좌표 처리 (별도 후속 조사) — 본 작업 범위 외. +- `coordinate-system.ts` dead code 정리 (`documentToPage` 호출자 0건) — 별도 cleanup 사이클. +- 헬퍼 이름 단축 (`getPageLeftResolved` → 짧은 이름) 등 추가 리팩터 — 코드 품질 사이클 후보. + +## 9. 검증된 권위 영역 + +- **그리드 모드 모든 col click 정합 (자동)**: zoom=0.5 col 0/1, zoom=0.25 col 0~4 모두 e2e PASS. +- **단일 컬럼 무회귀 (자동)**: `body-outside-click-fallback.test.mjs` 무회귀 + zoom=1.0 baseline strict assert PASS. +- **양식 오버레이 무회귀 (sanity)**: `formBboxToOverlayRect` 동치성 (#685 stage 1) + 본 작업에서 `pageLeft` 헬퍼 추가 사용처 확장 무회귀. +- **사용자 시각 검증 (수동)**: `hwpctl_action_table_v11.hwp` 그리드 모드 모든 col 정상 클릭 — 작업지시자 직접 확인.