diff --git a/docs/figure-ux.md b/docs/figure-ux.md index 8fd3a3e..b67881c 100644 --- a/docs/figure-ux.md +++ b/docs/figure-ux.md @@ -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 담당). @@ -43,7 +44,9 @@ 복귀 디테일: 참조 클릭으로 패널이 열린 경우 그 참조(원점)에 해당하는 언급 칩을 목록 맨 위에 **↩ 마커**와 함께 표시한다 — 카드 클릭으로 피규어에 다녀온 뒤 이 칩 하나로 원래 자리로 돌아간다. -패널 핀 규칙은 기존 R5 그대로: 핀 해제 상태에서 2·3의 이동 후 패널이 자동으로 닫힌다. +핀 규칙(v1.1 개정, 리뷰에서 확정): **카드 클릭(2)은 핀과 무관하게 패널을 유지**한다 — 닫으면 복귀 +칩(3)을 쓸 수 없어 왕복 흐름이 끊기기 때문. 본문으로 돌아가는 언급 칩 이동(3)만 핀 해제 시 패널을 +닫는다(R5). 원래 초안은 2·3 모두 닫는 것이었으나 구현 리뷰에서 위 이유로 개정. 기각: A(참조 클릭 즉시 피규어로 이동 — 읽던 맥락 이탈), C(중앙 팝오버 — 레이어 비용, 조용한 UI 헌법과 긴장). 호버 미니 프리뷰(§5.5)는 phase 2 백로그 유지. @@ -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. 분담 | 담당 | 산출물 | diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 99459fe..40cf0f7 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -245,6 +245,7 @@ onMouseUp: - 본문 멘션 전체 문서 스캔: FigureEntry가 준비된 뒤 백그라운드로 1페이지부터 순차 `getTextContent`(이미 캐시된 페이지는 재사용, 페이지당 idle 처리) → `{figId, page, start, end}[]` 완성 후 목록 갱신. 진행 중에는 "스캔 중 n/N" 표시. 결과는 세션 메모리 캐시. - 링크 DOM 주입: `textlayerrendered`마다 해당 페이지 매치들에 대해 span 내부 텍스트 노드를 Range로 잘라 ``로 감싼다(데모의 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`) @@ -263,7 +264,7 @@ renderRegion(pdfDoc, page, rectPdf, maxCssWidth): HTMLCanvasElement 상태 머신: `idle → armed(figId) → dragging → preview → idle` -- 진입: 그림·표 탭 상세의 버튼 — region 있으면 "영역 다시 지정", 없으면 "영역 지정". 진입 시 대상 페이지로 스크롤(기존 region 또는 캡션 위치). +- 진입: 그림·표 카드 이미지 우상단 호버 크롭 아이콘(⌗). 진입 시 대상 페이지로 스크롤(기존 region 또는 캡션 위치). - 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). @@ -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회). diff --git a/docs/progress.md b/docs/progress.md index 8bd1392..006ce3d 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -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. diff --git a/src/core/figures.ts b/src/core/figures.ts new file mode 100644 index 0000000..1af258f --- /dev/null +++ b/src/core/figures.ts @@ -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(); + 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 }; +} diff --git a/src/core/mentions.ts b/src/core/mentions.ts new file mode 100644 index 0000000..3b16ff3 --- /dev/null +++ b/src/core/mentions.ts @@ -0,0 +1,160 @@ +import { rangeFromOffsets, type PageTextIndex } from './text-index'; +import type { FigureEntry } from './types'; + +const REF_RE = /\b(Fig(?:ure)?s?|Tab(?:le)?s?)\.?\s*([A-Za-z]?\d+(?:\.\d+)*(?:[a-zA-Z])?)/gi; + +export type FigureReference = { + key: string; + figId: string; + page: number; + start: number; + end: number; + quote: string; + yPdf?: number; + isCaptionLabel: boolean; +}; + +type TextIndexLike = Pick; + +export function scanFigureReferences( + index: TextIndexLike, + figures: FigureEntry[], + yForOffset?: (offset: number) => number | undefined +): FigureReference[] { + if (!figures.length || !index.text) return []; + + const byRef = buildFigureLookup(figures); + const refs: FigureReference[] = []; + REF_RE.lastIndex = 0; + + for (let match = REF_RE.exec(index.text); match; match = REF_RE.exec(index.text)) { + const kind = normalizeKind(match[1]); + const num = normalizeNum(match[2]); + const figure = byRef.get(`${kind}:${num}`); + if (!figure) continue; + + const start = match.index; + const end = start + match[0].length; + const isCaptionLabel = isInsideCaptionLabel(index.page, start, end, figure); + refs.push({ + key: referenceKey(figure.id, index.page, start, end), + figId: figure.id, + page: index.page, + start, + end, + quote: match[0], + yPdf: yForOffset?.(start), + isCaptionLabel + }); + } + + return refs; +} + +export function figureMentions(references: FigureReference[]): FigureReference[] { + return references.filter((reference) => !reference.isCaptionLabel); +} + +export function referenceKey(figId: string, page: number, start: number, end: number): string { + return `${figId}:${page}:${start}:${end}`; +} + +/** 내장 링크 원점 매칭(FG-R1) — 클릭 페이지에서 y가 가장 가까운 같은 피규어 언급. 다른 페이지는 후보로 삼지 않는다. */ +export function nearestFigureMention( + references: FigureReference[], + figId: string, + page: number, + yPdf: number +): FigureReference | null { + let best: FigureReference | null = null; + let bestDistance = Infinity; + for (const reference of references) { + if (reference.figId !== figId || reference.isCaptionLabel) continue; + if (reference.page !== page || reference.yPdf === undefined) continue; + const distance = Math.abs(reference.yPdf - yPdf); + if (distance < bestDistance) { + best = reference; + bestDistance = distance; + } + } + return best; +} + +export function injectFigureReferenceLinks( + pageDiv: HTMLElement, + index: PageTextIndex, + references: FigureReference[], + activeFigureId: string | null = null +): number { + const textLayer = pageDiv.querySelector('.textLayer'); + if (!textLayer || pageDiv.dataset.mgnRefs === '1') { + updateReferenceLinkActive(pageDiv, activeFigureId); + return 0; + } + + const pageRefs = references + .filter((reference) => reference.page === index.page) + .filter((reference) => referenceFitsSingleSpan(index, reference.start, reference.end)) + .sort((a, b) => b.start - a.start); + + let count = 0; + for (const reference of pageRefs) { + const range = rangeFromOffsets(index, reference.start, reference.end); + if (!range) continue; + const common = range.commonAncestorContainer; + const commonElement = common.nodeType === Node.TEXT_NODE ? common.parentElement : common as Element | null; + if (commonElement?.closest('a')) { + range.detach(); + continue; + } + + const link = document.createElement('a'); + link.className = 'mgn-ref'; + link.href = '#'; + link.dataset.fig = reference.figId; + link.dataset.refKey = reference.key; + link.dataset.page = String(reference.page); + link.dataset.start = String(reference.start); + link.dataset.end = String(reference.end); + if (reference.isCaptionLabel) link.dataset.cap = '1'; + if (reference.figId === activeFigureId) link.classList.add('on'); + link.append(range.extractContents()); + range.insertNode(link); + range.detach(); + count += 1; + } + + pageDiv.dataset.mgnRefs = '1'; + return count; +} + +export function updateReferenceLinkActive(root: ParentNode, activeFigureId: string | null): void { + for (const link of root.querySelectorAll('a.mgn-ref[data-fig]')) { + link.classList.toggle('on', Boolean(activeFigureId) && link.dataset.fig === activeFigureId); + } +} + +function buildFigureLookup(figures: FigureEntry[]): Map { + const lookup = new Map(); + for (const figure of figures) { + lookup.set(`${figure.kind}:${normalizeNum(figure.num)}`, figure); + } + return lookup; +} + +function normalizeKind(value: string): FigureEntry['kind'] { + return value.toLocaleLowerCase().startsWith('tab') ? 'table' : 'figure'; +} + +function normalizeNum(value: string): string { + return value.toLocaleLowerCase(); +} + +function isInsideCaptionLabel(page: number, start: number, end: number, figure: FigureEntry): boolean { + const anchor = figure.captionAnchor; + return Boolean(anchor && anchor.page === page && start >= anchor.start && end <= anchor.end); +} + +function referenceFitsSingleSpan(index: PageTextIndex, start: number, end: number): boolean { + return index.spans.some((span) => start >= span.start && end <= span.end); +} diff --git a/src/core/render-region.ts b/src/core/render-region.ts new file mode 100644 index 0000000..e2a86ea --- /dev/null +++ b/src/core/render-region.ts @@ -0,0 +1,48 @@ +import type { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api'; +import { normalizePdfRect } from './anchor'; +import type { PdfRect } from './types'; + +const DEFAULT_MAX_CSS_WIDTH = 360; + +export async function renderRegionDataURL( + pdfDocument: PDFDocumentProxy, + pageNumber: number, + rect: PdfRect, + maxCssWidth = DEFAULT_MAX_CSS_WIDTH +): Promise { + const page = await pdfDocument.getPage(pageNumber); + const normalized = normalizePdfRect(rect); + const widthPt = Math.max(1, normalized[2] - normalized[0]); + const cssScale = clamp(maxCssWidth / widthPt, 1, 3); + const pixelRatio = Math.max(1, Math.min(window.devicePixelRatio || 1, 2)); + const scale = cssScale * pixelRatio; + const viewport = page.getViewport({ scale }); + + const pageCanvas = document.createElement('canvas'); + pageCanvas.width = Math.ceil(viewport.width); + pageCanvas.height = Math.ceil(viewport.height); + const pageContext = pageCanvas.getContext('2d'); + if (!pageContext) return null; + + await page.render({ canvasContext: pageContext, viewport }).promise; + + const [left, top, right, bottom] = normalizeViewportRect(viewport.convertToViewportRectangle(normalized)); + const cropWidth = Math.max(1, Math.ceil(right - left)); + const cropHeight = Math.max(1, Math.ceil(bottom - top)); + const cropCanvas = document.createElement('canvas'); + cropCanvas.width = cropWidth; + cropCanvas.height = cropHeight; + const cropContext = cropCanvas.getContext('2d'); + if (!cropContext) return null; + cropContext.drawImage(pageCanvas, left, top, right - left, bottom - top, 0, 0, cropWidth, cropHeight); + return cropCanvas.toDataURL('image/png'); +} + +function normalizeViewportRect(rect: number[]): [number, number, number, number] { + const [x1, y1, x2, y2] = rect; + return [Math.min(x1, x2), Math.min(y1, y2), Math.max(x1, x2), Math.max(y1, y2)]; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/src/core/types.ts b/src/core/types.ts index ad4d38c..71bf358 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -53,9 +53,8 @@ export interface FigureEntry { label: string; page: number; captionText: string; - captionAnchor: { page: number; start: number; end: number }; + captionAnchor?: { page: number; start: number; end: number }; region: { page: number; rect: PdfRect } | null; regionSource: 'auto' | 'manual'; confidence: number; } - diff --git a/src/viewer/crop-mode.ts b/src/viewer/crop-mode.ts new file mode 100644 index 0000000..97b3981 --- /dev/null +++ b/src/viewer/crop-mode.ts @@ -0,0 +1,203 @@ +import type { PageViewport } from 'pdfjs-dist/types/src/display/display_utils'; +import { normalizePdfRect } from '../core/anchor'; +import type { FigureEntry, PdfRect } from '../core/types'; +import { jumpToRegion, type JumpAccess } from './jump'; + +const MIN_SIZE_PT = 12; + +export type CropRegion = { + page: number; + rect: PdfRect; +}; + +type CropModeCallbacks = { + onPreview(region: CropRegion): void; + onCancel(): void; +}; + +type PageAccess = JumpAccess & { + pageCount(): number; +}; + +export class CropMode { + #access: PageAccess; + #callbacks: CropModeCallbacks; + #figId: string | null = null; + #overlays: HTMLElement[] = []; + #drag: { + pageNumber: number; + overlay: HTMLElement; + viewport: PageViewport; + startX: number; + startY: number; + rectEl: HTMLElement; + } | null = null; + #onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape' && this.active) this.cancel(); + }; + + constructor(access: PageAccess, callbacks: CropModeCallbacks) { + this.#access = access; + this.#callbacks = callbacks; + } + + get active(): boolean { + return this.#figId !== null; + } + + get figId(): string | null { + return this.#figId; + } + + async start(figure: FigureEntry): Promise { + this.cancel(false); + this.#figId = figure.id; + document.body.classList.add('mgn-cropping'); + window.addEventListener('keydown', this.#onKeyDown); + this.#installOverlays(figure); + if (figure.region) { + await jumpToRegion(this.#access, figure.region.page, figure.region.rect); + } else { + this.#access.viewer.scrollPageIntoView({ pageNumber: figure.page }); + } + } + + accept(): void { + this.#clear(false); + } + + cancel(emit = true): void { + const wasActive = this.active; + this.#clear(false); + if (emit && wasActive) this.#callbacks.onCancel(); + } + + #installOverlays(figure: FigureEntry): void { + for (let pageNumber = 1; pageNumber <= this.#access.pageCount(); pageNumber += 1) { + const pageDiv = this.#access.getPageDiv(pageNumber); + const viewport = this.#access.getPageViewport(pageNumber); + if (!pageDiv || !viewport) continue; + + const overlay = document.createElement('div'); + overlay.className = 'mgn-crop-overlay'; + overlay.dataset.page = String(pageNumber); + overlay.addEventListener('pointerdown', (event) => this.#pointerDown(event, pageNumber, overlay, viewport)); + pageDiv.append(overlay); + this.#overlays.push(overlay); + + if (figure.region?.page === pageNumber) { + this.#drawRect(overlay, viewport.convertToViewportRectangle(normalizePdfRect(figure.region.rect)), 'mgn-crop-existing'); + } + } + } + + #pointerDown(event: PointerEvent, pageNumber: number, overlay: HTMLElement, viewport: PageViewport): void { + if (event.button !== 0 || !this.active) return; + event.preventDefault(); + overlay.setPointerCapture(event.pointerId); + overlay.querySelector('.mgn-crop-rubber')?.remove(); + const point = this.#eventPoint(event, overlay); + const rectEl = document.createElement('div'); + rectEl.className = 'mgn-crop-rubber'; + overlay.append(rectEl); + this.#drag = { + pageNumber, + overlay, + viewport, + startX: point.x, + startY: point.y, + rectEl + }; + + const onMove = (moveEvent: PointerEvent): void => this.#pointerMove(moveEvent); + const onUp = (upEvent: PointerEvent): void => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + this.#pointerUp(upEvent); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp, { once: true }); + } + + #pointerMove(event: PointerEvent): void { + if (!this.#drag) return; + const point = this.#eventPoint(event, this.#drag.overlay); + this.#positionRect(this.#drag.rectEl, this.#drag.startX, this.#drag.startY, point.x, point.y); + } + + #pointerUp(event: PointerEvent): void { + if (!this.#drag) return; + const drag = this.#drag; + this.#drag = null; + const point = this.#eventPoint(event, drag.overlay); + this.#positionRect(drag.rectEl, drag.startX, drag.startY, point.x, point.y); + + const rectPdf = viewportRectToPdfRect(drag.viewport, drag.startX, drag.startY, point.x, point.y); + const normalized = normalizePdfRect(rectPdf); + if (normalized[2] - normalized[0] < MIN_SIZE_PT || normalized[3] - normalized[1] < MIN_SIZE_PT) { + drag.rectEl.remove(); + return; + } + + drag.rectEl.classList.add('preview'); + this.#callbacks.onPreview({ page: drag.pageNumber, rect: normalized }); + } + + #drawRect(overlay: HTMLElement, viewportRect: number[], className: string): HTMLElement { + const rectEl = document.createElement('div'); + rectEl.className = className; + const [x1, y1, x2, y2] = normalizeViewportRect(viewportRect); + this.#positionRect(rectEl, x1, y1, x2, y2); + overlay.append(rectEl); + return rectEl; + } + + #positionRect(rectEl: HTMLElement, x1: number, y1: number, x2: number, y2: number): void { + const left = Math.min(x1, x2); + const top = Math.min(y1, y2); + rectEl.style.left = `${left}px`; + rectEl.style.top = `${top}px`; + rectEl.style.width = `${Math.abs(x2 - x1)}px`; + rectEl.style.height = `${Math.abs(y2 - y1)}px`; + } + + #eventPoint(event: PointerEvent, overlay: HTMLElement): { x: number; y: number } { + const bounds = overlay.getBoundingClientRect(); + return { + x: clamp(event.clientX - bounds.left, 0, bounds.width), + y: clamp(event.clientY - bounds.top, 0, bounds.height) + }; + } + + #clear(emit: boolean): void { + document.body.classList.remove('mgn-cropping'); + window.removeEventListener('keydown', this.#onKeyDown); + for (const overlay of this.#overlays) overlay.remove(); + this.#overlays = []; + this.#drag = null; + const hadFigure = this.#figId !== null; + this.#figId = null; + if (emit && hadFigure) this.#callbacks.onCancel(); + } +} + +function viewportRectToPdfRect( + viewport: PageViewport, + x1: number, + y1: number, + x2: number, + y2: number +): PdfRect { + const [px1, py1] = viewport.convertToPdfPoint(x1, y1); + const [px2, py2] = viewport.convertToPdfPoint(x2, y2); + return [px1, py1, px2, py2]; +} + +function normalizeViewportRect(rect: number[]): [number, number, number, number] { + const [x1, y1, x2, y2] = rect; + return [Math.min(x1, x2), Math.min(y1, y2), Math.max(x1, x2), Math.max(y1, y2)]; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/src/viewer/jump.ts b/src/viewer/jump.ts new file mode 100644 index 0000000..2ed64f8 --- /dev/null +++ b/src/viewer/jump.ts @@ -0,0 +1,120 @@ +import type { PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'; +import type { PageViewport } from 'pdfjs-dist/types/src/display/display_utils'; +import { normalizePdfRect } from '../core/anchor'; +import type { PdfRect } from '../core/types'; + +const TEXT_ALIGN = 1 / 8; +const REGION_ALIGN = 1 / 2; +const LARGE_REGION_ALIGN = 1 / 8; +const LARGE_REGION_RATIO = 3 / 4; + +export type JumpAccess = { + container: HTMLElement; + viewer: PDFViewer; + getPageDiv(pageNumber: number): HTMLElement | null; + getPageViewport(pageNumber: number): PageViewport | null; +}; + +export type ScrollDeltaInput = { + containerTop: number; + containerHeight: number; + pageTop: number; + targetY: number; + alignRatio: number; +}; + +export function computeScrollDelta(input: ScrollDeltaInput): number { + return input.pageTop + input.targetY - (input.containerTop + input.containerHeight * input.alignRatio); +} + +export async function jumpToText(access: JumpAccess, pageNumber: number, yPdf?: number): Promise { + access.viewer.scrollPageIntoView({ pageNumber }); + await nextFrame(); + if (typeof yPdf === 'number' && Number.isFinite(yPdf)) { + alignToViewportY(access, pageNumber, pdfYToViewportY(access, pageNumber, yPdf), TEXT_ALIGN); + flashTextBand(access, pageNumber, yPdf); + } +} + +export async function jumpToRegion(access: JumpAccess, pageNumber: number, rect: PdfRect): Promise { + access.viewer.scrollPageIntoView({ pageNumber }); + await nextFrame(); + const viewport = access.getPageViewport(pageNumber); + if (!viewport) return; + + const viewportRect = normalizeViewportRect(viewport.convertToViewportRectangle(normalizePdfRect(rect))); + const height = viewportRect[3] - viewportRect[1]; + const align = height > access.container.clientHeight * LARGE_REGION_RATIO ? LARGE_REGION_ALIGN : REGION_ALIGN; + const targetY = align === REGION_ALIGN ? viewportRect[1] + height / 2 : viewportRect[1]; + alignToViewportY(access, pageNumber, targetY, align); + flashRegion(access, pageNumber, viewportRect); +} + +function alignToViewportY(access: JumpAccess, pageNumber: number, targetY: number | undefined, alignRatio: number): void { + if (targetY === undefined) return; + const pageDiv = access.getPageDiv(pageNumber); + if (!pageDiv) return; + + const delta = computeScrollDelta({ + containerTop: access.container.getBoundingClientRect().top, + containerHeight: access.container.clientHeight, + pageTop: pageDiv.getBoundingClientRect().top, + targetY, + alignRatio + }); + access.container.scrollTop = clamp( + access.container.scrollTop + delta, + 0, + Math.max(0, access.container.scrollHeight - access.container.clientHeight) + ); +} + +function pdfYToViewportY(access: JumpAccess, pageNumber: number, yPdf: number): number | undefined { + const viewport = access.getPageViewport(pageNumber); + return viewport?.convertToViewportPoint(0, yPdf)[1]; +} + +function flashTextBand(access: JumpAccess, pageNumber: number, yPdf: number): void { + const pageDiv = access.getPageDiv(pageNumber); + const targetY = pdfYToViewportY(access, pageNumber, yPdf); + if (!pageDiv || targetY === undefined) return; + + const band = document.createElement('div'); + band.className = 'mgn-jump-flash mgn-jump-flash-text'; + band.style.top = `${Math.max(0, targetY - 10)}px`; + pageDiv.append(band); + removeAfterAnimation(band); +} + +function flashRegion(access: JumpAccess, pageNumber: number, rect: [number, number, number, number]): void { + const pageDiv = access.getPageDiv(pageNumber); + if (!pageDiv) return; + + const [left, top, right, bottom] = rect; + const region = document.createElement('div'); + region.className = 'mgn-jump-flash mgn-jump-flash-region'; + region.style.left = `${left}px`; + region.style.top = `${top}px`; + region.style.width = `${right - left}px`; + region.style.height = `${bottom - top}px`; + pageDiv.append(region); + removeAfterAnimation(region); +} + +function removeAfterAnimation(element: HTMLElement): void { + element.addEventListener('animationend', () => element.remove(), { once: true }); + window.setTimeout(() => element.remove(), 1600); +} + +function nextFrame(): Promise { + return new Promise((resolve) => window.requestAnimationFrame(() => resolve())); +} + +function normalizeViewportRect(rect: number[]): [number, number, number, number] { + const [x1, y1, x2, y2] = rect; + return [Math.min(x1, x2), Math.min(y1, y2), Math.max(x1, x2), Math.max(y1, y2)]; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/src/viewer/main.ts b/src/viewer/main.ts index 3f29754..2fd6dd7 100644 --- a/src/viewer/main.ts +++ b/src/viewer/main.ts @@ -1,8 +1,12 @@ import 'pdfjs-dist/web/pdf_viewer.css'; import './viewer.css'; import { createAnchorFromRange, repairAnchor } from '../core/anchor'; +import { FigExtract, toFigureEntries, type FigureSeed } from '../core/fig-engine'; +import { findCaptionAnchor, mergeFigureEntries } from '../core/figures'; import { escapeHtml, parseLinks, parseTags } from '../core/format'; +import { figureMentions, injectFigureReferenceLinks, nearestFigureMention, scanFigureReferences, updateReferenceLinkActive, type FigureReference } from '../core/mentions'; import { isFileUrl, parseViewableUrl } from '../core/pdf-url'; +import { renderRegionDataURL } from '../core/render-region'; import { DEFAULT_PEN_THEME, isPenTheme, @@ -12,18 +16,28 @@ import { type PenTheme } from '../core/pen-theme'; import { makeId, MarginStore, type DocData } from '../core/store'; -import { buildPageTextIndex, type PageTextIndex } from '../core/text-index'; -import type { Highlight, Memo, PenColor } from '../core/types'; +import { buildPageTextIndex, rangeFromOffsets, type PageTextIndex } from '../core/text-index'; +import type { FigureEntry, Highlight, Memo, PenColor, PdfRect } from '../core/types'; +import { CropMode, type CropRegion } from './crop-mode'; +import { jumpToRegion, jumpToText, type JumpAccess } from './jump'; import { HighlightOverlay } from './overlay-highlights'; -import { FiguresTab } from './panel/tab-figures'; +import { FiguresTab, type CropPreviewState } from './panel/tab-figures'; import { MemoTab } from './panel/tab-memos'; -import { type FlatOutlineItem, PdfHost } from './pdf-host'; +import { type FlatOutlineItem, PdfHost, type ResolvedPdfDestination } from './pdf-host'; const PANEL_WIDTH_KEY = 'margin:panelWidth'; const PANEL_MIN_WIDTH = 264; const PANEL_MAX_WIDTH = 560; const VIEWER_MIN_WIDTH = 360; const PANEL_STEP = 16; +const DEST_MATCH_TOLERANCE_PT = 40; +const ANNOTATION_ORIGIN_TTL_MS = 3000; + +type TextScanIndex = { + page: number; + text: string; + segments: Array<{ start: number; end: number; yPdf?: number }>; +}; function readFileParam(): string | null { const search = location.search.startsWith('?') ? location.search.slice(1) : location.search; @@ -236,10 +250,20 @@ async function initializeDoc(titleFallback: string, url?: string): Promise const meta = await host.getDocMeta(titleFallback, url); docData = await store.loadDoc(meta); pageIndexes.clear(); + pageScanIndexes.clear(); + figureReferences = []; + captionYByFigure.clear(); + activeFigureId = null; + activeOriginRefKey = null; + pendingCrop = null; lostHighlights.clear(); buildRenderedPageIndexes(); syncAnnotationViews(); repairRenderedPages(); + figuresTab.setDocument(docData.figures); + cropMode.cancel(false); + void refreshFigureReferences(); + figuresTab.ensureScanned(); } function markTocForPage(page: number): void { @@ -255,7 +279,7 @@ function markTocForPage(page: number): void { async function loadUrl(file: string): Promise { setLoading(file); - figuresTab.setDocument(null); + figuresTab.setDocument([]); if (fileLabel) fileLabel.textContent = basenameFromUrl(file); const isLocalFile = isFileSchemeUrl(file); if (isLocalFile && !(await canReadFileSchemeUrls())) { @@ -263,8 +287,7 @@ async function loadUrl(file: string): Promise { return; } try { - const doc = await host.loadUrl(file); - figuresTab.setDocument(doc); + await host.loadUrl(file); await initializeDoc(basenameFromUrl(file), file); if (fileLabel && docData) fileLabel.textContent = docData.meta.title; downloadName = pdfDownloadName(basenameFromUrl(file)); @@ -274,7 +297,7 @@ async function loadUrl(file: string): Promise { setPageUi(host.currentPage, host.pageCount); renderToc(await host.getOutlineItems()); } catch (error) { - figuresTab.setDocument(null); + figuresTab.setDocument([]); if (isLocalFile && isMissingPdfError(error)) { showMissingFileState(file); return; @@ -285,11 +308,10 @@ async function loadUrl(file: string): Promise { async function loadSelectedFile(file: File): Promise { setLoading(file.name); - figuresTab.setDocument(null); + figuresTab.setDocument([]); if (fileLabel) fileLabel.textContent = file.name; try { - const doc = await host.loadFile(file); - figuresTab.setDocument(doc); + await host.loadFile(file); await initializeDoc(file.name); if (fileLabel && docData) fileLabel.textContent = docData.meta.title; downloadName = pdfDownloadName(file.name); @@ -299,7 +321,7 @@ async function loadSelectedFile(file: File): Promise { setPageUi(host.currentPage, host.pageCount); renderToc(await host.getOutlineItems()); } catch (error) { - figuresTab.setDocument(null); + figuresTab.setDocument([]); setError(error); } } @@ -326,6 +348,75 @@ function getOrBuildPageIndex(pageNumber: number): PageTextIndex | null { return index; } +function getJumpAccess(): JumpAccess { + return { + container: viewerContainer, + viewer: host.viewer, + getPageDiv: (pageNumber) => host.getPageDiv(pageNumber), + getPageViewport: (pageNumber) => host.getPageViewport(pageNumber) + }; +} + +async function getTextScanIndex(pageNumber: number): Promise { + const existing = pageScanIndexes.get(pageNumber); + if (existing) return existing; + const pdfDocument = host.pdfDocument; + if (!pdfDocument) { + const rendered = pageIndexes.get(pageNumber); + if (!rendered) return null; + const scanIndex: TextScanIndex = { page: rendered.page, text: rendered.text, segments: [] }; + pageScanIndexes.set(pageNumber, scanIndex); + return scanIndex; + } + + const page = await pdfDocument.getPage(pageNumber); + const textContent = await page.getTextContent(); + const segments: TextScanIndex['segments'] = []; + let text = ''; + for (const item of textContent.items as Array<{ str?: string; transform?: unknown[] }>) { + const str = item.str ?? ''; + const start = text.length; + text += str; + const yPdf = Array.isArray(item.transform) && typeof item.transform[5] === 'number' + ? item.transform[5] + : undefined; + segments.push({ start, end: text.length, yPdf }); + } + const scanIndex: TextScanIndex = { page: pageNumber, text, segments }; + pageScanIndexes.set(pageNumber, scanIndex); + return scanIndex; +} + +function yForScanOffset(index: TextScanIndex, offset: number): number | undefined { + return index.segments.find((segment) => offset >= segment.start && offset < segment.end)?.yPdf; +} + +function yForRenderedOffset(index: PageTextIndex, pageDiv: HTMLElement, offset: number): number | undefined { + const viewport = host.getPageViewport(index.page); + if (!viewport) return undefined; + const end = Math.min(index.text.length, Math.max(offset + 1, offset)); + const range = rangeFromOffsets(index, offset, end); + if (!range) return undefined; + const rect = range.getClientRects()[0]; + range.detach(); + if (!rect) return undefined; + const pageBounds = pageDiv.getBoundingClientRect(); + return viewport.convertToPdfPoint(rect.left - pageBounds.left, rect.top - pageBounds.top)[1]; +} + +function replacePageReferences(pageNumber: number, refs: FigureReference[]): void { + figureReferences = [ + ...figureReferences.filter((reference) => reference.page !== pageNumber), + ...refs + ].sort((a, b) => a.page - b.page || a.start - b.start); + updateFigureTabData(); +} + +function updateFigureTabData(): void { + if (!docData) return; + figuresTab.setData(docData.figures, figureMentions(figureReferences)); +} + function buildRenderedPageIndexes(): void { for (let pageNumber = 1; pageNumber <= host.pageCount; pageNumber += 1) { const pageDiv = host.getPageDiv(pageNumber); @@ -340,10 +431,47 @@ function handleTextLayerRendered(pageNumber: number): void { if (!pageDiv) return; const index = buildPageTextIndex(pageNumber, pageDiv); pageIndexes.set(pageNumber, index); + pageScanIndexes.delete(pageNumber); + updateRenderedFigureReferences(pageNumber, pageDiv, index); repairPageAnchors(pageNumber); syncAnnotationViews(); } +function updateRenderedFigureReferences(pageNumber: number, pageDiv: HTMLElement, index: PageTextIndex): void { + if (!docData?.figures.length) return; + const refs = scanFigureReferences( + index, + docData.figures, + (offset) => yForRenderedOffset(index, pageDiv, offset) + ); + for (const reference of refs) { + if (reference.isCaptionLabel && reference.yPdf !== undefined) { + captionYByFigure.set(reference.figId, reference.yPdf); + } + } + replacePageReferences(pageNumber, refs); + injectFigureReferencesForPage(pageNumber); +} + +function injectFigureReferencesForPage(pageNumber: number): void { + if (!docData?.figures.length) return; + const pageDiv = host.getPageDiv(pageNumber); + const index = pageIndexes.get(pageNumber); + if (!pageDiv || !index) return; + injectFigureReferenceLinks( + pageDiv, + index, + figureReferences.filter((reference) => reference.page === pageNumber), + activeFigureId + ); +} + +function injectFigureReferencesIntoRenderedPages(): void { + for (const pageNumber of pageIndexes.keys()) { + injectFigureReferencesForPage(pageNumber); + } +} + function repairRenderedPages(): void { for (const pageNumber of pageIndexes.keys()) { repairPageAnchors(pageNumber); @@ -375,6 +503,81 @@ function repairPageAnchors(pageNumber: number): void { if (changed) scheduleDocSave(); } +async function scanFigures(setStatus: (text: string) => void): Promise { + const data = docData; + if (!data || !host.pdfDocument) return []; + const result = await FigExtract.extract(null, { + pdfDocument: host.pdfDocument, + onProgress: setStatus + }); + const seeds = toFigureEntries(result, pageHeightPt); + const incoming: FigureEntry[] = []; + for (const seed of seeds) { + if (docData !== data) return data.figures; + incoming.push(await completeFigureEntry(seed)); + } + if (docData !== data) return data.figures; + docData.figures = mergeFigureEntries(docData.figures, incoming); + scheduleDocSave(); + await refreshFigureReferences(); + return docData.figures; +} + +async function completeFigureEntry(seed: FigureSeed): Promise { + if (!docData) throw new Error('Document data is not loaded.'); + const scanIndex = await getTextScanIndex(seed.page); + const captionAnchor = scanIndex + ? findCaptionAnchor(seed.page, scanIndex.text, seed.captionText) + : undefined; + if (captionAnchor && scanIndex) { + const captionY = yForScanOffset(scanIndex, captionAnchor.start); + if (captionY !== undefined) captionYByFigure.set(seed.id, captionY); + } + return { + ...seed, + doc: docData.meta.id, + captionAnchor + }; +} + +function pageHeightPt(pageNumber: number): number { + const viewport = host.getPageViewport(pageNumber); + const scale = ((viewport as { scale?: number } | null)?.scale ?? host.viewer.currentScale) || 1; + return viewport ? viewport.height / scale : 792; +} + +async function refreshFigureReferences(): Promise { + if (!docData?.figures.length) { + figureReferences = []; + captionYByFigure.clear(); + updateFigureTabData(); + return; + } + + const refs: FigureReference[] = []; + captionYByFigure.clear(); + for (const figure of docData.figures) { + if (!figure.captionAnchor) continue; + const scanIndex = await getTextScanIndex(figure.captionAnchor.page); + const captionY = scanIndex ? yForScanOffset(scanIndex, figure.captionAnchor.start) : undefined; + if (captionY !== undefined) captionYByFigure.set(figure.id, captionY); + } + + for (let pageNumber = 1; pageNumber <= host.pageCount; pageNumber += 1) { + const scanIndex = await getTextScanIndex(pageNumber); + if (!scanIndex) continue; + refs.push(...scanFigureReferences( + scanIndex, + docData.figures, + (offset) => yForScanOffset(scanIndex, offset) + )); + } + + figureReferences = refs.sort((a, b) => a.page - b.page || a.start - b.start); + updateFigureTabData(); + injectFigureReferencesIntoRenderedPages(); +} + function handleSelection(): void { if (!docData) return; const selection = window.getSelection(); @@ -492,14 +695,194 @@ function jumpToHighlight(highlightId: string): void { if (!docData) return; const highlight = docData.highlights.find((candidate) => candidate.id === highlightId); if (!highlight) return; - host.viewer.scrollPageIntoView({ pageNumber: highlight.anchor.page }); - window.setTimeout(() => { + const yPdf = highlight.anchor.quads[0] + ? Math.max(highlight.anchor.quads[0][1], highlight.anchor.quads[0][3]) + : undefined; + void jumpToText(getJumpAccess(), highlight.anchor.page, yPdf).then(() => { const el = overlay.getFirstElement(highlightId); if (el) flashElement(el); - }, 100); + }); if (!pinned) closePanel(); } +function openFigurePanel(figId: string, originRefKey: string | null = null): void { + if (!docData?.figures.some((figure) => figure.id === figId)) return; + activeFigureId = figId; + activeOriginRefKey = originRefKey; + openPanel('figures'); + figuresTab.focusFigure(figId, activeOriginRefKey); + for (const pageNumber of pageIndexes.keys()) { + const pageDiv = host.getPageDiv(pageNumber); + if (pageDiv) updateReferenceLinkActive(pageDiv, activeFigureId); + } +} + +function handleFigureReferenceClick(event: MouseEvent): void { + const link = (event.target as Element).closest('a.mgn-ref[data-fig]'); + if (!link || !viewerContainer.contains(link)) return; + event.preventDefault(); + event.stopPropagation(); + const figId = link.dataset.fig; + if (!figId) return; + openFigurePanel(figId, link.dataset.cap === '1' ? null : link.dataset.refKey ?? null); +} + +function rememberAnnotationLinkOrigin(event: MouseEvent): void { + const link = (event.target as Element).closest('.annotationLayer a'); + if (!link || !viewerContainer.contains(link)) return; + const pageDiv = link.closest('.page'); + const pageNumber = Number(pageDiv?.dataset.pageNumber); + if (!pageDiv || !Number.isInteger(pageNumber) || pageNumber < 1) return; + const viewport = host.getPageViewport(pageNumber); + if (!viewport) return; + const pageBounds = pageDiv.getBoundingClientRect(); + const yPdf = viewport.convertToPdfPoint( + event.clientX - pageBounds.left, + event.clientY - pageBounds.top + )[1]; + annotationLinkOrigin = { page: pageNumber, yPdf, at: Date.now() }; +} + +function takeAnnotationLinkOrigin(): { page: number; yPdf: number } | null { + const origin = annotationLinkOrigin; + annotationLinkOrigin = null; + if (!origin || Date.now() - origin.at > ANNOTATION_ORIGIN_TTL_MS) return null; + return { page: origin.page, yPdf: origin.yPdf }; +} + +function handleInternalDestination(destination: ResolvedPdfDestination): boolean { + const origin = takeAnnotationLinkOrigin(); + const figure = findFigureForDestination(destination); + if (!figure) return false; + const originMention = origin + ? nearestFigureMention(figureReferences, figure.id, origin.page, origin.yPdf) + : null; + openFigurePanel(figure.id, originMention?.key ?? null); + return true; +} + +function findFigureForDestination(destination: ResolvedPdfDestination): FigureEntry | null { + if (!docData?.figures.length) return null; + const samePage = docData.figures.filter((figure) => figure.page === destination.pageNumber); + if (!samePage.length) return null; + + const y = destination.yPdf; + const x = destination.xPdf; + if (y !== undefined) { + const byRegion = samePage.find((figure) => { + if (!figure.region || figure.region.page !== destination.pageNumber) return false; + const [x1, y1, x2, y2] = normalizeRect(figure.region.rect); + const yMatches = y >= y1 - DEST_MATCH_TOLERANCE_PT && y <= y2 + DEST_MATCH_TOLERANCE_PT; + const xMatches = x === undefined || (x >= x1 - DEST_MATCH_TOLERANCE_PT && x <= x2 + DEST_MATCH_TOLERANCE_PT); + return yMatches && xMatches; + }); + if (byRegion) return byRegion; + + const byCaption = samePage.find((figure) => { + const captionY = captionYByFigure.get(figure.id); + return captionY !== undefined && Math.abs(captionY - y) <= DEST_MATCH_TOLERANCE_PT; + }); + if (byCaption) return byCaption; + } + + return samePage.length === 1 ? samePage[0] : null; +} + +function jumpToFigure(figId: string): void { + if (!docData) return; + const figure = docData.figures.find((candidate) => candidate.id === figId); + if (!figure) return; + if (activeFigureId !== figId) activeOriginRefKey = null; + activeFigureId = figId; + figuresTab.focusFigure(figId, activeOriginRefKey); + if (figure.region) { + void jumpToRegion(getJumpAccess(), figure.region.page, figure.region.rect); + } else if (figure.captionAnchor) { + const yPdf = captionYByFigure.get(figure.id); + void jumpToText(getJumpAccess(), figure.captionAnchor.page, yPdf); + } else { + host.setPage(figure.page); + } +} + +function jumpToFigureMention(refKey: string): void { + const mention = figureReferences.find((reference) => reference.key === refKey); + if (!mention) return; + void jumpToText(getJumpAccess(), mention.page, mention.yPdf); + if (!pinned) closePanel(); +} + +async function jumpToOutlineItem(item: FlatOutlineItem): Promise { + if (item.url) { + await host.jumpToOutline(item); + return; + } + const resolved = item.dest ? await host.resolveDestination(item.dest as string | unknown[]) : null; + if (resolved?.yPdf !== undefined) { + await jumpToText(getJumpAccess(), resolved.pageNumber, resolved.yPdf); + } else { + await host.jumpToOutline(item); + } +} + +async function renderFigureRegion(region: { page: number; rect: PdfRect }): Promise { + return host.pdfDocument ? renderRegionDataURL(host.pdfDocument, region.page, region.rect) : null; +} + +function startFigureCrop(figId: string): void { + if (!docData) return; + const figure = docData.figures.find((candidate) => candidate.id === figId); + if (!figure) return; + pendingCrop = null; + figuresTab.setCropPreview(null); + openFigurePanel(figId); + void cropMode.start(figure); +} + +function saveFigureCrop(): void { + if (!docData || !pendingCrop) return; + const figure = docData.figures.find((candidate) => candidate.id === pendingCrop?.figId); + if (!figure) return; + figure.page = pendingCrop.region.page; + figure.region = pendingCrop.region; + figure.regionSource = 'manual'; + figure.confidence = 1; + cropMode.accept(); + pendingCrop = null; + figuresTab.setCropPreview(null); + scheduleDocSave(); + updateFigureTabData(); +} + +function redoFigureCrop(figId: string): void { + pendingCrop = null; + figuresTab.setCropPreview(null); + startFigureCrop(figId); +} + +function cancelFigureCrop(): void { + cropMode.cancel(false); + pendingCrop = null; + figuresTab.setCropPreview(null); +} + +function handleCropPreview(region: CropRegion): void { + const figId = cropMode.figId; + if (!figId) return; + pendingCrop = { figId, region }; + figuresTab.setCropPreview(pendingCrop); +} + +function handleCropCancel(): void { + pendingCrop = null; + figuresTab.setCropPreview(null); +} + +function normalizeRect(rect: PdfRect): PdfRect { + const [x1, y1, x2, y2] = rect; + return [Math.min(x1, x2), Math.min(y1, y2), Math.max(x1, x2), Math.max(y1, y2)]; +} + function isFileDrag(event: DragEvent): boolean { return Array.from(event.dataTransfer?.types ?? []).includes('Files'); } @@ -714,7 +1097,14 @@ let currentPen: PenColor = 'amber'; let docData: DocData | null = null; const store = new MarginStore(); const pageIndexes = new Map(); +const pageScanIndexes = new Map(); const lostHighlights = new Set(); +let figureReferences: FigureReference[] = []; +const captionYByFigure = new Map(); +let activeFigureId: string | null = null; +let activeOriginRefKey: string | null = null; +let annotationLinkOrigin: { page: number; yPdf: number; at: number } | null = null; +let pendingCrop: CropPreviewState | null = null; function setActivePen(color: PenColor): void { currentPen = color; @@ -747,7 +1137,19 @@ const host = new PdfHost( { container: viewerContainer, viewer: viewerElement }, { onPageChange: setPageUi, - onScaleChange: setScaleUi + onScaleChange: setScaleUi, + onInternalDestination: handleInternalDestination + } +); + +const cropMode = new CropMode( + { + ...getJumpAccess(), + pageCount: () => host.pageCount + }, + { + onPreview: handleCropPreview, + onCancel: handleCropCancel } ); @@ -831,6 +1233,8 @@ fileInput.addEventListener('change', () => { viewerContainer.addEventListener('mouseup', () => { window.setTimeout(handleSelection, 0); }); +viewerContainer.addEventListener('click', rememberAnnotationLinkOrigin, true); +viewerContainer.addEventListener('click', handleFigureReferenceClick); prevPage.addEventListener('click', () => host.previousPage()); nextPage.addEventListener('click', () => host.nextPage()); @@ -843,10 +1247,14 @@ pageNumberInput.addEventListener('change', () => { }); const figuresTab = new FiguresTab(figList, { - onJumpToPage: (page) => { - host.setPage(page); - if (!pinned) closePanel(); - } + onScan: scanFigures, + onJumpFigure: jumpToFigure, + onJumpMention: jumpToFigureMention, + onStartCrop: startFigureCrop, + onSaveCrop: saveFigureCrop, + onRedoCrop: redoFigureCrop, + onCancelCrop: cancelFigureCrop, + renderRegion: renderFigureRegion }); for (const button of document.querySelectorAll('.ptab')) { @@ -886,7 +1294,7 @@ tocList.addEventListener('click', (event) => { if (!row) return; const item = outlineItems.find((candidate) => candidate.id === row.dataset.id); if (!item) return; - void host.jumpToOutline(item).then(() => { + void jumpToOutlineItem(item).then(() => { if (!pinned) closePanel(); }); }); diff --git a/src/viewer/panel/tab-figures.ts b/src/viewer/panel/tab-figures.ts index 884f431..f0ea614 100644 --- a/src/viewer/panel/tab-figures.ts +++ b/src/viewer/panel/tab-figures.ts @@ -1,85 +1,154 @@ -import type { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api'; -import { FigExtract, type EngineFigure, type FigExtractApi } from '../../core/fig-engine'; +import type { FigureReference } from '../../core/mentions'; +import type { FigureEntry, PdfRect } from '../../core/types'; -export type FiguresTabCallbacks = { - onJumpToPage: (page: number) => void; +type Region = { page: number; rect: PdfRect }; + +export type CropPreviewState = { + figId: string; + region: Region; }; -type FiguresTabEngine = Pick; +export type FiguresTabCallbacks = { + onScan: (setStatus: (text: string) => void) => Promise; + onJumpFigure: (figId: string) => void; + onJumpMention: (refKey: string) => void; + onStartCrop: (figId: string) => void; + onSaveCrop: () => void; + onRedoCrop: (figId: string) => void; + onCancelCrop: () => void; + renderRegion: (region: Region) => Promise; +}; /** - * 그림·표 탭 — fig-extract 엔진으로 문서를 스캔해 figure 프리뷰 카드를 렌더한다. - * 스캔은 PDFDocumentProxy가 준비되는 즉시 1회 실행한다. 문서가 바뀌면 setDocument()로 리셋. + * 그림·표 탭 — 저장된 FigureEntry를 렌더하고, 없으면 fig-extract 스캔을 요청한다. + * 스캔은 문서 로드 직후 자동 시작한다(탭 클릭 시 재호출돼도 1회, 실패 상태에서는 재시도). + * 문서가 바뀌면 setDocument()로 리셋. */ export class FiguresTab { #list: HTMLElement; #callbacks: FiguresTabCallbacks; - #engine: FiguresTabEngine; - #doc: PDFDocumentProxy | null = null; #state: 'idle' | 'scanning' | 'done' | 'error' = 'idle'; - #figures: EngineFigure[] = []; + #figures: FigureEntry[] = []; + #mentions: FigureReference[] = []; + #activeFigId: string | null = null; + #originRefKey: string | null = null; + #cropPreview: CropPreviewState | null = null; + #imageUrls = new Map(); + #renderGeneration = 0; #scanGeneration = 0; - constructor( - list: HTMLElement, - callbacks: FiguresTabCallbacks, - engine: FiguresTabEngine = FigExtract - ) { + constructor(list: HTMLElement, callbacks: FiguresTabCallbacks) { this.#list = list; this.#callbacks = callbacks; - this.#engine = engine; - this.#list.addEventListener('click', (event) => { - const target = event.target as Element | null; - if (target?.closest('.fig-retry')) { - this.ensureScanned(); - return; - } - const card = target?.closest('.fig-card'); - if (!card) return; - const page = Number(card.dataset.page); - if (page) this.#callbacks.onJumpToPage(page); - }); + this.#list.addEventListener('click', (event) => this.#handleClick(event)); + this.#list.addEventListener('keydown', (event) => this.#handleKeyDown(event)); } - setDocument(doc: PDFDocumentProxy | null): void { + setDocument(figures: FigureEntry[]): void { this.#scanGeneration += 1; - this.#doc = doc; - this.#state = 'idle'; - this.#figures = []; - this.#setStatus(doc ? 'figure 스캔 준비 중…' : 'PDF를 열면 그림·표를 자동으로 스캔합니다.'); - this.ensureScanned(); + this.#figures = figures; + this.#mentions = []; + this.#activeFigId = null; + this.#originRefKey = null; + this.#cropPreview = null; + this.#imageUrls.clear(); + this.#state = figures.length ? 'done' : 'idle'; + this.#render(); + } + + setData(figures: FigureEntry[], mentions: FigureReference[]): void { + this.#figures = figures; + this.#mentions = mentions; + if (figures.length && this.#state !== 'scanning') this.#state = 'done'; + this.#render(); + } + + setCropPreview(preview: CropPreviewState | null): void { + this.#cropPreview = preview; + this.#render(); } - /** 문서 로드 직후 호출된다. 실패 상태에서는 같은 문서 스캔을 다시 시도할 수 있다. */ + focusFigure(figId: string, originRefKey: string | null = null): void { + this.#activeFigId = figId; + this.#originRefKey = originRefKey; + this.#render(); + window.requestAnimationFrame(() => { + const card = this.#list.querySelector(`.fig-card[data-fig="${CSS.escape(figId)}"]`); + card?.scrollIntoView({ block: 'nearest' }); + }); + } + + /** 문서 로드 직후 호출 — 저장 데이터가 없으면 최초 1회 스캔. 실패 상태에서는 재시도. */ ensureScanned(): void { - if ((this.#state !== 'idle' && this.#state !== 'error') || !this.#doc) return; + if (this.#state === 'scanning' || this.#figures.length) return; this.#state = 'scanning'; - void this.#scan(this.#scanGeneration); + const generation = this.#scanGeneration; + this.#setStatus('figure 스캔 중...'); + void this.#callbacks.onScan((msg) => { + if (generation === this.#scanGeneration) this.#setStatus(msg); + }) + .then((figures) => { + if (generation !== this.#scanGeneration) return; + this.#figures = figures; + this.#state = 'done'; + this.#render(); + }) + .catch((error) => { + if (generation !== this.#scanGeneration) return; + console.error('figure 스캔 실패', error); + this.#state = 'error'; + this.#setStatus('figure 스캔에 실패했어요.', true); + }); } - async #scan(scanGeneration: number): Promise { - const doc = this.#doc; - if (!doc) return; - this.#setStatus('figure 스캔 중…'); - try { - const result = await this.#engine.extract(null, { - pdfDocument: doc, - onProgress: (msg) => { - if (this.#scanGeneration === scanGeneration) this.#setStatus(msg); - } - }); - if (this.#scanGeneration !== scanGeneration) return; - this.#figures = result.figures; - this.#state = 'done'; - this.#render(); - } catch (error) { - if (this.#scanGeneration !== scanGeneration) return; - console.error('figure 스캔 실패', error); - this.#state = 'error'; - this.#setStatus('figure 스캔에 실패했어요.', true); + #handleClick(event: Event): void { + const target = event.target as Element; + const action = target.closest('[data-action]'); + if (!action) return; + event.preventDefault(); + event.stopPropagation(); + + const figId = action.dataset.fig; + switch (action.dataset.action) { + case 'jump': + if (figId) this.#callbacks.onJumpFigure(figId); + break; + case 'mention': + if (action.dataset.refKey) this.#callbacks.onJumpMention(action.dataset.refKey); + break; + case 'crop': + if (figId) this.#callbacks.onStartCrop(figId); + break; + case 'save-crop': + this.#callbacks.onSaveCrop(); + break; + case 'redo-crop': + if (figId) this.#callbacks.onRedoCrop(figId); + break; + case 'cancel-crop': + this.#callbacks.onCancelCrop(); + break; + case 'retry': + this.ensureScanned(); + break; + case 'rescan': + this.#scanGeneration += 1; + this.#state = 'idle'; + this.#figures = []; + this.#mentions = []; + this.ensureScanned(); + break; } } + #handleKeyDown(event: KeyboardEvent): void { + const target = event.target as Element; + const action = target.closest('[role="button"][data-action]'); + if (!action || (event.key !== 'Enter' && event.key !== ' ')) return; + event.preventDefault(); + action.click(); + } + #setStatus(text: string, retry = false): void { const state = document.createElement('div'); state.className = 'empty'; @@ -88,6 +157,7 @@ export class FiguresTab { const button = document.createElement('button'); button.className = 'lnkbtn fig-retry'; button.type = 'button'; + button.dataset.action = 'retry'; button.textContent = '다시 시도'; state.append(button); } @@ -95,32 +165,180 @@ export class FiguresTab { } #render(): void { + this.#renderGeneration += 1; + const generation = this.#renderGeneration; + if (this.#state === 'idle') { + this.#setStatus('PDF를 열면 그림·표를 자동으로 스캔합니다.'); + return; + } if (!this.#figures.length) { + if (this.#state === 'scanning') return; + if (this.#state === 'error') return; this.#setStatus('감지된 figure가 없어요. (스캔 PDF이거나 캡션 형식 미지원일 수 있어요)'); return; } - this.#list.replaceChildren(); + + this.#list.innerHTML = ''; + this.#list.classList.add('fig-list'); for (const fig of this.#figures) { - const card = document.createElement('button'); - card.className = 'fig-card'; - card.type = 'button'; - card.dataset.page = String(fig.page); - card.setAttribute('aria-label', `Figure ${fig.num}, ${fig.page}페이지로 이동`); - const img = document.createElement('img'); - img.src = this.#engine.cropDataURL(fig); - img.alt = `Figure ${fig.num}`; - const head = document.createElement('span'); - head.className = 'fig-head'; - const label = document.createElement('b'); - label.textContent = `Figure ${fig.num}`; - const page = document.createElement('span'); - page.textContent = `p.${fig.page}`; - head.append(label, page); - const caption = document.createElement('span'); - caption.className = 'fig-cap'; - caption.textContent = fig.caption; - card.append(img, head, caption); - this.#list.append(card); + this.#list.append(this.#renderCard(fig, generation)); + } + + const rescan = document.createElement('button'); + rescan.type = 'button'; + rescan.className = 'lnkbtn fig-rescan'; + rescan.dataset.action = 'rescan'; + rescan.textContent = '다시 스캔'; + this.#list.append(rescan); + } + + #renderCard(fig: FigureEntry, generation: number): HTMLElement { + const card = document.createElement('article'); + card.className = `fig-card${fig.id === this.#activeFigId ? ' on' : ''}`; + card.dataset.fig = fig.id; + + const preview = document.createElement('div'); + preview.className = 'fig-preview'; + preview.dataset.action = 'jump'; + preview.dataset.fig = fig.id; + preview.role = 'button'; + preview.tabIndex = 0; + preview.title = '본문 위치로 이동'; + this.#appendRegionImage(preview, fig.region, fig.label, generation, fig.id); + + const crop = document.createElement('button'); + crop.type = 'button'; + crop.className = 'fig-crop'; + crop.dataset.action = 'crop'; + crop.dataset.fig = fig.id; + crop.title = '영역 지정'; + crop.setAttribute('aria-label', '영역 지정'); + crop.textContent = '⌗'; + preview.append(crop); + + const head = document.createElement('div'); + head.className = 'fig-head'; + const label = document.createElement('button'); + label.type = 'button'; + label.className = 'fig-label'; + label.dataset.action = 'jump'; + label.dataset.fig = fig.id; + label.textContent = fig.label; + const page = document.createElement('span'); + page.textContent = `p.${fig.page}`; + head.append(label, page); + + const caption = document.createElement('p'); + caption.className = 'fig-cap'; + caption.textContent = fig.captionText; + + card.append(preview, head, caption); + const cropControls = this.#renderCropControls(fig, generation); + if (cropControls) card.append(cropControls); + + const mentions = this.#mentionsForFigure(fig.id); + if (mentions.length) card.append(this.#renderMentions(mentions)); + return card; + } + + #appendRegionImage( + parent: HTMLElement, + region: Region | null, + alt: string, + generation: number, + cachePrefix: string + ): void { + if (!region) { + const empty = document.createElement('div'); + empty.className = 'fig-img-empty'; + empty.textContent = '영역 없음'; + parent.append(empty); + return; } + + const key = `${cachePrefix}:${region.page}:${region.rect.join(',')}`; + const img = document.createElement('img'); + img.alt = alt; + img.decoding = 'async'; + const cached = this.#imageUrls.get(key); + if (cached) { + img.src = cached; + parent.append(img); + return; + } + + const loading = document.createElement('div'); + loading.className = 'fig-img-empty'; + loading.textContent = '렌더 중...'; + parent.append(loading); + void this.#callbacks.renderRegion(region).then((url) => { + if (!url || generation !== this.#renderGeneration) return; + this.#imageUrls.set(key, url); + img.src = url; + loading.replaceWith(img); + }); } + + #renderCropControls(fig: FigureEntry, generation: number): HTMLElement | null { + if (this.#cropPreview?.figId !== fig.id) return null; + + const controls = document.createElement('div'); + controls.className = 'fig-crop-panel'; + const preview = document.createElement('div'); + preview.className = 'fig-crop-preview'; + this.#appendRegionImage(preview, this.#cropPreview.region, '새 영역 미리보기', generation, `crop-${fig.id}`); + + const row = document.createElement('div'); + row.className = 'fig-crop-actions'; + row.append( + actionButton('save-crop', '저장'), + actionButton('redo-crop', '다시 지정', fig.id), + actionButton('cancel-crop', '취소') + ); + controls.append(preview, row); + return controls; + } + + #mentionsForFigure(figId: string): FigureReference[] { + const mentions = this.#mentions.filter((mention) => mention.figId === figId); + if (!this.#originRefKey) return mentions; + return mentions.slice().sort((a, b) => { + if (a.key === this.#originRefKey) return -1; + if (b.key === this.#originRefKey) return 1; + return a.page - b.page || a.start - b.start; + }); + } + + #renderMentions(mentions: FigureReference[]): HTMLElement { + const wrap = document.createElement('div'); + wrap.className = 'fig-mentions'; + const label = document.createElement('div'); + label.className = 'fig-mentions-title'; + label.textContent = `본문 언급 ${mentions.length}곳`; + wrap.append(label); + + const chips = document.createElement('div'); + chips.className = 'fig-mention-list'; + for (const mention of mentions) { + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = `fig-mention${mention.key === this.#originRefKey ? ' origin' : ''}`; + chip.dataset.action = 'mention'; + chip.dataset.refKey = mention.key; + chip.textContent = `${mention.key === this.#originRefKey ? '↩ ' : ''}p.${mention.page} ${mention.quote}`; + chips.append(chip); + } + wrap.append(chips); + return wrap; + } +} + +function actionButton(action: string, text: string, figId?: string): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = action === 'save-crop' ? 'btn pri' : 'btn'; + button.dataset.action = action; + if (figId) button.dataset.fig = figId; + button.textContent = text; + return button; } diff --git a/src/viewer/pdf-host.ts b/src/viewer/pdf-host.ts index 8b47da4..145ac75 100644 --- a/src/viewer/pdf-host.ts +++ b/src/viewer/pdf-host.ts @@ -32,6 +32,13 @@ export type FlatOutlineItem = { page: number | null; }; +export type ResolvedPdfDestination = { + pageNumber: number; + destArray: unknown[]; + xPdf?: number; + yPdf?: number; +}; + type PdfHostElements = { container: HTMLDivElement; viewer: HTMLDivElement; @@ -40,8 +47,27 @@ type PdfHostElements = { type PdfHostCallbacks = { onPageChange?: (page: number, pageCount: number) => void; onScaleChange?: (scale: number, presetValue?: string) => void; + onInternalDestination?: (destination: ResolvedPdfDestination) => boolean; }; +class MarginLinkService extends PDFLinkService { + #onInternalDestination?: (destination: ResolvedPdfDestination) => boolean; + + constructor( + options: ConstructorParameters[0], + onInternalDestination?: (destination: ResolvedPdfDestination) => boolean + ) { + super(options); + this.#onInternalDestination = onInternalDestination; + } + + override async goToDestination(dest: string | unknown[]): Promise { + const resolved = await resolvePdfDestination(this.pdfDocument as PDFDocumentProxy | null, dest); + if (resolved && this.#onInternalDestination?.(resolved)) return; + await super.goToDestination(dest); + } +} + export class PdfHost { readonly eventBus: EventBus; readonly linkService: PDFLinkService; @@ -54,11 +80,14 @@ export class PdfHost { constructor(elements: PdfHostElements, callbacks: PdfHostCallbacks = {}) { this.#callbacks = callbacks; this.eventBus = new EventBus(); - this.linkService = new PDFLinkService({ - eventBus: this.eventBus, - externalLinkTarget: LinkTarget.BLANK, - ignoreDestinationZoom: true - }); + this.linkService = new MarginLinkService( + { + eventBus: this.eventBus, + externalLinkTarget: LinkTarget.BLANK, + ignoreDestinationZoom: true + }, + callbacks.onInternalDestination + ); this.viewer = new PDFViewer({ container: elements.container, viewer: elements.viewer, @@ -241,6 +270,10 @@ export class PdfHost { } } + async resolveDestination(dest: string | unknown[]): Promise { + return resolvePdfDestination(this.#doc, dest); + } + #setDocument(doc: PDFDocumentProxy, url?: string): void { this.#doc = doc; const linkService = this.linkService as PDFLinkService & { @@ -253,18 +286,7 @@ export class PdfHost { async #resolveDestPage(dest: string | unknown[] | null): Promise { if (!this.#doc || !dest) return null; - try { - const destArray = typeof dest === 'string' ? await this.#doc.getDestination(dest) : dest; - if (!destArray?.length) return null; - const first = destArray[0]; - if (typeof first === 'number') return first + 1; - if (first && typeof first === 'object' && 'num' in first && 'gen' in first) { - return (await this.#doc.getPageIndex(first as { num: number; gen: number })) + 1; - } - return null; - } catch { - return null; - } + return (await resolvePdfDestination(this.#doc, dest))?.pageNumber ?? null; } #emitPageChange(): void { @@ -275,3 +297,66 @@ export class PdfHost { this.#callbacks.onScaleChange?.(this.viewer.currentScale, presetValue); } } + +export async function resolvePdfDestination( + pdfDocument: PDFDocumentProxy | null, + dest: string | unknown[] +): Promise { + if (!pdfDocument) return null; + try { + const destArray = typeof dest === 'string' ? await pdfDocument.getDestination(dest) : await dest; + if (!Array.isArray(destArray) || !destArray.length) return null; + + const destRef = destArray[0]; + let pageNumber: number | null = null; + if (typeof destRef === 'number' && Number.isInteger(destRef)) { + pageNumber = destRef + 1; + } else if (destRef && typeof destRef === 'object') { + const cachedPageNumber = (pdfDocument as PDFDocumentProxy & { + cachedPageNumber?: (ref: unknown) => number | null; + }).cachedPageNumber?.(destRef); + pageNumber = cachedPageNumber ?? (await pdfDocument.getPageIndex(destRef as { num: number; gen: number })) + 1; + } + if (!pageNumber || pageNumber < 1 || pageNumber > pdfDocument.numPages) return null; + + return { + pageNumber, + destArray, + ...destinationCoordinates(destArray) + }; + } catch { + return null; + } +} + +function destinationCoordinates(destArray: unknown[]): Pick { + const mode = destinationMode(destArray[1]); + if (mode === 'XYZ') { + return { + xPdf: numberOrUndefined(destArray[2]), + yPdf: numberOrUndefined(destArray[3]) + }; + } + if (mode === 'FitH' || mode === 'FitBH') { + return { yPdf: numberOrUndefined(destArray[2]) }; + } + if (mode === 'FitR') { + return { + xPdf: numberOrUndefined(destArray[2]), + yPdf: numberOrUndefined(destArray[5]) + }; + } + return {}; +} + +function destinationMode(value: unknown): string { + if (typeof value === 'string') return value.replace(/^\//, ''); + if (value && typeof value === 'object' && 'name' in value) { + return String((value as { name: unknown }).name).replace(/^\//, ''); + } + return ''; +} + +function numberOrUndefined(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} diff --git a/src/viewer/viewer.css b/src/viewer/viewer.css index 8735b35..f136573 100644 --- a/src/viewer/viewer.css +++ b/src/viewer/viewer.css @@ -347,6 +347,81 @@ textarea { animation: mflash 1.4s ease; } +/* 텍스트 레이어 글자는 캔버스 글리프 위의 투명 사본 — 색을 입히면 이중 인쇄처럼 겹쳐 보인다. + 링크 표시는 글자색 대신 점선 밑줄 + 반투명 배경으로만 한다. */ +.mgn-ref { + border-bottom: 1.5px dotted rgb(24 95 165 / .8); + color: transparent; + cursor: pointer; + pointer-events: auto; + text-decoration: none; +} + +.mgn-ref:hover, +.mgn-ref:focus-visible { + background: rgb(24 95 165 / .12); +} + +.mgn-ref.on { + background: rgb(24 95 165 / .18); +} + +.mgn-jump-flash { + position: absolute; + z-index: 5; + pointer-events: none; + animation: mgnJumpFlash 1.2s ease-out forwards; +} + +.mgn-jump-flash-text { + left: 0; + right: 0; + height: 22px; + background: linear-gradient(90deg, transparent, rgb(var(--hl-amber-rgb) / .45), transparent); +} + +.mgn-jump-flash-region { + border: 2px solid var(--accent); + background: rgb(var(--hl-amber-rgb) / .06); + box-shadow: 0 0 0 3px rgb(var(--hl-amber-rgb) / .22); +} + +@keyframes mgnJumpFlash { + 0% { opacity: 1; } + 100% { opacity: 0; } +} + +.mgn-cropping { + user-select: none; +} + +.mgn-crop-overlay { + position: absolute; + inset: 0; + z-index: 6; + cursor: crosshair; +} + +.mgn-crop-existing, +.mgn-crop-rubber { + position: absolute; + pointer-events: none; +} + +.mgn-crop-existing { + border: 2px solid rgb(24 95 165 / .75); + background: rgb(230 241 251 / .18); +} + +.mgn-crop-rubber { + border: 2px solid var(--accent); + background: rgb(var(--hl-amber-rgb) / .16); +} + +.mgn-crop-rubber.preview { + box-shadow: 0 0 0 3px rgb(var(--hl-amber-rgb) / .22); +} + @keyframes mflash { 0% { box-shadow: 0 0 0 4px rgba(24, 95, 165, .35); @@ -1005,39 +1080,96 @@ body.resizing-panel .panel-resize::after { display: block; width: 100%; overflow: hidden; - cursor: pointer; - border: 1px solid #e2e4e8; + border: 1px solid #e2ded2; border-radius: 8px; - background: #fff; - color: inherit; - font: inherit; - padding: 0; - text-align: left; + background: var(--paper); +} + +.fig-card.on { + border-color: #d6a94a; + box-shadow: 0 0 0 2px rgb(var(--hl-amber-rgb) / .28); } -.fig-card:hover { - box-shadow: 0 2px 8px rgb(30 40 90 / 0.12); +.fig-preview { + position: relative; + min-height: 82px; + cursor: pointer; + background: #f7f5ee; } -.fig-card:focus-visible { - outline: 3px solid var(--accent); - outline-offset: 2px; +.fig-preview:hover { + background: #f3f0e6; } -.fig-card img { +.fig-preview img, +.fig-crop-preview img { display: block; width: 100%; } +.fig-img-empty { + min-height: 82px; + display: grid; + place-items: center; + color: var(--soft); + font-size: 11px; +} + +.fig-crop { + position: absolute; + top: 7px; + right: 7px; + width: 26px; + height: 26px; + display: grid; + place-items: center; + border: 0; + border-radius: 7px; + background: rgb(247 245 240 / .94); + color: var(--accent-deep); + cursor: pointer; + font-size: 15px; + line-height: 1; + opacity: 0; + transition: opacity .12s ease; +} + +.fig-preview:hover .fig-crop, +.fig-crop:focus-visible { + opacity: 1; +} + .fig-card .fig-head { display: flex; + align-items: center; justify-content: space-between; - padding: 8px 10px 0; + gap: 8px; + padding: 9px 10px 0; font-size: 12px; } +.fig-label { + min-width: 0; + overflow: hidden; + border: 0; + background: transparent; + color: var(--text); + cursor: pointer; + font-weight: 700; + padding: 0; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fig-label:hover { + color: var(--blue); +} + .fig-card .fig-head span { - color: #778; + flex: none; + color: var(--muted); + font-variant-numeric: tabular-nums; } .fig-card .fig-cap { @@ -1047,7 +1179,70 @@ body.resizing-panel .panel-resize::after { overflow: auto; font-size: 11px; line-height: 1.45; - color: #556; + color: #55534c; +} + +.fig-mentions { + border-top: 1px solid var(--hairline); + padding: 9px 10px 10px; +} + +.fig-mentions-title { + color: var(--muted); + font-size: 10.5px; + font-weight: 700; + margin-bottom: 7px; +} + +.fig-mention-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.fig-mention { + max-width: 100%; + overflow: hidden; + border: 1px solid var(--line-soft); + border-radius: 999px; + background: #f7f5ee; + color: var(--blue); + cursor: pointer; + font-size: 10.5px; + padding: 3px 8px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fig-mention:hover, +.fig-mention.origin { + border-color: #d6a94a; + background: rgb(var(--hl-amber-rgb) / .28); + color: var(--accent-deep); +} + +.fig-crop-panel { + border-top: 1px solid var(--hairline); + padding: 10px; +} + +.fig-crop-preview { + overflow: hidden; + border: 1px solid var(--line-soft); + border-radius: 7px; + background: #f7f5ee; +} + +.fig-crop-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 9px; +} + +.fig-rescan { + align-self: flex-start; + margin: 2px 0 0 2px; } .fig-retry { diff --git a/test/figures.test.ts b/test/figures.test.ts new file mode 100644 index 0000000..a1b9f98 --- /dev/null +++ b/test/figures.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { findCaptionAnchor, mergeFigureEntries } from '../src/core/figures'; +import type { FigureEntry } from '../src/core/types'; + +function figure(patch: Partial = {}): FigureEntry { + return { + id: 'fig1-p2', + doc: 'doc', + kind: 'figure', + num: '1', + label: 'Figure 1', + page: 2, + captionText: 'Figure 1. A small result.', + captionAnchor: { page: 2, start: 20, end: 45 }, + region: { page: 2, rect: [10, 20, 30, 40] }, + regionSource: 'auto', + confidence: 1, + ...patch + }; +} + +describe('figure helpers', () => { + it('finds exact caption anchors', () => { + const anchor = findCaptionAnchor(2, 'Intro. Figure 1. A small result. Body.', 'Figure 1. A small result.'); + + expect(anchor).toEqual({ page: 2, start: 7, end: 32 }); + }); + + it('matches captions across whitespace differences', () => { + const anchor = findCaptionAnchor(3, 'Figure 2.\nA compact caption.', 'Figure 2. A compact caption.'); + + expect(anchor).toEqual({ page: 3, start: 0, end: 30 }); + }); + + // 회귀: 스캔 텍스트는 pdf.js 아이템을 구분자 없이 이어 붙여 단어가 붙을 수 있다 + // (arXiv 2606.12848 p.15 실측 — "threereviewers"). 공백이 빠져도 앵커를 찾아야 + // 캡션 라벨이 본문 언급으로 새지 않는다. + it('finds caption anchors when the page text drops inter-item spaces', () => { + const pageText = 'defects in a given output.Figure 2: Evaluation framework for HLER outputs. ' + + 'Each generated output is independently graded by threereviewers on feasibility.'; + const caption = 'Figure 2: Evaluation framework for HLER outputs. ' + + 'Each generated output is independently graded by three reviewers on feasibility.'; + + const anchor = findCaptionAnchor(15, pageText, caption); + + expect(anchor).toBeDefined(); + expect(pageText.slice(anchor!.start, anchor!.start + 8)).toBe('Figure 2'); + expect(pageText.slice(anchor!.start, anchor!.end)).toContain('threereviewers'); + }); + + it('preserves manual regions when engine results are merged', () => { + const existing = figure({ + region: { page: 2, rect: [1, 2, 3, 4] }, + regionSource: 'manual' + }); + const incoming = figure({ + captionText: 'Figure 1. Updated caption.', + region: { page: 2, rect: [10, 20, 30, 40] }, + regionSource: 'auto' + }); + + expect(mergeFigureEntries([existing], [incoming])[0]).toMatchObject({ + captionText: 'Figure 1. Updated caption.', + region: { page: 2, rect: [1, 2, 3, 4] }, + regionSource: 'manual' + }); + }); +}); diff --git a/test/jump.test.ts b/test/jump.test.ts new file mode 100644 index 0000000..027090f --- /dev/null +++ b/test/jump.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { computeScrollDelta } from '../src/viewer/jump'; + +describe('jump helpers', () => { + it('computes the scroll delta for 1/8 text alignment', () => { + expect(computeScrollDelta({ + containerTop: 50, + containerHeight: 800, + pageTop: 200, + targetY: 320, + alignRatio: 1 / 8 + })).toBe(370); + }); + + it('computes the scroll delta for centered region alignment', () => { + expect(computeScrollDelta({ + containerTop: 20, + containerHeight: 600, + pageTop: 100, + targetY: 500, + alignRatio: 1 / 2 + })).toBe(280); + }); +}); diff --git a/test/mentions.test.ts b/test/mentions.test.ts new file mode 100644 index 0000000..f146a1f --- /dev/null +++ b/test/mentions.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { figureMentions, nearestFigureMention, scanFigureReferences, type FigureReference } from '../src/core/mentions'; +import type { FigureEntry } from '../src/core/types'; + +const figures: FigureEntry[] = [ + { + id: 'fig1-p1', + doc: 'doc', + kind: 'figure', + num: '1', + label: 'Figure 1', + page: 1, + captionText: 'Figure 1. Result caption.', + captionAnchor: { page: 1, start: 0, end: 25 }, + region: { page: 1, rect: [10, 20, 30, 40] }, + regionSource: 'auto', + confidence: 1 + }, + { + id: 'fig2-p2', + doc: 'doc', + kind: 'figure', + num: '2', + label: 'Figure 2', + page: 2, + captionText: 'Figure 2. More.', + captionAnchor: { page: 2, start: 100, end: 115 }, + region: { page: 2, rect: [10, 20, 30, 40] }, + regionSource: 'auto', + confidence: 1 + } +]; + +describe('figure mentions', () => { + it('links caption labels but excludes them from mention chips', () => { + const refs = scanFigureReferences( + { page: 1, text: 'Figure 1. Result caption. As shown in Fig. 2, the trend holds.' }, + figures, + (offset) => 700 - offset + ); + + expect(refs).toHaveLength(2); + expect(refs[0]).toMatchObject({ figId: 'fig1-p1', isCaptionLabel: true }); + expect(refs[1]).toMatchObject({ figId: 'fig2-p2', isCaptionLabel: false, yPdf: 662 }); + expect(figureMentions(refs).map((ref) => ref.figId)).toEqual(['fig2-p2']); + }); + + it('normalizes figure labels before lookup', () => { + const refs = scanFigureReferences({ page: 4, text: 'See figure 1 and FIG. 2.' }, figures); + + expect(refs.map((ref) => ref.figId)).toEqual(['fig1-p1', 'fig2-p2']); + }); +}); + +describe('nearestFigureMention', () => { + const mention = (key: string, figId: string, page: number, yPdf: number | undefined, isCaptionLabel = false): FigureReference => ({ + key, + figId, + page, + start: 0, + end: 6, + quote: 'Fig. 2', + yPdf, + isCaptionLabel + }); + + const refs: FigureReference[] = [ + mention('top', 'fig2-p2', 3, 700), + mention('mid', 'fig2-p2', 3, 420), + mention('caption', 'fig2-p2', 3, 415, true), + mention('other-fig', 'fig1-p1', 3, 418), + mention('other-page', 'fig2-p2', 5, 421), + mention('no-y', 'fig2-p2', 3, undefined) + ]; + + it('picks the same-page mention nearest to the click y', () => { + expect(nearestFigureMention(refs, 'fig2-p2', 3, 419)?.key).toBe('mid'); + expect(nearestFigureMention(refs, 'fig2-p2', 3, 690)?.key).toBe('top'); + }); + + it('ignores caption labels, other figures, other pages, and y-less mentions', () => { + expect(nearestFigureMention(refs, 'fig2-p2', 3, 415)?.key).toBe('mid'); + expect(nearestFigureMention(refs, 'fig2-p2', 5, 421)?.key).toBe('other-page'); + expect(nearestFigureMention(refs, 'fig1-p1', 1, 400)).toBeNull(); + }); + + it('returns null when no mention qualifies', () => { + expect(nearestFigureMention([], 'fig2-p2', 3, 100)).toBeNull(); + expect(nearestFigureMention(refs, 'fig2-p2', 7, 100)).toBeNull(); + }); +}); diff --git a/test/tab-figures.test.ts b/test/tab-figures.test.ts index 402b256..4e2ace5 100644 --- a/test/tab-figures.test.ts +++ b/test/tab-figures.test.ts @@ -1,9 +1,12 @@ -import type { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { EngineFigure, EngineResult, FigExtractApi } from '../src/core/fig-engine'; -import { FiguresTab } from '../src/viewer/panel/tab-figures'; +import type { FigureEntry } from '../src/core/types'; +import { FiguresTab, type FiguresTabCallbacks } from '../src/viewer/panel/tab-figures'; -type Listener = (event: { target: FakeElement }) => void; +type Listener = (event: { + target: FakeElement; + preventDefault: () => void; + stopPropagation: () => void; +}) => void; class FakeElement { readonly tagName: string; @@ -15,13 +18,26 @@ class FakeElement { type = ''; src = ''; alt = ''; + role = ''; + tabIndex = -1; + title = ''; + decoding = ''; attributes: Record = {}; + classList = { + add: () => {}, + toggle: () => {}, + contains: () => false + }; #listeners = new Map(); constructor(tagName = 'div') { this.tagName = tagName.toUpperCase(); } + set innerHTML(value: string) { + if (value === '') this.children = []; + } + addEventListener(type: string, listener: Listener): void { const listeners = this.#listeners.get(type) ?? []; listeners.push(listener); @@ -29,7 +45,9 @@ class FakeElement { } emit(type: string, target: FakeElement): void { - for (const listener of this.#listeners.get(type) ?? []) listener({ target }); + for (const listener of this.#listeners.get(type) ?? []) { + listener({ target, preventDefault: () => {}, stopPropagation: () => {} }); + } } append(...children: FakeElement[]): void { @@ -48,35 +66,46 @@ class FakeElement { this.attributes[name] = value; } + querySelector(): FakeElement | null { + return null; + } + closest(selector: string): FakeElement | null { - if (selector.startsWith('.') && this.className.split(/\s+/).includes(selector.slice(1))) { + const dataMatch = selector.match(/^\[data-([a-z-]+)\]$/); + if (dataMatch) { + const key = dataMatch[1].replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + if (this.dataset[key] !== undefined) return this; + } else if (selector.startsWith('.') && this.className.split(/\s+/).includes(selector.slice(1))) { return this; } return this.parent?.closest(selector) ?? null; } } -const doc = {} as PDFDocumentProxy; -const figure = (page: number, num = String(page)): EngineFigure => ({ - num, +const entry = (id: string, page: number, label: string): FigureEntry => ({ + id, + doc: 'doc', + kind: 'figure', + num: label.replace(/\D+/g, ''), + label, page, - confidence: 1, - caption: `Figure ${num}`, - bboxPt: { x0: 0, y0: 0, x1: 10, y1: 10 }, - captionBoxPt: { x0: 0, y0: 10, x1: 10, y1: 12 }, - bboxPx: { x0: 0, y0: 0, x1: 22, y1: 22 }, - canvas: {} as HTMLCanvasElement + captionText: `${label}. Caption.`, + region: null, + regionSource: 'auto', + confidence: 1 }); -const result = (figures: EngineFigure[]): EngineResult => ({ - title: null, - numPages: 10, - engineVersion: 'test', - figures -}); - -function makeEngine(extract: FigExtractApi['extract']): Pick { - return { extract, cropDataURL: vi.fn(() => 'data:image/png;base64,test') }; +function makeCallbacks(onScan: FiguresTabCallbacks['onScan']): FiguresTabCallbacks { + return { + onScan, + onJumpFigure: vi.fn(), + onJumpMention: vi.fn(), + onStartCrop: vi.fn(), + onSaveCrop: vi.fn(), + onRedoCrop: vi.fn(), + onCancelCrop: vi.fn(), + renderRegion: vi.fn(async () => null) + }; } describe('FiguresTab', () => { @@ -97,67 +126,59 @@ describe('FiguresTab', () => { vi.restoreAllMocks(); }); - it('renders native buttons and jumps when a card is activated', async () => { + it('renders accessible cards after a scan and jumps via the preview control', async () => { const list = new FakeElement(); - const onJumpToPage = vi.fn(); - const engine = makeEngine(vi.fn(async () => result([figure(4)]))); - const tab = new FiguresTab( - list as unknown as HTMLElement, - { onJumpToPage }, - engine - ); - - tab.setDocument(doc); - await vi.waitFor(() => expect(list.children[0]?.className).toBe('fig-card')); - - const card = list.children[0]; - expect(card.tagName).toBe('BUTTON'); - expect(card.type).toBe('button'); - expect(card.attributes['aria-label']).toBe('Figure 4, 4페이지로 이동'); - list.emit('click', card); - expect(onJumpToPage).toHaveBeenCalledWith(4); + const callbacks = makeCallbacks(vi.fn(async () => [entry('fig4-p4', 4, 'Figure 4')])); + const tab = new FiguresTab(list as unknown as HTMLElement, callbacks); + + tab.setDocument([]); + tab.ensureScanned(); + await vi.waitFor(() => expect(list.children[0]?.className).toContain('fig-card')); + + const preview = list.children[0].children[0]; + expect(preview.className).toBe('fig-preview'); + expect(preview.role).toBe('button'); + expect(preview.tabIndex).toBe(0); + list.emit('click', preview); + expect(callbacks.onJumpFigure).toHaveBeenCalledWith('fig4-p4'); }); it('offers a retry after failure and succeeds without replacing the document', async () => { const list = new FakeElement(); const error = vi.spyOn(console, 'error').mockImplementation(() => {}); - const extract = vi.fn() + const onScan = vi.fn() .mockRejectedValueOnce(new Error('temporary failure')) - .mockResolvedValueOnce(result([])); - const tab = new FiguresTab( - list as unknown as HTMLElement, - { onJumpToPage: vi.fn() }, - makeEngine(extract) - ); - - tab.setDocument(doc); + .mockResolvedValueOnce([]); + const tab = new FiguresTab(list as unknown as HTMLElement, makeCallbacks(onScan)); + + tab.setDocument([]); + tab.ensureScanned(); await vi.waitFor(() => expect(list.children[0]?.children[0]?.className).toContain('fig-retry')); + expect(list.children[0].children[0].dataset.action).toBe('retry'); list.emit('click', list.children[0].children[0]); - await vi.waitFor(() => expect(extract).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(onScan).toHaveBeenCalledTimes(2)); await vi.waitFor(() => expect(list.children[0]?.textContent).toContain('감지된 figure가 없어요')); expect(error).toHaveBeenCalledTimes(1); }); it('discards a stale scan when the document changes', async () => { const list = new FakeElement(); - let resolveFirst: ((value: EngineResult) => void) | undefined; - const first = new Promise((resolve) => { resolveFirst = resolve; }); - const extract = vi.fn() + let resolveFirst: ((value: FigureEntry[]) => void) | undefined; + const first = new Promise((resolve) => { resolveFirst = resolve; }); + const onScan = vi.fn() .mockReturnValueOnce(first) - .mockResolvedValueOnce(result([figure(2)])); - const tab = new FiguresTab( - list as unknown as HTMLElement, - { onJumpToPage: vi.fn() }, - makeEngine(extract) - ); - - tab.setDocument(doc); - tab.setDocument({} as PDFDocumentProxy); - await vi.waitFor(() => expect(list.children[0]?.dataset.page).toBe('2')); - - resolveFirst?.(result([figure(9)])); + .mockResolvedValueOnce([entry('fig2-p2', 2, 'Figure 2')]); + const tab = new FiguresTab(list as unknown as HTMLElement, makeCallbacks(onScan)); + + tab.setDocument([]); + tab.ensureScanned(); + tab.setDocument([]); + tab.ensureScanned(); + await vi.waitFor(() => expect(list.children[0]?.dataset.fig).toBe('fig2-p2')); + + resolveFirst?.([entry('fig9-p9', 9, 'Figure 9')]); await Promise.resolve(); - expect(list.children[0]?.dataset.page).toBe('2'); + expect(list.children[0]?.dataset.fig).toBe('fig2-p2'); }); });