diff --git a/mydocs/orders/20260506.md b/mydocs/orders/20260506.md new file mode 100644 index 000000000..214cd25d7 --- /dev/null +++ b/mydocs/orders/20260506.md @@ -0,0 +1,7 @@ +# 오늘 할일 - 2026년 5월 6일 + +## M100 — v1.0.0 조판 엔진 체계화 + +| Issue | 타스크 | 상태 | 비고 | +|------|--------|------|------| +| #598 | rhwp-studio 각주 삭제 2차: 본문 각주 삭제 API/UI | 완료 | PR #642 open 전 e2e/요구사항 점검 완료 | diff --git a/mydocs/plans/task_m100_598.md b/mydocs/plans/task_m100_598.md new file mode 100644 index 000000000..0b5bf5870 --- /dev/null +++ b/mydocs/plans/task_m100_598.md @@ -0,0 +1,172 @@ +# Task #598 수행 계획서 — 각주 삭제 1차: 본문 각주 마커 hit test + 커서 이동 정합 + +## 이슈 정합 + +- **Issue**: [#598](https://github.com/edwardkim/rhwp/issues/598) — rhwp-studio: 각주 삭제 기능 구현 +- **마일스톤**: M100 — v1.0.0 조판 엔진 체계화 +- **작업 브랜치**: `local/task598` +- **기준 브랜치**: `upstream/devel` +- **기준 커밋**: `9b49063` +- **대표 샘플**: `samples/footnote-01.hwp` +- **1차 작업 범위**: 본문 각주 마커 hit test + 좌우 커서 이동 단위 정합 + +## 배경 + +이슈 #598은 각주 삭제 기능 전체를 다룬다. 그러나 삭제 UX의 전제는 본문 각주 마커가 편집 영역에서 실제 커서 이동 단위로 취급되는 것이다. + +현재 코드 기준으로는 다음 기반이 이미 존재한다. + +- 각주 삽입 API: `insert_footnote_native`, `WasmBridge.insertFootnote()`, `insert:footnote` +- 각주 영역 내부 hit test/edit API: `hitTestFootnote`, `hitTestInFootnote`, `getCursorRectInFootnote`, `getPageFootnoteInfo` +- 본문 렌더링의 각주 마커 노드: `RenderNodeType::FootnoteMarker` + +반면 본문 커서 이동 및 hit test에는 각주 마커가 아직 한컴 정합의 1칸 단위로 편입되어 있지 않다. 따라서 1차 작업은 삭제 API/UI 구현 전에 본문 각주 마커를 클릭/방향키 대상으로 만드는 데 집중한다. + +## 사전 분석 + +### 1. 본문 렌더링에는 각주 마커 bbox가 있다 + +`src/renderer/layout/paragraph_layout.rs` 는 문단 내 각주 위치에 `FootnoteMarkerNode` 를 생성한다. 이 노드에는 `section_index`, `para_index`, `control_index`, `number`, `text` 가 들어 있으므로 본문 마커 hit test API의 반환값을 구성할 수 있다. + +### 2. 기존 hit test는 TextRun 중심이다 + +`src/document_core/queries/cursor_rect.rs` 의 일반 `hit_test_native` 는 주로 `TextRun` 을 수집해 문자 위치를 반환한다. 본문 `FootnoteMarker` 노드는 별도 수집 대상이 아니므로 마커 자체 클릭이 각주 영역 이동으로 연결되지 않는다. + +### 3. 커서 이동 길이 계산에서 Footnote가 빠져 있다 + +`src/document_core/helpers.rs` 의 `navigable_text_len()` 은 Shape/Table/Picture/Equation/CharOverlap 중심으로 보정하며 `Control::Footnote` 를 1칸 단위로 세지 않는다. + +`src/document_core/queries/doc_tree_nav.rs` 의 `classify_navigable()` 도 `Control::Footnote` 를 네비게이션 가능한 inline control 로 분류하지 않는다. 이 때문에 좌우 방향키 이동에서 각주 마커 앞/뒤 위치를 안정적으로 만들 수 없다. + +## 작업 목표 + +1차 작업의 완료 기준은 다음과 같다. + +- 본문 각주 마커 클릭 시 해당 각주 영역으로 커서가 이동한다. +- 본문 좌우 방향키 이동에서 각주 마커가 1칸 이동 단위로 취급된다. +- 각주 마커 앞/뒤 커서 위치가 Delete/Backspace 삭제 분기를 붙일 수 있는 형태로 안정화된다. +- 기존 각주 영역 내부 편집 동작은 회귀하지 않는다. + +## 범위 + +### 포함 + +- 본문 `FootnoteMarker` bbox 기반 hit test API 추가 +- WasmBridge 본문 각주 마커 hit test 메서드 추가 +- 마우스 처리에서 본문 각주 마커 클릭 시 각주 영역 진입 처리 +- `Control::Footnote` 를 본문 커서 이동의 1칸 inline unit 으로 반영 +- 본문 각주 마커 위치의 cursor rect 계산 보정 +- `samples/footnote-01.hwp` 기반 수동/자동 검증 +- 가능하면 rhwp-studio e2e에 hit test 및 좌우 이동 회귀 테스트 추가 + +### 제외 + +- 각주 삭제 WASM API 구현 +- Delete/Backspace 삭제 분기 +- 삭제 확인 다이얼로그 +- 삭제 Undo/Redo +- 표 셀/글상자 내부 각주 삭제 UX 전면 구현 +- 미주 삭제 기능 + +## 의심 코드 영역 + +| 영역 | 파일 | 검토 포인트 | +|------|------|-------------| +| 본문 hit test | `src/document_core/queries/cursor_rect.rs` | `FootnoteMarker` 수집 및 bbox hit API 추가 | +| 렌더 노드 | `src/renderer/render_tree.rs` | `FootnoteMarkerNode` 메타데이터 재사용 가능성 확인 | +| 본문 마커 생성 | `src/renderer/layout/paragraph_layout.rs` | `control_index` 가 실제 footnote control index 와 정합하는지 확인 | +| 네비게이션 길이 | `src/document_core/helpers.rs` | `Control::Footnote` 를 logical/navigable length 에 포함할지 결정 | +| DFS 이동 | `src/document_core/queries/doc_tree_nav.rs` | `classify_navigable()` 및 forward/backward 이동 규칙 보정 | +| WASM export | `src/wasm_api.rs` | 신규 hit test API 노출 | +| TS 브리지 | `rhwp-studio/src/core/wasm-bridge.ts` | 신규 API JSON 파싱 래퍼 추가 | +| 마우스 처리 | `rhwp-studio/src/engine/input-handler-mouse.ts` | 본문 마커 hit 시 각주 영역 진입 | +| 커서 상태 | `rhwp-studio/src/engine/cursor.ts` | 각주 모드 진입 전 저장 본문 위치와 rect 갱신 확인 | + +## 단계 분리 (5 stages) + +### Stage 1 — 진단 확정 + +- `samples/footnote-01.hwp` 에서 본문 각주 마커와 각주 영역 위치를 재현한다. +- `dump-pages`, `dump` 또는 WASM 직접 호출로 footnote source 의 `sec/para/controlIdx` 를 확인한다. +- `FootnoteMarkerNode.control_index` 가 실제 `Control::Footnote` 인덱스와 일치하는지 검증한다. +- 현재 `navigateNextEditable` 좌우 이동이 각주 마커를 건너뛰는 지점을 확인한다. + +**산출물**: `mydocs/working/task_m100_598_stage1.md` + +### Stage 2 — 구현 계획서 작성 + +- 본문 마커 hit test API의 반환 JSON 구조를 확정한다. +- 각주 마커를 cursor offset 1칸으로 세는 기준을 확정한다. +- `getCursorRect` 에서 각주 마커 앞/뒤 위치를 어떻게 표현할지 결정한다. +- e2e 검증 방식과 수동 시각 판정 기준을 확정한다. + +**산출물**: `mydocs/plans/task_m100_598_impl.md` + +### Stage 3 — 1차 구현 + +- Rust 본문 각주 마커 hit test API 추가 +- WASM export 및 `WasmBridge` 래퍼 추가 +- `input-handler-mouse.ts` 에 본문 마커 클릭 분기 추가 +- `navigable_text_len()` / `navigate_next_editable()` 에 각주 마커 1칸 이동 규칙 반영 +- 필요 시 본문 각주 마커 위치의 cursor rect 보정 추가 + +**산출물**: `mydocs/working/task_m100_598_stage3.md` + +### Stage 4 — 검증 + +- `cargo test` +- `cargo build` +- rhwp-studio 타입/빌드 검증 +- `samples/footnote-01.hwp` 수동 검증 +- 가능하면 `rhwp-studio/e2e/` 각주 마커 click/arrow 테스트 추가 및 실행 + +**산출물**: `mydocs/working/task_m100_598_stage4.md` + +### Stage 5 — 1차 작업 보고 + +- 본문 각주 마커 hit test와 좌우 이동 정합 결과 정리 +- 2차 작업 범위인 삭제 API/UI의 연결 지점 정리 +- 오늘 할일 상태 갱신 +- 작업지시자 승인 후 2차 작업 계획으로 이동 + +**산출물**: `mydocs/working/task_m100_598_stage5.md` + +## 검증 게이트 + +| 검증 | 기준 | +|------|------| +| 본문 마커 클릭 | 클릭한 각주 번호의 각주 영역 커서 위치로 진입 | +| ArrowRight | 각주 앞 위치 → 각주 뒤 위치로 1칸 이동 | +| ArrowLeft | 각주 뒤 위치 → 각주 앞 위치로 1칸 이동 | +| 기존 각주 영역 클릭 | `hitTestFootnote` / `hitTestInFootnote` 기존 동작 유지 | +| `cargo test` | 회귀 0 | +| rhwp-studio e2e | 각주 마커 hit/move 회귀 0 | + +## 위험 영역 + +| 위험 | 가능성 | 회피책 | +|------|--------|--------| +| `control_index` 가 실제 footnote control index 와 불일치 | 중간 | Stage 1 에서 렌더 노드와 IR control 위치를 먼저 대조 | +| `navigable_text_len()` 변경이 표/도형/수식 이동에 회귀 유발 | 중간 | Footnote 전용 조건으로 최소 변경, 기존 inline control 테스트 확인 | +| cursor offset 과 text offset 혼동 | 중간 | logical offset 변환 헬퍼와 기존 `control_text_positions()` 의미를 문서화 후 구현 | +| 각주 영역 내부 hit test 회귀 | 낮음~중간 | 기존 footnote zone API는 수정 최소화 | +| 표 셀/글상자 내부 각주까지 범위 확장 | 중간 | 1차 작업은 body source 를 우선 대상으로 제한하고 후속 범위로 분리 | + +## 처리 정합 + +- 소스 수정은 본 수행계획서 승인 후에도 바로 진행하지 않고, Stage 1 진단 완료보고서와 구현 계획서 승인 이후 진행한다. +- 단계별 완료보고서는 해당 단계 변경과 함께 task 브랜치에서 커밋한다. +- 최종 결과보고서 및 오늘 할일 갱신도 task 브랜치에서 커밋한다. +- 이슈 close 는 작업지시자 승인 후에만 수행한다. + +## 승인 게이트 + +1. 본 수행계획서 승인 → Stage 1 진단 확정 진행 +2. Stage 1 완료보고서 승인 → 구현 계획서 작성 +3. 구현 계획서 승인 → Stage 3 1차 구현 시작 +4. Stage 3/4 완료보고서 승인 → Stage 5 1차 작업 보고 +5. 1차 작업 보고 승인 → 2차 삭제 API/UI 작업 계획으로 이동 + +## 다음 단계 + +작업지시자 승인 후 Stage 1 진단 확정 진행. diff --git a/mydocs/plans/task_m100_598_delete_impl.md b/mydocs/plans/task_m100_598_delete_impl.md new file mode 100644 index 000000000..3192c06d4 --- /dev/null +++ b/mydocs/plans/task_m100_598_delete_impl.md @@ -0,0 +1,275 @@ +# Task #598 2차 구현 계획서 — 본문 각주 삭제 API/UI + +## 이슈 정합 + +- **Issue**: [#598](https://github.com/edwardkim/rhwp/issues/598) +- **마일스톤**: M100 — v1.0.0 조판 엔진 체계화 +- **작업 브랜치**: `local/task598` +- **기준 커밋**: `upstream/devel` `9b49063` +- **선행 완료 범위**: + - 1차: 본문 각주 마커 hit test + - 1차: 각주 마커 앞/뒤 cursor unit 및 caret rect + - 1차: rhwp-studio 본문 마커 클릭 → 각주 편집 모드 진입 + +## 구현 범위 + +이번 2차 작업은 본문 각주 컨트롤 자체를 삭제하는 API와 UI 연결이다. + +대상: + +- 본문 `Control::Footnote` 삭제 +- Delete/Backspace 키 처리 +- Undo/Redo 연결 +- 삭제 후 각주 번호 재계산, 문단 리플로우, 페이지네이션 갱신 + +비대상: + +- 표 셀/글상자 내부 각주 삭제 +- 미주(`Endnote`) 삭제 +- 각주 영역 내부 텍스트 편집 Undo 정밀화 + +## 현재 구조 요약 + +### Rust + +- 각주 삽입은 `insert_footnote_native()` 가 담당한다. +- 각주 내용 편집은 `footnote_ops.rs` 의 `insert_text_in_footnote_native()` / `delete_text_in_footnote_native()` 가 담당한다. +- 본문 `Control::Footnote` 자체를 삭제하는 API는 아직 없다. +- 그림/도형/표 삭제 API는 control 제거 후 `char_offsets`, `char_count`, `line_segs`, `pagination` 을 갱신하는 패턴을 갖고 있다. + +### rhwp-studio + +- 본문 Backspace/Delete 는 `input-handler-text.ts` 에서 `DeleteTextCommand` 로 라우팅된다. +- 현재는 footnote marker 위치에서도 일반 텍스트 삭제로 처리되어 실제 `Control::Footnote` 를 제거하지 못한다. +- 복잡한 구조 변경은 `SnapshotCommand` 경로로 Undo/Redo 를 처리할 수 있다. + +## API 설계 + +### 신규 WASM API + +```rust +#[wasm_bindgen(js_name = deleteFootnote)] +pub fn delete_footnote( + &mut self, + section_idx: u32, + para_idx: u32, + control_idx: u32, +) -> Result +``` + +반환 JSON: + +```json +{ + "ok": true, + "sectionIndex": 0, + "paragraphIndex": 3, + "charOffset": 7, + "deletedNumber": 1 +} +``` + +오류: + +- 구역/문단/control index 범위 초과 +- 대상 control 이 `Control::Footnote` 가 아닌 경우 +- body source 가 아닌 위치에서 호출한 경우는 이번 UI에서 호출하지 않음 + +### WasmBridge 래퍼 + +```ts +deleteFootnote( + sectionIndex: number, + paragraphIndex: number, + controlIndex: number, +): { + ok: boolean; + sectionIndex: number; + paragraphIndex: number; + charOffset: number; + deletedNumber: number; +} +``` + +### 삭제 대상 조회 + +UI에서 Backspace/Delete 시 다음 로직으로 삭제 대상을 찾는다. + +- Backspace: + - 커서가 각주 마커 뒤 위치(`marker_pos + 1`)면 해당 각주 삭제 +- Delete: + - 커서가 각주 마커 앞 위치(`marker_pos`)면 해당 각주 삭제 + +이를 위해 Rust 쪽에 현재 커서 주변 각주 조회 API를 추가한다. + +```rust +#[wasm_bindgen(js_name = getFootnoteAtCursor)] +pub fn get_footnote_at_cursor( + &self, + section_idx: u32, + para_idx: u32, + char_offset: u32, + direction: &str, +) -> Result +``` + +반환 JSON: + +```json +{ + "hit": true, + "sectionIndex": 0, + "paragraphIndex": 3, + "controlIndex": 0, + "charOffset": 7, + "footnoteNumber": 1 +} +``` + +miss: + +```json +{ "hit": false } +``` + +`direction` 의미: + +| direction | 조건 | +|-----------|------| +| `backward` | `char_offset == marker_pos + 1` | +| `forward` | `char_offset == marker_pos` | + +## 구현 상세 + +### 1. Rust 삭제 API + +대상 파일: + +- `src/document_core/commands/footnote_ops.rs` +- `src/document_core/commands/object_ops.rs` 또는 공용 helper 후보 +- `src/model/event.rs` +- `src/wasm_api.rs` + +구현: + +1. `find_control_text_positions(para)` 로 삭제 대상 각주의 marker position 을 찾는다. +2. 대상 control 이 `Control::Footnote` 인지 검증한다. +3. 기존 그림/도형 삭제와 같은 방식으로 UTF-16 control gap 8 code unit 을 제거한다. +4. `controls` 와 `ctrl_data_records` 에서 control 을 제거한다. +5. `char_count` 를 8 감소시킨다. +6. 남은 각주 번호를 문서 순서대로 재계산한다. +7. 본문 문단 `line_segs` 를 리플로우한다. +8. `raw_stream = None`, `recompose_section()`, `paginate_if_needed()`, `invalidate_page_tree_cache()` 를 수행한다. +9. `DocumentEvent::FootnoteDeleted` 를 추가하거나, 이벤트 추가가 과하면 기존 이벤트 로그에는 구조 변경 이벤트를 최소 기록한다. 기본 계획은 `FootnoteDeleted` 추가다. + +### 2. Rust 조회 API + +대상 파일: + +- `src/document_core/queries/cursor_rect.rs` 또는 별도 query 모듈 +- `src/wasm_api.rs` + +구현: + +- `get_footnote_at_cursor_native(section, para, char_offset, direction)` 추가 +- 현재 문단의 `Control::Footnote` 와 control position 을 대조해 hit 여부 반환 +- Backspace/Delete UI가 control index 를 직접 추론하지 않도록 Rust가 source of truth 역할을 담당 + +### 3. rhwp-studio UI 연결 + +대상 파일: + +- `rhwp-studio/src/core/types.ts` +- `rhwp-studio/src/core/wasm-bridge.ts` +- `rhwp-studio/src/engine/input-handler-text.ts` +- 필요 시 `rhwp-studio/src/engine/command.ts` + +구현: + +1. `WasmBridge.getFootnoteAtCursor()` / `deleteFootnote()` 추가 +2. `handleBackspace()` 의 본문 분기에서 일반 텍스트 삭제 전에 `getFootnoteAtCursor(..., 'backward')` 검사 +3. `handleDelete()` 의 본문 분기에서 일반 텍스트 삭제 전에 `getFootnoteAtCursor(..., 'forward')` 검사 +4. hit 시 `executeOperation({ kind: 'snapshot', operationType: 'deleteFootnote', operation })` 로 삭제 +5. 삭제 후 커서는 반환된 `charOffset` 에 둔다 + +Undo/Redo: + +- 각주 control 삭제는 내부 문단, control gap, 번호 재계산, 페이지네이션이 함께 바뀌므로 정밀 텍스트 command 보다 `SnapshotCommand` 가 안전하다. + +### 4. 검증 + +Rust: + +- `samples/footnote-01.hwp` 문단 0.3 offset 7/8 기준 조회 API 검증 +- 삭제 API 호출 후: + - 해당 문단의 footnote control 제거 + - `get_control_text_positions(0, 3)` 에서 해당 control 사라짐 + - 두 번째 각주가 번호 `1)` 로 재계산되는지 확인 + - `hit_test_body_footnote_marker_native()` 가 첫 마커 좌표에서 miss 또는 다른 결과를 반환하는지 확인 + +rhwp-studio: + +- 본문 마커 앞에서 Delete → 각주 삭제 +- 본문 마커 뒤에서 Backspace → 각주 삭제 +- Undo → 각주 복원 +- Redo → 각주 재삭제 + +명령: + +```bash +cargo test --test issue_598_footnote_marker_nav +cargo test issue_598_delete_footnote +cargo build +docker-compose --env-file .env.docker run --rm wasm +cd rhwp-studio && npm run build +git diff --check +``` + +## 구현 단계 + +### Stage 4-1 — Rust 삭제/조회 API + +- `get_footnote_at_cursor_native()` 추가 +- `delete_footnote_native()` 추가 +- 각주 번호 재계산 및 리플로우 처리 +- WASM export 추가 +- Rust 단위/통합 테스트 추가 + +완료 기준: + +- 삭제 전 조회 API hit +- 삭제 후 control 제거 및 번호 재계산 확인 +- `cargo test issue_598_delete_footnote` 통과 + +### Stage 4-2 — rhwp-studio 키 처리 + Undo + +- `WasmBridge` 래퍼 추가 +- Backspace/Delete 에서 각주 삭제 우선 처리 +- `SnapshotCommand` 로 Undo/Redo 연결 +- TypeScript 빌드 통과 + +완료 기준: + +- 본문 마커 앞 Delete 삭제 +- 본문 마커 뒤 Backspace 삭제 +- Undo/Redo 가능 + +### Stage 4-3 — WASM/browser 검증 + +- Docker WASM 빌드 +- rhwp-studio 빌드 +- Vite dev server 수동 확인 요청 + +완료 기준: + +- 브라우저에서 `samples/footnote-01.hwp` 로 각주 삭제/복원 확인 + +### Stage 4-4 — 최종 보고 + +- 자동/수동 검증 결과 정리 +- 오늘 할일 갱신 +- 최종 보고서 또는 2차 완료보고서 작성 + +## 승인 요청 + +위 계획대로 Stage 4-1부터 진행한다. diff --git a/mydocs/plans/task_m100_598_impl.md b/mydocs/plans/task_m100_598_impl.md new file mode 100644 index 000000000..4f0574296 --- /dev/null +++ b/mydocs/plans/task_m100_598_impl.md @@ -0,0 +1,315 @@ +# Task #598 구현 계획서 — 1차: 본문 각주 마커 hit test + 커서 이동 정합 + +## 이슈 정합 + +- **Issue**: [#598](https://github.com/edwardkim/rhwp/issues/598) +- **마일스톤**: M100 — v1.0.0 조판 엔진 체계화 +- **작업 브랜치**: `local/task598` +- **기준 커밋**: `upstream/devel` `9b49063` +- **선행 문서**: + - 수행계획서: `mydocs/plans/task_m100_598.md` + - Stage 1 완료보고서: `mydocs/working/task_m100_598_stage1.md` +- **구현 범위**: 이슈 #598 전체 중 1차 작업인 본문 각주 마커 hit test 및 좌우 커서 이동 단위 정합 + +## Stage 1 진단 요약 + +`samples/footnote-01.hwp` 1페이지에서 본문 각주 마커는 이미 SVG에 정상 렌더링된다. + +```text +문단 0.3: text_len=47, controls=1, [0] 각주 + SVG: x=263.25 y=384.07 "1)" + +문단 0.7: text_len=13, controls=1, [0] 각주 + SVG: x=212.92 y=702.25 "2)" +``` + +그러나 일반 hit test는 `RenderNodeType::FootnoteMarker` 를 수집하지 않으며, `navigable_text_len()` / `classify_navigable()` 도 `Control::Footnote` 를 본문 inline unit 으로 취급하지 않는다. + +## 구현 원칙 + +1. 기존 각주 영역 API는 유지한다. + - `hitTestFootnote` + - `hitTestInFootnote` + - `getPageFootnoteInfo` + - `getCursorRectInFootnote` + +2. 본문 마커 전용 API를 새로 추가한다. + - 기존 `hitTestFootnote` 는 각주 영역 zone hit test 라는 의미가 이미 있으므로 재사용하지 않는다. + +3. 1차 작업은 body source 각주를 우선 대상으로 한다. + - 표 셀/글상자 내부 각주는 후속 확장 대상으로 남긴다. + - 단, Rust 내부 데이터 구조는 후속 확장이 가능하도록 source 식별을 명확히 둔다. + +4. 본문 마커의 앞/뒤 위치를 삭제 UX가 사용할 수 있게 안정화한다. + - Delete/Backspace 삭제 자체는 2차 작업에서 구현한다. + - 1차 작업에서는 커서가 마커 앞/뒤 위치로 이동하고 caret rect 가 일관되게 표시되는 것까지 처리한다. + +## API 설계 + +### 신규 WASM API + +```rust +#[wasm_bindgen(js_name = hitTestBodyFootnoteMarker)] +pub fn hit_test_body_footnote_marker( + &self, + page_num: u32, + x: f64, + y: f64, +) -> Result +``` + +반환 JSON: + +```json +{ + "hit": true, + "sectionIndex": 0, + "paragraphIndex": 3, + "controlIndex": 0, + "footnoteNumber": 1, + "footnoteIndex": 0, + "bbox": { "x": 263.3, "y": 376.1, "w": 9.0, "h": 20.0 }, + "cursorRect": { "pageIndex": 0, "x": 272.3, "y": 376.1, "height": 20.0 } +} +``` + +miss: + +```json +{ "hit": false } +``` + +필드 의미: + +| 필드 | 의미 | +|------|------| +| `sectionIndex` | 본문 각주 마커가 속한 구역 | +| `paragraphIndex` | 본문 각주 마커가 속한 문단 | +| `controlIndex` | 실제 `para.controls` 내 `Control::Footnote` 인덱스 | +| `footnoteNumber` | 렌더링된 각주 번호 | +| `footnoteIndex` | 해당 페이지 `page.footnotes` 배열 내 인덱스 | +| `bbox` | 본문 마커 bbox | +| `cursorRect` | 본문 마커 오른쪽 기준 caret 후보. 실제 각주 영역 진입은 기존 `enterFootnoteMode()` 경로 사용 | + +### WasmBridge 래퍼 + +```ts +hitTestBodyFootnoteMarker( + pageNum: number, + x: number, + y: number, +): { + hit: boolean; + sectionIndex?: number; + paragraphIndex?: number; + controlIndex?: number; + footnoteNumber?: number; + footnoteIndex?: number; + bbox?: { x: number; y: number; w: number; h: number }; + cursorRect?: { pageIndex: number; x: number; y: number; height: number }; +} +``` + +## 구현 상세 + +### 1. 본문 각주 마커 메타데이터 정합 + +대상 파일: + +- `src/renderer/composer.rs` +- `src/renderer/layout/paragraph_layout.rs` +- `src/renderer/render_tree.rs` + +현재 `ComposedParagraph.footnote_positions` 는 `(position, number)` 형태다. `paragraph_layout.rs` 는 이 배열의 순번 `fni` 를 `FootnoteMarkerNode.control_index` 로 넣고 있다. 문단에 비각주 컨트롤이 섞이면 실제 control index 와 불일치할 수 있다. + +수정 방향: + +- `footnote_positions` 를 `(position, number, control_index)` 형태로 확장한다. +- `paragraph_layout.rs` 의 모든 `FootnoteMarkerNode.control_index` 를 실제 control index 로 채운다. +- 기존 번호/위치 기반 로직은 동일하게 유지한다. + +검증: + +- `samples/footnote-01.hwp` 의 문단 0.3, 0.7에서 `controlIndex=0` 반환 +- 문단에 footnote 이전 비각주 컨트롤이 있는 synthetic 테스트 또는 단위 테스트를 추가할 수 있으면 실제 control index 보정 확인 + +### 2. Rust 본문 마커 hit test 추가 + +대상 파일: + +- `src/document_core/queries/cursor_rect.rs` +- `src/wasm_api.rs` + +구현 방향: + +1. `build_page_tree_cached(page_num)` 또는 기존 page tree 경로를 사용한다. +2. 렌더 트리를 순회해 `RenderNodeType::FootnoteMarker` 를 수집한다. +3. `x/y` 가 marker bbox 안에 들어오면 hit 처리한다. +4. 해당 marker 의 `section_index`, `para_index`, `control_index` 와 현재 페이지 `page.footnotes` 의 `FootnoteSource::Body` 를 대조해 `footnoteIndex` 를 찾는다. +5. body source 가 아닌 경우 1차 작업에서는 miss 또는 `sourceType` 포함 후 TS에서 무시하는 방식 중 하나로 제한한다. 구현은 body source hit 만 `hit=true` 로 반환하는 쪽을 기본값으로 한다. + +추가 helper 후보: + +```rust +fn find_page_body_footnote_index( + &self, + page_num: u32, + section_idx: usize, + para_idx: usize, + control_idx: usize, +) -> Option +``` + +### 3. 본문 각주 마커 cursor unit 반영 + +대상 파일: + +- `src/document_core/helpers.rs` +- `src/document_core/queries/doc_tree_nav.rs` +- `src/document_core/queries/cursor_rect.rs` + +수정 방향: + +- `Control::Footnote` 를 본문 inline control 1칸으로 취급한다. +- `navigable_text_len()` 에서 Footnote 위치 뒤 offset 을 허용한다. +- `classify_navigable()` 은 `Control::Footnote(_) => Some(false)` 로 분류한다. +- `navigate_next_editable()` 은 기존 Shape/Picture/Equation 경로와 같은 방식으로 footnote 위치에서 한 번 멈추고, 다음 이동에서 마커 뒤 위치로 이동하도록 한다. +- `get_cursor_rect_native()` 는 `FootnoteMarker` 에 대해 다음을 처리한다. + - offset == marker logical position: marker 왼쪽 caret + - offset == marker logical position + 1: marker 오른쪽 caret + +주의점: + +- 현 구조는 텍스트 offset 과 inline control logical offset 이 완전히 분리되어 있지 않다. 따라서 구현 시 `find_control_text_positions()` 를 그대로 사용하되, Footnote가 있는 문단에 한해 marker 앞/뒤 caret rect 를 우선 반환하도록 한다. +- 텍스트 삽입/삭제의 logical offset 전환은 2차 삭제 API/UI 작업에서 별도 검토한다. 1차 작업은 방향키 및 hit test 안정화에 집중한다. + +### 4. rhwp-studio 마우스 처리 연결 + +대상 파일: + +- `rhwp-studio/src/core/wasm-bridge.ts` +- `rhwp-studio/src/engine/input-handler-mouse.ts` + +수정 방향: + +1. `WasmBridge.hitTestBodyFootnoteMarker()` 추가 +2. `input-handler-mouse.ts` 에서 일반 `wasm.hitTest()` 보다 먼저 본문 각주 마커 hit 를 검사한다. +3. hit 시: + - `pageInfo` 를 별도 조회하지 않고 hit 결과의 `sectionIndex/paragraphIndex/controlIndex/footnoteIndex` 를 사용한다. + - `cursor.enterFootnoteMode(section, para, control, footnoteIndex, pageIdx)` 호출 + - `cursor.setFnCursorPosition(0, 0)` 또는 기존 `getCursorRectInFootnote` 기본 위치 사용 + - `footnoteModeChanged` 이벤트 emit + - caret 갱신 후 return + +기존 각주 영역 클릭 처리 순서는 유지한다. + +```text +1. 각주 편집 모드에서 클릭 처리 +2. 본문 각주 마커 hit test +3. 각주 영역 클릭 → 각주 편집 모드 진입 +4. 일반 본문 hitTest +``` + +### 5. 검증 및 e2e + +대상 파일 후보: + +- `src/wasm_api/tests.rs` +- `rhwp-studio/e2e/footnote-marker-nav.test.mjs` 신규 + +검증 항목: + +1. Rust/WASM 단위 또는 통합 검증 + - `samples/footnote-01.hwp` 로드 + - 페이지 0 marker hit API가 문단 0.3 / control 0 / footnoteIndex 0을 반환하는지 확인 + - 페이지 0 두 번째 marker가 문단 0.7 / control 0 / footnoteIndex 1을 반환하는지 확인 + +2. 커서 이동 검증 + - 문단 0.3의 footnote 위치 앞에서 `navigateNextEditable(..., +1)` 호출 + - 다음 위치가 marker 뒤 위치로 진행되는지 확인 + - 반대 방향도 동일 확인 + +3. rhwp-studio e2e + - `footnote-01.hwp` 로드 + - 본문 각주 마커 클릭 좌표를 API 또는 SVG 좌표 기반으로 계산 + - 클릭 후 `cursor.isInFootnote()` 또는 UI 상태 이벤트가 true 인지 확인 + - 좌우 방향키 이동 시 marker 앞/뒤 caret x 좌표가 marker bbox 좌/우로 이동하는지 확인 + +4. 회귀 검증 + - `cargo test` + - `cargo build` + - `cd rhwp-studio && npm run build` + - 가능하면 `cd rhwp-studio && node e2e/footnote-marker-nav.test.mjs --mode=headless` + +## 구현 단계 (4 stages) + +### Stage 3-1 — Rust marker metadata + hit API + +- `footnote_positions` 를 실제 control index 포함 형태로 확장 +- `FootnoteMarkerNode.control_index` 정합 보정 +- `hit_test_body_footnote_marker_native()` 추가 +- `wasm_api.rs` export 추가 + +완료 기준: + +- cargo build 통과 +- 신규 API가 `samples/footnote-01.hwp` 의 본문 각주 마커 source 를 정확히 반환 + +### Stage 3-2 — Cursor navigation / rect 보정 + +- `Control::Footnote` 를 navigable inline control 로 분류 +- footnote marker 앞/뒤 caret rect 계산 추가 +- `navigateNextEditable` 좌우 이동에서 각주 마커를 1칸 단위로 처리 + +완료 기준: + +- ArrowLeft/ArrowRight 기반으로 문단 0.3, 0.7 각주 마커 앞/뒤 위치 도달 +- 기존 Shape/Picture/Equation navigation 회귀 없음 + +### Stage 3-3 — WasmBridge + mouse 연결 + +- `WasmBridge.hitTestBodyFootnoteMarker()` 추가 +- `input-handler-mouse.ts` 본문 마커 클릭 분기 추가 +- 본문 마커 클릭 시 각주 영역 편집 모드 진입 + +완료 기준: + +- 본문 각주 마커 클릭 후 각주 영역 cursor 표시 +- 기존 각주 영역 직접 클릭 진입 동작 유지 + +### Stage 3-4 — 테스트 및 완료보고 + +- Rust 테스트 또는 wasm_api 테스트 추가 +- rhwp-studio e2e 추가 +- `cargo test`, `cargo build`, `npm run build`, e2e 실행 +- Stage 3 완료보고서 작성 + +완료 기준: + +- 검증 게이트 통과 +- 남은 이슈는 2차 삭제 API/UI 작업 범위로 정리 + +## 위험 및 대응 + +| 위험 | 대응 | +|------|------| +| `control_index` 정합 변경으로 렌더링 회귀 | `footnote_positions` 확장은 marker 메타만 바꾸고 번호/위치 렌더링은 유지 | +| offset 체계 혼동 | 1차는 marker 앞/뒤 caret rect 와 navigation 에 집중하고, 텍스트 편집 변환은 2차에서 명시적으로 다룸 | +| body 외 source 각주 혼입 | hit API는 `FootnoteSource::Body` 만 true 반환 | +| 기존 각주 영역 클릭 회귀 | 기존 `hitTestFootnote` / `hitTestInFootnote` 경로는 수정하지 않음 | +| e2e 좌표 불안정 | marker hit API 또는 bbox 반환값을 사용해 클릭 좌표 계산 | + +## 최종 검증 명령 + +```bash +cargo test +cargo build +cd rhwp-studio && npm run build +cd rhwp-studio && node e2e/footnote-marker-nav.test.mjs --mode=headless +``` + +headless Chrome 경로 또는 환경 문제로 e2e 실행이 불가능하면, 실패 원인을 Stage 3 완료보고서에 기록하고 WASM API 직접 검증 결과를 함께 제출한다. + +## 다음 승인 지점 + +본 구현 계획서 승인 후 Stage 3-1 구현을 시작한다. diff --git a/mydocs/working/task_m100_598_report.md b/mydocs/working/task_m100_598_report.md new file mode 100644 index 000000000..baa220e0d --- /dev/null +++ b/mydocs/working/task_m100_598_report.md @@ -0,0 +1,139 @@ +# Task #598 최종 결과보고서 — rhwp-studio 본문 각주 마커 이동/삭제 + +## 작업 개요 + +- **Issue**: [#598](https://github.com/edwardkim/rhwp/issues/598) +- **브랜치**: `local/task598` +- **범위**: `(1) hit test + 커서 이동`, `(2) 삭제 API/UI` + +본문에 렌더링되는 각주 마커를 커서 이동 단위로 취급하고, 본문 마커 기준으로 각주 편집 모드 진입 및 각주 삭제를 지원하도록 구현했다. + +## 구현 요약 + +### 1차: hit test + 커서 이동 + +- `ComposedParagraph.footnote_positions` 에 실제 `para.controls` 인덱스를 보존하도록 확장했다. +- `FootnoteMarkerNode.control_index` 가 실제 각주 control index 를 가리키도록 보정했다. +- `hitTestBodyFootnoteMarker()` / `hit_test_body_footnote_marker_native()` 를 추가했다. +- 본문 각주/미주 마커를 inline cursor unit 으로 취급하도록 탐색 길이와 문단 control position 폴백을 보정했다. +- `get_cursor_rect_native()` 가 각주 마커 왼쪽/오른쪽 caret 위치를 반환하도록 보정했다. +- rhwp-studio 마우스 처리에서 본문 각주 마커 클릭 시 각주 편집 모드로 진입하도록 연결했다. + +### 2차: 삭제 API/UI + +- `getFootnoteAtCursor()` / `get_footnote_at_cursor_native()` 를 추가했다. + - Backspace: `direction="backward"` + - Delete: `direction="forward"` +- `deleteFootnote()` / `delete_footnote_native()` 를 추가했다. + - 본문 각주 control 검증 + - 8 UTF-16 code unit 컨트롤 슬롯 제거에 맞춘 `char_offsets` / `char_count` 보정 + - `controls` / `ctrl_data_records` 동시 제거 + - 각주 번호 재계산 + - 본문 reflow, section recompose, pagination, page tree cache 무효화 +- `DocumentEvent::FootnoteDeleted` 를 추가했다. +- rhwp-studio Backspace/Delete 처리에서 일반 텍스트 삭제 전에 각주 마커 삭제를 우선 처리하도록 연결했다. +- 삭제 작업은 `SnapshotCommand` 로 실행해 Undo/Redo 경로를 사용한다. +- Delete/Fn+Delete 및 Backspace 양쪽에서 동일한 `showConfirm()` 확인창을 표시하도록 보강했다. +- 확인창 취소 시 삭제하지 않고, 확인 후에는 textarea 포커스를 복원해 Ctrl+Z Undo 가 바로 동작하도록 보정했다. +- 본문 각주 마커 바로 앞에서 `Backspace` 로 일반 텍스트를 삭제하는 경우 각주 anchor 가 줄 끝으로 이동하지 않도록 `delete_text_at()` 의 UTF-16 삭제 길이 계산을 보정했다. +- 같은 위치에서 Undo 성격의 텍스트 삽입이 각주 마커 뒤가 아니라 앞쪽 원위치로 들어가도록 `insert_text_at()` 의 inline control 위치 삽입을 보정했다. +- `insert_text_at()` 의 inline control 앞 삽입 보정이 `SectionDef` / `ColumnDef` 같은 문단 메타 컨트롤에 적용되지 않도록 제한해 빈 문서 저장 caret 회귀를 보정했다. + +## 검증 결과 + +실행 명령: + +```bash +cargo test --test issue_598_footnote_marker_nav +cargo test wasm_api::tests::test_save_text_only --lib -- --nocapture +cargo test --lib +cargo test navigable_text_len_counts_trailing_footnote_marker +cargo build +cd rhwp-studio && npm run build +docker-compose --env-file .env.docker run --rm wasm +cd rhwp-studio && npm run build +CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" node e2e/footnote-delete-confirm.test.mjs --mode=headless +git diff --check +``` + +결과: + +- `cargo test --test issue_598_footnote_marker_nav`: 4 passed +- `cargo test wasm_api::tests::test_save_text_only --lib -- --nocapture`: 통과 +- `cargo test --lib`: 1135 passed, 0 failed, 2 ignored +- `cargo test navigable_text_len_counts_trailing_footnote_marker`: 1 passed +- `cargo build`: 통과 +- `npm run build`: 통과 +- `docker-compose --env-file .env.docker run --rm wasm`: 통과 +- 새 WASM 반영 후 `npm run build`: 통과 +- `footnote-delete-confirm.test.mjs`: 통과 +- `git diff --check`: 통과 + +추가 확인: + +- `pkg/rhwp.js` 와 rhwp-studio 번들에 `hitTestBodyFootnoteMarker`, `getFootnoteAtCursor`, `deleteFootnote` 가 포함됨을 확인했다. + +참고: + +- `cargo test navigable_text_len_counts_trailing_footnote_marker` 실행 시 기존 테스트 코드의 warning 이 함께 출력됐다. 이번 변경과 무관한 기존 warning 이며 테스트는 통과했다. +- `npm run build` 실행 시 Vite chunk size warning 이 출력됐다. 기존 번들 크기 경고이며 빌드는 성공했다. + +## 수동 검증 + +작업지시자가 `http://localhost:7700/` 에서 rhwp-studio 를 실행해 확인했다. + +확인된 동작: + +- `samples/footnote-01.hwp` 로드 +- 본문 각주 마커 클릭 시 하단 각주 영역으로 caret 이동 +- 본문 각주 마커 뒤 커서 위치에서 Backspace 로 각주 삭제 +- 본문 각주 마커 앞 커서 위치에서 `Fn+Delete` 로 각주 삭제 +- `Fn+Delete` 삭제 후 첫 번째 각주 본문 제거 및 기존 두 번째 각주가 `1)` 로 재번호화됨 +- Delete/Backspace 양쪽에서 동일한 "각주를 삭제하시겠습니까?" 확인창 표시 +- 확인창 취소 시 각주가 삭제되지 않음 +- 삭제 후 Ctrl+Z 로 각주 마커/본문/번호가 복원됨 +- 본문 각주 마커 바로 앞 `액체|1)와` 위치에서 Backspace 를 누르면 각주 삭제 확인창 없이 직전 일반 텍스트만 삭제됨 +- 위 상태에서 각주 마커가 줄 끝으로 이동하지 않고 남은 텍스트와 다음 텍스트 사이에 유지됨 +- 위 상태에서 Ctrl+Z 로 텍스트와 각주 마커 위치가 함께 복원됨 +- rhwp-studio e2e에서 좌/우 방향키로 본문 각주 마커를 한 칸 단위로 통과함을 검증함 +- rhwp-studio e2e에서 본문 각주 마커 클릭 시 각주 편집 모드로 진입하고 원본 본문 문단/컨트롤/각주 인덱스가 연결됨을 검증함 + +## 산출물 + +- 구현 파일: + - `src/document_core/queries/cursor_rect.rs` + - `src/document_core/commands/footnote_ops.rs` + - `src/document_core/commands/object_ops.rs` + - `src/document_core/helpers.rs` + - `src/document_core/queries/doc_tree_nav.rs` + - `src/model/paragraph.rs` + - `src/model/event.rs` + - `src/renderer/composer.rs` + - `src/renderer/layout/paragraph_layout.rs` + - `src/wasm_api.rs` + - `rhwp-studio/src/core/types.ts` + - `rhwp-studio/src/core/wasm-bridge.ts` + - `rhwp-studio/src/engine/input-handler-mouse.ts` + - `rhwp-studio/src/engine/input-handler-text.ts` +- 테스트: + - `tests/issue_598_footnote_marker_nav.rs` + - `rhwp-studio/e2e/footnote-delete-confirm.test.mjs` +- 문서: + - `mydocs/plans/task_m100_598.md` + - `mydocs/plans/task_m100_598_impl.md` + - `mydocs/plans/task_m100_598_delete_impl.md` + - `mydocs/working/task_m100_598_stage1.md` + - `mydocs/working/task_m100_598_stage3_1.md` + - `mydocs/working/task_m100_598_stage3_2.md` + - `mydocs/working/task_m100_598_stage3_3.md` + - `mydocs/working/task_m100_598_stage3_4.md` + - `mydocs/working/task_m100_598_stage4_1.md` + - `mydocs/working/task_m100_598_stage4_2.md` + - `mydocs/working/task_m100_598_stage4_3.md` + - `mydocs/working/task_m100_598_stage4_4.md` + - `mydocs/working/task_m100_598_stage4_5.md` + - `mydocs/working/task_m100_598_stage4_6.md` + +## 남은 확인 항목 + +- PR #642 추가 커밋 push 및 CI 확인 diff --git a/mydocs/working/task_m100_598_stage1.md b/mydocs/working/task_m100_598_stage1.md new file mode 100644 index 000000000..fb3090c4a --- /dev/null +++ b/mydocs/working/task_m100_598_stage1.md @@ -0,0 +1,151 @@ +# Task #598 Stage 1 완료보고서 — 본문 각주 마커 hit test + 커서 이동 진단 + +## 작업 개요 + +- **Issue**: [#598](https://github.com/edwardkim/rhwp/issues/598) +- **브랜치**: `local/task598` +- **기준 커밋**: `upstream/devel` `9b49063` +- **진단 범위**: 본문 각주 마커 hit test 및 좌우 커서 이동 단위 +- **대표 샘플**: `samples/footnote-01.hwp` + +본 단계에서는 소스 수정 없이 재현 명령과 코드 대조만 수행했다. + +## 실행 명령 + +```bash +cargo run --bin rhwp -- dump-pages samples/footnote-01.hwp -p 0 +cargo run --bin rhwp -- dump samples/footnote-01.hwp +target/debug/rhwp dump samples/footnote-01.hwp -s 0 -p 3 +target/debug/rhwp dump samples/footnote-01.hwp -s 0 -p 7 +target/debug/rhwp dump-pages samples/footnote-01.hwp -p 1 +target/debug/rhwp export-svg samples/footnote-01.hwp -o output/debug/task598_stage1 --debug-overlay -p 0 +``` + +초기 `cargo run` 은 바이너리 지정 누락으로 실패했으며, 이후 `--bin rhwp` 로 재실행했다. crates.io 인덱스 접근이 sandbox 네트워크 제한에 막혀 승인 후 재실행했고, 그 뒤 빌드 및 진단 명령은 정상 완료했다. + +## 샘플 재현 결과 + +`samples/footnote-01.hwp` 는 6페이지 문서다. 1페이지에서 본문 각주가 있는 대표 문단은 다음과 같다. + +```text +문단 0.3: cc=56, text_len=47, controls=1 + 텍스트: "플라스틱 액체와 같은 원료를 ..." + [0] 각주: paragraphs=1 + +문단 0.7: cc=22, text_len=13, controls=1 + 텍스트: "3D 프린팅 기술의 장점" + [0] 각주: paragraphs=1 +``` + +`dump-pages -p 0` 기준 1페이지 본문 배치에는 해당 문단이 정상 포함된다. + +```text +FullParagraph pi=3 ... "플라스틱 액체와 같은 원료를 ..." +FullParagraph pi=7 ... "3D 프린팅 기술의 장점" +``` + +SVG debug overlay 출력에서도 본문 각주 마커가 실제 렌더링된다. + +```text +output/debug/task598_stage1/footnote-01_001.svg + text x=263.25 y=384.07 "1)" + text x=212.92 y=702.25 "2)" + s0:pi=3 y=376.1 + s0:pi=7 y=694.3 +``` + +따라서 문제는 마커 렌더링 부재가 아니라, 렌더된 `FootnoteMarker` 를 편집 hit test 및 cursor navigation 이 소비하지 않는 구조로 판단된다. + +## 코드 대조 결과 + +### 1. 본문 각주 마커 렌더 노드에는 필요한 메타가 있다 + +`src/renderer/render_tree.rs` 의 `FootnoteMarkerNode` 는 번호, 텍스트, 구역, 문단, 컨트롤 인덱스를 가진다. + +`src/renderer/layout/paragraph_layout.rs` 는 `ComposedParagraph.footnote_positions` 를 기반으로 `RenderNodeType::FootnoteMarker` 를 생성한다. + +```rust +RenderNodeType::FootnoteMarker(FootnoteMarkerNode { + number: fnum, + text: fn_text, + section_index, + para_index, + control_index: fni, +}) +``` + +단, 여기서 `control_index` 로 들어가는 `fni` 는 현재 `footnote_positions` 배열 내 인덱스다. 문단에 비각주 컨트롤이 섞이는 경우 실제 `para.controls` 의 control index 와 달라질 수 있으므로 구현 계획에서 확인 및 보정이 필요하다. + +### 2. 일반 hit test 는 `FootnoteMarker` 를 수집하지 않는다 + +`src/document_core/queries/cursor_rect.rs` 의 `hit_test_native()` 는 `TextRun`, 안내문 TextRun, TableCell bbox 를 수집한다. 이후 인라인 Shape/Picture 전용 bbox 검사는 있지만 `RenderNodeType::FootnoteMarker` 검사는 없다. + +현재 구조에서는 본문 각주 마커를 클릭해도 별도 hit 결과가 생성되지 않고, 주변 `TextRun` hit 로 흡수되거나 본문 위치 계산으로만 처리된다. + +### 3. 각주 영역 hit test 는 이미 별도 구현되어 있다 + +각주 영역 내부는 다음 API로 처리된다. + +- `hit_test_footnote_native(page, x, y)` +- `hit_test_in_footnote_native(page, x, y)` +- `get_page_footnote_info_native(page, footnoteIndex)` +- `get_cursor_rect_in_footnote_native(page, footnoteIndex, fnParaIdx, charOffset)` + +따라서 본문 마커 hit API는 기존 각주 영역 API를 대체하지 않고, 본문 마커 클릭 시 `pageFootnoteIndex` 또는 source 정보를 찾아 기존 `enterFootnoteMode()` 경로로 연결하는 역할이면 충분하다. + +### 4. 좌우 이동 길이 계산에서 `Control::Footnote` 가 빠져 있다 + +`src/document_core/helpers.rs` 의 `navigable_text_len()` 은 Shape/Table/Picture/Equation 및 CharOverlap 일부만 반영한다. + +```rust +matches!(c, Control::Shape(_) | Control::Table(_) | Control::Picture(_) | Control::Equation(_)) +``` + +`Control::Footnote` 는 여기 포함되지 않는다. + +`src/document_core/queries/doc_tree_nav.rs` 의 `classify_navigable()` 도 `Control::Footnote` 를 `Some(false)` 로 분류하지 않는다. 결과적으로 `navigate_next_editable()` 은 각주 컨트롤 위치를 1칸 inline unit 으로 취급하지 못한다. + +### 5. 현재 offset 체계는 마커 앞/뒤를 분리하지 못한다 + +문단 0.3의 첫 각주는 SVG상 `플라스틱 액체` 뒤, `와` 앞에 렌더링된다. 이 위치는 텍스트 기준 char offset 7이다. + +현재 렌더링은 각주 마커를 `TextRun` 사이에 끼워 넣지만, 각주 마커 자체는 char offset 을 소비하지 않는다. 따라서 offset 7은 마커 앞 위치로도, 마커 뒤의 다음 `TextRun` 시작 위치로도 해석될 수 있다. + +문단 0.7처럼 각주가 문단 끝에 있는 경우도 `text_len=13` 이고, `navigable_text_len()` 이 13으로 유지되면 마커 뒤 위치인 14를 만들 수 없다. 이 상태에서는 이슈 #598의 Delete/Backspace 삭제 전제인 “각주 앞/뒤 위치”가 성립하지 않는다. + +## Stage 1 결론 + +1차 구현은 다음 두 축을 함께 처리해야 한다. + +1. **본문 각주 마커 hit test** + - 렌더 트리에서 `FootnoteMarker` bbox 를 수집한다. + - 클릭 좌표가 bbox 안이면 `sectionIndex`, `paragraphIndex`, 실제 `controlIndex`, `footnoteNumber`, `pageFootnoteIndex`, `cursorRect` 를 반환한다. + - `pageFootnoteIndex` 는 현재 페이지의 `page.footnotes` 와 source 를 대조해 산출하는 방식이 적합하다. + +2. **본문 각주 마커의 logical cursor unit 반영** + - `Control::Footnote` 를 본문 inline control 1칸으로 취급한다. + - `navigable_text_len()` 또는 신규 footnote-aware length helper 에서 footnote 위치 뒤 offset 을 허용한다. + - `navigate_next_editable()` 에서 forward/backward 이동 시 footnote 위치에서 멈추고, 한 번 더 이동하면 footnote 뒤 위치로 이동하도록 조정한다. + - `get_cursor_rect_native()` 는 마커 앞/뒤 offset 을 구분할 수 있도록 `FootnoteMarker` bbox 또는 주변 TextRun 좌표를 사용해야 한다. + +## 구현 계획서에 반영할 결정 사항 + +| 항목 | Stage 1 판단 | +|------|--------------| +| 본문 hit API | 신규 API가 필요하다. 기존 `hitTestFootnote` 는 각주 영역 전용으로 유지한다. | +| 반환값 | `hit`, `sectionIndex`, `paragraphIndex`, `controlIndex`, `footnoteNumber`, `footnoteIndex`, `cursorRect` 권장 | +| control index | `FootnoteMarkerNode.control_index` 를 실제 `para.controls` 인덱스로 보정하거나, hit API 내부에서 source 대조로 보정한다. | +| 커서 단위 | Footnote를 Shape/Picture/Equation과 같은 1칸 inline unit 으로 취급하되, 각주 영역 진입과 삭제 전제를 위해 앞/뒤 offset 분리 필요 | +| 1차 범위 | body source 우선. 표 셀/글상자 내부 각주는 후속 확장 대상으로 분리 | + +## 검증 산출물 + +- `output/debug/task598_stage1/footnote-01_001.svg` +- 문단 0.3, 0.7 `dump` 결과 +- 1페이지 `dump-pages` 결과 + +`output/` 하위 SVG는 `.gitignore` 대상이므로 커밋 대상에서 제외한다. + +## 다음 단계 + +작업지시자 승인 후 Stage 2 구현 계획서를 작성한다. diff --git a/mydocs/working/task_m100_598_stage3_1.md b/mydocs/working/task_m100_598_stage3_1.md new file mode 100644 index 000000000..c26ca050f --- /dev/null +++ b/mydocs/working/task_m100_598_stage3_1.md @@ -0,0 +1,93 @@ +# Task #598 Stage 3-1 완료보고서 — Rust marker metadata + hit API + +## 작업 개요 + +- **Issue**: [#598](https://github.com/edwardkim/rhwp/issues/598) +- **브랜치**: `local/task598` +- **기준 커밋**: `upstream/devel` `9b49063` +- **단계 범위**: 본문 각주 마커 메타데이터 정합 및 Rust/WASM hit test API 추가 + +본 단계에서는 구현 계획서의 Stage 3-1 범위만 처리했다. 커서 좌우 이동 단위 보정, rhwp-studio 마우스 연결, 삭제 API/UI는 다음 단계로 남겨두었다. + +## 변경 파일 + +| 파일 | 변경 내용 | +|------|-----------| +| `src/renderer/composer.rs` | `ComposedParagraph.footnote_positions` 를 `(position, number, control_index)` 형태로 확장 | +| `src/renderer/layout/paragraph_layout.rs` | `FootnoteMarkerNode.control_index` 에 배열 순번이 아니라 실제 `para.controls` 인덱스를 전달 | +| `src/document_core/queries/cursor_rect.rs` | `hit_test_body_footnote_marker_native()` 및 페이지 각주 source 대조 helper 추가 | +| `src/wasm_api.rs` | `hitTestBodyFootnoteMarker(pageNum, x, y)` WASM export 추가 | + +## 구현 내용 + +### 1. 각주 마커 control index 보정 + +기존에는 `footnote_positions` 배열 내 순번이 `FootnoteMarkerNode.control_index` 로 전달됐다. + +이제 `compose_paragraph()` 단계에서 `para.controls` 의 실제 인덱스를 함께 보존하고, 렌더링 단계에서 해당 값을 그대로 `FootnoteMarkerNode.control_index` 로 전달한다. + +이 변경으로 문단 안에 각주보다 앞선 그림/표/수식 등 다른 컨트롤이 있어도 본문 마커 hit 결과가 실제 `Control::Footnote` 인덱스를 가리킬 수 있다. + +### 2. 본문 각주 마커 hit test API 추가 + +`DocumentCore::hit_test_body_footnote_marker_native(page_num, x, y)` 를 추가했다. + +처리 흐름은 다음과 같다. + +1. `build_page_tree_cached(page_num)` 으로 페이지 렌더 트리를 얻는다. +2. 트리를 재귀 순회해 `RenderNodeType::FootnoteMarker` bbox 를 찾는다. +3. hit 된 마커의 `section_index`, `para_index`, `control_index` 를 현재 페이지의 `page.footnotes` 와 대조한다. +4. `FootnoteSource::Body` 와 일치하는 항목만 `hit=true` 로 반환한다. +5. 표 셀/글상자 등 body source 가 아닌 항목은 1차 범위 밖이므로 `hit=false` 로 처리한다. + +반환 JSON 형태: + +```json +{ + "hit": true, + "sectionIndex": 0, + "paragraphIndex": 3, + "controlIndex": 0, + "footnoteNumber": 1, + "footnoteIndex": 0, + "bbox": { "x": 263.3, "y": 376.1, "w": 9.0, "h": 20.0 }, + "cursorRect": { "pageIndex": 0, "x": 272.3, "y": 376.1, "height": 20.0 } +} +``` + +miss: + +```json +{ "hit": false } +``` + +### 3. WASM export 추가 + +`src/wasm_api.rs` 에 `hitTestBodyFootnoteMarker` 를 추가했다. rhwp-studio 연결은 Stage 3-3에서 `WasmBridge` 래퍼와 마우스 입력 처리 순서에 반영한다. + +## 검증 + +실행 결과: + +```bash +cargo build +git diff --check +``` + +결과: + +- `cargo build` 통과 +- `git diff --check` 통과 + +참고: + +- 전역 `cargo fmt --check` 는 기존 저장소의 다수 파일 포맷 차이를 함께 보고하므로 이번 단계 검증 기준에서 제외했다. +- 포맷 명령이 하위 모듈까지 따라가며 불필요한 포맷 변경을 만든 부분은 되돌렸고, 최종 diff 는 Stage 3-1 대상 4개 파일로 제한했다. + +## 남은 작업 + +다음 승인을 받은 뒤 Stage 3-2에서 진행한다. + +1. `Control::Footnote` 를 본문 navigable inline unit 으로 분류 +2. 각주 마커 앞/뒤 offset 을 좌우 커서 이동에 반영 +3. `get_cursor_rect_native()` 에서 `FootnoteMarker` bbox 기반 caret rect 반환 diff --git a/mydocs/working/task_m100_598_stage3_2.md b/mydocs/working/task_m100_598_stage3_2.md new file mode 100644 index 000000000..29288c59b --- /dev/null +++ b/mydocs/working/task_m100_598_stage3_2.md @@ -0,0 +1,102 @@ +# Task #598 Stage 3-2 완료보고서 — Cursor navigation / rect 보정 + +## 작업 개요 + +- **Issue**: [#598](https://github.com/edwardkim/rhwp/issues/598) +- **브랜치**: `local/task598` +- **기준 커밋**: `upstream/devel` `9b49063` +- **단계 범위**: 본문 각주 마커를 좌우 커서 이동 단위로 취급하고, 마커 앞/뒤 caret rect 를 반환하도록 보정 + +본 단계에서는 Rust 쪽 커서 이동과 caret rect 만 처리했다. rhwp-studio `WasmBridge`/마우스 이벤트 연결은 Stage 3-3 범위로 남겨두었다. + +## 변경 파일 + +| 파일 | 변경 내용 | +|------|-----------| +| `src/document_core/helpers.rs` | `navigable_text_len()` 이 `Footnote`/`Endnote` 위치 뒤 offset 을 허용하도록 확장 | +| `src/document_core/queries/doc_tree_nav.rs` | `classify_navigable()` 에서 `Footnote`/`Endnote` 를 1칸 inline unit 으로 분류 | +| `src/document_core/queries/cursor_rect.rs` | `FootnoteMarker` bbox 기반으로 마커 왼쪽/오른쪽 caret rect 반환 | +| `src/model/paragraph.rs` | `char_offsets` 없는 폴백 경로에서도 `Footnote`/`Endnote` 를 1칸 inline control 로 배치 | +| `tests/issue_598_footnote_marker_nav.rs` | `samples/footnote-01.hwp` 기반 회귀 테스트 추가 | + +## 구현 내용 + +### 1. 탐색 가능한 문단 길이 보정 + +`navigable_text_len()` 의 inline control 대상에 `Control::Footnote(_)` 와 `Control::Endnote(_)` 를 추가했다. + +이로써 각주 마커가 문단 끝에 있는 경우에도 `marker_pos + 1` offset 이 문단 내 유효한 커서 위치가 된다. + +### 2. 좌우 커서 이동 단위 보정 + +`doc_tree_nav.rs` 의 `classify_navigable()` 에 `Footnote`/`Endnote` 를 `Some(false)` 로 추가했다. + +따라서 기존 Shape/Picture/Equation 과 같은 방식으로 동작한다. + +```text +offset == marker_pos → 마커 앞 +ArrowRight / next → marker_pos + 1 +offset == marker_pos + 1 → 마커 뒤 +ArrowLeft / previous → marker_pos +``` + +### 3. caret rect 보정 + +`get_cursor_rect_native()` 에서 현재 문단의 note marker control position 을 미리 계산하고, 렌더 트리의 `RenderNodeType::FootnoteMarker` 와 대조한다. + +반환 기준: + +| offset | 반환 좌표 | +|--------|-----------| +| `marker_pos` | `FootnoteMarker` bbox 왼쪽 | +| `marker_pos + 1` | `FootnoteMarker` bbox 오른쪽 | + +이 처리는 TextRun 일반 탐색보다 먼저 수행되므로, 마커 뒤 offset 이 후속 TextRun 안쪽 위치로 흡수되지 않는다. + +### 4. 샘플 회귀 테스트 추가 + +`tests/issue_598_footnote_marker_nav.rs` 를 추가했다. + +검증 항목: + +- `samples/footnote-01.hwp` 문단 0.3의 첫 번째 본문 각주 마커 + - `hit_test_body_footnote_marker_native()` 가 `paragraphIndex=3`, `controlIndex=0`, `footnoteIndex=0` 반환 + - offset `7`/`8` 의 caret x 좌표가 마커 왼쪽/오른쪽 순서로 반환 + - `navigateNextEditable(7, +1)` → `8`, `navigateNextEditable(8, -1)` → `7` +- 문단 0.7의 두 번째 본문 각주 마커 + - `hit_test_body_footnote_marker_native()` 가 `paragraphIndex=7`, `controlIndex=0`, `footnoteIndex=1` 반환 + - offset `6`/`7` 의 caret x 좌표와 좌우 이동 검증 +- synthetic 문단 단위 테스트 + - 문단 끝 `Footnote` 가 `navigable_text_len()` 을 `text_len + 1` 로 확장하는지 검증 + +## 검증 + +실행 결과: + +```bash +cargo test --test issue_598_footnote_marker_nav +cargo test navigable_text_len_counts_trailing_footnote_marker +cargo build +git diff --check +``` + +결과: + +- `cargo test --test issue_598_footnote_marker_nav`: 2 passed +- `cargo test navigable_text_len_counts_trailing_footnote_marker`: 1 passed +- `cargo build`: 통과 +- `git diff --check`: 통과 + +참고: + +- `cargo test navigable_text_len_counts_trailing_footnote_marker` 실행 중 기존 테스트 코드의 warning 이 함께 출력됐지만, 이번 변경과 무관한 기존 warning 이며 테스트는 통과했다. +- rhwp-studio 웹 서버 검증은 아직 필요하지 않다. 다음 Stage 3-3에서 `WasmBridge` 와 마우스 입력 처리를 연결한 뒤 웹 서버 실행 검증을 요청한다. + +## 남은 작업 + +다음 승인을 받은 뒤 Stage 3-3에서 진행한다. + +1. `rhwp-studio/src/core/wasm-bridge.ts` 에 `hitTestBodyFootnoteMarker()` 래퍼 추가 +2. `rhwp-studio/src/engine/input-handler-mouse.ts` 에 본문 각주 마커 클릭 처리 연결 +3. 마커 클릭 시 기존 각주 편집 모드 진입 경로와 연결 +4. 웹 서버 실행 후 실제 클릭/커서 동작 검증 diff --git a/mydocs/working/task_m100_598_stage3_3.md b/mydocs/working/task_m100_598_stage3_3.md new file mode 100644 index 000000000..27a2f376d --- /dev/null +++ b/mydocs/working/task_m100_598_stage3_3.md @@ -0,0 +1,119 @@ +# Task #598 Stage 3-3 완료보고서 — WasmBridge + mouse 연결 + +## 작업 개요 + +- **Issue**: [#598](https://github.com/edwardkim/rhwp/issues/598) +- **브랜치**: `local/task598` +- **기준 커밋**: `upstream/devel` `9b49063` +- **단계 범위**: rhwp-studio 에서 본문 각주 마커 클릭 시 각주 편집 모드로 진입하도록 연결 + +본 단계에서는 Stage 3-1/3-2에서 추가한 Rust/WASM API를 rhwp-studio 입력 흐름에 연결했다. 삭제 API/UI는 아직 구현하지 않았다. + +## 변경 파일 + +| 파일 | 변경 내용 | +|------|-----------| +| `rhwp-studio/src/core/types.ts` | `BodyFootnoteMarkerHit` 타입 추가 | +| `rhwp-studio/src/core/wasm-bridge.ts` | `hitTestBodyFootnoteMarker(pageNum, x, y)` 래퍼 추가 | +| `rhwp-studio/src/engine/input-handler-mouse.ts` | 본문 각주 마커 클릭 처리 분기 추가 | + +## 구현 내용 + +### 1. WasmBridge 래퍼 추가 + +`WasmBridge.hitTestBodyFootnoteMarker()` 를 추가했다. + +반환 타입은 다음 필드를 가진다. + +```ts +{ + hit: boolean; + sectionIndex?: number; + paragraphIndex?: number; + controlIndex?: number; + footnoteNumber?: number; + footnoteIndex?: number; + bbox?: { x: number; y: number; w: number; h: number }; + cursorRect?: CursorRect; +} +``` + +현재 `pkg/` 바인딩이 갱신되지 않은 환경에서도 앱이 즉시 깨지지 않도록, 메서드가 없으면 `{ hit: false }` 를 반환하게 했다. + +### 2. 마우스 입력 처리 순서 변경 + +`input-handler-mouse.ts` 의 클릭 처리 순서를 다음처럼 확장했다. + +```text +1. 각주 편집 모드 내부/외부 클릭 처리 +2. 본문 각주 마커 hit test +3. 각주 영역 클릭 → 각주 편집 모드 진입 +4. 일반 본문 hitTest +``` + +본문 각주 마커 hit 시 다음을 수행한다. + +1. `cursor.enterFootnoteMode(sectionIndex, paragraphIndex, controlIndex, footnoteIndex, pageIdx)` 호출 +2. `footnoteModeChanged` 이벤트 emit +3. 각주 내부 커서를 첫 문단 offset 0으로 설정 +4. caret 갱신 후 입력 포커스 유지 + +### 3. WASM 바인딩 재생성 + +브라우저 런타임에서 새 Rust export 를 실제로 호출할 수 있도록 Docker WASM 빌드를 실행해 로컬 `pkg/` 를 갱신했다. + +확인 결과: + +```text +pkg/rhwp.js +pkg/rhwp.d.ts +pkg/rhwp_bg.wasm.d.ts +``` + +위 파일들에 `hitTestBodyFootnoteMarker` / `hwpdocument_hitTestBodyFootnoteMarker` 가 생성됐다. + +참고: 현재 저장소 설정상 `pkg/` 는 Git 추적 대상이 아니므로 `git status` 에는 표시되지 않는다. + +## 검증 + +실행 결과: + +```bash +docker-compose --env-file .env.docker run --rm wasm +cd rhwp-studio && npm run build +cargo build +git diff --check +curl -I http://localhost:7700/ +``` + +결과: + +- Docker WASM 빌드 통과 +- `pkg/` 바인딩에 `hitTestBodyFootnoteMarker` 생성 확인 +- `npm run build` 통과 +- `cargo build` 통과 +- `git diff --check` 통과 +- Vite dev server 응답 확인: `HTTP/1.1 200 OK` + +## 수동 확인 요청 + +Vite dev server 를 다음 주소로 실행해 두었다. + +```text +http://localhost:7700/ +``` + +브라우저에서 확인할 절차: + +1. `samples/footnote-01.hwp` 를 연다. +2. 1페이지 본문 각주 마커 `1)` 또는 `2)` 를 클릭한다. +3. 하단 각주 영역으로 caret 이 이동하고 각주 편집 모드가 켜지는지 확인한다. +4. 각주 영역 밖 본문을 클릭하면 각주 편집 모드가 해제되는지 확인한다. + +## 남은 작업 + +다음 승인을 받은 뒤 Stage 3-4에서 진행한다. + +1. 가능한 e2e 또는 브라우저 수동 검증 결과 반영 +2. 1차 작업 최종 회귀 검증 +3. Stage 3 전체 완료보고서 정리 diff --git a/mydocs/working/task_m100_598_stage3_4.md b/mydocs/working/task_m100_598_stage3_4.md new file mode 100644 index 000000000..9be5b9f11 --- /dev/null +++ b/mydocs/working/task_m100_598_stage3_4.md @@ -0,0 +1,83 @@ +# Task #598 Stage 3-4 완료보고서 — 1차 작업 검증 정리 + +## 작업 개요 + +- **Issue**: [#598](https://github.com/edwardkim/rhwp/issues/598) +- **브랜치**: `local/task598` +- **기준 커밋**: `upstream/devel` `9b49063` +- **단계 범위**: 1차 작업인 본문 각주 마커 hit test + 커서 이동 정합 검증 + +본 단계에서는 Stage 3-1~3-3 구현 결과를 최종 확인했다. 이슈 #598의 두 번째 축인 삭제 API/UI는 아직 구현하지 않았다. + +## 구현 요약 + +### Rust/WASM + +- `ComposedParagraph.footnote_positions` 에 실제 `para.controls` 인덱스를 보존하도록 확장했다. +- `FootnoteMarkerNode.control_index` 가 배열 순번이 아니라 실제 각주 control index 를 가리키도록 보정했다. +- `hit_test_body_footnote_marker_native()` / `hitTestBodyFootnoteMarker()` 를 추가했다. +- `Control::Footnote` / `Control::Endnote` 를 본문 inline cursor unit 으로 취급했다. +- `get_cursor_rect_native()` 가 `FootnoteMarker` bbox 기준으로 마커 왼쪽/오른쪽 caret rect 를 반환하도록 보정했다. + +### rhwp-studio + +- `WasmBridge.hitTestBodyFootnoteMarker()` 래퍼를 추가했다. +- 마우스 클릭 처리에서 본문 각주 마커 hit 를 각주 영역 hit test 보다 먼저 검사하도록 연결했다. +- 본문 각주 마커 클릭 시 `enterFootnoteMode()` 로 각주 편집 모드에 진입하도록 했다. + +## 검증 결과 + +### 자동 검증 + +실행 명령: + +```bash +cargo test --test issue_598_footnote_marker_nav +cargo test navigable_text_len_counts_trailing_footnote_marker +cd rhwp-studio && npm run build +cargo build +git diff --check +``` + +결과: + +- `cargo test --test issue_598_footnote_marker_nav`: 2 passed +- `cargo test navigable_text_len_counts_trailing_footnote_marker`: 1 passed +- `npm run build`: 통과 +- `cargo build`: 통과 +- `git diff --check`: 통과 + +참고: + +- `cargo test navigable_text_len_counts_trailing_footnote_marker` 실행 시 기존 테스트 코드의 warning 이 함께 출력됐다. 이번 변경과 무관한 기존 warning 이며 테스트는 통과했다. +- `npm run build` 실행 시 Vite chunk size warning 이 출력됐다. 기존 번들 크기 경고이며 빌드는 성공했다. + +### 수동 검증 + +작업지시자가 `http://localhost:7700/` 에서 rhwp-studio 를 실행해 다음 동작을 확인했다. + +- `samples/footnote-01.hwp` 로드 +- 1페이지 본문 각주 마커 클릭 +- 하단 각주 영역으로 caret 이동 +- 각주 편집 모드 진입 + +수동 확인 결과는 정상으로 보고받았다. + +## 산출물 + +- 신규 테스트: `tests/issue_598_footnote_marker_nav.rs` +- Stage 3-1 보고서: `mydocs/working/task_m100_598_stage3_1.md` +- Stage 3-2 보고서: `mydocs/working/task_m100_598_stage3_2.md` +- Stage 3-3 보고서: `mydocs/working/task_m100_598_stage3_3.md` +- Stage 3-4 보고서: `mydocs/working/task_m100_598_stage3_4.md` + +## 남은 작업 + +이슈 #598의 다음 작업은 삭제 API/UI 구현이다. + +후속 구현 범위: + +1. 본문 각주 마커 앞/뒤 위치에서 Delete/Backspace 로 각주 control 삭제 +2. 삭제 후 각주 번호/페이지 각주 목록 리플로우 +3. rhwp-studio Undo/Redo 명령 연결 +4. 각주 삭제 UI 및 회귀 테스트 추가 diff --git a/mydocs/working/task_m100_598_stage4_1.md b/mydocs/working/task_m100_598_stage4_1.md new file mode 100644 index 000000000..7fc118efd --- /dev/null +++ b/mydocs/working/task_m100_598_stage4_1.md @@ -0,0 +1,76 @@ +# Task #598 Stage 4-1 완료보고서 — 본문 각주 삭제 Rust API + +## 작업 개요 + +- **Issue**: [#598](https://github.com/edwardkim/rhwp/issues/598) +- **브랜치**: `local/task598` +- **단계 범위**: 2차 작업 중 Rust/WASM 삭제 API 기반 구현 + +본 단계에서는 본문 각주 마커 앞/뒤 커서 위치를 조회하고, 해당 각주 컨트롤을 삭제하는 Rust native API 및 WASM 공개 API를 추가했다. +rhwp-studio 키보드 입력 연결은 다음 단계(Stage 4-2) 범위로 남겨둔다. + +## 구현 내용 + +### Rust native API + +- `get_footnote_at_cursor_native(section_idx, para_idx, char_offset, direction)` 추가 + - `direction="backward"`: Backspace 기준으로 커서 바로 앞 각주 마커 조회 + - `direction="forward"`: Delete 기준으로 커서 바로 뒤 각주 마커 조회 + - 반환: `hit`, `sectionIndex`, `paragraphIndex`, `controlIndex`, `charOffset`, `footnoteNumber` + +- `delete_footnote_native(section_idx, para_idx, control_idx)` 추가 + - 본문 `Control::Footnote` 검증 + - 각주 마커의 텍스트 위치 복원 + - HWP 컨트롤 슬롯 8 UTF-16 code unit 제거에 맞춰 `char_offsets` / `char_count` 조정 + - `controls` / `ctrl_data_records` 동시 제거 + - 남은 각주 번호를 문서 순서대로 재계산 + - 본문 문단 reflow, section recompose, pagination, page tree cache 무효화 수행 + +### WASM API + +- `getFootnoteAtCursor(sectionIdx, paraIdx, charOffset, direction)` 추가 +- `deleteFootnote(sectionIdx, paraIdx, controlIdx)` 추가 + +### 보조 변경 + +- 컨트롤 삭제 후 문단 reflow helper 를 각주 삭제 경로에서도 재사용할 수 있도록 `pub(crate)` 로 조정했다. +- 각주 삽입 시 `control_mask` 에 각주/미주 비트(`1 << 0x0011`)가 설정되도록 보정했다. +- `DocumentEvent::FootnoteDeleted` 를 추가했다. + +## 검증 결과 + +실행 명령: + +```bash +cargo test --test issue_598_footnote_marker_nav +cargo test navigable_text_len_counts_trailing_footnote_marker +cargo build +git diff --check +``` + +결과: + +- `cargo test --test issue_598_footnote_marker_nav`: 3 passed +- `cargo test navigable_text_len_counts_trailing_footnote_marker`: 1 passed +- `cargo build`: 통과 +- `git diff --check`: 통과 + +## 테스트 추가 + +`tests/issue_598_footnote_marker_nav.rs` 에 삭제 회귀 테스트를 추가했다. + +확인 항목: + +1. 첫 번째 본문 각주 마커가 Backspace/forward 조회 API에서 정상 탐지되는지 +2. `delete_footnote_native(0, 3, 0)` 실행 후 해당 문단의 각주 컨트롤 위치가 사라지는지 +3. 기존 마커 좌표 hit test 가 삭제 후 `hit:false` 로 바뀌는지 +4. 두 번째 각주 번호가 `1` 로 재계산되는지 + +## 남은 작업 + +다음 단계(Stage 4-2)에서는 rhwp-studio 쪽 키보드 입력을 연결한다. + +- Backspace: 커서 바로 앞 각주 마커 삭제 +- Delete: 커서 바로 뒤 각주 마커 삭제 +- `SnapshotCommand` 기반 Undo/Redo 연결 +- 브라우저 수동 검증 준비 diff --git a/mydocs/working/task_m100_598_stage4_2.md b/mydocs/working/task_m100_598_stage4_2.md new file mode 100644 index 000000000..57e2407a1 --- /dev/null +++ b/mydocs/working/task_m100_598_stage4_2.md @@ -0,0 +1,69 @@ +# Task #598 Stage 4-2 완료보고서 — rhwp-studio 각주 삭제 UI 연결 + +## 작업 개요 + +- **Issue**: [#598](https://github.com/edwardkim/rhwp/issues/598) +- **브랜치**: `local/task598` +- **단계 범위**: 본문 각주 마커 삭제 API를 rhwp-studio Backspace/Delete 입력에 연결 + +본 단계에서는 Stage 4-1에서 추가한 `getFootnoteAtCursor` / `deleteFootnote` WASM API를 rhwp-studio 입력 처리에 연결했다. + +## 구현 내용 + +### WASM Bridge / 타입 + +- `FootnoteAtCursorResult` 타입 추가 +- `DeleteFootnoteResult` 타입 추가 +- `WasmBridge.getFootnoteAtCursor()` 추가 +- `WasmBridge.deleteFootnote()` 추가 + +### 키보드 입력 연결 + +- 본문 모드 Backspace 처리 전에 커서 바로 앞 각주 마커를 조회한다. +- 본문 모드 Delete 처리 전에 커서 바로 뒤 각주 마커를 조회한다. +- 각주 마커가 있으면 일반 `DeleteTextCommand` 대신 `SnapshotCommand` 로 `deleteFootnote` 를 실행한다. +- 삭제 후 커서는 삭제된 각주 마커 위치(`charOffset`)로 이동한다. +- 셀/글상자 내부 위치에서는 이번 범위에서 제외하고 기존 동작을 유지한다. + +## 검증 결과 + +실행 명령: + +```bash +cd rhwp-studio && npm run build +docker-compose --env-file .env.docker run --rm wasm +cd rhwp-studio && npm run build +git diff --check +``` + +결과: + +- `npm run build`: 통과 +- `docker-compose --env-file .env.docker run --rm wasm`: 통과 +- 새 WASM `pkg/` 반영 후 `npm run build`: 통과 +- `git diff --check`: 통과 + +참고: + +- `npm run build` 에서 Vite chunk size warning 이 출력됐다. 기존 번들 크기 경고이며 빌드는 성공했다. + +## 수동 검증 + +작업지시자가 macOS 환경에서 `http://localhost:7700/` 로 rhwp-studio 를 실행해 Backspace 및 `Fn+Delete` 경로를 확인했다. + +확인 내용: + +- `samples/footnote-01.hwp` 로드 +- 본문 각주 마커 뒤 커서 위치에서 Backspace 실행 +- 본문 각주 마커 앞 커서 위치에서 `Fn+Delete` 실행 +- 각주 삭제 동작 확인 +- `Fn+Delete` 삭제 후 첫 번째 각주 본문 제거 및 기존 두 번째 각주가 `1)` 로 재번호화됨 + +수동 테스트용 Vite 서버는 확인 후 종료했다. + +## 남은 작업 + +다음 단계(Stage 4-3)에서는 전체 검증과 PR 전 정리를 진행한다. + +- Rust/Studio 빌드 재확인 +- 최종 보고서 작성 diff --git a/mydocs/working/task_m100_598_stage4_3.md b/mydocs/working/task_m100_598_stage4_3.md new file mode 100644 index 000000000..4f8a6c241 --- /dev/null +++ b/mydocs/working/task_m100_598_stage4_3.md @@ -0,0 +1,50 @@ +# Task #598 Stage 4-3 완료보고서 — 각주 삭제 확인창/취소/Undo 검증 + +## 작업 개요 + +- **Issue**: [#598](https://github.com/edwardkim/rhwp/issues/598) +- **브랜치**: `feature/issue-598-footnote-marker-delete` +- **단계 범위**: Delete/Backspace 각주 삭제 확인창, 취소 동작, Undo 검증 보강 + +이슈 본문 요구사항 재점검 결과, 기존 PR 구현은 각주 삭제 핵심 경로는 동작했지만 동일 확인창과 취소/Undo 명시 검증이 부족했다. 본 단계에서 해당 범위를 보강했다. + +## 구현 내용 + +- rhwp-studio 본문 각주 삭제 분기에서 기존 공통 `showConfirm()` 확인 다이얼로그를 호출하도록 연결했다. +- Delete/Fn+Delete 및 Backspace 양쪽 모두 같은 제목/메시지를 사용한다. + - 제목: `각주 삭제` + - 메시지: `각주를 삭제하시겠습니까?` +- 사용자가 취소하면 `deleteFootnote` 를 호출하지 않고 textarea 포커스만 복원한다. +- 사용자가 확인하면 기존 `SnapshotCommand` 기반 `deleteFootnote` 삭제 작업을 실행한다. +- 확인 후 textarea 포커스를 복원해 바로 Ctrl+Z 입력이 Undo 핸들러로 전달되도록 보정했다. + +## 검증 결과 + +실행 명령: + +```bash +cd rhwp-studio && npm run build +CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" node e2e/footnote-delete-confirm.test.mjs --mode=headless +git diff --check +``` + +결과: + +- `npm run build`: 통과 +- `footnote-delete-confirm.test.mjs`: 통과 + - Delete 경로 확인창 메시지 표시 + - 취소 후 각주 마커/본문/번호 유지 + - Backspace 경로 동일 확인창 메시지 표시 + - 확인 후 각주 마커/본문 삭제 및 후속 각주 재번호화 + - Ctrl+Z 후 각주 마커/본문/번호 복원 +- `git diff --check`: 통과 + +참고: + +- `npm run build` 에서 Vite chunk size warning 이 출력됐다. 기존 번들 크기 경고이며 빌드는 성공했다. +- 첫 E2E 시도에서 확인 후 포커스가 textarea로 복원되지 않아 Ctrl+Z가 입력 핸들러에 전달되지 않는 문제가 확인됐고, 포커스 복원으로 수정 후 재실행해 통과했다. + +## 산출물 + +- `rhwp-studio/src/engine/input-handler-text.ts` +- `rhwp-studio/e2e/footnote-delete-confirm.test.mjs` diff --git a/mydocs/working/task_m100_598_stage4_4.md b/mydocs/working/task_m100_598_stage4_4.md new file mode 100644 index 000000000..dd18431bc --- /dev/null +++ b/mydocs/working/task_m100_598_stage4_4.md @@ -0,0 +1,49 @@ +# Task #598 Stage 4-4 완료보고서 — 각주 앞 Backspace anchor/Undo 보정 + +## 작업 범위 + +- 본문 각주 마커 바로 앞 위치에서 `Backspace` 로 일반 텍스트를 삭제할 때 각주 마커가 줄 끝으로 이동하는 문제 보정 +- 동일 상황에서 `Undo` 시 텍스트만 복원되고 각주 마커 위치가 복원되지 않는 문제 보정 +- Rust 단위 테스트와 rhwp-studio E2E 테스트에 회귀 케이스 추가 + +## 원인 + +`Paragraph::delete_text_at()` 이 삭제 구간의 UTF-16 길이를 다음 `char_offset` 값으로 계산하고 있었다. 삭제 문자 뒤에 각주 컨트롤 슬롯이 있으면 다음 `char_offset` 에 컨트롤 gap 이 포함되어 일반 텍스트 삭제가 컨트롤 gap 까지 함께 당기는 결과가 됐다. + +또한 `Paragraph::insert_text_at()` 은 각주 컨트롤과 같은 위치에 텍스트를 삽입할 때 해당 위치의 기존 `char_offsets` 값을 그대로 사용했다. 이 값은 컨트롤 뒤쪽 UTF-16 위치를 가리킬 수 있어 Undo 성격의 삽입이 각주 마커 뒤로 들어가며 anchor 복원이 깨졌다. + +## 구현 내용 + +- `delete_text_at()` 의 `utf16_delta` 를 삭제 대상 문자들의 실제 UTF-16 길이 합으로 계산하도록 변경했다. +- `insert_text_at()` 에서 삽입 위치가 inline control position 과 같으면 이전 문자 끝 위치를 UTF-16 삽입 지점으로 사용하도록 보정했다. +- `tests/issue_598_footnote_marker_nav.rs` 에 `issue_598_backspace_before_marker_keeps_marker_anchor_and_undo_restores_it` 테스트를 추가했다. +- `rhwp-studio/e2e/footnote-delete-confirm.test.mjs` 에 각주 앞 `Backspace` 일반 텍스트 삭제 및 Undo anchor 복원 검증을 추가했다. + +## 검증 + +실행 명령: + +```bash +cargo test --test issue_598_footnote_marker_nav +docker-compose --env-file .env.docker run --rm wasm +cd rhwp-studio && npm run build +CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" node e2e/footnote-delete-confirm.test.mjs --mode=headless +``` + +결과: + +- `cargo test --test issue_598_footnote_marker_nav`: 4 passed +- WASM 빌드: 통과 +- rhwp-studio build: 통과 +- `footnote-delete-confirm.test.mjs`: 통과 + +## 수동 확인 요청 항목 + +작업지시자가 `http://localhost:7700/` 에서 다음 흐름을 재확인할 수 있다. + +1. `samples/footnote-01.hwp` 를 연다. +2. 첫 번째 본문 각주 마커 바로 앞, 즉 `액체|1)와` 위치에 caret 을 둔다. +3. `Backspace` 를 누른다. +4. 각주 삭제 확인창이 뜨지 않고, 직전 일반 텍스트만 삭제되는지 확인한다. +5. 각주 마커가 줄 끝으로 이동하지 않고 남은 텍스트와 다음 텍스트 사이에 유지되는지 확인한다. +6. `Cmd+Z` 를 눌러 텍스트와 각주 마커 위치가 함께 원래대로 복원되는지 확인한다. diff --git a/mydocs/working/task_m100_598_stage4_5.md b/mydocs/working/task_m100_598_stage4_5.md new file mode 100644 index 000000000..35857c0ba --- /dev/null +++ b/mydocs/working/task_m100_598_stage4_5.md @@ -0,0 +1,47 @@ +# Task #598 Stage 4-5 완료보고서 — CI 저장 테스트 회귀 보정 + +## 작업 범위 + +- PR #642 `Build & Test` 실패 원인 분석 +- `wasm_api::tests::test_save_text_only` 회귀 보정 +- #598 각주 앞 Backspace anchor/Undo 보정 유지 확인 + +## 원인 + +Stage 4-4에서 `insert_text_at()` 에 추가한 inline control 앞 삽입 보정이 모든 control position에 적용됐다. + +`template/empty.hwp` 의 첫 문단은 텍스트가 비어 있지만 `SectionDef`, `ColumnDef` 컨트롤 2개를 가진다. 기존 동작은 텍스트를 두 컨트롤 뒤 UTF-16 offset `16`부터 삽입해야 한다. 그러나 새 조건이 두 컨트롤의 position `0`을 inline control 앞 삽입으로 오판해 offset `0`부터 텍스트를 삽입했고, 저장 후 caret 위치가 `24`가 아니라 `8`로 기록됐다. + +## 구현 내용 + +- `insert_text_at()` 의 `inserts_before_inline_control` 조건을 실제 본문 흐름 inline control로 제한했다. +- 대상 컨트롤: + - `Shape` + - `Table` + - `Picture` + - `Equation` + - `Footnote` + - `Endnote` +- `SectionDef`, `ColumnDef` 같은 문단 메타 컨트롤은 해당 분기에 들어가지 않도록 했다. + +## 검증 + +실행 명령: + +```bash +cargo test wasm_api::tests::test_save_text_only --lib -- --nocapture +cargo test --test issue_598_footnote_marker_nav +cargo test --lib +git diff --check +``` + +결과: + +- `test_save_text_only`: 통과 +- `issue_598_footnote_marker_nav`: 4 passed +- `cargo test --lib`: 1135 passed, 0 failed, 2 ignored +- `git diff --check`: 통과 + +## 판단 + +CI 실패는 GitHub Actions 환경 문제가 아니라 Stage 4-4 구현 범위가 과하게 넓었던 실제 회귀였다. inline control 판정을 본문 흐름 컨트롤로 제한해 빈 문서 저장 caret 동작과 각주 앞 Backspace Undo 동작을 모두 만족하도록 보정했다. diff --git a/mydocs/working/task_m100_598_stage4_6.md b/mydocs/working/task_m100_598_stage4_6.md new file mode 100644 index 000000000..532ab4326 --- /dev/null +++ b/mydocs/working/task_m100_598_stage4_6.md @@ -0,0 +1,45 @@ +# Task #598 Stage 4-6 완료보고서 — PR open 전 검증 보강 + +## 작업 범위 + +- #598 이슈 본문의 `검증` 항목과 PR #642 검증 항목 재대조 +- rhwp-studio e2e에 본문 각주 마커 좌/우 커서 이동 검증 추가 +- rhwp-studio e2e에 본문 각주 마커 클릭 후 각주 편집 모드 진입 검증 추가 + +## 구현 내용 + +`rhwp-studio/e2e/footnote-delete-confirm.test.mjs` 에 다음 검증을 추가했다. + +- `ArrowRight`: 본문 각주 마커 왼쪽 `charOffset=7` 에서 오른쪽 `charOffset=8` 로 한 칸 이동 +- `ArrowLeft`: 본문 각주 마커 오른쪽 `charOffset=8` 에서 왼쪽 `charOffset=7` 로 한 칸 이동 +- 본문 첫 번째 각주 마커 좌표 클릭 후 각주 편집 모드 진입 확인 +- 클릭 후 원본 본문 문단/컨트롤/각주 인덱스 연결 확인 + +기존 e2e 검증은 유지했다. + +- 각주 앞 Backspace 일반 텍스트 삭제 및 Undo anchor 복원 +- Delete 경로 확인창/취소 +- Backspace 경로 동일 확인창/확인 삭제 +- 후속 각주 번호 재계산 +- Ctrl+Z 복원 + +## 검증 + +실행 명령: + +```bash +CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" node e2e/footnote-delete-confirm.test.mjs --mode=headless +``` + +결과: + +- 본문 각주 마커 좌/우 방향키 이동: 통과 +- 본문 각주 마커 클릭 후 각주 편집 모드 진입: 통과 +- 각주 앞 Backspace 일반 텍스트 삭제/Undo: 통과 +- Delete 확인창/취소: 통과 +- Backspace 확인창/삭제: 통과 +- Ctrl+Z 복원: 통과 + +## 판단 + +이슈 본문의 e2e 검증 항목 중 PR 본문에서 약하게 표현되던 `본문 각주 마커 클릭` 과 `좌우 화살표 이동 단위` 검증을 자동화했다. 한컴 직접 비교는 컨트리뷰터 환경상 수행하지 못했으나, 이슈의 진행 안내에 따라 PR 본문과 이슈 코멘트에 메인테이너 시각 판정 포인트로 남긴다. diff --git a/rhwp-studio/e2e/footnote-delete-confirm.test.mjs b/rhwp-studio/e2e/footnote-delete-confirm.test.mjs new file mode 100644 index 000000000..dc31ccf21 --- /dev/null +++ b/rhwp-studio/e2e/footnote-delete-confirm.test.mjs @@ -0,0 +1,186 @@ +/** + * E2E 테스트: #598 본문 각주 삭제 확인창/취소/Undo + */ +import { runTest, loadHwpFile, screenshot, assert } from './helpers.mjs'; + +async function moveCursor(page, sectionIndex, paragraphIndex, charOffset) { + await page.evaluate((sec, para, offset) => { + const handler = window.__inputHandler; + handler?.cursor?.moveTo?.({ sectionIndex: sec, paragraphIndex: para, charOffset: offset }); + if (handler) handler.active = true; + handler?.focus?.(); + handler?.updateCaret?.(); + }, sectionIndex, paragraphIndex, charOffset); + await page.evaluate(() => new Promise(r => setTimeout(r, 100))); +} + +async function footnoteState(page) { + return await page.evaluate(() => { + const w = window.__wasm; + const info = (sec, para, ctrl) => { + try { return w.getFootnoteInfo(sec, para, ctrl); } + catch { return null; } + }; + return { + markerP3: w.getControlTextPositions(0, 3), + markerP7: w.getControlTextPositions(0, 7), + fnP3: info(0, 3, 0), + fnP7: info(0, 7, 0), + }; + }); +} + +async function cursorState(page) { + return await page.evaluate(() => { + const cursor = window.__inputHandler?.cursor; + const pos = cursor?.getPosition?.(); + return { + position: pos ? { + sectionIndex: pos.sectionIndex, + paragraphIndex: pos.paragraphIndex, + charOffset: pos.charOffset, + } : null, + inFootnote: cursor?.isInFootnote?.() ?? false, + fnSectionIdx: cursor?.fnSectionIdx, + fnParaIdx: cursor?.fnParaIdx, + fnControlIdx: cursor?.fnControlIdx, + fnInnerParaIdx: cursor?.fnInnerParaIdx, + fnCharOffset: cursor?.fnCharOffset, + fnFootnoteIndex: cursor?.fnFootnoteIndex, + }; + }); +} + +async function exitFootnoteMode(page) { + await page.evaluate(() => { + const handler = window.__inputHandler; + handler?.cursor?.exitFootnoteMode?.(); + handler?.eventBus?.emit?.('footnoteModeChanged', false); + handler?.focus?.(); + handler?.updateCaret?.(); + }); + await page.evaluate(() => new Promise(r => setTimeout(r, 100))); +} + +async function clickPagePoint(page, pageIndex, pageX, pageY) { + const point = await page.evaluate((idx, x, y) => { + const handler = window.__inputHandler; + const scrollContent = document.querySelector('#scroll-content'); + if (!handler || !scrollContent) throw new Error('input handler 또는 scroll-content 없음'); + const zoom = handler.viewportManager?.getZoom?.() ?? 1; + const rect = scrollContent.getBoundingClientRect(); + const pageOffset = handler.virtualScroll?.getPageOffset?.(idx) ?? 0; + const pageWidth = handler.virtualScroll?.getPageWidth?.(idx) ?? 0; + const pageLeft = (scrollContent.clientWidth - pageWidth) / 2; + return { + x: rect.left + pageLeft + x * zoom, + y: rect.top + pageOffset + y * zoom, + }; + }, pageIndex, pageX, pageY); + await page.mouse.click(point.x, point.y); + await page.evaluate(() => new Promise(r => setTimeout(r, 300))); +} + +async function dialogText(page) { + return await page.$eval('.modal-overlay .dialog-wrap', el => el.textContent || ''); +} + +async function clickDialogButton(page, label) { + await page.evaluate((text) => { + const buttons = Array.from(document.querySelectorAll('.modal-overlay .dialog-btn')); + const button = buttons.find(btn => (btn.textContent || '').trim() === text); + if (!button) throw new Error(`${text} 버튼을 찾을 수 없습니다`); + button.click(); + }, label); + await page.waitForFunction(() => !document.querySelector('.modal-overlay'), { timeout: 3000 }); + await page.evaluate(() => new Promise(r => setTimeout(r, 300))); +} + +runTest('본문 각주 삭제 확인창/취소/Undo', async ({ page }) => { + await loadHwpFile(page, 'footnote-01.hwp'); + + const initial = await footnoteState(page); + assert(JSON.stringify(initial.markerP3) === '[7]', '초기 첫 번째 본문 각주 마커 위치 확인'); + assert(initial.fnP3?.number === 1, '초기 첫 번째 각주 번호 확인'); + assert(initial.fnP7?.number === 2, '초기 두 번째 각주 번호 확인'); + + console.log('\n[1] 좌/우 방향키 각주 마커 1칸 이동 확인...'); + await moveCursor(page, 0, 3, 7); + await page.keyboard.press('ArrowRight'); + await page.evaluate(() => new Promise(r => setTimeout(r, 200))); + const afterArrowRight = await cursorState(page); + assert(afterArrowRight.position?.charOffset === 8, 'ArrowRight가 각주 마커 오른쪽으로 1칸 이동'); + + await page.keyboard.press('ArrowLeft'); + await page.evaluate(() => new Promise(r => setTimeout(r, 200))); + const afterArrowLeft = await cursorState(page); + assert(afterArrowLeft.position?.charOffset === 7, 'ArrowLeft가 각주 마커 왼쪽으로 1칸 이동'); + + console.log('\n[2] 본문 각주 마커 클릭 후 각주 편집 모드 진입 확인...'); + await clickPagePoint(page, 0, 264, 380); + const afterMarkerClick = await cursorState(page); + assert(afterMarkerClick.inFootnote === true, '본문 각주 마커 클릭 후 각주 편집 모드 진입'); + assert(afterMarkerClick.fnParaIdx === 3, '본문 각주 마커 클릭 후 원본 문단 인덱스 연결'); + assert(afterMarkerClick.fnControlIdx === 0, '본문 각주 마커 클릭 후 원본 control index 연결'); + assert(afterMarkerClick.fnFootnoteIndex === 0, '본문 각주 마커 클릭 후 첫 번째 각주 영역 연결'); + await exitFootnoteMode(page); + + console.log('\n[3] 각주 앞 Backspace 일반 텍스트 삭제/Undo 확인...'); + await moveCursor(page, 0, 3, 7); + await page.keyboard.press('Backspace'); + await page.evaluate(() => new Promise(r => setTimeout(r, 300))); + const dialogAfterPlainBackspace = await page.$('.modal-overlay .dialog-wrap'); + assert(dialogAfterPlainBackspace === null, '각주 앞 Backspace는 각주 삭제 확인창을 표시하지 않음'); + + const afterPlainBackspace = await footnoteState(page); + assert(JSON.stringify(afterPlainBackspace.markerP3) === '[6]', '각주 앞 Backspace 후 marker anchor가 이전 위치로 따라감'); + assert(afterPlainBackspace.fnP3?.number === 1, '각주 앞 Backspace 후 각주 본문 유지'); + + await page.keyboard.down('Control'); + await page.keyboard.press('KeyZ'); + await page.keyboard.up('Control'); + await page.evaluate(() => new Promise(r => setTimeout(r, 500))); + + const afterPlainBackspaceUndo = await footnoteState(page); + assert(JSON.stringify(afterPlainBackspaceUndo.markerP3) === '[7]', '각주 앞 Backspace Undo 후 marker anchor 복원'); + assert(afterPlainBackspaceUndo.fnP3?.number === 1, '각주 앞 Backspace Undo 후 각주 본문 유지'); + + console.log('\n[4] Delete 취소 확인...'); + await moveCursor(page, 0, 3, 7); + await page.keyboard.press('Delete'); + await page.waitForSelector('.modal-overlay .dialog-wrap', { timeout: 3000 }); + const cancelDialog = await dialogText(page); + assert(cancelDialog.includes('각주를 삭제하시겠습니까?'), 'Delete 경로 확인창 메시지 표시'); + await screenshot(page, 'footnote-delete-confirm-delete'); + await clickDialogButton(page, '취소'); + + const afterCancel = await footnoteState(page); + assert(JSON.stringify(afterCancel.markerP3) === '[7]', '취소 후 첫 번째 각주 마커 유지'); + assert(afterCancel.fnP3?.number === 1, '취소 후 첫 번째 각주 유지'); + assert(afterCancel.fnP7?.number === 2, '취소 후 두 번째 각주 번호 유지'); + + console.log('\n[5] Backspace 확인 후 삭제...'); + await moveCursor(page, 0, 3, 8); + await page.keyboard.press('Backspace'); + await page.waitForSelector('.modal-overlay .dialog-wrap', { timeout: 3000 }); + const confirmDialog = await dialogText(page); + assert(confirmDialog.includes('각주를 삭제하시겠습니까?'), 'Backspace 경로 동일 확인창 메시지 표시'); + await clickDialogButton(page, '확인'); + + const afterDelete = await footnoteState(page); + assert(JSON.stringify(afterDelete.markerP3) === '[]', '확인 후 첫 번째 각주 마커 삭제'); + assert(afterDelete.fnP3 === null, '확인 후 첫 번째 각주 본문 삭제'); + assert(afterDelete.fnP7?.number === 1, '확인 후 두 번째 각주가 1번으로 재번호화'); + + console.log('\n[6] Ctrl+Z 복원...'); + await page.keyboard.down('Control'); + await page.keyboard.press('KeyZ'); + await page.keyboard.up('Control'); + await page.evaluate(() => new Promise(r => setTimeout(r, 500))); + + const afterUndo = await footnoteState(page); + assert(JSON.stringify(afterUndo.markerP3) === '[7]', 'Undo 후 첫 번째 각주 마커 복원'); + assert(afterUndo.fnP3?.number === 1, 'Undo 후 첫 번째 각주 본문 복원'); + assert(afterUndo.fnP7?.number === 2, 'Undo 후 두 번째 각주 번호 복원'); + await screenshot(page, 'footnote-delete-confirm-undo'); +}); diff --git a/rhwp-studio/src/core/types.ts b/rhwp-studio/src/core/types.ts index 1541b960d..7792e9fba 100644 --- a/rhwp-studio/src/core/types.ts +++ b/rhwp-studio/src/core/types.ts @@ -110,6 +110,38 @@ export interface HitTestResult { fieldType?: string; } +/** WASM hitTestBodyFootnoteMarker() 반환 타입 */ +export interface BodyFootnoteMarkerHit { + hit: boolean; + sectionIndex?: number; + paragraphIndex?: number; + controlIndex?: number; + footnoteNumber?: number; + footnoteIndex?: number; + bbox?: { x: number; y: number; w: number; h: number }; + cursorRect?: CursorRect; +} + +/** WASM getFootnoteAtCursor() 반환 타입 */ +export interface FootnoteAtCursorResult { + hit: boolean; + sectionIndex?: number; + paragraphIndex?: number; + controlIndex?: number; + charOffset?: number; + footnoteNumber?: number; +} + +/** WASM deleteFootnote() 반환 타입 */ +export interface DeleteFootnoteResult { + ok: boolean; + sectionIndex: number; + paragraphIndex: number; + controlIndex: number; + charOffset: number; + deletedNumber: number; +} + /** 커서 위치의 필드 범위 정보 */ export interface FieldInfoResult { inField: boolean; diff --git a/rhwp-studio/src/core/wasm-bridge.ts b/rhwp-studio/src/core/wasm-bridge.ts index 0856d73d5..1c1a256ca 100644 --- a/rhwp-studio/src/core/wasm-bridge.ts +++ b/rhwp-studio/src/core/wasm-bridge.ts @@ -1,5 +1,5 @@ import init, { HwpDocument, version } from '@wasm/rhwp.js'; -import type { DocumentInfo, PageInfo, PageDef, SectionDef, CursorRect, HitTestResult, LineInfo, TableDimensions, CellInfo, CellBbox, CellProperties, TableProperties, DocumentPosition, MoveVerticalResult, SelectionRect, CharProperties, ParaProperties, CellPathEntry, NavContextEntry, FieldInfoResult, BookmarkInfo } from './types'; +import type { DocumentInfo, PageInfo, PageDef, SectionDef, CursorRect, HitTestResult, BodyFootnoteMarkerHit, FootnoteAtCursorResult, DeleteFootnoteResult, LineInfo, TableDimensions, CellInfo, CellBbox, CellProperties, TableProperties, DocumentPosition, MoveVerticalResult, SelectionRect, CharProperties, ParaProperties, CellPathEntry, NavContextEntry, FieldInfoResult, BookmarkInfo } from './types'; /** HWPX 비표준 감지 경고 리포트 (#177). */ export interface ValidationReport { @@ -241,6 +241,13 @@ export class WasmBridge { return JSON.parse(this.doc.hitTest(pageNum, x, y)); } + hitTestBodyFootnoteMarker(pageNum: number, x: number, y: number): BodyFootnoteMarkerHit { + if (!this.doc) return { hit: false }; + const hitTest = (this.doc as any).hitTestBodyFootnoteMarker; + if (typeof hitTest !== 'function') return { hit: false }; + return JSON.parse(hitTest.call(this.doc, pageNum, x, y)); + } + insertText(sec: number, para: number, charOffset: number, text: string): string { if (!this.doc) throw new Error('문서가 로드되지 않았습니다'); return this.doc.insertText(sec, para, charOffset, text); @@ -643,6 +650,18 @@ export class WasmBridge { return JSON.parse((this.doc as any).getFootnoteInfo(sec, para, controlIdx)); } + getFootnoteAtCursor(sec: number, para: number, charOffset: number, direction: 'backward' | 'forward'): FootnoteAtCursorResult { + if (!this.doc) return { hit: false }; + const getter = (this.doc as any).getFootnoteAtCursor; + if (typeof getter !== 'function') return { hit: false }; + return JSON.parse(getter.call(this.doc, sec, para, charOffset, direction)); + } + + deleteFootnote(sec: number, para: number, controlIdx: number): DeleteFootnoteResult { + if (!this.doc) throw new Error('문서가 로드되지 않았습니다'); + return JSON.parse((this.doc as any).deleteFootnote(sec, para, controlIdx)); + } + insertTextInFootnote(sec: number, para: number, controlIdx: number, fnParaIdx: number, charOffset: number, text: string): { ok: boolean; charOffset: number } { if (!this.doc) throw new Error('문서가 로드되지 않았습니다'); return JSON.parse((this.doc as any).insertTextInFootnote(sec, para, controlIdx, fnParaIdx, charOffset, text)); diff --git a/rhwp-studio/src/engine/input-handler-mouse.ts b/rhwp-studio/src/engine/input-handler-mouse.ts index 685de52e3..8c801b6ae 100644 --- a/rhwp-studio/src/engine/input-handler-mouse.ts +++ b/rhwp-studio/src/engine/input-handler-mouse.ts @@ -537,6 +537,34 @@ export function onClick(this: any, e: MouseEvent): void { } catch { /* 무시 */ } } + // 본문 각주 마커 클릭 → 각주 편집 모드 진입 + if (!this.cursor.isInFootnote()) { + try { + const markerHit = this.wasm.hitTestBodyFootnoteMarker(pageIdx, pageX, pageY); + if ( + markerHit.hit && + markerHit.sectionIndex !== undefined && + markerHit.paragraphIndex !== undefined && + markerHit.controlIndex !== undefined && + markerHit.footnoteIndex !== undefined + ) { + this.cursor.enterFootnoteMode( + markerHit.sectionIndex, + markerHit.paragraphIndex, + markerHit.controlIndex, + markerHit.footnoteIndex, + pageIdx, + ); + this.eventBus.emit('footnoteModeChanged', true); + this.cursor.setFnCursorPosition(0, 0); + this.active = true; + this.updateCaret(); + this.textarea.focus(); + return; + } + } catch { /* 무시 */ } + } + // 각주 영역 클릭 → 각주 편집 모드 진입 if (!this.cursor.isInFootnote()) { try { diff --git a/rhwp-studio/src/engine/input-handler-text.ts b/rhwp-studio/src/engine/input-handler-text.ts index 4a671f7c7..9df53986b 100644 --- a/rhwp-studio/src/engine/input-handler-text.ts +++ b/rhwp-studio/src/engine/input-handler-text.ts @@ -3,6 +3,10 @@ import { InsertTextCommand, DeleteTextCommand, MergeParagraphCommand, MergeNextParagraphCommand, MergeParagraphInCellCommand, MergeNextParagraphInCellCommand } from './command'; import type { DocumentPosition } from '@/core/types'; +import { showConfirm } from '@/ui/confirm-dialog'; + +const FOOTNOTE_DELETE_TITLE = '각주 삭제'; +const FOOTNOTE_DELETE_MESSAGE = '각주를 삭제하시겠습니까?'; /** IME 조합 종료 후 대기 중인 탐색 키를 처리한다 */ function processPendingNav(this: any, nav: { code: string; shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }): void { @@ -44,6 +48,55 @@ function processPendingNav(this: any, nav: { code: string; shiftKey: boolean; ct } } +function tryDeleteBodyFootnoteAtCursor( + this: any, + pos: DocumentPosition, + direction: 'backward' | 'forward', +): boolean { + if (pos.parentParaIndex !== undefined || pos.cellPath || pos.isTextBox) return false; + + try { + const hit = this.wasm.getFootnoteAtCursor( + pos.sectionIndex, + pos.paragraphIndex, + pos.charOffset, + direction, + ); + if (!hit.hit || hit.controlIndex === undefined) return false; + + const sectionIndex = hit.sectionIndex ?? pos.sectionIndex; + const paragraphIndex = hit.paragraphIndex ?? pos.paragraphIndex; + const controlIndex = hit.controlIndex; + + void showConfirm(FOOTNOTE_DELETE_TITLE, FOOTNOTE_DELETE_MESSAGE) + .then((ok) => { + if (!ok) { + this.textarea?.focus(); + return; + } + this.executeOperation({ + kind: 'snapshot', + operationType: 'deleteFootnote', + operation: (wasm: any) => { + const result = wasm.deleteFootnote(sectionIndex, paragraphIndex, controlIndex); + return { + sectionIndex: result.sectionIndex, + paragraphIndex: result.paragraphIndex, + charOffset: result.charOffset, + }; + }, + }); + this.textarea?.focus(); + }) + .catch(() => { + this.textarea?.focus(); + }); + return true; + } catch { + return false; + } +} + export function handleBackspace(this: any, pos: DocumentPosition, inCell: boolean): void { // 머리말/꼬리말 편집 모드 if (this.cursor.isInHeaderFooter()) { @@ -86,6 +139,7 @@ export function handleBackspace(this: any, pos: DocumentPosition, inCell: boolea } } else { const { sectionIndex: sec, paragraphIndex: para } = pos; + if (tryDeleteBodyFootnoteAtCursor.call(this, pos, 'backward')) return; if (charOffset > 0) { const deletePos = { ...pos, charOffset: charOffset - 1 }; this.executeOperation({ kind: 'command', command: new DeleteTextCommand(deletePos, 1, 'backward') }); @@ -151,6 +205,7 @@ export function handleDelete(this: any, pos: DocumentPosition, inCell: boolean): } } else { const { sectionIndex: sec, paragraphIndex: para } = pos; + if (tryDeleteBodyFootnoteAtCursor.call(this, pos, 'forward')) return; const paraLen = this.wasm.getParagraphLength(sec, para); if (charOffset < paraLen) { this.executeOperation({ kind: 'command', command: new DeleteTextCommand(pos, 1, 'forward') }); @@ -444,4 +499,3 @@ export function deleteTextAt(this: any, pos: DocumentPosition, count: number): v this.wasm.deleteText(sec, para, charOffset, count); } } - diff --git a/src/document_core/commands/footnote_ops.rs b/src/document_core/commands/footnote_ops.rs index 4eff892b5..1dc4b96ab 100644 --- a/src/document_core/commands/footnote_ops.rs +++ b/src/document_core/commands/footnote_ops.rs @@ -8,6 +8,182 @@ use crate::error::HwpError; use crate::model::event::DocumentEvent; impl DocumentCore { + fn renumber_footnotes_in_section(&mut self, section_idx: usize) { + let mut number = 1u16; + for para in &mut self.document.sections[section_idx].paragraphs { + for ctrl in &mut para.controls { + match ctrl { + Control::Footnote(footnote) => { + footnote.number = number; + number += 1; + } + Control::Table(table) => { + for cell in &mut table.cells { + for cell_para in &mut cell.paragraphs { + for cell_ctrl in &mut cell_para.controls { + if let Control::Footnote(footnote) = cell_ctrl { + footnote.number = number; + number += 1; + } + } + } + } + } + Control::Shape(shape) => { + if let Some(text_box) = shape.drawing_mut().and_then(|d| d.text_box.as_mut()) { + for text_para in &mut text_box.paragraphs { + for text_ctrl in &mut text_para.controls { + if let Control::Footnote(footnote) = text_ctrl { + footnote.number = number; + number += 1; + } + } + } + } + } + _ => {} + } + } + } + } + + /// 본문 커서 위치의 각주 마커를 조회한다. + /// + /// direction: + /// - "backward": 커서 바로 앞 마커(Backspace) + /// - "forward": 커서 바로 뒤 마커(Delete) + pub fn get_footnote_at_cursor_native( + &self, + section_idx: usize, + para_idx: usize, + char_offset: usize, + direction: &str, + ) -> Result { + if direction != "backward" && direction != "forward" { + return Err(HwpError::RenderError(format!( + "지원하지 않는 각주 조회 방향입니다: {}", direction + ))); + } + + let section = self.document.sections.get(section_idx) + .ok_or_else(|| HwpError::RenderError(format!( + "구역 인덱스 {} 범위 초과", section_idx + )))?; + let para = section.paragraphs.get(para_idx) + .ok_or_else(|| HwpError::RenderError(format!( + "문단 인덱스 {} 범위 초과", para_idx + )))?; + + let positions = crate::document_core::helpers::find_control_text_positions(para); + for (control_idx, ctrl) in para.controls.iter().enumerate() { + let Control::Footnote(footnote) = ctrl else { + continue; + }; + let Some(marker_pos) = positions.get(control_idx).copied() else { + continue; + }; + let matches_cursor = match direction { + "backward" => char_offset == marker_pos + 1, + "forward" => char_offset == marker_pos, + _ => false, + }; + if matches_cursor { + return Ok(format!( + "{{\"hit\":true,\"sectionIndex\":{},\"paragraphIndex\":{},\"controlIndex\":{},\"charOffset\":{},\"footnoteNumber\":{}}}", + section_idx, + para_idx, + control_idx, + marker_pos, + footnote.number, + )); + } + } + + Ok("{\"hit\":false}".to_string()) + } + + /// 본문 각주 컨트롤을 삭제한다. + /// + /// 각주 내부 내용과 본문 마커를 함께 제거하고, 남은 각주 번호를 문서 순서대로 재계산한다. + pub fn delete_footnote_native( + &mut self, + section_idx: usize, + para_idx: usize, + control_idx: usize, + ) -> Result { + let (marker_pos, deleted_number) = { + let section = self.document.sections.get(section_idx) + .ok_or_else(|| HwpError::RenderError(format!( + "구역 인덱스 {} 범위 초과", section_idx + )))?; + let para = section.paragraphs.get(para_idx) + .ok_or_else(|| HwpError::RenderError(format!( + "문단 인덱스 {} 범위 초과", para_idx + )))?; + let ctrl = para.controls.get(control_idx) + .ok_or_else(|| HwpError::RenderError(format!( + "컨트롤 인덱스 {} 범위 초과", control_idx + )))?; + let Control::Footnote(footnote) = ctrl else { + return Err(HwpError::RenderError(format!( + "컨트롤 {}은 각주가 아닙니다", control_idx + ))); + }; + let positions = crate::document_core::helpers::find_control_text_positions(para); + let marker_pos = positions.get(control_idx).copied() + .ok_or_else(|| HwpError::RenderError(format!( + "각주 컨트롤 {}의 본문 위치를 찾을 수 없습니다", control_idx + )))?; + (marker_pos, footnote.number) + }; + + { + let section = &mut self.document.sections[section_idx]; + let para = &mut section.paragraphs[para_idx]; + + for offset in para.char_offsets.iter_mut().skip(marker_pos) { + if *offset >= 8 { + *offset -= 8; + } + } + + para.controls.remove(control_idx); + if control_idx < para.ctrl_data_records.len() { + para.ctrl_data_records.remove(control_idx); + } + if para.char_count >= 8 { + para.char_count -= 8; + } + if !para.controls.iter().any(|c| matches!(c, Control::Footnote(_) | Control::Endnote(_))) { + para.control_mask &= !(1u32 << 0x0011); + } + + Self::reflow_paragraph_line_segs_after_control_delete(para, &self.styles, self.dpi); + section.raw_stream = None; + } + + self.renumber_footnotes_in_section(section_idx); + self.mark_section_dirty(section_idx); + self.recompose_section(section_idx); + self.paginate_if_needed(); + self.invalidate_page_tree_cache(); + + self.event_log.push(DocumentEvent::FootnoteDeleted { + section: section_idx, + para: para_idx, + ctrl: control_idx, + }); + + Ok(format!( + "{{\"ok\":true,\"sectionIndex\":{},\"paragraphIndex\":{},\"controlIndex\":{},\"charOffset\":{},\"deletedNumber\":{}}}", + section_idx, + para_idx, + control_idx, + marker_pos, + deleted_number, + )) + } + /// 각주 컨트롤 내부 문단의 가변 참조를 얻는다. fn get_footnote_paragraph_mut( &mut self, diff --git a/src/document_core/commands/object_ops.rs b/src/document_core/commands/object_ops.rs index 5548b3450..66a2741df 100644 --- a/src/document_core/commands/object_ops.rs +++ b/src/document_core/commands/object_ops.rs @@ -463,7 +463,7 @@ impl DocumentCore { /// /// 그림/도형 삭제 시 문단의 line_segs에 컨트롤 높이가 그대로 남아, /// 레이아웃이 갱신되지 않는 문제를 방지한다. - fn reflow_paragraph_line_segs_after_control_delete( + pub(crate) fn reflow_paragraph_line_segs_after_control_delete( para: &mut Paragraph, styles: &crate::renderer::style_resolver::ResolvedStyleSet, dpi: f64, @@ -3566,7 +3566,7 @@ impl DocumentCore { } } paragraph.char_count += 8; - paragraph.control_mask |= 0x00000010; // 각주 비트 + paragraph.control_mask |= 1u32 << 0x0011; // 각주/미주 비트 paragraph.has_para_text = true; // 전체 각주 순서 번호 재계산 (1부터 순차) diff --git a/src/document_core/helpers.rs b/src/document_core/helpers.rs index 0712dc9bb..11d29d06c 100644 --- a/src/document_core/helpers.rs +++ b/src/document_core/helpers.rs @@ -22,7 +22,8 @@ pub(crate) fn navigable_text_len(para: &Paragraph) -> usize { let max_inline_pos = para.controls.iter().enumerate() .filter(|(_, c)| matches!(c, Control::Shape(_) | Control::Table(_) | - Control::Picture(_) | Control::Equation(_) + Control::Picture(_) | Control::Equation(_) | + Control::Footnote(_) | Control::Endnote(_) )) .filter_map(|(i, _)| positions.get(i).copied()) .max() @@ -1070,3 +1071,22 @@ pub(crate) fn border_fills_equal(a: &crate::model::style::BorderFill, b: &crate: _ => false, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::footnote::Footnote; + + #[test] + fn navigable_text_len_counts_trailing_footnote_marker() { + let para = Paragraph { + text: "abc".to_string(), + char_offsets: vec![0, 1, 2], + controls: vec![Control::Footnote(Box::new(Footnote::default()))], + ..Default::default() + }; + + assert_eq!(find_control_text_positions(¶), vec![3]); + assert_eq!(navigable_text_len(¶), 4); + } +} diff --git a/src/document_core/queries/cursor_rect.rs b/src/document_core/queries/cursor_rect.rs index df5003e8e..408dc4ac3 100644 --- a/src/document_core/queries/cursor_rect.rs +++ b/src/document_core/queries/cursor_rect.rs @@ -5,7 +5,7 @@ use crate::model::paragraph::Paragraph; use crate::model::path::PathSegment; use crate::document_core::DocumentCore; use crate::error::HwpError; -use super::super::helpers::{LineInfoResult, utf16_pos_to_char_idx, color_ref_to_css, has_table_control, find_char_at_x, navigable_text_len}; +use super::super::helpers::{LineInfoResult, utf16_pos_to_char_idx, color_ref_to_css, has_table_control, find_char_at_x, find_control_text_positions, navigable_text_len}; use crate::renderer::render_tree::TextRunNode; /// PUA 다자리 글자겹침 TextRun의 논리적 char_count (1) 반환, 아니면 실제 글자 수 반환 @@ -32,6 +32,18 @@ impl DocumentCore { // 문단이 포함된 페이지 찾기 let pages = self.find_pages_for_paragraph(section_idx, para_idx)?; + let footnote_marker_positions: Vec<(usize, usize)> = self.document.sections + .get(section_idx) + .and_then(|section| section.paragraphs.get(para_idx)) + .map(|para| { + let ctrl_positions = find_control_text_positions(para); + para.controls.iter().enumerate() + .filter(|(_, ctrl)| matches!(ctrl, Control::Footnote(_) | Control::Endnote(_))) + .filter_map(|(ci, _)| ctrl_positions.get(ci).copied().map(|pos| (ci, pos))) + .collect() + }) + .unwrap_or_default(); + // 커서 결과를 담을 구조체 struct CursorHit { page_index: u32, @@ -49,7 +61,25 @@ impl DocumentCore { offset: usize, page_index: u32, exact_only: bool, + footnote_marker_positions: &[(usize, usize)], ) -> Option { + if let RenderNodeType::FootnoteMarker(ref marker) = node.node_type { + if marker.section_index == sec && marker.para_index == para { + if let Some((_, marker_pos)) = footnote_marker_positions.iter() + .find(|(ci, _)| *ci == marker.control_index) + { + if offset == *marker_pos || offset == *marker_pos + 1 { + return Some(CursorHit { + page_index, + x: if offset == *marker_pos { node.bbox.x } else { node.bbox.x + node.bbox.width }, + y: node.bbox.y, + height: node.bbox.height.max(10.0), + }); + } + } + } + } + if let RenderNodeType::TextRun(ref text_run) = node.node_type { // 번호/글머리표 TextRun (char_start: None)은 건너뛴다 if let Some(char_start) = text_run.char_start { @@ -128,7 +158,7 @@ impl DocumentCore { } } for child in &node.children { - if let Some(hit) = find_cursor_in_node(child, sec, para, offset, page_index, exact_only) { + if let Some(hit) = find_cursor_in_node(child, sec, para, offset, page_index, exact_only, footnote_marker_positions) { return Some(hit); } } @@ -139,8 +169,8 @@ impl DocumentCore { // 1차: 정확한 앵커(zero-width 노드) 우선 검색, 2차: 일반 검색 for &page_num in &pages { let tree = self.build_page_tree(page_num)?; - let exact_hit = find_cursor_in_node(&tree.root, section_idx, para_idx, char_offset, page_num, true); - let hit_result = exact_hit.or_else(|| find_cursor_in_node(&tree.root, section_idx, para_idx, char_offset, page_num, false)); + let exact_hit = find_cursor_in_node(&tree.root, section_idx, para_idx, char_offset, page_num, true, &footnote_marker_positions); + let hit_result = exact_hit.or_else(|| find_cursor_in_node(&tree.root, section_idx, para_idx, char_offset, page_num, false, &footnote_marker_positions)); if let Some(hit) = hit_result { return Ok(format!( "{{\"pageIndex\":{},\"x\":{:.1},\"y\":{:.1},\"height\":{:.1}}}", @@ -2385,6 +2415,117 @@ impl DocumentCore { )) } + fn find_page_body_footnote_index( + &self, + page_num: u32, + section_idx: usize, + para_idx: usize, + control_idx: usize, + ) -> Option { + use crate::renderer::pagination::FootnoteSource; + + let (page_section_idx, local_page) = self.find_section_for_page(page_num); + if page_section_idx != section_idx { + return None; + } + + let pr = self.pagination.get(section_idx)?; + let page = pr.pages.get(local_page)?; + page.footnotes.iter().position(|fn_ref| { + matches!( + &fn_ref.source, + FootnoteSource::Body { para_index, control_index } + if *para_index == para_idx && *control_index == control_idx + ) + }) + } + + /// 본문 인라인 각주 마커 히트테스트 + /// + /// 각주 영역(zone)이 아니라 본문 TextLine 안의 FootnoteMarker bbox를 대상으로 한다. + /// 반환: JSON `{"hit":true,...}` 또는 `{"hit":false}` + pub fn hit_test_body_footnote_marker_native( + &self, + page_num: u32, + x: f64, + y: f64, + ) -> Result { + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; + + struct MarkerHit { + section_index: usize, + paragraph_index: usize, + control_index: usize, + footnote_number: u16, + bbox_x: f64, + bbox_y: f64, + bbox_w: f64, + bbox_h: f64, + } + + fn find_marker(node: &RenderNode, x: f64, y: f64) -> Option { + if let RenderNodeType::FootnoteMarker(ref marker) = node.node_type { + if x >= node.bbox.x + && x <= node.bbox.x + node.bbox.width + && y >= node.bbox.y + && y <= node.bbox.y + node.bbox.height + { + return Some(MarkerHit { + section_index: marker.section_index, + paragraph_index: marker.para_index, + control_index: marker.control_index, + footnote_number: marker.number, + bbox_x: node.bbox.x, + bbox_y: node.bbox.y, + bbox_w: node.bbox.width, + bbox_h: node.bbox.height, + }); + } + } + + for child in &node.children { + if let Some(hit) = find_marker(child, x, y) { + return Some(hit); + } + } + + None + } + + let tree = self.build_page_tree_cached(page_num)?; + let hit = match find_marker(&tree.root, x, y) { + Some(hit) => hit, + None => return Ok("{\"hit\":false}".to_string()), + }; + + let footnote_index = match self.find_page_body_footnote_index( + page_num, + hit.section_index, + hit.paragraph_index, + hit.control_index, + ) { + Some(idx) => idx, + None => return Ok("{\"hit\":false}".to_string()), + }; + + Ok(format!( + "{{\"hit\":true,\"sectionIndex\":{},\"paragraphIndex\":{},\"controlIndex\":{},\"footnoteNumber\":{},\"footnoteIndex\":{},\"bbox\":{{\"x\":{:.1},\"y\":{:.1},\"w\":{:.1},\"h\":{:.1}}},\"cursorRect\":{{\"pageIndex\":{},\"x\":{:.1},\"y\":{:.1},\"height\":{:.1}}}}}", + hit.section_index, + hit.paragraph_index, + hit.control_index, + hit.footnote_number, + footnote_index, + hit.bbox_x, + hit.bbox_y, + hit.bbox_w, + hit.bbox_h, + page_num, + hit.bbox_x + hit.bbox_w, + hit.bbox_y, + hit.bbox_h + )) + } + /// 페이지의 각주 참조 정보를 반환한다. /// /// footnoteIndex에 해당하는 FootnoteRef의 source(para_index, control_index)를 반환. diff --git a/src/document_core/queries/doc_tree_nav.rs b/src/document_core/queries/doc_tree_nav.rs index bc5875f27..29381c40f 100644 --- a/src/document_core/queries/doc_tree_nav.rs +++ b/src/document_core/queries/doc_tree_nav.rs @@ -55,7 +55,7 @@ pub enum NavResult { } /// 컨트롤이 편집 가능(네비게이션 가능)한지 판별. -/// Some(true)=TextBox, Some(false)=Table/CharOverlap, None=건너뜀 +/// Some(true)=TextBox, Some(false)=Table/인라인 개체/각주 마커, None=건너뜀 /// /// 텍스트가 전혀 없는 빈 글상자(장식용 프레임 등)는 건너뛴다. /// CharOverlap(글자겹침)은 1글자 단위로 건너뛴다 (표와 동일 취급). @@ -74,6 +74,8 @@ fn classify_navigable(ctrl: &Control) -> Option { Control::Table(_) => Some(false), Control::Picture(_) => Some(false), Control::Equation(_) => Some(false), + Control::Footnote(_) => Some(false), + Control::Endnote(_) => Some(false), // CharOverlap은 layout에서 char_count=1로 처리되므로 // 별도의 건너뛰기 없이 일반 문자처럼 1칸 이동 Control::CharOverlap(_) => None, diff --git a/src/model/event.rs b/src/model/event.rs index ace7c7ed3..c4769ce24 100644 --- a/src/model/event.rs +++ b/src/model/event.rs @@ -32,6 +32,7 @@ pub enum DocumentEvent { PictureDeleted { section: usize, para: usize, ctrl: usize }, PictureMoved { section: usize, para: usize, ctrl: usize }, PictureResized { section: usize, para: usize, ctrl: usize }, + FootnoteDeleted { section: usize, para: usize, ctrl: usize }, // ── 클립보드/HTML ── ContentPasted { section: usize, para: usize }, @@ -87,6 +88,8 @@ impl DocumentEvent { format!(r#"{{"type":"PictureMoved","section":{},"para":{},"ctrl":{}}}"#, section, para, ctrl), DocumentEvent::PictureResized { section, para, ctrl } => format!(r#"{{"type":"PictureResized","section":{},"para":{},"ctrl":{}}}"#, section, para, ctrl), + DocumentEvent::FootnoteDeleted { section, para, ctrl } => + format!(r#"{{"type":"FootnoteDeleted","section":{},"para":{},"ctrl":{}}}"#, section, para, ctrl), // 클립보드/HTML DocumentEvent::ContentPasted { section, para } => diff --git a/src/model/paragraph.rs b/src/model/paragraph.rs index f2706a15f..bfeaf50ee 100644 --- a/src/model/paragraph.rs +++ b/src/model/paragraph.rs @@ -231,6 +231,24 @@ impl Paragraph { // 이 경우 char_offset을 text_len으로 clamp하되, UTF-16 위치는 // 마지막 문자 + 후행 컨트롤 갭을 포함한 값으로 계산 let effective_char_offset = char_offset.min(text_len); + let control_positions = self.control_text_positions(); + let inserts_before_inline_control = char_offset <= text_len + && self + .controls + .iter() + .zip(control_positions.iter()) + .any(|(ctrl, &pos)| { + pos == effective_char_offset + && matches!( + ctrl, + Control::Shape(_) + | Control::Table(_) + | Control::Picture(_) + | Control::Equation(_) + | Control::Footnote(_) + | Control::Endnote(_) + ) + }); // 바이트 삽입 위치 계산 let byte_offset: usize = text_chars[..effective_char_offset].iter().map(|c| c.len_utf8()).sum(); @@ -243,6 +261,15 @@ impl Paragraph { // 후행 컨트롤 수 = char_offset - text_len let trailing_ctrl_count = (char_offset - text_len) as u32; last_char_end + trailing_ctrl_count * 8 + } else if inserts_before_inline_control { + if effective_char_offset == 0 { + 0 + } else if !self.char_offsets.is_empty() { + let prev_idx = effective_char_offset - 1; + self.char_offsets[prev_idx] + Self::char_utf16_len(text_chars[prev_idx]) + } else { + 0 + } } else if effective_char_offset < self.char_offsets.len() { self.char_offsets[effective_char_offset] } else if !self.char_offsets.is_empty() { @@ -355,15 +382,11 @@ impl Paragraph { } else { 0 }; - let utf16_end: u32 = if del_end < self.char_offsets.len() { - self.char_offsets[del_end] - } else if !self.char_offsets.is_empty() { - let last_idx = self.char_offsets.len() - 1; - self.char_offsets[last_idx] + Self::char_utf16_len(text_chars[last_idx]) - } else { - 0 - }; - let utf16_delta = utf16_end - utf16_start; + let utf16_delta: u32 = text_chars[char_offset..del_end] + .iter() + .map(|c| Self::char_utf16_len(*c)) + .sum(); + let utf16_end = utf16_start + utf16_delta; // 1. 텍스트 삭제 self.text.drain(byte_start..byte_end); @@ -725,8 +748,9 @@ impl Paragraph { /// position 을 분배한다 — 컨트롤 variant 를 보지 않으므로 각주·미주, 그림, 표, 수식, /// 자동번호 등 모든 inline 컨트롤이 동일하게 character offset 을 부여받는다. /// - /// `char_offsets` 가 비어있는 폴백 경로에서는 Shape/Table/Picture/Equation 만 폭 1을 - /// 가산하고, 그 외 컨트롤은 모두 position 0 에 누적된다 (정밀도 손실 분기). + /// `char_offsets` 가 비어있는 폴백 경로에서는 본문 흐름에서 1칸을 차지하는 + /// Shape/Table/Picture/Equation/Footnote/Endnote 만 폭 1을 가산하고, + /// 그 외 컨트롤은 모두 position 0 에 누적된다 (정밀도 손실 분기). /// /// # Returns /// @@ -753,6 +777,8 @@ impl Paragraph { | Control::Table(_) | Control::Picture(_) | Control::Equation(_) + | Control::Footnote(_) + | Control::Endnote(_) ) { pos += 1; } diff --git a/src/renderer/composer.rs b/src/renderer/composer.rs index 4a7e242d0..1578042c6 100644 --- a/src/renderer/composer.rs +++ b/src/renderer/composer.rs @@ -97,8 +97,8 @@ pub struct ComposedParagraph { /// treat_as_char 컨트롤의 텍스트 위치와 HWPUNIT 너비 목록 /// (para.text 내 절대 char 인덱스, 폭 HWPUNIT, para.controls 내 인덱스) pub tac_controls: Vec<(usize, i32, usize)>, - /// 각주/미주 위치: (텍스트 내 char 인덱스, 번호) - pub footnote_positions: Vec<(usize, u16)>, + /// 각주/미주 위치: (텍스트 내 char 인덱스, 번호, para.controls 내 인덱스) + pub footnote_positions: Vec<(usize, u16, usize)>, /// 탭 확장 데이터 (HWP tab_extended / HWPX 인라인 탭) /// ext[0]=width, ext[1]=leader/fill_type, ext[2]=tab_type pub tab_extended: Vec<[u16; 7]>, @@ -149,12 +149,12 @@ pub fn compose_paragraph(para: &Paragraph) -> ComposedParagraph { .collect(); // 각주/미주 위치 수집 - let footnote_positions: Vec<(usize, u16)> = para.controls.iter().enumerate() + let footnote_positions: Vec<(usize, u16, usize)> = para.controls.iter().enumerate() .filter_map(|(i, ctrl)| { let pos = *tac_positions.get(i)?; match ctrl { - Control::Footnote(fn_) => Some((pos, fn_.number)), - Control::Endnote(en) => Some((pos, en.number)), + Control::Footnote(fn_) => Some((pos, fn_.number, i)), + Control::Endnote(en) => Some((pos, en.number, i)), _ => None, } }) diff --git a/src/renderer/layout/paragraph_layout.rs b/src/renderer/layout/paragraph_layout.rs index 681e3a97c..3399a97c1 100644 --- a/src/renderer/layout/paragraph_layout.rs +++ b/src/renderer/layout/paragraph_layout.rs @@ -327,7 +327,7 @@ impl LayoutEngine { for ch_idx in *s..*e { // 각주 마커 삽입: 현재 문자 위치에 각주가 있으면 먼저 run flush + FootnoteMarker 노드 삽입 - if let Some(&(_, fn_num)) = composed.and_then(|c| c.footnote_positions.iter().find(|&&(pos, _)| pos == ch_idx)) { + if let Some(&(_, fn_num, fn_ctrl_idx)) = composed.and_then(|c| c.footnote_positions.iter().find(|&&(pos, _, _)| pos == ch_idx)) { // 현재까지 누적된 run 출력 if ch_idx > line_run_start { let run_text: String = text_chars[line_run_start..ch_idx].iter().collect(); @@ -363,10 +363,6 @@ impl LayoutEngine { let sup_ts = TextStyle { font_size: sup_font_size, font_family: base_ts.font_family.clone(), ..Default::default() }; let sup_w = estimate_text_width(&fn_text, &sup_ts); let run_bbox_h = if wrapped_below_table { text_line_baseline } else { baseline_dist }; - // 각주 컨트롤 인덱스 찾기 - let fn_ctrl_idx = composed.map(|c| { - c.footnote_positions.iter().position(|&(p, _)| p == ch_idx).unwrap_or(0) - }).unwrap_or(0); let marker_id = tree.next_id(); let marker_node = RenderNode::new(marker_id, RenderNodeType::FootnoteMarker(FootnoteMarkerNode { @@ -1042,7 +1038,7 @@ impl LayoutEngine { } // 각주 마커 폭: run 내에 각주가 있으면 마커 위첨자 폭 추가 let is_last_run_est = run_char_end_est >= comp_line.runs.iter().map(|r| r.text.chars().count()).sum::() + comp_line.char_start; - for &(fpos, fnum) in composed.footnote_positions.iter() { + for &(fpos, fnum, _) in composed.footnote_positions.iter() { if fpos >= run_char_pos_est && (fpos < run_char_end_est || (is_last_run_est && fpos == run_char_end_est)) { let fn_text = format!("{})", fnum); let sup_size = (ts.font_size * 0.55).max(7.0); @@ -1335,7 +1331,7 @@ impl LayoutEngine { }; // 각주 마커 위치 수집 - let fn_positions: &[(usize, u16)] = &composed.footnote_positions; + let fn_positions: &[(usize, u16, usize)] = &composed.footnote_positions; let mut fn_marker_inserted = vec![false; fn_positions.len()]; let mut pending_right_tab_render: Option<(f64, u8, u8)> = None; @@ -1600,7 +1596,7 @@ impl LayoutEngine { // 마지막 run에서는 run_char_end 위치의 각주도 포함 (문단 끝 각주) let is_last = is_last_run_of_line(run_idx); let run_fn_markers: Vec<(usize, u16, usize)> = fn_positions.iter().enumerate() - .filter_map(|(fni, &(fpos, fnum))| { + .filter_map(|(fni, &(fpos, fnum, _))| { let in_range = fpos >= run_char_pos && (fpos < run_char_end || (is_last && fpos == run_char_end)); if !fn_marker_inserted[fni] && in_range { Some((fpos - run_char_pos, fnum, fni)) diff --git a/src/wasm_api.rs b/src/wasm_api.rs index 6ec64ba2e..d084bf4ed 100644 --- a/src/wasm_api.rs +++ b/src/wasm_api.rs @@ -2525,6 +2525,42 @@ impl HwpDocument { .map_err(|e| e.into()) } + /// 본문 커서 위치의 각주 마커를 조회한다. + /// + /// direction: "backward" 또는 "forward" + #[wasm_bindgen(js_name = getFootnoteAtCursor)] + pub fn get_footnote_at_cursor( + &self, + section_idx: u32, + para_idx: u32, + char_offset: u32, + direction: &str, + ) -> Result { + self.get_footnote_at_cursor_native( + section_idx as usize, + para_idx as usize, + char_offset as usize, + direction, + ) + .map_err(|e| e.into()) + } + + /// 본문 각주 컨트롤을 삭제한다. + #[wasm_bindgen(js_name = deleteFootnote)] + pub fn delete_footnote( + &mut self, + section_idx: u32, + para_idx: u32, + control_idx: u32, + ) -> Result { + self.delete_footnote_native( + section_idx as usize, + para_idx as usize, + control_idx as usize, + ) + .map_err(|e| e.into()) + } + /// 각주 내 텍스트를 삽입한다. #[wasm_bindgen(js_name = insertTextInFootnote)] pub fn insert_text_in_footnote( @@ -2650,6 +2686,18 @@ impl HwpDocument { .map_err(|e| e.into()) } + /// 본문 인라인 각주 마커 히트테스트 + #[wasm_bindgen(js_name = hitTestBodyFootnoteMarker)] + pub fn hit_test_body_footnote_marker( + &self, + page_num: u32, + x: f64, + y: f64, + ) -> Result { + self.hit_test_body_footnote_marker_native(page_num, x, y) + .map_err(|e| e.into()) + } + /// 수직 커서 이동 (ArrowUp/Down) — 단일 호출로 줄/문단/표/구역 경계를 모두 처리한다. /// /// delta: -1=위, +1=아래 diff --git a/tests/issue_598_footnote_marker_nav.rs b/tests/issue_598_footnote_marker_nav.rs new file mode 100644 index 000000000..54566f6ff --- /dev/null +++ b/tests/issue_598_footnote_marker_nav.rs @@ -0,0 +1,194 @@ +use std::path::Path; + +use rhwp::wasm_api::HwpDocument; + +fn json_number(json: &str, key: &str) -> f64 { + let pattern = format!("\"{}\":", key); + let start = json.find(&pattern).expect("json key not found") + pattern.len(); + let rest = &json[start..]; + let end = rest + .find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')) + .unwrap_or(rest.len()); + rest[..end].parse::().expect("json number parse") +} + +#[test] +fn issue_598_body_footnote_marker_has_hit_and_cursor_unit() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("samples/footnote-01.hwp"); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {}", path.display(), e)); + let doc = HwpDocument::from_bytes(&bytes).expect("parse footnote-01.hwp"); + + assert_eq!(doc.get_control_text_positions(0, 3), "[7]"); + + let hit = doc + .hit_test_body_footnote_marker_native(0, 264.0, 380.0) + .expect("hit body footnote marker"); + assert!(hit.contains("\"hit\":true"), "hit json: {hit}"); + assert!(hit.contains("\"sectionIndex\":0"), "hit json: {hit}"); + assert!(hit.contains("\"paragraphIndex\":3"), "hit json: {hit}"); + assert!(hit.contains("\"controlIndex\":0"), "hit json: {hit}"); + assert!(hit.contains("\"footnoteIndex\":0"), "hit json: {hit}"); + + let marker_left = doc + .get_cursor_rect_native(0, 3, 7) + .expect("marker left rect"); + let marker_right = doc + .get_cursor_rect_native(0, 3, 8) + .expect("marker right rect"); + let left_x = json_number(&marker_left, "x"); + let right_x = json_number(&marker_right, "x"); + assert!( + right_x > left_x, + "marker right caret should be after left caret: left={marker_left}, right={marker_right}" + ); + + let next = doc.navigate_next_editable_wasm(0, 3, 7, 1, "[]"); + assert!(next.contains("\"charOffset\":8"), "next json: {next}"); + let prev = doc.navigate_next_editable_wasm(0, 3, 8, -1, "[]"); + assert!(prev.contains("\"charOffset\":7"), "prev json: {prev}"); +} + +#[test] +fn issue_598_second_body_footnote_marker_has_same_cursor_unit() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("samples/footnote-01.hwp"); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {}", path.display(), e)); + let doc = HwpDocument::from_bytes(&bytes).expect("parse footnote-01.hwp"); + + assert_eq!(doc.get_control_text_positions(0, 7), "[6]"); + + let hit = doc + .hit_test_body_footnote_marker_native(0, 214.0, 698.0) + .expect("hit second body footnote marker"); + assert!(hit.contains("\"hit\":true"), "hit json: {hit}"); + assert!(hit.contains("\"paragraphIndex\":7"), "hit json: {hit}"); + assert!(hit.contains("\"controlIndex\":0"), "hit json: {hit}"); + assert!(hit.contains("\"footnoteIndex\":1"), "hit json: {hit}"); + + let next = doc.navigate_next_editable_wasm(0, 7, 6, 1, "[]"); + assert!(next.contains("\"charOffset\":7"), "next json: {next}"); + let prev = doc.navigate_next_editable_wasm(0, 7, 7, -1, "[]"); + assert!(prev.contains("\"charOffset\":6"), "prev json: {prev}"); + + let marker_left = doc + .get_cursor_rect_native(0, 7, 6) + .expect("second marker left rect"); + let marker_right = doc + .get_cursor_rect_native(0, 7, 7) + .expect("second marker right rect"); + let left_x = json_number(&marker_left, "x"); + let right_x = json_number(&marker_right, "x"); + assert!( + right_x > left_x, + "second marker right caret should be after left caret: left={marker_left}, right={marker_right}" + ); +} + +#[test] +fn issue_598_body_footnote_marker_can_be_found_and_deleted_from_cursor() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("samples/footnote-01.hwp"); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {}", path.display(), e)); + let mut doc = HwpDocument::from_bytes(&bytes).expect("parse footnote-01.hwp"); + + let backward = doc + .get_footnote_at_cursor_native(0, 3, 8, "backward") + .expect("find footnote before cursor"); + assert!( + backward.contains("\"hit\":true"), + "backward json: {backward}" + ); + assert!( + backward.contains("\"controlIndex\":0"), + "backward json: {backward}" + ); + assert!( + backward.contains("\"charOffset\":7"), + "backward json: {backward}" + ); + assert!( + backward.contains("\"footnoteNumber\":1"), + "backward json: {backward}" + ); + + let forward = doc + .get_footnote_at_cursor_native(0, 3, 7, "forward") + .expect("find footnote after cursor"); + assert!(forward.contains("\"hit\":true"), "forward json: {forward}"); + assert!( + forward.contains("\"controlIndex\":0"), + "forward json: {forward}" + ); + assert!( + forward.contains("\"charOffset\":7"), + "forward json: {forward}" + ); + + let deleted = doc + .delete_footnote_native(0, 3, 0) + .expect("delete first body footnote"); + assert!(deleted.contains("\"ok\":true"), "deleted json: {deleted}"); + assert!( + deleted.contains("\"charOffset\":7"), + "deleted json: {deleted}" + ); + assert!( + deleted.contains("\"deletedNumber\":1"), + "deleted json: {deleted}" + ); + + assert_eq!(doc.get_control_text_positions(0, 3), "[]"); + + let missed = doc + .get_footnote_at_cursor_native(0, 3, 8, "backward") + .expect("deleted footnote should not be found"); + assert_eq!(missed, "{\"hit\":false}"); + + let old_marker_hit = doc + .hit_test_body_footnote_marker_native(0, 264.0, 380.0) + .expect("hit old marker position after delete"); + assert_eq!(old_marker_hit, "{\"hit\":false}"); + + let second_info = doc + .get_footnote_info_native(0, 7, 0) + .expect("remaining footnote info"); + assert!( + second_info.contains("\"number\":1"), + "second info json: {second_info}" + ); +} + +#[test] +fn issue_598_backspace_before_marker_keeps_marker_anchor_and_undo_restores_it() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("samples/footnote-01.hwp"); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {}", path.display(), e)); + let mut doc = HwpDocument::from_bytes(&bytes).expect("parse footnote-01.hwp"); + + assert_eq!(doc.get_control_text_positions(0, 3), "[7]"); + assert_eq!( + doc.get_text_range_native(0, 3, 6, 2).expect("text around marker"), + "체와" + ); + + doc.delete_text_native(0, 3, 6, 1) + .expect("delete text before footnote marker"); + assert_eq!( + doc.get_control_text_positions(0, 3), + "[6]", + "footnote marker should stay between remaining previous text and following text" + ); + assert_eq!( + doc.get_text_range_native(0, 3, 5, 2).expect("text around marker after delete"), + "액와" + ); + + doc.insert_text_native(0, 3, 6, "체") + .expect("undo-like insert before footnote marker"); + assert_eq!( + doc.get_control_text_positions(0, 3), + "[7]", + "undo-like insert should restore original footnote marker anchor" + ); + assert_eq!( + doc.get_text_range_native(0, 3, 6, 2).expect("text around marker after restore"), + "체와" + ); +}