Skip to content
66 changes: 62 additions & 4 deletions docs/figure-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
- 배경: 2026-07-09 QA 중 은우 피드백 5건. 대부분 implementation-plan §5.4~§8의 **계획됨·미구현** 조각과 일치하며,
본 문서는 그 조각들을 사용자 의도에 맞게 확정하고 구현 순서를 정한다.
- 작성: Claude(설계/기획). 구현은 Codex — §6 태스크. **이슈 #1 브랜치와 분리해 `feature/figure-ux`에서 진행**(이 문서만 먼저 커밋).
- 상태: **DC-F1 확정(B — 패널 경유, 2026-07-09 은우). G1–G8 착수 가능.**
- 상태: **G1–G8 + FG-R1 구현 완료, dev 리베이스(#9·#15 이식) + 리뷰·런타임 검증 완료(§6.1),
실기기 QA §7 1·3·4·5·6 통과 + F1 수정(2026-07-13, §7.1).** 남은 것: §7 2·7(샘플 미확보), PR 머지.
- 관련 정본: implementation-plan §5.4(멘션)·§5.5(프리뷰 렌더)·§6(수동 크롭)·§7(캡션 라벨)·§8(그림·표 탭 상세),
fig-extract-integration.md(엔진 경계 — captionAnchor·mentions·수동 크롭은 Margin 담당).

Expand Down Expand Up @@ -43,7 +44,9 @@

복귀 디테일: 참조 클릭으로 패널이 열린 경우 그 참조(원점)에 해당하는 언급 칩을 목록 맨 위에 **↩
마커**와 함께 표시한다 — 카드 클릭으로 피규어에 다녀온 뒤 이 칩 하나로 원래 자리로 돌아간다.
패널 핀 규칙은 기존 R5 그대로: 핀 해제 상태에서 2·3의 이동 후 패널이 자동으로 닫힌다.
핀 규칙(v1.1 개정, 리뷰에서 확정): **카드 클릭(2)은 핀과 무관하게 패널을 유지**한다 — 닫으면 복귀
칩(3)을 쓸 수 없어 왕복 흐름이 끊기기 때문. 본문으로 돌아가는 언급 칩 이동(3)만 핀 해제 시 패널을
닫는다(R5). 원래 초안은 2·3 모두 닫는 것이었으나 구현 리뷰에서 위 이유로 개정.

기각: A(참조 클릭 즉시 피규어로 이동 — 읽던 맥락 이탈), C(중앙 팝오버 — 레이어 비용, 조용한 UI
헌법과 긴장). 호버 미니 프리뷰(§5.5)는 phase 2 백로그 유지.
Expand Down Expand Up @@ -131,18 +134,73 @@ jumpToRegion(page, rectPdf) // 세로 중앙(큰 region은 1/8) + region 플
의존성: G1 → {G3, G4, G5, G6, G7}, G2는 독립(먼저 가능), G3 → G5(언급 칩 데이터).
제안 순서: G2 → G1 → G3 → G4·G5 → G7 → G6 → G8.

## 6.1 구현 리뷰 노트 (2026-07-09, Claude — G1–G8 스펙 준수 리뷰)

**확인 완료**: 정적 리뷰(전 모듈)와 런타임 검증(표준 테스트 PDF arXiv 2606.12848, 17p — dev 프리뷰) 모두 통과.
merge의 전제인 FigureEntry id 결정성(`fig{num}-p{page}`) 확인, captionAnchor 정규화 역매핑·역순 링크
주입(오프셋 보존)·세대 가드·키보드 접근성 등 견실. 런타임: 스캔 → 카드 2개(이미지·크롭 아이콘·언급 칩
1/2개) → 본문 링크 3개 주입 → 참조 클릭 시 본문 무이동 + 원점 칩 `↩ p.2` 최상단 + 활성 스타일 → 카드
클릭 p.3 region 점프 + region 플래시 → 원점 칩 p.2 복귀 + 밴드 플래시 → 크롭 진입(17p 오버레이 + 기존
영역 rect) → Esc 정리. 콘솔 무오류. typecheck·test 23·build 리뷰어 재현 통과.

**스펙 개정(코드가 옳음)**: 카드 클릭 후 패널 유지 — §3 핀 규칙 v1.1로 개정 완료(위).

**후속 (우선순위 순)**:

- **FG-R1 (구현 완료, 2026-07-13)** — PDF 내장 링크(hyperref)로 패널이 열리면 원점 칩이 없던 문제
(`openFigurePanel(figure.id)` — originRefKey 미지정). arXiv 논문은 참조 대부분이 내장 링크라 복귀 UX가
자주 빠졌다. 구현: 캡처 단계 클릭 리스너가 annotation 링크의 좌표(페이지+y)를 기억하고,
`handleInternalDestination`이 이를 소비해 `nearestFigureMention`(같은 페이지에서 y가 가장 가까운 같은
피규어 언급, 캡션 라벨 제외)으로 원점 칩을 지정한다. 원점은 3초 TTL의 일회성 소비라 목차 점프 등
다른 goToDestination 경로에 새지 않는다. 언급이 스캔에 안 잡힌 참조(FG-R4 한계)는 같은 페이지의
가장 가까운 언급으로 폴백되고, 같은 페이지에 언급이 없으면 원점 없이 열린다. dev 프리뷰 검증:
내장 "Figure 1" 클릭 → 본문 무이동 + `↩ p.2` 원점 칩 최상단 → 카드 클릭 p.3 점프 → 원점 칩으로
p.2 복귀(1/8 + 밴드 플래시), 각주 내장 링크는 기본 동작 유지. 단위 테스트 3건(`mentions.test.ts`).
- **FG-R2 (사소, 성능)** — `render-region.ts`에 §5.5의 페이지 캔버스 LRU(3장)가 없어 같은 페이지에 피규어가
여럿이면 첫 렌더 때 페이지를 중복 렌더한다. 탭이 결과 dataURL을 캐시하므로 v1 수용 — 문서 클수록
체감되면 후속 최적화.
- **FG-R3 (기록만)** — 재스캔 merge 시 manual 항목의 `page`가 엔진 값으로 되돌아감(region은 보존).
`figure.page`는 폴백 용도라 실해 없음.
- **FG-R4 (수정 완료 + 잔여 한계, 2026-07-09)** — 스캔 텍스트는 pdf.js 아이템을 구분자 없이 이어
붙여 공백이 소실될 수 있다(실측: 표준 테스트 논문 p.15 "threereviewers"). 이 때문에 캡션 매칭이
실패해 **자기 캡션 라벨이 "본문 언급"으로 새는 버그**(fig2 언급 2곳 표기, 실제 1곳)가 있었고,
`findCaptionAnchor`를 공백 무시 매칭으로 바꿔 수정(실측 원문 회귀 테스트 포함). 잔여 한계(수용):
단어가 붙으면 `\b` 경계가 사라져 (a) 캡션 라벨이 링크화되지 않거나(같은 논문 p.3 fig1 — 언급
목록엔 영향 없음, 캡션 라벨 클릭만 조용히 비활성) (b) 붙은 본문 참조를 놓칠 수 있다. 근본 해결은
렌더 인덱스와의 오프셋 호환을 유지한 채 아이템 결합을 개선해야 해서 후속 과제.
주의: 이미 저장된 문서는 앵커가 저장 시점 값 — 그림·표 탭 **"다시 스캔"으로 재계산** 필요.

## 7. QA 시나리오 (구현 후 issue-1-qa.md 방식으로 상세화)

1. arXiv 논문(hyperref 있음): 본문 "Figure 2" 클릭 → 본문 무이동, 패널 그림·표 탭 열림 + 해당 카드
강조 + 원점 칩 ↩ 표시. 각주 링크는 기존 동작.
2. 링크 없는 PDF: 같은 텍스트가 mgn-ref로 링크화되어 동일 동작. 캡션 안의 자기 라벨은 언급 목록에 없음.
3. 패널 왕복: 카드 클릭 → 피규어 세로 중앙 + 앰버 플래시 → 원점 칩 클릭 → 읽던 문장 상단 1/8 + 밴드
플래시로 복귀. 핀 해제 상태면 각 이동 후 패널 자동 닫힘, 핀 상태면 유지.
3. 패널 왕복: 카드 클릭 → 피규어 세로 중앙 + 앰버 플래시(패널은 핀과 무관하게 유지) → 원점 칩 클릭 →
읽던 문장 상단 1/8 + 밴드 플래시로 복귀(핀 해제 상태면 이때 패널 닫힘).
4. 카드 이미지 호버 → 크롭 아이콘 → 드래그 재지정 → 저장 → 카드·점프 모두 새 영역 반영, 재스캔에도 유지.
5. 본문 캡션 라벨 클릭 → 패널 그림·표 탭이 열리고 해당 카드 강조.
6. 목차·메모 점프가 1/8 정렬 + 플래시로 동작, 200% 줌에서도 정확.
7. 스캔 PDF(텍스트 레이어 없음): 그림·표 탭 빈 상태 문구, 본문 링크화 없음, 오류 없음.

## 7.1 실기기 QA 결과 (2026-07-13, Claude — Mac Chrome 원격 드라이브)

- 환경: 실제 Chrome(macOS)을 Claude in Chrome으로 조작. 타 확장 페이지는 자동화 불가(Chrome 보안 경계)라
**인터셉트는 실확장으로, 뷰어 내부는 같은 Chrome에서 dev 서버 뷰어로** 검증. 표준 테스트 PDF(17p).
- 결과: **1 ✅** 내장 링크 → 본문 무이동 + 패널 + `↩ p.2` 원점 칩 최상단(FG-R1 실동작) · **3 ✅** 카드 클릭
세로 중앙(플래시 중심 0.52) + region 플래시, ↩ 복귀 상단 0.17 + 밴드 플래시, 핀 OFF 복귀 시 패널 닫힘·
핀 ON 유지 · **4 ✅** 크롭 아이콘 → 드래그 → 라이브 미리보기 → 저장 → 카드·점프가 드래그 사각형과 일치,
재스캔 후 픽셀 단위 보존 · **5 ✅** fig2 캡션 라벨 → 무이동 + 카드 강조, 원점 칩 없음(설계대로); fig1
라벨은 FG-R4 한계로 비링크 재확인 · **6 ✅** 목차 점프 0.17, 줌 확대 후 동일 정렬 · **2·7 ⏭️** 링크 없는
PDF·스캔 PDF 샘플 미확보로 잔여.
- **F1 (수정 완료)** — 핀 해제 상태에서 카드 클릭이 `jumpToFigure`의 `closePanel()`로 패널을 닫아 ↩ 복귀
경로가 사라졌다(§3 v1.1 "카드 클릭은 핀과 무관하게 유지" 위반 — v1.1 개정 당시 리뷰가 핀 ON에서 검증해
놓친 것). 해당 호출 제거로 수정. 언급 칩 복귀의 핀 OFF 닫힘은 스펙대로 유지.
- 관찰(기록만): (a) 점프 정렬이 목표 1/8(0.125) 대비 모든 점프에서 일관되게 0.17에 안착 — 체감 자연스러움.
(b) 탭이 가려지면 rAF 동결로 pdf.js 렌더·스캔이 일시정지, 탭이 보이면 재개 — 백그라운드 문서의 자동
스캔은 탭 방문 시 이어짐. (c) 문서 로드 직후(멘션 인덱스 준비 전) 내장 링크 클릭은 원점 칩 없이 패널만
열림, 수 초 뒤부터 정상. (d) 내장 링크 인터셉트는 실확장에서 확인 — arXiv 자동 전환 + URL 꼬리가
`.pdf`로 끝나는 비PDF 페이지 URL도 DNR에 걸림(dev 서버 뷰어 URL로 실측).

## 8. 분담

| 담당 | 산출물 |
Expand Down
5 changes: 3 additions & 2 deletions docs/implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ onMouseUp:
- 본문 멘션 전체 문서 스캔: FigureEntry가 준비된 뒤 백그라운드로 1페이지부터 순차 `getTextContent`(이미 캐시된 페이지는 재사용, 페이지당 idle 처리) → `{figId, page, start, end}[]` 완성 후 목록 갱신. 진행 중에는 "스캔 중 n/N" 표시. 결과는 세션 메모리 캐시.
- 링크 DOM 주입: `textlayerrendered`마다 해당 페이지 매치들에 대해 span 내부 텍스트 노드를 Range로 잘라 `<a class="mgn-ref" data-fig …>`로 감싼다(데모의 wrapRange와 동일 기법, span 경계에 걸치면 조각별로 감싼다). 이미 감싼 페이지는 `dataset.mgnRefs='1'`로 멱등 처리. 단일 span 내 매치만 처리(경계에 걸린 극소수는 v1 제한).
- PDF 자체 하이퍼링크(hyperref) 연동: `PDFLinkService`를 서브클래스해 `goToDestination(dest)`를 오버라이드 — dest를 페이지·좌표로 해석했을 때 어떤 FigureEntry의 region/caption에 들어가면 점프 대신 패널 프리뷰를 연다(R1과 동일 동작). 그 외 dest는 원래 동작. annotation 링크와 우리 regex 링크가 같은 텍스트에 겹치면 annotation을 우선하고 regex 주입을 생략한다.
- 점프 정렬: 텍스트 목적지(목차·메모·언급 칩)는 뷰포트 상단 1/8 지점에 맞추고 앰버 밴드로 1.2초 플래시한다. 피규어 region 목적지는 세로 중앙에 맞추되, region 높이가 뷰포트의 3/4보다 크면 상단 1/8 정렬로 폴백하고 region 외곽선/필 플래시를 사용한다.

### 5.5 프리뷰 렌더 (`core/render-region.ts`)

Expand All @@ -263,7 +264,7 @@ renderRegion(pdfDoc, page, rectPdf, maxCssWidth): HTMLCanvasElement

상태 머신: `idle → armed(figId) → dragging → preview → idle`

- 진입: 그림·표 탭 상세의 버튼 — region 있으면 "영역 다시 지정", 없으면 "영역 지정". 진입 시 대상 페이지로 스크롤(기존 region 또는 캡션 위치).
- 진입: 그림·표 카드 이미지 우상단 호버 크롭 아이콘(⌗). 진입 시 대상 페이지로 스크롤(기존 region 또는 캡션 위치).
Comment on lines 265 to +267

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

R8(27행대) 규칙이 이번에 갱신된 크롭 진입 방식과 불일치.

이 구간에서 크롭 진입을 "카드 이미지 우상단 호버 크롭 아이콘"으로 명시했고 289행의 §8도 같은 방식으로 갱신됐지만, 상단의 상호작용 규칙 R8(변경되지 않은 27-41행 구간)은 여전히 "그림·표 탭의 '영역 지정' 버튼으로 진입"이라고 서술되어 있습니다. figure-ux.md도 "§8의 '영역 지정/다시 지정' 텍스트 버튼은 아이콘으로 대체... 계획서 §8 서술 개정 필요(G8)"라고 명시했는데, §8은 갱신됐으나 R8은 빠진 것으로 보입니다. R8 문구도 아이콘 진입 방식으로 맞춰주세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/implementation-plan.md` around lines 265 - 267, 상단 상호작용 규칙 R8의 크롭 진입 설명을
그림·표 탭의 ‘영역 지정’ 버튼이 아니라 카드 이미지 우상단 호버 크롭 아이콘(⌗)을 사용하는 방식으로 수정하세요. 상태 머신과 §8에 반영된
진입 동작과 일치시키고, R8의 나머지 규칙은 유지하세요.

- armed: 각 pageDiv에 `div.mgn-crop-overlay`(absolute inset 0, crosshair, z-index 텍스트 레이어 위) 삽입, 뷰어에 `user-select:none`. 기존 region은 파란 외곽선 rect로 표시. 패널에는 안내 카드("드래그해서 영역을 지정하세요 · Esc 취소")가 뜬다.
- dragging: mousedown한 페이지로 클램프, 러버밴드 rect 표시.
- preview: mouseup 시 rect 확정 표시 유지, **패널 카드가 라이브 미리보기(renderRegion) + [저장] [다시 지정] [취소]로 전환**. 커서 근처에는 아무것도 띄우지 않는다(R8).
Expand All @@ -285,7 +286,7 @@ renderRegion(pdfDoc, page, rectPdf, maxCssWidth): HTMLCanvasElement

- 공통: 탭 [목차 | 그림·표 | 메모(n)], 핀·닫기(R5), 닫힘 시 우측 26px 스트립. 패널 폭 312px(뷰포트 900px 미만이면 264px).
- 목차 탭: `pdfDocument.getOutline()` 사용. 항목 클릭 → `getDestination`/`getPageIndex`로 해석해 점프. 스크롤 스파이는 outline 항목의 대상 페이지·y를 기준으로 현재 위치 표시. **outline이 없으면 탭에 "이 PDF에는 목차가 없어요"만 표시**(헤딩 휴리스틱 생성은 phase 2).
- 그림·표 탭: 상세(라벨 + p.N 칩 + 프리뷰 캔버스 + 캡션 + [원문 위치로 이동] [메모 달기] **[영역 지정/다시 지정]** + confidence 낮으면 "영역 확인 필요" 배지) → "본문 언급 N" 목록(R7) → "이 문서의 그림·표" 전체 목록(활성 행 표시, 클릭 시 프리뷰 전환).
- 그림·표 탭: 카드(프리뷰 이미지 + 우상단 호버 크롭 아이콘 + 라벨/p.N 칩 + 캡션) → "본문 언급 N" 목록(R7). 프리뷰 이미지/라벨 클릭은 원문 region으로 이동하고, 크롭 저장 후에는 카드 이미지와 region 점프가 manual 영역을 즉시 반영한다.
- 메모 탭: 데모와 동일 — 작성 카드(자동 인용/색 반영/[[·#] 힌트/닫기·저장·삭제), 형광펜 4색 선택(현재 펜 = 조용한 저장에도 적용), 검색, 카드 목록(인용 1줄 + 리치 텍스트 + p.N/링크 n/날짜 + 편집·삭제, 본문 점프). 검색은 단순 includes.
- 허브(hub.html): 상단(제목·총계·검색) + 태그 칩 + **문서별 그룹**(storage의 모든 doc, `DocMeta.title`, "PDF 열기" = url 있으면 딥링크, 없으면 파일 재선택 흐름) + 카드 펼침(연결 [[링크]], [PDF에서 이 위치 열기], 삭제) + "링크된 노트" 스텁 섹션(역참조 목록, "문서에서 보기" 딥링크). 데모 대비 추가: 다중 문서 그룹, 문서 삭제(문서의 모든 데이터 제거, confirm 1회).

Expand Down
4 changes: 2 additions & 2 deletions docs/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ M0 and M1 are complete. The first M2 implementation pass is complete and pushed,

## Next

- 이슈 #1 대응: C1–C8 + 리뷰 후속 R1–R3 반영 완료 (`feature/1-open-ux` 브랜치) — macOS + Windows 수동 Chrome QA 필요.
- 피규어 UX 개정: [figure-ux.md](figure-ux.md) 설계 확정(DC-F1=패널 경유, 참조↔피규어 양방향 링크, 점프 1/8 정렬+플래시, 카드 크롭 아이콘, 캡션 라벨) — `feature/figure-ux` 브랜치에서 G1–G8 구현 착수 가능.
- 이슈 #1 대응: PR #5 머지 완료 (2026-07-09) — Windows 수동 QA(진형, issue-1-qa.md W-1~33)와 이슈 답변 게시·클로즈만 남음.
- 피규어 UX 개정: G1–G8 + FG-R1(내장 링크 원점 칩) 구현 완료 (2026-07-13) — dev 리베이스(#9 "로드 즉시 스캔"을 새 아키텍처에 이식: 로드 후 자동 `ensureScanned`, 스캔 세대 가드, 문서 전환 시 저장 가드), dev 프리뷰 런타임 검증 통과. 남은 것: PR 머지, 실기기 QA([figure-ux.md](figure-ux.md) §7). 상세는 [figure-ux.md](figure-ux.md) §6.1.
- Finish M2 manual QA fixes.
- Keep figure/table extraction out of the immediate path until the separate figure feature direction is decided.
- After M2 acceptance, move to either Hub work or the separate figure workflow, depending on priority.
76 changes: 76 additions & 0 deletions src/core/figures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { FigureEntry } from './types';

export type CaptionMatch = {
page: number;
start: number;
end: number;
};

type NormalizedText = {
text: string;
map: number[];
};

export function findCaptionAnchor(page: number, pageText: string, captionText: string): CaptionMatch | undefined {
const exactStart = pageText.indexOf(captionText);
if (exactStart >= 0) {
return { page, start: exactStart, end: exactStart + captionText.length };
}

const haystack = normalizeSearchText(pageText);
const needle = normalizeSearchText(captionText);
if (!needle.text) return undefined;

const normalizedStart = haystack.text.indexOf(needle.text);
if (normalizedStart < 0) return undefined;

const normalizedEnd = normalizedStart + needle.text.length - 1;
const start = haystack.map[normalizedStart];
const end = (haystack.map[normalizedEnd] ?? start) + 1;
return { page, start, end };
}

export function mergeFigureEntries(existing: FigureEntry[], incoming: FigureEntry[]): FigureEntry[] {
const existingById = new Map(existing.map((figure) => [figure.id, figure]));
const seen = new Set<string>();
const merged = incoming.map((figure) => {
seen.add(figure.id);
const previous = existingById.get(figure.id);
if (previous?.regionSource === 'manual' && previous.region) {
return {
...figure,
region: previous.region,
regionSource: 'manual' as const,
confidence: Math.max(previous.confidence, figure.confidence)
};
}
return figure;
});

for (const previous of existing) {
if (previous.regionSource === 'manual' && !seen.has(previous.id)) {
merged.push(previous);
}
}
return merged.sort((a, b) => a.page - b.page || a.kind.localeCompare(b.kind) || naturalNumberCompare(a.num, b.num));
}

function naturalNumberCompare(left: string, right: string): number {
return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' });
}

// 스캔 텍스트는 pdf.js 아이템을 구분자 없이 이어 붙여 공백 유무가 불안정하다
// (예: "threereviewers"). 엔진 캡션과의 대조는 공백을 아예 무시하고 한다.
function normalizeSearchText(value: string): NormalizedText {
const chars: string[] = [];
const map: number[] = [];

for (let index = 0; index < value.length; index += 1) {
const char = value[index];
if (/\s/.test(char)) continue;
chars.push(char.toLocaleLowerCase());
map.push(index);
}

return { text: chars.join(''), map };
}
Loading
Loading