From 32aed0cefa4636e0f07815a03314d0b47b7ba45d Mon Sep 17 00:00:00 2001 From: Jinhyeong Kim <83393021+onetwothr1@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:26:53 +0900 Subject: [PATCH 01/16] Chore/sync dev with main (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev branch was introduced in @enu3379's #4, but it was behind main. This PR syncs dev with main so future work can branch from dev. ## Summary by CodeRabbit * **New Features** * PDF 뷰어에 **저장** 기능이 추가되고, 단축키로도 저장할 수 있습니다. * 로컬 `file://` PDF 자동 열기 지원이 확대되었으며, 권한 상태에 따라 안내 화면이 표시됩니다. * 파일을 찾을 수 없을 때 전용 오류 화면과 재선택 동작이 추가되었습니다. * **Bug Fixes** * 새 탭/일반 페이지/PDF 이동 처리와 자동 열기 흐름이 더 안정적으로 개선되었습니다. * 로컬 PDF 접근이 꺼진 경우에도 적절한 안내로 전환됩니다. * **Documentation** * 피규어 UX, 로컬 PDF, QA 절차 관련 문서를 업데이트했습니다. --------- Co-authored-by: enu3379 Co-authored-by: Claude Fable 5 Co-authored-by: EUNWOO LEE --- docs/figure-ux.md | 152 +++++++++++++++++++++ docs/implementation-plan.md | 50 ++++--- docs/issue-1-open-ux.md | 240 ++++++++++++++++++++++++++++++++++ docs/issue-1-qa.md | 177 +++++++++++++++++++++++++ docs/progress.md | 28 +++- docs/windows-local-loading.md | 28 ++++ public/manifest.json | 12 +- src/core/pdf-url.ts | 25 ++++ src/sw.ts | 176 ++++++++++++++++++++----- src/viewer/main.ts | 135 +++++++++++++++---- src/viewer/viewer.css | 54 ++++++++ test/pdf-url.test.ts | 22 +++- viewer.html | 19 +++ 13 files changed, 1044 insertions(+), 74 deletions(-) create mode 100644 docs/figure-ux.md create mode 100644 docs/issue-1-open-ux.md create mode 100644 docs/issue-1-qa.md diff --git a/docs/figure-ux.md b/docs/figure-ux.md new file mode 100644 index 0000000..8fd3a3e --- /dev/null +++ b/docs/figure-ux.md @@ -0,0 +1,152 @@ +# 피규어 UX 개정 설계 — 참조 링크·점프·크롭 (v1) + +- 배경: 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 착수 가능.** +- 관련 정본: implementation-plan §5.4(멘션)·§5.5(프리뷰 렌더)·§6(수동 크롭)·§7(캡션 라벨)·§8(그림·표 탭 상세), + fig-extract-integration.md(엔진 경계 — captionAnchor·mentions·수동 크롭은 Margin 담당). + +## 1. "충돌하는 듯"의 진단 + +현재 본문에서 클릭되는 파란 "Figure 1" 링크는 **PDF 원본에 내장된 하이퍼링크**이고, pdf.js의 기본 +`PDFLinkService`가 그대로 처리한다(목적지 = 캡션 텍스트 위치, 뷰포트 최상단 정렬). Margin의 그림·표 +탭(엔진 스캔 결과)과는 아직 아무 연결이 없다 — 그래서 "피규어 기능인데 글자 위치로 가는" 어긋남으로 +보인다. 즉 버그 충돌이 아니라 **두 시스템이 미연결**인 상태다. (QA 중 이 외에 실제 오류/깨짐을 봤다면 +증상을 별도로 알려줄 것 — 본 문서는 미연결 진단 기준으로 작성.) + +## 2. 요청 ↔ 현황 ↔ 계획 매핑 + +| # | 요청 (은우) | 현재 동작 (코드) | 계획서 근거 | 판정 | +|---|---|---|---|---| +| R-a | 피규어 설명에서 본문 언급으로 가는 역참조 링크 | 없음 — 카드에 이미지·캡션만 (`tab-figures.ts`) | §8 그림·표 탭 "본문 언급 N" 목록(R7), §5.4 멘션 스캔 | 미구현 — 계획대로 구현 | +| R-b | figure 링크 클릭 시 피규어 이미지가 화면 중간에 오길 기대 | PDF 내장 링크 → pdf.js 기본 점프(캡션 글자, 최상단 정렬) | §5.4 마지막 불릿: LinkService 오버라이드로 figure dest 가로채기 | 미구현 + 목적지 동작은 DC-F1로 확정 | +| R-c | 점프 시 목표를 최상단이 아닌 상단 1/8에 + 잠깐 하이라이팅 | `goToDestination`/`scrollPageIntoView` 기본(최상단), 플래시는 메모 점프에만 존재 | 계획에 없음 — **신규 결정** | 전역 점프 유틸로 신설 (§4.2) | +| R-d | 피규어 이미지 우상단 자르기 아이콘 → 직접 크롭 | 없음 | §6 수동 크롭 모드(M4) — 진입 버튼만 다름 | 계획 + 진입점을 아이콘으로 확정 | +| R-e | 본문 피규어 하단 "Figure 1" 라벨에 링크 → 그림·표 탭에서 바로 보기 | 없음 (텍스트일 뿐) | §7 캡션 라벨 클릭(R6) | 미구현 — 계획대로 + 목적지는 패널 카드 | + +공통 전제(M3 잔여, fig-extract-integration.md "미구현" 목록): 엔진 스캔 결과를 세션 메모리에만 두고 +있어 **FigureEntry 저장·captionAnchor 계산부터** 깔아야 위 기능들이 붙는다(→ G1). + +## 3. 디자인 결정 + +### DC-F1. 본문 참조 클릭의 목적지 — **확정: B 패널 경유 (2026-07-09 은우 결정)** + +읽던 자리를 지키는 것이 우선. 참조 클릭 시 본문은 움직이지 않고 패널이 그 피규어를 보여주며, +이동은 패널 안에서 사용자가 명시적으로 고른다: + +1. **본문 참조(`mgn-ref`)·PDF 내장 figure 링크·캡션 라벨 클릭** → 패널 열기 + 그림·표 탭 + 해당 카드 + 스크롤·강조. **본문 스크롤은 그대로.** (세 진입점 모두 동일 동작 — 원계획 §5.4 R1·§7 그대로) +2. **패널 카드의 이미지/라벨 클릭** → 본문이 해당 피규어로 이동(`jumpToRegion`, 세로 중앙 + 플래시). +3. **패널의 언급 칩 클릭** → 본문이 그 언급 문장으로 이동(`jumpToText`, 1/8 + 밴드 플래시) — 피규어를 + 보고 난 뒤 **읽던 자리로 복귀하는 경로**이기도 하다. + +복귀 디테일: 참조 클릭으로 패널이 열린 경우 그 참조(원점)에 해당하는 언급 칩을 목록 맨 위에 **↩ +마커**와 함께 표시한다 — 카드 클릭으로 피규어에 다녀온 뒤 이 칩 하나로 원래 자리로 돌아간다. +패널 핀 규칙은 기존 R5 그대로: 핀 해제 상태에서 2·3의 이동 후 패널이 자동으로 닫힌다. + +기각: A(참조 클릭 즉시 피규어로 이동 — 읽던 맥락 이탈), C(중앙 팝오버 — 레이어 비용, 조용한 UI +헌법과 긴장). 호버 미니 프리뷰(§5.5)는 phase 2 백로그 유지. + +### 확정 사항 (DC 아님) + +- **점프 정렬 규칙(R-c)**: 텍스트 목적지 = 뷰포트 상단 **1/8** 지점. 피규어 region = 세로 **중앙** + (region 높이가 뷰포트의 3/4보다 크면 상단 1/8로 폴백). 목차·메모·참조·역참조 모두 이 규칙. +- **점프 플래시**: 텍스트 = 목적지 줄 위치에 앰버 그라데이션 가로 밴드를 1.2s 페이드아웃. + 피규어 = region에 앰버 외곽선 + 6% 필을 1.2s 페이드아웃. 기존 `flashElement` 패턴 확장. +- **크롭 진입(R-d)**: 그림·표 카드 이미지 **우상단 호버 시 크롭 아이콘(⌗)** 노출 → 클릭 시 §6 상태 + 머신 그대로(대상 페이지로 이동해 오버레이 드래그, 패널에 라이브 미리보기+저장/취소). 저장 시 + `regionSource='manual'`로 storage 갱신 + 카드 이미지 즉시 교체. §8의 "영역 지정/다시 지정" 텍스트 + 버튼은 아이콘으로 대체(중복 UI 두지 않음 — 계획서 §8 서술 개정 필요, G8). +- **역참조 UI(R-a)**: §8 그대로 — 카드 캡션 아래 "본문 언급 N곳" 칩 목록. 칩 클릭 → 해당 문장으로 + 점프(1/8 + 밴드 플래시). 언급이 0곳이면 목록 자체를 숨긴다. + +## 4. 스펙 요점 (계획서와 중복 서술 않음 — 차이·신규만) + +### 4.1 기반 (G1) — FigureEntry 영속화 + +- 그림·표 탭 스캔 완료 시 `toFigureEntries()`로 변환해 storage 저장(문서당 1회, 재스캔 시 병합: + `regionSource='manual'` 항목은 엔진 결과로 덮지 않음 — fig-extract-integration.md 규약). +- captionAnchor: 엔진 captionText를 해당 페이지 텍스트 인덱스(S_p)에서 검색해 오프셋 계산(동 문서 규약). + 실패 시 anchor 없이 저장(캡션 라벨 링크만 비활성, 나머지 기능 동작). + +### 4.2 점프 유틸 (G2) — `viewer/jump.ts` + +```ts +jumpToText(page, yPdf, opts?) // 상단 1/8 정렬 + 밴드 플래시 +jumpToRegion(page, rectPdf) // 세로 중앙(큰 region은 1/8) + region 플래시 +``` + +- 구현: `scrollPageIntoView({pageNumber, destArray})`로 페이지 이동 후 컨테이너 scrollTop 보정 + (목표 y의 뷰포트 좌표 계산 → `scrollTop += targetY - viewportH * α`). 플래시 요소는 해당 pageDiv에 + 절대배치로 삽입, 애니메이션 종료 후 제거. +- 교체 지점: 목차 클릭(`jumpToOutline` 후 보정), 메모 카드 점프(기존 플래시 유지+정렬만), 그림·표 + 카드 클릭(현재 페이지 점프 → region 점프로), 언급 칩 클릭(신규). 본문 참조·캡션 라벨은 점프가 + 아니라 패널을 연다(DC-F1-B). +- 목차의 경우 dest 해석 결과에 y가 없으면(페이지만) 보정 생략(페이지 상단 그대로). + +### 4.3 멘션·링크 (G3·G4) — §5.4 그대로 + 목적지만 DC-F1-A + +- `core/mentions.ts` 스캔·주입은 §5.4 스펙 그대로(정규식·멱등 주입·캡션 자기 제외·단일 span 제한). +- PDF 내장 링크: `PDFLinkService` 서브클래스 `goToDestination` 오버라이드 — dest 좌표가 어떤 + FigureEntry의 region 또는 captionAnchor 근방(같은 페이지 ±40pt)이면 **패널 열기 + 해당 카드 강조 + + 원점 칩 마킹**(DC-F1-B — §5.4 원계획의 R1 그대로), 아니면 기본 동작. +- `a.mgn-ref`(본문 참조)와 `a.mgn-ref[data-cap]`(캡션 라벨) 클릭 = 위와 동일한 패널 열기 핸들러 공유. + annotation 링크와 겹치면 annotation 우선(§5.4 유지). + +### 4.4 그림·표 탭 개편 (G5) + +- 카드 = 이미지(호버 시 우상단 크롭 아이콘) + 라벨/페이지 칩 + 캡션 + 언급 칩 목록. +- 카드 이미지/라벨 클릭 = `jumpToRegion`(기존 "페이지 점프"에서 변경). 활성 카드 표시 유지. +- 스캔 상태 문구·에러 처리는 현행 유지. storage에 저장된 문서는 재방문 시 스캔 없이 즉시 렌더, + "다시 스캔" 링크 제공(엔진 버전 갱신 대비). + +## 5. 비목표 (이번 개정에서 제외) + +- 호버 미니 프리뷰(§5.5의 팝오버 소비처), 새 그림 수동 추가(§12), 허브 썸네일. + (플로팅 "돌아가기" 칩은 원점 언급 칩(§3 복귀 디테일)이 대체 — 별도 UI 없음) +- 엔진(fig-extract) 수정 일절 없음 — 감지 품질 이슈는 엔진 repo로 전달. + +## 6. 구현 태스크 (Codex, `feature/figure-ux`) + +> 공통: typecheck·test·build 통과. 순수 로직(mentions 정규식·anchor 검색·정렬 계산)은 vitest 커버. + +- **G1 — FigureEntry 영속화** (`core/store.ts`, `tab-figures.ts`): §4.1. 수용: 재방문 시 무스캔 렌더, + manual region이 재스캔에도 보존. +- **G2 — 점프 유틸** (`viewer/jump.ts`, 교체 지점 4곳): §4.2. 수용: 목차/메모/카드 점프가 1/8·중앙 + 규칙과 플래시로 동작, 줌 변경 후에도 정렬 정확. +- **G3 — 멘션 스캔·주입** (`core/mentions.ts`, main.ts textlayerrendered 훅): §5.4. 수용: 본문 + "Fig. 3" 클릭 → 본문 무이동, 패널 그림·표 탭에 해당 카드 강조. 링크 없는 PDF에서도 동일. +- **G4 — 내장 링크 인터셉트** (pdf-host.ts LinkService 서브클래스): §4.3. 수용: hyperref 링크 클릭이 + 피규어면 패널 열림(본문 무이동), 각주 등 비피규어 dest는 기존 동작. +- **G5 — 그림·표 탭 개편** (tab-figures.ts, viewer.css): §4.4 + 언급 칩(R-a) + 원점 칩 ↩ 마킹(§3). + 수용: 카드 클릭 → 피규어 세로 중앙 점프, 칩 클릭 → 본문 문장 1/8 점프 + 밴드 플래시, 참조로 연 + 경우 원점 칩이 맨 위에 표시되고 클릭 시 읽던 자리 복귀. 핀 해제 시 이동 후 패널 자동 닫힘(R5). +- **G6 — 수동 크롭** (`viewer/crop-mode.ts`, §6 상태 머신 + 카드 아이콘 진입): 수용: 아이콘 → 드래그 → + 저장 → 카드 이미지 갱신 + 이후 점프가 새 region 기준. +- **G7 — 캡션 라벨** (§7): 수용: 본문 캡션 라벨 클릭 → 패널 그림·표 탭 해당 카드 스크롤+강조. +- **G8 — 계획서 개정** (implementation-plan §8 크롭 진입 버튼 서술 + 점프 정렬 규칙 추가): 본 문서 + 기준으로 갱신. §5.4의 "점프 대신 패널 프리뷰" 서술은 DC-F1-B로 원안 유지가 확정 — 수정 불요. + +의존성: G1 → {G3, G4, G5, G6, G7}, G2는 독립(먼저 가능), G3 → G5(언급 칩 데이터). +제안 순서: G2 → G1 → G3 → G4·G5 → G7 → G6 → G8. + +## 7. QA 시나리오 (구현 후 issue-1-qa.md 방식으로 상세화) + +1. arXiv 논문(hyperref 있음): 본문 "Figure 2" 클릭 → 본문 무이동, 패널 그림·표 탭 열림 + 해당 카드 + 강조 + 원점 칩 ↩ 표시. 각주 링크는 기존 동작. +2. 링크 없는 PDF: 같은 텍스트가 mgn-ref로 링크화되어 동일 동작. 캡션 안의 자기 라벨은 언급 목록에 없음. +3. 패널 왕복: 카드 클릭 → 피규어 세로 중앙 + 앰버 플래시 → 원점 칩 클릭 → 읽던 문장 상단 1/8 + 밴드 + 플래시로 복귀. 핀 해제 상태면 각 이동 후 패널 자동 닫힘, 핀 상태면 유지. +4. 카드 이미지 호버 → 크롭 아이콘 → 드래그 재지정 → 저장 → 카드·점프 모두 새 영역 반영, 재스캔에도 유지. +5. 본문 캡션 라벨 클릭 → 패널 그림·표 탭이 열리고 해당 카드 강조. +6. 목차·메모 점프가 1/8 정렬 + 플래시로 동작, 200% 줌에서도 정확. +7. 스캔 PDF(텍스트 레이어 없음): 그림·표 탭 빈 상태 문구, 본문 링크화 없음, 오류 없음. + +## 8. 분담 + +| 담당 | 산출물 | +|---|---| +| Claude | 본 문서, DC-F1 확정 지원, 플래시·아이콘 시각 스펙 상세(구현 시작 시 카피·CSS 값 제공), 구현 후 리뷰 | +| Codex | G1–G8, QA 시나리오 상세화·수행 | +| 은우 | ~~DC-F1 확정~~(B, 2026-07-09 완료), "충돌" 관련 실오류 여부 회신, 최종 QA | diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index d8c22ed..74efed7 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -46,7 +46,7 @@ 크롬 내장 PDF 뷰어(PDFium)는 확장이 내부를 수정할 수 없다. 따라서 Hypothesis·Weava와 같은 방식으로 **PDF 내비게이션을 가로채 확장 자체 뷰어 페이지로 대체**한다. PDF.js로 직접 렌더링하므로 텍스트 레이어·좌표·주석을 완전히 통제할 수 있다. ```text -[탐색: https://…/x.pdf 또는 arxiv.org/pdf/…] +[탐색: https://…/x.pdf, arxiv.org/pdf/…, file://…/x.pdf] │ (service worker: declarativeNetRequest 동적 리다이렉트) ▼ viewer.html?file=<원본 URL> hub.html (별도 탭) @@ -62,8 +62,8 @@ viewer.html?file=<원본 URL> hub.html (별도 탭) ### PDF가 열리는 4가지 경로 -1. **자동 리다이렉트(주 경로)**: 서비스 워커가 설치 시 DNR 동적 규칙 2개를 등록한다 — (a) `^https://arxiv\.org/(pdf|abs 제외)/…` 형태의 arXiv PDF URL, (b) `\.pdf`로 끝나는 http(s) URL. 규칙 (b)는 해당 오리진에 대한 호스트 권한이 있을 때만 실제로 동작한다(§9). 이 자동 동작은 사용자가 토글로 끌 수 있다(§9 자동 열기 토글) — 끄면 모든 PDF가 크롬 내장 뷰어로 열리고 경로 2만 사용된다. -2. **툴바 버튼(폴백)**: 임의 URL(확장자 없는 PDF 등)에서 확장 아이콘 클릭 → 현재 탭을 `viewer.html?file=`로 전환. 뷰어에서 fetch가 CORS로 실패하면 "이 사이트 접근 권한이 필요합니다" 배너 + `chrome.permissions.request({origins:[origin+'/*']})` → 재시도. +1. **자동 리다이렉트(주 경로)**: 서비스 워커가 설치 시 DNR 동적 규칙 3개를 등록한다 — (a) `^https://arxiv\.org/pdf/…` 형태의 arXiv PDF URL, (b) `\.pdf`로 끝나는 http(s) URL, (c) `^file://.*\.pdf$` 로컬 PDF URL. 규칙 (c)는 Chrome 확장 세부정보의 "파일 URL에 대한 액세스 허용"이 켜져 있을 때 커밋 전에 동작하며 Windows UNC(`file://server/share/x.pdf`)도 포함한다. 이 자동 동작은 사용자가 토글로 끌 수 있다(§9 자동 열기 토글) — 끄면 PDF가 크롬 내장 뷰어로 열리고 경로 2만 사용된다. +2. **툴바 버튼(폴백)**: 임의 URL(확장자 없는 PDF 등)에서 확장 아이콘 클릭 → 확장자가 명확하거나 GET+헤더 판별로 PDF임이 확인되면 현재 탭을 `viewer.html?file=`로 전환한다. 확정 비PDF는 제자리 토스트, 판별 실패는 낙관적으로 뷰어 전환한다. 3. **뷰어 내 열기**: viewer.html을 file 파라미터 없이 열면 빈 상태 화면(파일 선택 버튼 + 드래그&드롭). `file:` PDF는 "파일 URL 접근 허용" 안내를 함께 표시. 4. **허브 딥링크**: `viewer.html?file=&anno=` 또는 `&fig=` — 로드 완료 후 해당 주석/그림으로 스크롤 + 플래시. 문서가 URL 없이 저장된 경우(로컬 파일) 허브는 "파일 다시 선택" 흐름으로 유도하고 fingerprint 일치를 검증한다. @@ -298,9 +298,16 @@ renderRegion(pdfDoc, page, rectPdf, maxCssWidth): HTMLCanvasElement "minimum_chrome_version": "121", "action": { "default_title": "Margin으로 열기" }, "background": { "service_worker": "sw.js", "type": "module" }, - "permissions": ["storage", "declarativeNetRequest", "activeTab", "contextMenus"], - "host_permissions": ["https://arxiv.org/*"], - "optional_host_permissions": ["*://*/*"], + "permissions": [ + "storage", + "declarativeNetRequestWithHostAccess", + "activeTab", + "contextMenus", + "scripting", + "webNavigation", + "notifications" + ], + "host_permissions": ["http://*/*", "https://*/*", "file:///*"], "web_accessible_resources": [ { "resources": ["viewer.html"], "matches": [""] } ] @@ -315,7 +322,7 @@ const VIEWER = chrome.runtime.getURL('viewer.html'); async function syncInterceptRules() { const got = await chrome.storage.local.get('margin:settings'); const auto = got['margin:settings']?.autoIntercept ?? true; - await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [1, 2] }); + await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [1, 2, 3] }); if (!auto) return; // OFF: 자동 리다이렉트 없음 → 크롬 내장 뷰어 그대로 await chrome.declarativeNetRequest.updateDynamicRules({ addRules: [ @@ -323,34 +330,45 @@ async function syncInterceptRules() { condition: { regexFilter: '^https://arxiv\\.org/pdf/[^?#]+', resourceTypes: ['main_frame'] }, action: { type: 'redirect', redirect: { regexSubstitution: VIEWER + '?file=\\0' } } }, { id: 2, priority: 1, - condition: { regexFilter: '^https?://.+\\.pdf([?#].*)?$', resourceTypes: ['main_frame'] }, + condition: { regexFilter: '^https?://.+\\.pdf([?#].*)?$', isUrlFilterCaseSensitive: false, resourceTypes: ['main_frame'] }, + action: { type: 'redirect', redirect: { regexSubstitution: VIEWER + '?file=\\0' } } }, + { id: 3, priority: 1, + condition: { regexFilter: '^file://.*\\.pdf$', isUrlFilterCaseSensitive: false, resourceTypes: ['main_frame'] }, action: { type: 'redirect', redirect: { regexSubstitution: VIEWER + '?file=\\0' } } } ] }); } chrome.runtime.onInstalled.addListener(async () => { + await chrome.contextMenus.removeAll(); chrome.contextMenus.create({ id: 'open-hub', title: '메모 허브 열기', contexts: ['action'] }); - chrome.contextMenus.create({ id: 'auto-open', type: 'checkbox', checked: true, + chrome.contextMenus.create({ id: 'auto-open', type: 'checkbox', title: 'PDF 자동으로 Margin에서 열기', contexts: ['action'] }); + await syncAutoOpenMenuChecked(); // 저장된 autoIntercept 값과 체크 상태 동기화 await syncInterceptRules(); }); -chrome.runtime.onStartup.addListener(syncInterceptRules); +chrome.runtime.onStartup.addListener(() => { + void syncAutoOpenMenuChecked(); + void syncInterceptRules(); +}); chrome.contextMenus.onClicked.addListener(async info => { if (info.menuItemId === 'open-hub') chrome.tabs.create({ url: chrome.runtime.getURL('hub.html') }); if (info.menuItemId === 'auto-open') { - await chrome.storage.local.set({ 'margin:settings': { autoIntercept: !!info.checked } }); + const current = (await chrome.storage.local.get('margin:settings'))['margin:settings'] ?? {}; + await chrome.storage.local.set({ 'margin:settings': { ...current, autoIntercept: !!info.checked } }); await syncInterceptRules(); + await syncAutoOpenMenuChecked(); } }); -chrome.action.onClicked.addListener(tab => { - if (tab.id && tab.url) chrome.tabs.update(tab.id, { url: VIEWER + '?file=' + encodeURIComponent(tab.url) }); +chrome.webNavigation.onBeforeNavigate.addListener(handleLocalPdfFallback, { + url: [{ urlPrefix: 'file://', pathSuffix: '.pdf' }, { urlPrefix: 'file://', pathSuffix: '.PDF' }] }); +chrome.action.onClicked.addListener(routeActionClick); ``` -- **자동 열기 토글**: `margin:settings.autoIntercept`(기본 true). 확장 아이콘 우클릭 메뉴의 체크박스 "PDF 자동으로 Margin에서 열기"로 제어한다. 끄면 규칙 1·2가 제거되어 모든 PDF가 크롬 내장 뷰어로 열리고, 아이콘 클릭(§1 경로 2)으로만 Margin이 열린다 — "평소엔 내장 뷰어, 원할 때만 전환" 사용 패턴. 로컬 `file:` PDF는 애초에 리다이렉트 규칙 대상이 아니므로 항상 클릭 방식이며, "파일 URL 액세스 허용"은 뷰어가 file URL을 fetch하기 위한 권한일 뿐 자동 가로채기와 무관하다. -- 규칙 2는 호스트 권한이 있는 오리진에서만 발동한다. 뷰어에서 fetch 실패 시 §1의 권한 요청 배너 흐름으로 사후 허용(`optional_host_permissions` 사용). 광범위 권한을 기본 요구하지 않는 것이 스토어 심사에 유리하다. -- PDF 로드: `pdfjsLib.getDocument({ url })` 기본 사용(작은 논문 PDF 기준 range 스트리밍 불요). `file:` URL은 "파일 URL 접근 허용" 미설정 시 안내 배너. +- **자동 열기 토글**: `margin:settings.autoIntercept`(기본 true). 확장 아이콘 우클릭 메뉴의 체크박스 "PDF 자동으로 Margin에서 열기"로 제어한다. 로컬 `file:` PDF도 자동 열기 대상이다: "파일 URL 액세스 허용"이 켜져 있으면 DNR 규칙 3(`^file://.*\.pdf$`)이 커밋 전에 뷰어로 리다이렉트하고, 꺼져 있으면 webNavigation 폴백이 탭을 뷰어로 교체해 권한 안내 상태를 띄운다(pdf.js 공식 확장과 동일 구조, docs/issue-1-open-ux.md §5.2). 자동 열기 토글 OFF면 두 경로 모두 비활성. +- 규칙 2는 `http://*/*`, `https://*/*` 호스트 권한이 있는 오리진에서 발동한다. 확장자 없는 PDF는 액션 클릭 시 GET+헤더 판별 후 뷰어로 전환한다. +- PDF 로드: `pdfjsLib.getDocument({ url })` 기본 사용(작은 논문 PDF 기준 range 스트리밍 불요). `file:` URL은 "파일 URL 접근 허용" 미설정 시 권한 안내 상태를 먼저 표시한다. --- diff --git a/docs/issue-1-open-ux.md b/docs/issue-1-open-ux.md new file mode 100644 index 0000000..9b8708c --- /dev/null +++ b/docs/issue-1-open-ux.md @@ -0,0 +1,240 @@ +# 이슈 #1 대응 설계 — 액션 클릭·자동 열기·로컬 파일 UX (v2) + +- 대상: [issues/1](https://github.com/enu3379/PDFViewer/issues/1) 본문 + 2026-07-07 코멘트(로컬 PDF 로드 실패) +- 작성: Claude(설계/기획). 구현은 Codex 담당 — §7 태스크가 착수 단위. +- v2 (2026-07-08): 은우 결정 반영(토스트 유지·팝업 영구 기각·뱃지 폐지) + 로컬 자동 열기 조사 결과 통합. + 조사 근거: mozilla/pdf.js 공식 크롬 확장 소스(MV3, 동일 문제의 검증된 선례) + Chrome 공식 문서·Chromium 소스. +- 상태: **구현 완료(C1–C8, 2026-07-08 Codex) + 리뷰 후속 R1–R3 반영 완료(2026-07-09, `feature/1-open-ux`).** 남은 것: 실기기 수동 QA(§8)·V1–V4. 결정 이력은 §3. + +## 1. 이슈 분해와 원인 + +| # | 보고 증상 | 원인 (코드 기준) | +|---|---|---| +| S1 | 아이콘 클릭 시 "PDF" 뱃지가 떴다 사라짐, 의미 불명 | 비PDF 탭에서 `showUnsupportedNotice`가 토스트를 페이지에 주입하는데, chrome://(새탭 포함)·웹스토어 등 주입 불가 페이지에선 폴백 `flashActionBadge`가 빨간 "PDF" 뱃지를 2.4초 표시(`sw.ts`). "PDF만 지원"이라는 뜻이지만 "이건 PDF다"로도 읽혀 역효과 | +| S2 | "PDF 자동으로 Margin에서 열기"가 효과 없어 보임 | 토글은 http(s) URL 패턴(`.pdf` 끝·arxiv)만 DNR 리다이렉트. 보고자는 로컬 `file://` PDF로 테스트 → 대상 밖. ~~DNR은 file 스킴 불가~~ → **정정(§4): DNR은 파일 액세스 토글이 켜져 있으면 file://도 잡는다.** 현 코드가 규칙을 http(s)로만 등록했을 뿐 | +| S3 | 크롬 내장 뷰어 → 아이콘 클릭 시 "Missing PDF file:///..." | 뷰어가 `file://`을 XHR로 읽으려면 확장 세부정보의 **"파일 URL에 대한 액세스 허용"** 토글 필요. 꺼져 있으면 PDF.js `MissingPDFException` → 일반 오류 화면. "됐다 안 됐다"는 unpacked 확장 삭제 후 재로드 시 토글 초기화와 일치 | +| S4 | (요청) 새탭에서 아이콘 클릭 → 바로 뷰어가 열리길 원함 | 현재는 S1 경로(뱃지)로 빠짐 | + +부수 발견 (같이 수리): ① `onInstalled`가 auto-open 체크박스를 항상 `checked: true`로 생성 → 저장값과 어긋날 수 있음(→ C4). ② 뷰어가 권한 문제와 "파일 없음"을 구분 못 하고 PDF.js 원문 오류 노출(→ C5). + +## 2. 설계 원칙 + +1. **아이콘 = "Margin 열기".** PDF 탭은 그 문서로, 빈 새탭은 빈 뷰어로 전환한다. 비PDF 콘텐츠 페이지에서는 보던 페이지를 지키고 **간결한 토스트로만** 알린다. 어떤 경우에도 의미 불명의 뱃지·무반응은 없다. **팝업 UI는 쓰지 않는다(영구 기각).** +2. **자동 열기 토글 = 라벨 그대로의 의미.** 웹 PDF와 로컬 PDF 모두에 적용한다. 권한이 없어 파일을 못 읽는 경우에도 뷰어가 받아서 이유와 1클릭 해결책을 보여준다. +3. **권한 실패를 일반 오류로 뭉개지 않는다.** 원인 설명 + 해결 버튼 + 즉시 대안(드래그&드롭·파일 다시 선택)을 한 화면에. + +로컬 파일 첫 사용 여정 (원칙 2·3): + +``` +[파일 액세스 OFF] 로컬 PDF 열기(더블클릭·다운로드 바·주소창) + → webNavigation 폴백이 탭을 Margin 뷰어로 교체 → [권한 안내 상태] + → "권한 설정 열기" → 토글 ON (확장이 리로드되며 확장 탭은 닫힘 — 알려진 크롬 동작) + → 이후 로컬 PDF는 DNR이 커밋 전에 무플래시 리다이렉트 (완전 자동) +``` + +## 3. 결정 이력 (v1 초이스의 확정) + +| 항목 | 결정 | 결정자·근거 | +|---|---|---| +| DC1 비PDF 탭 클릭 | 빈 뷰어 새탭 열기(v1 추천) **철회** → 제자리 유지 + 토스트. 단 **빈 새탭에서는 빈 뷰어로 전환**(이슈 S4 수용). 주입 불가 페이지는 `chrome.notifications` OS 토스트로 폴백 | 은우: "토스트로 간결하게 표기하는 방향이 맞다", 팝업은 최악의 디자인(영구 기각) | +| DC2 자동 열기 범위 | 로컬 포함으로 확장 — **pdf.js 검증 3단 패턴 채택**: DNR file 규칙(무플래시) + webNavigation 폴백(토글 OFF 안내) + 뷰어 권한 안내. implementation-plan §9 개정(§6) | 조사 결과(§4). v1의 `tabs.onUpdated` 안은 플래시 최다·기능 열세로 폐기 | +| DC3 권한 안내 위치 | 뷰어 상태 화면 (v1 추천안 확정) + pdf.js 디테일 채택: 설정 페이지는 **현재 탭**에서 열기, 파일 다시 선택 폴백 병설 | 은우 위임("알아서") | +| DC4 뱃지·토스트 | **뱃지만 폐지.** 페이지 내 토스트는 유지, 주입 불가 페이지는 notifications 폴백 | 은우: "다른 토스트 알림이 뜨게 변경" | + +## 4. 조사 결과 — 로컬 PDF 자동 열기 + +### 4.1 선례: pdf.js 공식 확장의 3단 구조 (MV3, 2024-09 전환 완료) + +로컬 PDF 자동 전환의 대표 확장인 pdf.js 공식 크롬 확장(`extensions/chromium/pdfHandler.js`)의 구조: + +1. **DNR 동적 규칙** — `regexFilter: "^file://.*\.pdf$"` → 뷰어로 redirect. **파일 액세스 토글 ON일 때만 매칭**되며, 요청 단계(커밋 전)에 잡히므로 **내장 뷰어 플래시가 없다.** DNR은 URL 인코딩 변환을 못 하므로 원문을 그대로 이어붙여 뷰어에서 파싱한다(Margin의 `readFileParam`은 이미 이 방식). +2. **webNavigation.onBeforeNavigate 폴백** — 토글 OFF면 DNR이 file 요청을 아예 못 보지만 webNavigation에는 보인다(호스트 권한 불필요, `"webNavigation"` 권한만). `{urlPrefix: "file://", pathSuffix: ".pdf"/".PDF"}` 필터 + `frameId === 0` → `isAllowedFileSchemeAccess()`가 false일 때만 `tabs.update`로 탭 교체. 관찰 API라 원래 내비게이션을 취소하진 못해 **내장 뷰어가 짧게 보일 수 있는 race 구조**(pdf.js도 감수). +3. **뷰어 안 권한 안내** — `isAllowedFileSchemeAccess()`(MV3 지원, Promise 가능) false면 다이얼로그: chrome://extensions 안내 + `` 폴백. chrome:// 페이지는 링크로 못 열므로 **tabs API로** `chrome://extensions/?id=<확장ID>`를 열되, **현재 탭에서** 연다 — 토글을 바꾸면 확장이 리로드되며 크롬이 확장 탭을 전부 닫기 때문. + +추가 확인: 토글 on/off 시 크롬이 확장을 **통째로 리로드**한다(Chromium `extension_util.cc`의 `SetAllowFileAccess` → `ReloadExtension`). 서비스 워커 재기동 + 열린 확장 페이지(뷰어 탭) 무효화. + +### 4.2 방법 비교 (조사 원문 요약) + +| 방법 | file:// | 필요 권한 | 타이밍 | +|---|---|---|---| +| **DNR 리다이렉트** | 토글 ON일 때만 | DNR 권한 + file 호스트 권한 + 토글 | **커밋 전, 무플래시** | +| **webNavigation.onBeforeNavigate** | 토글 무관하게 관찰 가능 | `"webNavigation"` (경고: "방문 기록 읽기") | 내비게이션 시작 시점, 짧은 플래시 가능 | +| tabs.onUpdated (v1 안) | `"tabs"` 권한이면 토글 없이 URL 보임 | `"tabs"` (동일 경고) | **커밋 후 — 플래시 최다. 폐기** | + +### 4.3 macOS / Windows 행동 (은우 질문 항목) + +| 상황 | Windows | macOS | +|---|---|---| +| file URL 형식 | `file:///C:/Users/...` + **UNC `file://server/share/...`(호스트 존재)** | `file:///Users/...` | +| 탐색기/Finder에서 PDF 더블클릭 (기본 앱 = Chrome) | 파일 경로가 chrome.exe 인수로 전달 → file:// 내비게이션으로 새 탭 | Launch Services 경유 → file:// 내비게이션. **앞에 새탭 페이지가 있으면 그 탭을 대체**(macOS 특유) | +| 다운로드 바·chrome://downloads "열기" | 둘 다 탭 내 file:// 내비게이션 → **인터셉트 가능.** 단 "시스템 뷰어로 열기/항상 시스템 뷰어로 열기"는 OS 앱으로 나가므로 브라우저 이벤트 자체가 없음(확장이 개입 불가) | 좌동. macOS엔 다운로드 완료 시 시스템 뷰어 자동 실행 사례 보고 있음 | +| "파일 URL 액세스 허용" 토글 | OS 차이 없음 (동일한 퍼-확장 설정). 엔터프라이즈는 정책(`file_url_navigation_allowed`)으로 대체 가능 | 좌동 | +| OS 토스트(chrome.notifications) | Win10+ 네이티브 토스트, 액션 센터 보관. 집중 지원(Focus Assist)이 숨길 수 있음 | **macOS 알림 센터 경유 — 시스템 설정에서 Chrome 알림이 꺼져 있으면 조용히 안 보임**(create는 성공해 코드로 감지 불가) | + +결론: **더블클릭·다운로드 열기 모두 두 OS에서 일반 file:// 내비게이션으로 도착하므로 같은 코드로 동작한다.** 함정 4개만 스펙에 반영하면 됨 — ① UNC 대응: DNR 정규식을 `^file:///`가 아니라 `^file://`로(§5.2), ② 시스템 뷰어 경로는 원리적으로 개입 불가(문서화만), ③ macOS 알림 억제 가능성(§5.1 잔여 한계), ④ 콜드 스타트(크롬 꺼진 상태에서 더블클릭)는 SW 기동 race가 있으나 DNR 규칙은 브라우저에 영속 등록되어 가장 안전 — 주 경로가 DNR인 이유 하나 추가. + +## 5. 확정 스펙 + +### 5.1 액션 클릭 라우팅 (`sw.ts`) + +| 탭 상태 | 동작 | +|---|---| +| 자기 확장 페이지(viewer/hub) | 무시 (no-op) | +| `file:`/`blob:` URL이 `.pdf`형 | **현재 탭**을 `viewer.html?file=`로 전환. 권한 검사 없이 — 안내는 뷰어 몫(§5.3) | +| http(s) `.pdf`형 또는 arxiv `/pdf/` | 현재 탭 전환 | +| http(s) 기타 URL | content-type 판별(GET+즉시 abort, 아래 노트): PDF → 현재 탭 전환 / **확정 비PDF → 제자리 유지 + 페이지 내 토스트**(기존 `showUnsupportedNotice` 유지) / 판별 실패 → 낙관적으로 현재 탭 전환 | +| 빈 새탭 (`chrome://newtab`, 또는 URL 미확인) | **현재 탭을 빈 뷰어로 전환** (S4) | +| 그 외 주입 불가 페이지 (chrome://settings, 웹스토어 등 URL은 보이나 스크립트 주입 불가) | **`chrome.notifications` OS 토스트** (§5.4 카피). 뱃지 폴백 삭제 | + +구현 노트: + +- content-type 판별은 `HEAD` 대신 **GET + 헤더 수신 즉시 abort**: `fetch(url, {signal, credentials:'include', cache:'no-store'})`는 헤더 도착 시 resolve → 곧바로 `controller.abort()`. 리다이렉트 추적 후 `response.url`에 `isPdfLikeUrl` 재적용(기존 유지). HEAD를 405로 막는 서버 대응. +- 토스트 폴백 순서: 페이지 주입(`scripting`) 시도 → 실패(chrome:// 등) 시 `chrome.notifications.create` (basic, icon-128). 뱃지는 어떤 경우에도 안 씀. +- 잔여 한계(수용): macOS에서 사용자가 OS 설정으로 Chrome 알림을 꺼 두면 notifications 토스트가 조용히 안 보인다. 감지 불가 — QA 항목으로만 기록(§8-Q12). +- `tab.pendingUrl`이 있으면 `tab.url`보다 우선. + +### 5.2 자동 열기 토글 (`sw.ts`) — pdf.js 패턴 이식 + +`syncInterceptRules()`가 관리하는 DNR 동적 규칙을 2개 → 3개로: + +- 규칙 1(arxiv)·2(http(s) `.pdf`) 유지. **규칙 2에 `isUrlFilterCaseSensitive: false` 명시**(`.PDF` 웹 링크도 커버). +- **규칙 3 (신규, 로컬)**: `regexFilter: '^file://.*\\.pdf$'`, `isUrlFilterCaseSensitive: false`, `resourceTypes: ['main_frame']`, action: `regexSubstitution: viewer.html?file=\0`. + - `^file:///`가 아니라 `^file://`인 이유: Windows UNC(`file://server/share/x.pdf`)는 호스트가 있음(§4.3). + - 파일 액세스 토글 ON일 때만 실제 매칭(크롬이 알아서 거름) — 커밋 전 무플래시. + - sub_frame은 v1 범위 외(임베드 PDF 미지원 — pdf.js와 의도적 차이). +- **webNavigation 폴백 (신규)**: `chrome.webNavigation.onBeforeNavigate`, 필터 `{url: [{urlPrefix:'file://', pathSuffix:'.pdf'}, {urlPrefix:'file://', pathSuffix:'.PDF'}]}`. + - 핸들러: `frameId !== 0`이면 무시 → `autoIntercept === false`면 무시 → `isAllowedFileSchemeAccess()`가 **true면 무시**(DNR이 처리) → false면 `chrome.tabs.update(tabId, {url: viewerUrl(details.url)})` → 뷰어가 권한 안내 상태 표시. + - 짧은 내장 뷰어 플래시 가능(관찰 API의 한계, pdf.js 동일) — 수용. +- 두 경로 모두 `autoIntercept`로 게이트: OFF면 규칙 3 제거 + 폴백 무시 → 로컬도 웹과 동일하게 "크롬 내장 뷰어 그대로". +- **체크박스 동기화**: `onInstalled`는 `contextMenus.removeAll()` 후 생성, `onInstalled`·`onStartup` 모두 저장값으로 `contextMenus.update('auto-open', {checked})`. + +### 5.3 뷰어 로컬 파일 상태 분기 (`viewer/main.ts`) + +`?file=`이 `file:` 스킴이면 로드 **전에** `chrome.extension.isAllowedFileSchemeAccess()`(Promise) 확인: + +| 조건 | 상태 화면 | +|---|---| +| file 스킴 && 액세스 **꺼짐** | **[권한 안내 상태]** — 카피 §5.5. 주 버튼 "권한 설정 열기" → **현재 탭**에서 `chrome://extensions/?id=` + `chrome.runtime.id` 열기(`chrome.tabs.update`). 근거: 토글 변경 시 확장 리로드로 뷰어 탭이 어차피 닫힘(§4.1) — 새 탭에 열면 죽은 탭만 남는다. update 실패 시 URL을 복사 가능한 텍스트로 폴백 노출. 보조: "파일 다시 선택"(file input) | +| file 스킴 && 액세스 켜짐 && 로드 실패가 `MissingPDFException` | **[파일 없음 상태]** — 이동/개명 안내 + 실패 경로 표시 + "다시 선택" | +| file 스킴 && 액세스 켜짐 && 그 외 예외(`InvalidPDFException` 등 손상 파일) | 기존 일반 오류 상태 (파일은 존재하므로 "찾을 수 없어요"로 안내하면 오답) | +| 그 외 로드 실패 | 기존 일반 오류 상태 유지 | + +- 예외 판별은 `error?.name === 'MissingPDFException'` (PDF.js 예외는 name 필드로 구분 — v2.1 정밀화, 리뷰 R1). + +- 두 신규 상태에서도 드래그&드롭·파일 선택 동작 유지(즉시 대안). +- UNC 주의: file URL을 화면 표시할 때 원문 그대로 쓴다(`new URL().pathname`만 쓰면 UNC 호스트 유실). + +### 5.4 manifest 변경 (`public/manifest.json`) + +| 항목 | 변경 | 이유 / 설치 경고 영향 | +|---|---|---| +| `host_permissions` | + `"file:///*"` | DNR 규칙 3의 file 매칭·리다이렉트에 필요(토글 OFF면 잠자는 권한). 매치 패턴은 file 스킴에서 호스트를 무시하므로 UNC도 커버 | +| `permissions` | + `"webNavigation"` | 토글 OFF 폴백. 경고 추가: "방문 기록 읽기" | +| `permissions` | + `"notifications"` | OS 토스트 폴백. 경고: "알림 표시" | +| `permissions` | `"declarativeNetRequest"` → `"declarativeNetRequestWithHostAccess"` | pdf.js 방식. 리다이렉트는 어차피 호스트 권한 필수라 기능 동일, "페이지 콘텐츠 차단" 경고가 사라짐 | +| `permissions` | `"scripting"` 유지 | 페이지 내 토스트가 존속하므로 (v1의 회수 검토 철회) | + +### 5.5 카피 일람 (한국어 확정 문안) + +| 위치 | 문안 | +|---|---| +| 페이지 내 토스트 (비PDF, 기존 유지) | Margin은 PDF 문서에서만 열 수 있어요. PDF 링크나 로컬 PDF 파일에서 다시 눌러 주세요. | +| OS 토스트 (신규) — 제목 | Margin | +| OS 토스트 — 본문 | PDF 문서에서만 열 수 있어요. PDF 탭에서 다시 눌러 주세요. | +| 권한 안내 — 제목 | 로컬 파일을 읽을 권한이 꺼져 있어요 | +| 권한 안내 — 본문 | 크롬은 확장 프로그램의 로컬 파일 접근을 기본으로 막아 둡니다. 확장 세부정보에서 「파일 URL에 대한 액세스 허용」(영어 UI: "Allow access to file URLs")을 켜 주세요. 켜는 순간 확장이 다시 시작되어 이 탭이 닫힐 수 있어요 — 그 후 PDF를 다시 열면 자동으로 Margin에서 열립니다. | +| 권한 안내 — 주 버튼 | 권한 설정 열기 | +| 권한 안내 — 보조 버튼 | 파일 다시 선택 | +| 권한 안내 — 대안 문구 | 지금은 PDF 파일을 이 창에 끌어다 놓아도 열 수 있어요. | +| 파일 없음 — 제목 | 파일을 찾을 수 없어요 | +| 파일 없음 — 본문 | 파일이 이동되었거나 이름이 바뀌었을 수 있어요. 다시 선택하거나 이 창에 끌어다 놓아 주세요. | +| 파일 없음 — 경로 표시 | (보조 텍스트로 실패한 file URL 원문 그대로) | +| 삭제되는 것 | 뱃지 텍스트 "PDF"와 `flashActionBadge` 전체 | +| 액션 타이틀 / 메뉴 라벨 | "Margin으로 열기" / "PDF 자동으로 Margin에서 열기" 유지 — 로컬 지원으로 라벨이 사실이 됨 | + +## 6. implementation-plan §9 개정안 (계획서 의도 변경) + +§9 마지막 불릿의 아래 서술을 교체한다 (→ C8): + +> (현행) 로컬 `file:` PDF는 애초에 리다이렉트 규칙 대상이 아니므로 항상 클릭 방식이며, "파일 URL 액세스 허용"은 뷰어가 file URL을 fetch하기 위한 권한일 뿐 자동 가로채기와 무관하다. + +> (개정) 로컬 `file:` PDF도 자동 열기 대상이다: "파일 URL 액세스 허용"이 켜져 있으면 DNR 규칙 3(`^file://.*\.pdf$`)이 커밋 전에 뷰어로 리다이렉트하고, 꺼져 있으면 webNavigation 폴백이 탭을 뷰어로 교체해 권한 안내 상태를 띄운다(pdf.js 공식 확장과 동일 구조, docs/issue-1-open-ux.md §5.2). 자동 열기 토글 OFF면 두 경로 모두 비활성. + +§1의 "PDF가 열리는 4가지 경로" 중 경로 1(자동 리다이렉트)에 로컬 규칙 3 언급 한 줄 추가. + +## 7. 구현 태스크 (Codex 담당) + +> 공통: TypeScript strict 유지, `npm run typecheck && npm test && npm run build`(Windows는 `:win`) 통과 후 커밋. 순수 로직은 `src/core/`에 두고 vitest 커버. + +- **C1 — 액션 라우팅 개편** (`src/sw.ts`) + §5.1 표대로 `chrome.action.onClicked` 재작성. `urlRespondsAsPdf`를 GET+abort로 교체. `flashActionBadge` 삭제, 주입 실패 폴백을 `chrome.notifications.create`로 교체. 수용 기준: 빈 새탭 클릭 → 현재 탭이 빈 뷰어 / https 일반 페이지 → 제자리 + 페이지 내 토스트 / chrome://settings → OS 토스트 / PDF 탭 → 전환 / 뷰어 탭 → 무동작 / 어떤 경우에도 뱃지 없음. +- **C2 — DNR 로컬 규칙** (`src/sw.ts`) + `syncInterceptRules`에 규칙 3 추가(§5.2 그대로: `^file://`, case-insensitive, main_frame). 규칙 2에 `isUrlFilterCaseSensitive: false` 추가. 수용 기준: 파일 액세스 ON + 토글 ON에서 로컬 `.pdf`/`.PDF`/UNC 경로가 내장 뷰어 플래시 없이 Margin으로 열림. 토글 OFF면 규칙 3 제거 확인. +- **C3 — webNavigation 폴백** (`src/sw.ts`) + §5.2 폴백 리스너. 수용 기준: 파일 액세스 OFF + 토글 ON에서 로컬 PDF를 열면 뷰어의 권한 안내 상태로 도착. 액세스 ON이면 이 경로가 개입하지 않음(DNR과 이중 리다이렉트 없음). autoIntercept OFF면 완전 무개입. +- **C4 — 체크박스 동기화** (`src/sw.ts`) + `onInstalled`: `contextMenus.removeAll()` → 생성 → 저장값으로 `update`. `onStartup`: `update`만. 수용 기준: `autoIntercept:false` 저장 상태에서 확장 재로드 후 메뉴가 체크 해제로 보임. +- **C5 — 뷰어 로컬 상태 화면** (`src/viewer/main.ts`, `viewer.html`, `src/viewer/viewer.css`) + §5.3 분기 + §5.5 카피. 기존 `showOnly` 상태 패턴에 2종 추가. "권한 설정 열기"는 **현재 탭** `chrome.tabs.update`. 수용 기준: 액세스 OFF에서 file PDF 진입 시 "Missing PDF" 대신 권한 안내가 뜨고, 버튼이 확장 세부정보를 현재 탭에 연다. 신규 상태에서 드래그&드롭 동작. +- **C6 — manifest 갱신** (`public/manifest.json`) + §5.4 표 그대로. 수용 기준: 빌드 후 unpacked 재로드 시 권한 경고 확인·문서화, 기존 웹 인터셉트 회귀 없음. +- **C7 — 순수 헬퍼 + 테스트** (`src/core/pdf-url.ts`, `test/pdf-url.test.ts`) + 라우팅 판별을 순수 함수로 추출(`isLocalPdfUrl` 등). 신규 케이스 테스트: `.PDF` 대문자, 쿼리 포함 file URL, **UNC `file://server/share/x.pdf`**, `chrome://newtab` 판별. +- **C8 — 문서 갱신** (`docs/implementation-plan.md`, `docs/windows-local-loading.md`, `docs/progress.md`) + §6 개정안 반영, Windows 문서에 UNC·더블클릭 경로 추가, progress.md 완료 기록. + +구현 중 실기기 검증 (결과를 §8 비고에 추기): + +- **V1**: webNavigation 폴백의 내장 뷰어 플래시 체감 길이 (수용 가능 수준인지). +- **V2**: 크롬 종료 상태에서 PDF 더블클릭(콜드 스타트) 시 DNR 리다이렉트 동작 — 규칙 영속성 확인. +- **V3**: 빈 새탭에서 `action.onClicked`의 `tab.url` 실값 (라우팅은 어느 쪽이든 동작하나 기록용). +- **V4**: 토글 변경 → 확장 리로드 시 열린 뷰어 탭의 최종 상태 (닫힘/죽은 탭 — 카피가 이미 안내하므로 확인만). + +해결된 v1 검증 항목: ~~tabs.onUpdated URL 가시성~~(방식 폐기), ~~chrome:// 열기 가능 여부~~(tabs API로 가능 — pdf.js 확증), ~~토글 시 확장 재시작 여부~~(재시작 확정 — Chromium 소스). + +### 7.1 구현 리뷰 노트 (2026-07-08, Claude — C1–C8 스펙 준수 리뷰) + +**확인 완료**: §5.1 라우팅 표 전 분기 일치(자기 페이지 no-op·빈 새탭 빈 뷰어·낙관 전환·토스트 이원화·뱃지 완전 삭제), §5.2 DNR 3규칙(`^file://`·case-insensitive·main_frame)과 webNavigation 폴백 게이트 순서(frameId → autoIntercept → 파일 액세스, 이중 리다이렉트 없음), §5.3 사전 권한 검사, §5.4 manifest 정확 일치, §5.5 카피 전문 일치, 체크박스 동기화(removeAll + update)와 설정 병합 저장 유지, UNC 포함 테스트. typecheck·test(18)·build 리뷰어 재현 통과. `@types/chrome`의 `WebNavigationBaseCallbackDetails`에 `url`/`frameId` 포함 확인. + +**후속** (2026-07-09 Claude가 직접 반영 완료 — 아래는 기록): + +- **R1 (권장)** — `viewer/main.ts` `loadUrl` catch: 로컬 파일의 모든 예외를 파일 없음 상태로 보냄. 손상 PDF(`InvalidPDFException`)면 파일이 존재하는데 "찾을 수 없어요"로 안내하는 오답이 됨. §5.3 정밀화(v2.1)대로 `error?.name === 'MissingPDFException'`일 때만 파일 없음, 그 외는 일반 오류로. +- **R2 (사소)** — `sw.ts` `handleLocalPdfNavigation`의 `chrome.tabs.update`를 try/catch로: 내비게이션 중 탭이 닫히는 레이스에서 unhandled rejection 로그 방지. +- **R3 (선택)** — `isChromeNewTabUrl(undefined) === true`는 이름과 의미가 어긋남(빈 값 처리는 호출부 `!rawUrl ||`에 이미 있음). 헬퍼는 URL 판별만 하도록 정리해도 됨. 동작 영향 없음. +- 참고: 이번 diff에 이전 세션의 `escapeHtml` → `core/format` 이동 리팩터가 섞여 있음 — 커밋 시 별도 커밋으로 분리 권장. + +## 8. QA 시나리오 (macOS + Windows 11 각각, 표기 없으면 공통) + +> 실행용 체크리스트는 [issue-1-qa.md](issue-1-qa.md) — OS별로 분리된 단계·기대 결과·준비물 포함. 아래는 설계 시점의 시나리오 원본. + +1. 빈 새탭에서 아이콘 클릭 → 현재 탭이 빈 뷰어로. 뱃지 없음. +2. 일반 https 페이지에서 클릭 → 제자리 유지 + 페이지 내 토스트. +3. chrome://settings 에서 클릭 → OS 토스트 표시. +4. 토글 ON: arxiv PDF·`.pdf`·`.PDF` URL 자동 전환. 토글 OFF: 내장 뷰어 + 아이콘 클릭으로만 전환. +5. 확장자 없는 웹 PDF(content-type만)에서 아이콘 클릭 → 전환. +6. 파일 액세스 ON + 토글 ON: 로컬 PDF를 주소창·더블클릭(탐색기/Finder)·다운로드 바 각각으로 열기 → 전부 무플래시 자동 전환. (macOS) 새탭이 앞에 있을 때 더블클릭 → 그 탭이 대체되어 열림. +7. (Windows) UNC 경로 `\\server\share\x.pdf` → 자동 전환 + 정상 로드 + 경로 표기 무손실. +8. 파일 액세스 OFF + 토글 ON: 로컬 PDF 열기 → 뷰어 권한 안내 상태. "권한 설정 열기" → 현재 탭에 세부정보. 토글 켬 → 확장 리로드 → 같은 파일 재열기 → 자동 전환. +9. 파일 액세스 OFF + 크롬 내장 뷰어에서 아이콘 클릭 → 권한 안내 상태(Missing PDF 아님). 그 화면에서 드래그&드롭으로도 열림. +10. 존재하지 않는 file 경로 → 파일 없음 상태 + 경로 표시. +11. `autoIntercept:false` 상태에서 확장 재로드·브라우저 재시작 → 메뉴 체크 상태 일치. 토글 조작 후 펜 테마 설정 보존. +12. (macOS) 시스템 설정에서 Chrome 알림 OFF → 시나리오 3의 토스트가 안 보임을 확인하고 알려진 한계로 기록. +13. 다운로드 항목 우클릭 "시스템 뷰어로 열기" → Margin 미개입(정상 — 브라우저 밖 경로). + +## 9. 이슈 답변 초안 (영문 — 은우 검토 후 게시) + +> Thanks for the detailed report — you hit real gaps, and this area is being reworked. Clarifying the current behavior first: +> +> - **Icon click** opens the current tab's PDF in Margin. On non-PDF pages it shows a small in-page notice, but on pages where that can't be injected (new tab, chrome:// pages) it fell back to a cryptic "PDF" badge. That badge is being removed: non-PDF pages will show a proper toast (OS notification where in-page injection isn't possible), and clicking the icon on a blank new tab will open the empty Margin viewer directly (your feature request). +> - **"Open PDF automatically in Margin"** currently only auto-redirects *web* (http/https) PDF URLs — local `file://` PDFs were out of its scope, which is why you saw no effect while testing local files. This is being extended: local PDFs will also open in Margin automatically (same approach as the official PDF.js extension). +> - **"Missing PDF" on local files**: Chrome blocks extensions from reading `file://` URLs unless you enable **"Allow access to file URLs"** on the extension's details page (`chrome://extensions` → Margin → Details). That toggle resets when the unpacked extension is removed and re-added — matching the intermittent behavior you saw. Enabling it should fix the error today. The viewer will also be updated to detect this case and show a guide screen (with a button to that settings page and drag-and-drop/file-picker fallbacks) instead of the raw "Missing PDF" error. +> +> Design doc: `docs/issue-1-open-ux.md`. + +## 10. 분담 요약 + +| 담당 | 산출물 | +|---|---| +| Claude (설계/기획) | 본 문서(원인·조사·스펙·카피·QA 설계), 계획서 개정안(§6), 이슈 답변 초안(§9), 구현 후 스펙 준수 리뷰, V1–V4 결과 반영 | +| Codex (코딩) | C1–C8 구현, V1–V4 실기기 검증 회신, 커밋·PR | +| 은우 | 최종 QA(§8), §9 게시 여부 결정 | diff --git a/docs/issue-1-qa.md b/docs/issue-1-qa.md new file mode 100644 index 0000000..d5092a7 --- /dev/null +++ b/docs/issue-1-qa.md @@ -0,0 +1,177 @@ +# 이슈 #1 수동 QA 가이드 (`feature/1-open-ux`) + +확장 아이콘 라우팅·로컬 PDF 자동 열기·파일 권한 안내 기능의 실기기 검증 체크리스트. +Windows와 macOS 섹션이 **각각 독립적**이므로 자기 OS 섹션만 위에서 아래로 따라가면 된다. +예상 소요: OS당 20–30분. 배경 설계는 [issue-1-open-ux.md](issue-1-open-ux.md) 참고(안 읽어도 QA 가능). + +**결과 기록**: 각 항목의 체크박스를 채우고, 실패한 항목은 번호(예: `W-12 실패`)와 함께 +화면 캡처 + 서비스 워커 콘솔 로그를 이슈 #1 코멘트로 남긴다. +서비스 워커 콘솔: `chrome://extensions` → Margin 카드의 **"서비스 워커"** 링크 클릭. + +**관측 기록 항목** (버그가 아니라 값을 적는 것): V1 폴백 플래시 길이, V2 콜드 스타트 동작, +V4 권한 토글 후 뷰어 탭 상태 — 아래 체크리스트의 해당 항목에 표시해 뒀다. + +--- + +## Windows 11 체크리스트 + +### W-준비 + +- [ ] W-1. 브랜치 준비: `git fetch origin && git checkout feature/1-open-ux` + (이 머신은 `git -c safe.directory=... ` 프리픽스 필요할 수 있음 — windows-local-loading.md 참고) +- [ ] W-2. 빌드: `npm.cmd ci` → `npm.cmd run build:win` (실패 시 `npm run build`) +- [ ] W-3. `chrome://extensions` → 개발자 모드 ON → **기존 Margin 확장 제거** 후 `dist/`를 새로 로드. + ⚠️ manifest 권한이 바뀌어 리로드로는 부족하고, 제거 시 저장된 메모·설정이 초기화된다(테스트 데이터만 있는지 확인). +- [ ] W-4. 로드 직후 확장 카드의 권한 경고 문구를 그대로 기록해 둔다(스토어 심사 대비 자료). +- [ ] W-5. 테스트 파일 준비: + 아무 PDF 하나를 바탕화면에 복사 → 사본 이름을 `내 논문 테스트.pdf`(한글+공백)로, + 또 하나를 `SAMPLE.PDF`(대문자 확장자)로. 손상 파일은 PowerShell에서 + `Set-Content broken.pdf 'hello'`로 생성. +- [ ] W-6. 확장 세부정보에서 **"파일 URL에 대한 액세스 허용"이 꺼져 있는지 확인**(켜져 있으면 끄기 — 아래 순서가 OFF → ON 순서로 설계됨). + +### W-A. 아이콘 클릭 라우팅 + +- [ ] W-7. 빈 새탭에서 아이콘 클릭 → 현재 탭이 빈 뷰어(드래그&드롭 대기 화면)로 바뀐다. 뱃지 없음. +- [ ] W-8. 일반 웹페이지(예: https://arxiv.org/abs/1706.03762)에서 클릭 → 페이지는 그대로, + 우상단에 "Margin은 PDF 문서에서만 열 수 있어요…" 토스트가 떴다 사라진다. +- [ ] W-9. `chrome://settings`에서 클릭 → Windows 알림(액션 센터 토스트)으로 같은 안내가 뜬다. + 집중 지원(Focus Assist)이 켜져 있으면 억제될 수 있음 — 그 경우 상태를 기록. +- [ ] W-10. Margin 뷰어 탭 자체에서 아이콘 클릭 → 아무 일도 일어나지 않는다. +- [ ] W-11. 확장자 없는 웹 PDF(예: https://openreview.net 의 아무 논문 `pdf?id=…` 링크)에서 클릭 + → 잠깐의 판별 후 현재 탭이 Margin으로 전환된다. + +### W-B. 자동 열기 (웹) + +- [ ] W-12. 자동 열기 ON(아이콘 우클릭 메뉴 체크 확인) 상태에서 https://arxiv.org/pdf/1706.03762 진입 + → 주소창부터 Margin 뷰어로 열린다(내장 뷰어 안 보임). +- [ ] W-13. `.pdf`로 끝나는 아무 웹 URL(예: https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf) + → 자동으로 Margin. +- [ ] W-14. 아이콘 우클릭 → "PDF 자동으로 Margin에서 열기" 체크 해제 → W-12 URL 재진입 + → 크롬 내장 뷰어로 열리고, 아이콘 클릭 시에만 Margin으로 전환된다. 확인 후 다시 ON. +- [ ] W-15. 토글을 껐다 켠 뒤 뷰어에서 형광펜 팔레트(소다/클래식) 설정이 유지되는지 확인(병합 저장 회귀). +- [ ] W-16. `chrome://extensions`에서 Margin 새로고침(리로드) → 우클릭 메뉴의 체크 상태가 + 실제 설정과 일치한다(예: OFF로 두고 리로드하면 체크 해제로 보임). 확인 후 ON으로. + +### W-C. 로컬 파일 — 파일 액세스 OFF 상태 (순서 중요) + +- [ ] W-17. 주소창에 `file:///C:/Users/<사용자>/Desktop/내 논문 테스트.pdf` 입력(또는 탐색기에서 Chrome으로 열기) + → 크롬 내장 뷰어가 아니라 **Margin의 "로컬 파일을 읽을 권한이 꺼져 있어요" 안내 화면**에 도착한다. + "Missing PDF" 오류가 아니어야 한다. 내장 뷰어가 잠깐 보였다 전환될 수 있음 — + **[V1 기록]** 플래시가 몇 초쯤인지, 거슬리는 수준인지 적기. +- [ ] W-18. 그 안내 화면에서 PDF를 드래그&드롭 → 정상으로 열린다(권한 없이도 대안 동작). +- [ ] W-19. 안내 화면의 "권한 설정 열기" 버튼 → **현재 탭**이 Margin 세부정보 페이지로 바뀐다. +- [ ] W-20. 세부정보에서 "파일 URL에 대한 액세스 허용" ON → 확장이 다시 시작된다. + **[V4 기록]** 이때 열려 있던 Margin 탭들이 어떻게 되는지(닫힘/오류 페이지) 적기. + +### W-D. 로컬 파일 — 파일 액세스 ON 상태 + +- [ ] W-21. W-17과 같은 파일 재진입 → 이번엔 **내장 뷰어 플래시 없이** 곧장 Margin으로 열린다(한글+공백 파일명 정상). +- [ ] W-22. `SAMPLE.PDF`(대문자) → 자동으로 Margin. +- [ ] W-23. 탐색기에서 PDF 더블클릭(기본 앱이 Chrome일 때, 아니면 우클릭 → 연결 프로그램 → Chrome) → 자동으로 Margin. +- [ ] W-24. Chrome을 **완전히 종료**한 뒤 탐색기에서 PDF 더블클릭 → Chrome이 켜지면서 Margin으로 열린다. + **[V2 기록]** 콜드 스타트에서 실패하면 재현 빈도 적기. +- [ ] W-25. 아무 PDF를 웹에서 다운로드 → 하단 다운로드 바(또는 chrome://downloads)에서 클릭 → Margin으로 열린다. +- [ ] W-26. 다운로드 항목 우클릭 → "시스템 뷰어로 열기" → OS 기본 앱으로 열림(Margin 미개입이 **정상**). +- [ ] W-27. (가능한 경우만) 네트워크 공유의 UNC 경로 `\\server\share\x.pdf` → 자동으로 Margin, 로드 정상. +- [ ] W-28. `broken.pdf`(손상 파일) 열기 → **"PDF를 열 수 없습니다" 일반 오류**가 뜬다. + "파일을 찾을 수 없어요"가 뜨면 버그. +- [ ] W-29. `내 논문 테스트.pdf`를 다른 폴더로 이동 → 방문 기록에서 이전 file:// URL 재진입 + → **"파일을 찾을 수 없어요"** + 경로 표시 + "파일 다시 선택" 동작. +- [ ] W-30. 자동 열기 OFF + 파일 액세스 ON 조합 → 로컬 PDF가 크롬 내장 뷰어로 열린다(Margin 무개입). + 확인 후 자동 열기 ON 복구. + +### W-E. PDF 저장 (2026-07-09 추가) + +- [ ] W-31. 웹 PDF를 연 상태에서 툴바 우측 "저장" 클릭 → 원본 파일명으로 다운로드되고, 받은 파일을 열면 같은 문서다. +- [ ] W-32. 같은 상태에서 `Ctrl+S` → 동일하게 저장되고, 브라우저의 "페이지 저장" 대화상자는 뜨지 않는다. +- [ ] W-33. 드래그&드롭으로 연 PDF도 저장된다(원본 파일명 유지). 빈 뷰어에서는 "저장" 버튼이 비활성이다. + +--- + +## macOS 체크리스트 + +### M-준비 + +- [ ] M-1. 브랜치 준비: `git fetch origin && git checkout feature/1-open-ux` +- [ ] M-2. 빌드: `npm ci && npm run build` +- [ ] M-3. `chrome://extensions` → 개발자 모드 ON → **기존 Margin 확장 제거** 후 `dist/`를 새로 로드. + ⚠️ manifest 권한이 바뀌어 리로드로는 부족하고, 제거 시 저장된 메모·설정이 초기화된다(테스트 데이터만 있는지 확인). +- [ ] M-4. 로드 직후 확장 카드의 권한 경고 문구를 그대로 기록해 둔다(스토어 심사 대비 자료). +- [ ] M-5. 테스트 파일 준비: + 아무 PDF 하나를 데스크톱에 복사 → 사본 이름을 `내 논문 테스트.pdf`(한글+공백)로, + 또 하나를 `SAMPLE.PDF`(대문자 확장자)로. 손상 파일은 `echo hello > ~/Desktop/broken.pdf`. +- [ ] M-6. 확장 세부정보에서 **"파일 URL에 대한 액세스 허용"이 꺼져 있는지 확인**(켜져 있으면 끄기 — 아래 순서가 OFF → ON 순서로 설계됨). + +### M-A. 아이콘 클릭 라우팅 + +- [ ] M-7. 빈 새탭에서 아이콘 클릭 → 현재 탭이 빈 뷰어(드래그&드롭 대기 화면)로 바뀐다. 뱃지 없음. +- [ ] M-8. 일반 웹페이지(예: https://arxiv.org/abs/1706.03762)에서 클릭 → 페이지는 그대로, + 우상단에 "Margin은 PDF 문서에서만 열 수 있어요…" 토스트가 떴다 사라진다. +- [ ] M-9. `chrome://settings`에서 클릭 → macOS 알림 센터 배너로 같은 안내가 뜬다. +- [ ] M-10. 시스템 설정 → 알림에서 Chrome(및 "Google Chrome Helper (Alerts)")을 잠시 꺼두고 M-9 반복 + → 알림이 조용히 안 뜬다. 이는 **알려진 한계**(버그 아님) — 확인만 하고 알림을 다시 켠다. +- [ ] M-11. Margin 뷰어 탭 자체에서 아이콘 클릭 → 아무 일도 일어나지 않는다. +- [ ] M-12. 확장자 없는 웹 PDF(예: https://openreview.net 의 아무 논문 `pdf?id=…` 링크)에서 클릭 + → 잠깐의 판별 후 현재 탭이 Margin으로 전환된다. + +### M-B. 자동 열기 (웹) + +- [ ] M-13. 자동 열기 ON(아이콘 우클릭 메뉴 체크 확인) 상태에서 https://arxiv.org/pdf/1706.03762 진입 + → 주소창부터 Margin 뷰어로 열린다(내장 뷰어 안 보임). +- [ ] M-14. `.pdf`로 끝나는 아무 웹 URL(예: https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf) + → 자동으로 Margin. +- [ ] M-15. 아이콘 우클릭 → "PDF 자동으로 Margin에서 열기" 체크 해제 → M-13 URL 재진입 + → 크롬 내장 뷰어로 열리고, 아이콘 클릭 시에만 Margin으로 전환된다. 확인 후 다시 ON. +- [ ] M-16. 토글을 껐다 켠 뒤 뷰어에서 형광펜 팔레트(소다/클래식) 설정이 유지되는지 확인(병합 저장 회귀). +- [ ] M-17. `chrome://extensions`에서 Margin 새로고침(리로드) → 우클릭 메뉴의 체크 상태가 + 실제 설정과 일치한다(예: OFF로 두고 리로드하면 체크 해제로 보임). 확인 후 ON으로. + +### M-C. 로컬 파일 — 파일 액세스 OFF 상태 (순서 중요) + +- [ ] M-18. 주소창에 `file:///Users/<사용자>/Desktop/내 논문 테스트.pdf` 입력 + → 크롬 내장 뷰어가 아니라 **Margin의 "로컬 파일을 읽을 권한이 꺼져 있어요" 안내 화면**에 도착한다. + "Missing PDF" 오류가 아니어야 한다. 내장 뷰어가 잠깐 보였다 전환될 수 있음 — + **[V1 기록]** 플래시가 몇 초쯤인지, 거슬리는 수준인지 적기. +- [ ] M-19. 그 안내 화면에서 PDF를 드래그&드롭 → 정상으로 열린다(권한 없이도 대안 동작). +- [ ] M-20. 안내 화면의 "권한 설정 열기" 버튼 → **현재 탭**이 Margin 세부정보 페이지로 바뀐다. +- [ ] M-21. 세부정보에서 "파일 URL에 대한 액세스 허용" ON → 확장이 다시 시작된다. + **[V4 기록]** 이때 열려 있던 Margin 탭들이 어떻게 되는지(닫힘/오류 페이지) 적기. + +### M-D. 로컬 파일 — 파일 액세스 ON 상태 + +- [ ] M-22. M-18과 같은 파일 재진입 → 이번엔 **내장 뷰어 플래시 없이** 곧장 Margin으로 열린다(한글+공백 파일명 정상). +- [ ] M-23. `SAMPLE.PDF`(대문자) → 자동으로 Margin. +- [ ] M-24. Finder에서 PDF 더블클릭(기본 앱이 Chrome일 때, 아니면 우클릭 → 다음으로 열기 → Chrome) → 자동으로 Margin. + 빈 새탭이 앞에 있으면 새 탭 대신 **그 탭이 대체**되는 게 macOS의 정상 동작. +- [ ] M-25. Chrome을 **완전히 종료(⌘Q)**한 뒤 Finder에서 PDF 더블클릭 → Chrome이 켜지면서 Margin으로 열린다. + **[V2 기록]** 콜드 스타트에서 실패하면 재현 빈도 적기. +- [ ] M-26. 아무 PDF를 웹에서 다운로드 → 하단 다운로드 바(또는 chrome://downloads)에서 클릭 → Margin으로 열린다. + 다운로드 완료 시 OS 미리보기 앱이 바로 뜨는 경우는 "항상 시스템 뷰어로 열기"가 설정된 것 — 그 경로는 Margin 미개입이 **정상**. +- [ ] M-27. `broken.pdf`(손상 파일) 열기 → **"PDF를 열 수 없습니다" 일반 오류**가 뜬다. + "파일을 찾을 수 없어요"가 뜨면 버그. +- [ ] M-28. `내 논문 테스트.pdf`를 다른 폴더로 이동 → 방문 기록에서 이전 file:// URL 재진입 + → **"파일을 찾을 수 없어요"** + 경로 표시 + "파일 다시 선택" 동작. +- [ ] M-29. 자동 열기 OFF + 파일 액세스 ON 조합 → 로컬 PDF가 크롬 내장 뷰어로 열린다(Margin 무개입). + 확인 후 자동 열기 ON 복구. + +### M-E. PDF 저장 (2026-07-09 추가) + +- [ ] M-30. 웹 PDF를 연 상태에서 툴바 우측 "저장" 클릭 → 원본 파일명으로 다운로드되고, 받은 파일을 열면 같은 문서다. +- [ ] M-31. 같은 상태에서 `⌘S` → 동일하게 저장되고, 브라우저의 "페이지 저장" 대화상자는 뜨지 않는다. +- [ ] M-32. 드래그&드롭으로 연 PDF도 저장된다(원본 파일명 유지). 빈 뷰어에서는 "저장" 버튼이 비활성이다. + +--- + +## 기록 (진행 중) + +- **macOS M-4 (2026-07-09)**: unpacked 로드는 권한 승인 대화상자가 뜨지 않음(정상 — 개발자 모드 자동 부여). + 세부정보 페이지 권한 목록: "방문 기록 확인"(webNavigation) / "알림 표시"(notifications) / + "방문하는 모든 웹사이트의 데이터 읽기·변경"(host). 스토어 설치 시 이 세 줄이 설치 경고로 노출될 예정. +- **macOS 관측**: 제거 후 같은 경로(`~/PDFViewer/dist`)로 재로드하면 확장 ID가 동일해 + "파일 URL에 대한 액세스 허용" 상태가 이전 값(ON)으로 이어짐 — QA 시작 전 수동으로 OFF 필요했음. + +## 완료 후 + +- 실패 항목이 없으면 이슈 #1에 "QA 통과 (OS, 항목 수)" 코멘트, 있으면 항목 번호별로 증상 코멘트. +- V1·V2·V4 관측값은 통과 여부와 무관하게 코멘트에 포함(설계 문서 §7의 검증 항목 마감용). +- 양쪽 OS 완료 시 progress.md의 "Issue #1 manual QA" 블록을 결과로 갱신하고 PR 생성 여부를 결정한다. diff --git a/docs/progress.md b/docs/progress.md index 6c619da..8bd1392 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,6 +1,6 @@ # Progress Log -Last updated: 2026-07-07 +Last updated: 2026-07-08 ## Current State @@ -31,6 +31,20 @@ M0 and M1 are complete. The first M2 implementation pass is complete and pushed, - `core/pen-theme.ts`(테마 목록·라벨·슬롯 이름·순환) + 유닛 테스트, 메모 탭 펜 줄의 팔레트 토글 버튼, `margin:settings.penTheme` 저장. - `MarginStore.loadSettings/updateSettings` 추가(병합 저장), sw.ts 자동 열기 토글이 settings를 통째로 덮어쓰던 문제 수정. - 메모 작성 카드 Enter 저장 (2026-07-08): Enter = 저장(Esc 취소와 대칭), Shift+Enter = 줄바꿈, 한글 IME 조합 확정 Enter는 `isComposing` 가드로 무시. 저장 로직은 버튼과 공용(`#saveCompose`). 작성 카드 힌트 문구에 키 안내 추가. +- 이슈 #1 open UX 구현 (2026-07-08): C1-C8 코드/문서 반영. + - 액션 클릭 라우팅 개편: 빈 새탭은 빈 뷰어로 전환, 비PDF http(s)는 제자리 토스트, 주입 불가 페이지는 OS 알림, PDF형 URL은 현재 탭에서 뷰어 전환, 확장 페이지는 no-op. + - 뱃지 폴백 제거, content-type 판별을 GET+헤더 수신 후 abort 방식으로 변경. + - DNR 규칙 3 추가: `^file://.*\.pdf$` 로컬 PDF 자동 리다이렉트, 규칙 2 case-insensitive 처리. + - 파일 접근 OFF용 `webNavigation.onBeforeNavigate` 폴백 추가, 자동 열기 토글 OFF 시 무개입. + - 컨텍스트 메뉴 `auto-open` 체크 상태를 저장값과 동기화하고, 설정 병합 저장으로 펜 테마 보존. + - 뷰어에 로컬 파일 권한 안내 상태와 파일 없음 상태 추가. 권한 설정 버튼은 현재 탭을 `chrome://extensions/?id=`로 전환하고, 실패 시 URL 텍스트를 노출. + - manifest 권한 갱신: `declarativeNetRequestWithHostAccess`, `webNavigation`, `notifications`, `file:///*`. + - URL 판별 헬퍼/테스트와 implementation-plan/windows-local-loading 문서 갱신. + +- 뷰어 PDF 저장 기능 (2026-07-09): 툴바 "저장" 버튼 + Ctrl/⌘+S — 내장 뷰어를 대체하며 사라졌던 저장 경로 복원. + - PDF.js `getData()` 바이트를 blob 앵커로 저장: 재다운로드 없음(오프라인·드래그&드롭 문서도 동작), 권한 추가 없음. + - 파일명은 원본 basename 유지(`.pdf` 보정, 금지 문자 치환), 문서 로드 전에는 버튼 비활성. Ctrl/⌘+S는 브라우저 "페이지 저장" 대화상자를 preventDefault로 대체. + - 좁은 창에서 툴바 텍스트가 글자 단위로 꺾이던 문제 수정(nowrap + 툴바 overflow-x 스크롤). ## Needs QA @@ -44,9 +58,21 @@ M0 and M1 are complete. The first M2 implementation pass is complete and pushed, - Click the extension action on a non-PDF webpage and confirm the current tab stays put with an unsupported-document notice. - Reload the unpacked extension and confirm the new icon shows in the toolbar and `chrome://extensions`, and the restyled viewer (ghost toolbar, amber tab dot, boxless empty state) renders on a real arXiv PDF. - Switch the pen palette to 소다, confirm existing highlights/dots/selection recolor, reload the tab and confirm the theme persists, then toggle 자동 열기 in the action context menu and confirm the theme setting survives (merge-write fix). +- Issue #1 manual QA — 실행 가이드는 [issue-1-qa.md](issue-1-qa.md) (Windows/macOS 분리 체크리스트): + - 빈 새탭 아이콘 클릭, 일반 https 비PDF 토스트, chrome://settings OS 알림, 뷰어 탭 no-op. + - autoIntercept ON/OFF에서 arXiv, `.pdf`, `.PDF`, 로컬 file PDF, Windows UNC 경로 동작. + - 파일 접근 OFF에서 로컬 PDF가 권한 안내 상태로 도착하고 "권한 설정 열기"가 현재 탭을 확장 세부정보로 전환하는지 확인. + - 파일 접근 ON에서 로컬 PDF가 DNR로 무플래시 전환되는지, OFF 폴백의 내장 뷰어 플래시가 수용 가능한지 확인. + - Chrome 종료 상태에서 PDF 더블클릭 콜드 스타트 DNR 영속성 확인. + - 파일 접근 토글 변경 시 열린 뷰어 탭 최종 상태 확인. + - 존재하지 않는 로컬 경로(이동/개명된 파일)가 파일 없음 상태로 도착하는지 확인. + - (macOS) 시스템 설정에서 Chrome 알림 OFF 시 OS 알림이 조용히 누락되는 알려진 한계 확인. + - 다운로드 항목 "시스템 뷰어로 열기"는 Margin 미개입이 정상임을 확인. ## 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 구현 착수 가능. - 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/docs/windows-local-loading.md b/docs/windows-local-loading.md index f5c0816..621bd3d 100644 --- a/docs/windows-local-loading.md +++ b/docs/windows-local-loading.md @@ -8,6 +8,10 @@ This note captures the Windows-specific loading and QA path verified on - Added Windows fallback npm scripts that call Node through `scripts/run-node.cmd`. - Added a resizable right-side viewer panel. - Enabled default PDF interception for `http://*/*` and `https://*/*` PDF URLs. +- Extended automatic interception to local `file://` PDF URLs, including Windows + UNC-style URLs such as `file://server/share/x.pdf`. +- Added a `webNavigation` fallback that opens the Margin viewer with a file-access + guide when Chrome's "Allow access to file URLs" toggle is off. - Added local PDF drag-and-drop loading in `viewer.html`. - Documented the Chrome extension reload and permission steps below. @@ -38,6 +42,17 @@ already-loaded extension. - Remote PDF URL: should redirect into `viewer.html?file=...` through `declarativeNetRequest`. +- Local file URL with file access allowed: should redirect into + `viewer.html?file=...` through `declarativeNetRequest` before Chrome's built-in + PDF viewer flashes. +- Local file URL with file access disabled: should be observed by + `webNavigation` and replaced with the Margin viewer's file-access guide. +- Double-clicking a PDF in File Explorer when Chrome is the default PDF handler + enters the same `file://` navigation path. Opening a PDF from Chrome downloads + also enters this path unless the user chooses a system-viewer action outside + the browser. +- UNC path coverage: `\\server\share\x.pdf` maps to a `file://server/share/x.pdf` + URL, so interception must match `^file://` rather than only `^file:///`. - Toolbar fallback: click the extension action on a PDF tab to open the current URL in Margin. - Local PDF drag and drop: drag a `.pdf` file onto the empty viewer page. @@ -51,6 +66,15 @@ already-loaded extension. - `npm.cmd run build:win` creates `dist/manifest.json`, `dist/sw.js`, and `dist/viewer.html`. - After reloading `dist/`, a normal HTTPS PDF link opens in Margin by default. +- With "Allow access to file URLs" enabled, local `.pdf` and `.PDF` files open + in Margin automatically from the address bar, File Explorer double-click, and + Chrome downloads. +- With "Allow access to file URLs" disabled and automatic open enabled, local PDF + navigation lands on the Margin file-access guide instead of a raw "Missing PDF" + error. +- With automatic open disabled, local PDFs remain in Chrome's built-in viewer. +- A UNC PDF path such as `\\server\share\x.pdf` redirects and displays the + original `file://server/share/x.pdf` path without dropping the host. - Dragging a local `.pdf` onto the empty viewer loads the document. - The right panel can be resized and keeps its width after refresh. @@ -60,3 +84,7 @@ already-loaded extension. commands use `git -c safe.directory=C:/Users/jakec/Downloads/PDFViewer ...`. - Node is installed under `D:\Program Files`; the fallback scripts avoid npm shims losing `node.exe` when PATH quoting is fragile. +- Chrome's "Allow access to file URLs" is a per-extension toggle. Removing and + re-adding the unpacked extension can reset it. +- Chrome reloads the extension when the file-access toggle changes, so any open + extension viewer tab may close or become invalid. diff --git a/public/manifest.json b/public/manifest.json index a32e955..52e78ba 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -20,8 +20,16 @@ "service_worker": "sw.js", "type": "module" }, - "permissions": ["storage", "declarativeNetRequest", "activeTab", "contextMenus", "scripting"], - "host_permissions": ["http://*/*", "https://*/*"], + "permissions": [ + "storage", + "declarativeNetRequestWithHostAccess", + "activeTab", + "contextMenus", + "scripting", + "webNavigation", + "notifications" + ], + "host_permissions": ["http://*/*", "https://*/*", "file:///*"], "web_accessible_resources": [ { "resources": ["viewer.html"], diff --git a/src/core/pdf-url.ts b/src/core/pdf-url.ts index b574b26..0b95de3 100644 --- a/src/core/pdf-url.ts +++ b/src/core/pdf-url.ts @@ -1,4 +1,10 @@ const VIEWABLE_SCHEMES = new Set(['http:', 'https:', 'file:', 'blob:']); +const CHROME_NEW_TAB_URLS = new Set([ + 'chrome://newtab/', + 'chrome://new-tab-page/', + 'chrome-search://local-ntp/local-ntp.html', + 'chrome-search://newtab/' +]); export function parseViewableUrl(raw: string): URL | null { let url: URL; @@ -14,12 +20,21 @@ export function isHttpUrl(url: URL): boolean { return url.protocol === 'http:' || url.protocol === 'https:'; } +export function isFileUrl(url: URL): boolean { + return url.protocol === 'file:'; +} + export function isPdfContentType(contentType: string | null): boolean { if (!contentType) return false; const mime = contentType.split(';', 1)[0]?.trim().toLowerCase(); return mime === 'application/pdf' || mime === 'application/x-pdf'; } +export function isLocalPdfUrl(raw: string): boolean { + const url = parseViewableUrl(raw); + return Boolean(url && isFileUrl(url) && url.pathname.toLowerCase().endsWith('.pdf')); +} + export function isPdfLikeUrl(raw: string): boolean { const url = parseViewableUrl(raw); if (!url) return false; @@ -28,3 +43,13 @@ export function isPdfLikeUrl(raw: string): boolean { if (pathname.endsWith('.pdf')) return true; return url.protocol === 'https:' && url.hostname === 'arxiv.org' && pathname.startsWith('/pdf/'); } + +export function isChromeNewTabUrl(raw: string): boolean { + let url: URL; + try { + url = new URL(raw); + } catch { + return false; + } + return CHROME_NEW_TAB_URLS.has(url.href); +} diff --git a/src/sw.ts b/src/sw.ts index a600bfa..2eb939b 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,50 +1,73 @@ -import { isHttpUrl, isPdfContentType, isPdfLikeUrl, parseViewableUrl } from './core/pdf-url'; +import { + isChromeNewTabUrl, + isHttpUrl, + isPdfContentType, + isPdfLikeUrl, + parseViewableUrl +} from './core/pdf-url'; const VIEWER_PATH = 'viewer.html'; const HUB_PATH = 'hub.html'; const SETTINGS_KEY = 'margin:settings'; -const ACTION_TITLE = 'Margin으로 열기'; const UNSUPPORTED_NOTICE = 'Margin은 PDF 문서에서만 열 수 있어요. PDF 링크나 로컬 PDF 파일에서 다시 눌러 주세요.'; +const UNSUPPORTED_NOTIFICATION_TITLE = 'Margin'; +const UNSUPPORTED_NOTIFICATION_MESSAGE = 'PDF 문서에서만 열 수 있어요. PDF 탭에서 다시 눌러 주세요.'; +const ICON_128 = 'icons/icon-128.png'; type Settings = { autoIntercept?: boolean; }; +type PdfResponseStatus = 'pdf' | 'not-pdf' | 'unknown'; + function viewerUrl(file?: string): string { const base = chrome.runtime.getURL(VIEWER_PATH); return file ? `${base}?file=${encodeURIComponent(file)}` : base; } -async function urlRespondsAsPdf(url: string): Promise { +function ownExtensionUrl(): string { + return chrome.runtime.getURL(''); +} + +function tabUrl(tab: chrome.tabs.Tab): string | undefined { + const pendingUrl = (tab as chrome.tabs.Tab & { pendingUrl?: string }).pendingUrl; + return pendingUrl ?? tab.url; +} + +function isOwnExtensionPage(rawUrl: string): boolean { + return rawUrl.startsWith(ownExtensionUrl()); +} + +async function detectPdfResponse(url: string): Promise { + const controller = new AbortController(); try { const response = await fetch(url, { - method: 'HEAD', + method: 'GET', credentials: 'include', - cache: 'no-store' + cache: 'no-store', + signal: controller.signal }); - return isPdfContentType(response.headers.get('content-type')) || isPdfLikeUrl(response.url); + const contentType = response.headers.get('content-type'); + if (isPdfContentType(contentType) || isPdfLikeUrl(response.url)) return 'pdf'; + return contentType ? 'not-pdf' : 'unknown'; } catch { - return false; + return 'unknown'; + } finally { + controller.abort(); } } -async function canOpenInViewer(rawUrl: string): Promise { - const url = parseViewableUrl(rawUrl); - if (!url) return false; - if (isPdfLikeUrl(rawUrl)) return true; - if (!isHttpUrl(url)) return false; - return urlRespondsAsPdf(rawUrl); -} - -async function flashActionBadge(tabId: number): Promise { - await chrome.action.setBadgeBackgroundColor({ tabId, color: '#9F2D20' }); - await chrome.action.setBadgeText({ tabId, text: 'PDF' }); - await chrome.action.setTitle({ tabId, title: UNSUPPORTED_NOTICE }); - await new Promise((resolve) => { - setTimeout(resolve, 2400); - }); - await chrome.action.setBadgeText({ tabId, text: '' }); - await chrome.action.setTitle({ tabId, title: ACTION_TITLE }); +async function showUnsupportedNotification(): Promise { + try { + await chrome.notifications.create({ + type: 'basic', + iconUrl: chrome.runtime.getURL(ICON_128), + title: UNSUPPORTED_NOTIFICATION_TITLE, + message: UNSUPPORTED_NOTIFICATION_MESSAGE + }); + } catch { + // OS-level notifications may be unavailable or disabled; Chrome exposes no reliable visibility signal. + } } async function showUnsupportedNotice(tabId: number): Promise { @@ -80,7 +103,7 @@ async function showUnsupportedNotice(tabId: number): Promise { } }); } catch { - await flashActionBadge(tabId); + await showUnsupportedNotification(); } } @@ -94,7 +117,7 @@ async function syncInterceptRules(): Promise { const auto = settings.autoIntercept ?? true; const viewer = chrome.runtime.getURL(VIEWER_PATH); - await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [1, 2] }); + await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [1, 2, 3] }); if (!auto) return; await chrome.declarativeNetRequest.updateDynamicRules({ @@ -116,6 +139,20 @@ async function syncInterceptRules(): Promise { priority: 1, condition: { regexFilter: '^https?://.+\\.pdf([?#].*)?$', + isUrlFilterCaseSensitive: false, + resourceTypes: ['main_frame'] + }, + action: { + type: 'redirect', + redirect: { regexSubstitution: `${viewer}?file=\\0` } + } + }, + { + id: 3, + priority: 1, + condition: { + regexFilter: '^file://.*\\.pdf$', + isUrlFilterCaseSensitive: false, resourceTypes: ['main_frame'] }, action: { @@ -127,7 +164,17 @@ async function syncInterceptRules(): Promise { }); } -chrome.runtime.onInstalled.addListener(async () => { +async function syncAutoOpenMenuChecked(): Promise { + const settings = await getSettings(); + try { + await chrome.contextMenus.update('auto-open', { checked: settings.autoIntercept ?? true }); + } catch { + // The menu can be absent during development reloads before onInstalled recreates it. + } +} + +async function setupContextMenus(): Promise { + await chrome.contextMenus.removeAll(); chrome.contextMenus.create({ id: 'open-hub', title: '메모 허브 열기', @@ -136,14 +183,21 @@ chrome.runtime.onInstalled.addListener(async () => { chrome.contextMenus.create({ id: 'auto-open', type: 'checkbox', - checked: true, title: 'PDF 자동으로 Margin에서 열기', contexts: ['action'] }); + await syncAutoOpenMenuChecked(); +} + +chrome.runtime.onInstalled.addListener(async () => { + await setupContextMenus(); await syncInterceptRules(); }); -chrome.runtime.onStartup.addListener(syncInterceptRules); +chrome.runtime.onStartup.addListener(() => { + void syncAutoOpenMenuChecked(); + void syncInterceptRules(); +}); chrome.contextMenus.onClicked.addListener(async (info) => { if (info.menuItemId === 'open-hub') { @@ -158,14 +212,72 @@ chrome.contextMenus.onClicked.addListener(async (info) => { [SETTINGS_KEY]: { ...settings, autoIntercept: Boolean(info.checked) } }); await syncInterceptRules(); + await syncAutoOpenMenuChecked(); } }); +async function isAllowedFileSchemeAccess(): Promise { + try { + return await chrome.extension.isAllowedFileSchemeAccess(); + } catch { + return false; + } +} + +async function handleLocalPdfNavigation(details: chrome.webNavigation.WebNavigationBaseCallbackDetails): Promise { + if (details.frameId !== 0 || details.tabId < 0) return; + const settings = await getSettings(); + if (settings.autoIntercept === false) return; + if (await isAllowedFileSchemeAccess()) return; + try { + await chrome.tabs.update(details.tabId, { url: viewerUrl(details.url) }); + } catch { + // 판별을 기다리는 사이 탭이 닫혔거나 다른 곳으로 이동한 레이스 — 무시. + } +} + +chrome.webNavigation.onBeforeNavigate.addListener( + (details) => { + void handleLocalPdfNavigation(details); + }, + { + url: [ + { urlPrefix: 'file://', pathSuffix: '.pdf' }, + { urlPrefix: 'file://', pathSuffix: '.PDF' } + ] + } +); + chrome.action.onClicked.addListener(async (tab) => { - if (!tab.id || !tab.url) return; - if (!(await canOpenInViewer(tab.url))) { + if (!tab.id) return; + + const rawUrl = tabUrl(tab); + if (!rawUrl || isChromeNewTabUrl(rawUrl)) { + await chrome.tabs.update(tab.id, { url: viewerUrl() }); + return; + } + if (isOwnExtensionPage(rawUrl)) return; + + const url = parseViewableUrl(rawUrl); + if (!url) { await showUnsupportedNotice(tab.id); return; } - await chrome.tabs.update(tab.id, { url: viewerUrl(tab.url) }); + + if (isPdfLikeUrl(rawUrl)) { + await chrome.tabs.update(tab.id, { url: viewerUrl(rawUrl) }); + return; + } + + if (isHttpUrl(url)) { + const status = await detectPdfResponse(rawUrl); + if (status === 'not-pdf') { + await showUnsupportedNotice(tab.id); + return; + } + await chrome.tabs.update(tab.id, { url: viewerUrl(rawUrl) }); + return; + } + + await showUnsupportedNotice(tab.id); }); diff --git a/src/viewer/main.ts b/src/viewer/main.ts index 2922121..89e18af 100644 --- a/src/viewer/main.ts +++ b/src/viewer/main.ts @@ -1,8 +1,8 @@ import 'pdfjs-dist/web/pdf_viewer.css'; import './viewer.css'; import { createAnchorFromRange, repairAnchor } from '../core/anchor'; -import { parseLinks, parseTags } from '../core/format'; -import { parseViewableUrl } from '../core/pdf-url'; +import { escapeHtml, parseLinks, parseTags } from '../core/format'; +import { isFileUrl, parseViewableUrl } from '../core/pdf-url'; import { DEFAULT_PEN_THEME, isPenTheme, @@ -54,6 +54,11 @@ function basenameFromUrl(value: string): string { } } +function pdfDownloadName(base: string): string { + const cleaned = base.replace(/[\\/:*?"<>|]/g, '-').trim() || 'document'; + return cleaned.toLowerCase().endsWith('.pdf') ? cleaned : `${cleaned}.pdf`; +} + function runtimeUrl(path: string): string { if (typeof chrome !== 'undefined' && chrome.runtime?.getURL) { return chrome.runtime.getURL(path); @@ -61,6 +66,31 @@ function runtimeUrl(path: string): string { return path; } +function isFileSchemeUrl(raw: string): boolean { + const url = parseViewableUrl(raw); + return Boolean(url && isFileUrl(url)); +} + +function fileAccessSettingsUrl(): string { + const extensionId = typeof chrome !== 'undefined' ? chrome.runtime?.id : ''; + return `chrome://extensions/?id=${extensionId}`; +} + +async function canReadFileSchemeUrls(): Promise { + if (typeof chrome === 'undefined' || !chrome.extension?.isAllowedFileSchemeAccess) return true; + try { + return await chrome.extension.isAllowedFileSchemeAccess(); + } catch { + return false; + } +} + +// PDF.js 예외는 Error 서브클래스가 아닐 수 있어 name 필드로 구분한다. +function isMissingPdfError(error: unknown): boolean { + return typeof error === 'object' && error !== null + && (error as { name?: unknown }).name === 'MissingPDFException'; +} + function requireElement(selector: string): T { const element = document.querySelector(selector); if (!element) throw new Error(`Missing required element: ${selector}`); @@ -68,7 +98,7 @@ function requireElement(selector: string): T { } function showOnly(section: HTMLElement | null): void { - for (const el of [emptyState, pendingState, errorState, readRow]) { + for (const el of [emptyState, pendingState, errorState, fileAccessState, missingFileState, readRow]) { el?.setAttribute('hidden', ''); } section?.removeAttribute('hidden'); @@ -76,6 +106,7 @@ function showOnly(section: HTMLElement | null): void { function setLoading(label: string): void { if (pendingUrl) pendingUrl.textContent = label; + downloadButton.disabled = true; showOnly(pendingState); } @@ -85,6 +116,48 @@ function setError(error: unknown): void { showOnly(errorState); } +function showFileAccessState(): void { + fileAccessSettingsFallback.hidden = true; + fileAccessSettingsFallback.textContent = ''; + showOnly(fileAccessState); +} + +function showMissingFileState(file: string): void { + missingFileUrl.textContent = file; + showOnly(missingFileState); +} + +async function openFileAccessSettings(): Promise { + const url = fileAccessSettingsUrl(); + try { + await chrome.tabs.update({ url }); + } catch { + fileAccessSettingsFallback.textContent = url; + fileAccessSettingsFallback.hidden = false; + } +} + +function openFilePicker(): void { + fileInput.value = ''; + fileInput.click(); +} + +let downloadName = 'document.pdf'; + +async function downloadCurrentPdf(): Promise { + const doc = host.pdfDocument; + if (!doc || downloadButton.disabled) return; + const data = await doc.getData(); + // getData()의 Uint8Array는 BlobPart와 타입이 안 맞아 ArrayBuffer 사본으로 감싼다. + const blob = new Blob([new Uint8Array(data)], { type: 'application/pdf' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = downloadName; + anchor.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); +} + function flashElement(el: HTMLElement): void { el.classList.remove('flash'); void el.offsetWidth; @@ -181,37 +254,29 @@ function markTocForPage(page: number): void { } } -function escapeHtml(value: string): string { - return value.replace(/[&<>"']/g, (char) => { - switch (char) { - case '&': - return '&'; - case '<': - return '<'; - case '>': - return '>'; - case '"': - return '"'; - case "'": - return '''; - default: - return char; - } - }); -} - async function loadUrl(file: string): Promise { setLoading(file); if (fileLabel) fileLabel.textContent = basenameFromUrl(file); + const isLocalFile = isFileSchemeUrl(file); + if (isLocalFile && !(await canReadFileSchemeUrls())) { + showFileAccessState(); + return; + } try { await host.loadUrl(file); await initializeDoc(basenameFromUrl(file), file); if (fileLabel && docData) fileLabel.textContent = docData.meta.title; + downloadName = pdfDownloadName(basenameFromUrl(file)); + downloadButton.disabled = false; showOnly(readRow); host.refreshLayoutSoon(); setPageUi(host.currentPage, host.pageCount); renderToc(await host.getOutlineItems()); } catch (error) { + if (isLocalFile && isMissingPdfError(error)) { + showMissingFileState(file); + return; + } setError(error); } } @@ -223,6 +288,8 @@ async function loadSelectedFile(file: File): Promise { await host.loadFile(file); await initializeDoc(file.name); if (fileLabel && docData) fileLabel.textContent = docData.meta.title; + downloadName = pdfDownloadName(file.name); + downloadButton.disabled = false; showOnly(readRow); host.refreshLayoutSoon(); setPageUi(host.currentPage, host.pageCount); @@ -597,10 +664,18 @@ function setupPanelResize(): void { const emptyState = requireElement('#emptyState'); const pendingState = requireElement('#pendingState'); const errorState = requireElement('#errorState'); +const fileAccessState = requireElement('#fileAccessState'); +const missingFileState = requireElement('#missingFileState'); const readRow = requireElement('#readRow'); const fileLabel = requireElement('#fileLabel'); const pendingUrl = requireElement('#pendingUrl'); const errorMessage = requireElement('#errorMessage'); +const downloadButton = requireElement('#downloadButton'); +const fileAccessSettings = requireElement('#fileAccessSettings'); +const fileAccessPickFile = requireElement('#fileAccessPickFile'); +const fileAccessSettingsFallback = requireElement('#fileAccessSettingsFallback'); +const missingFilePickFile = requireElement('#missingFilePickFile'); +const missingFileUrl = requireElement('#missingFileUrl'); const hubButton = requireElement('#hubButton'); const fileInput = requireElement('#fileInput'); const viewerContainer = requireElement('#viewerContainer'); @@ -732,6 +807,17 @@ hubButton.addEventListener('click', () => { location.href = runtimeUrl('hub.html'); }); +downloadButton.addEventListener('click', () => { + void downloadCurrentPdf(); +}); + +fileAccessSettings.addEventListener('click', () => { + void openFileAccessSettings(); +}); + +fileAccessPickFile.addEventListener('click', openFilePicker); +missingFilePickFile.addEventListener('click', openFilePicker); + fileInput.addEventListener('change', () => { const [file] = Array.from(fileInput.files ?? []); if (file) void loadSelectedFile(file); @@ -778,6 +864,11 @@ window.addEventListener('keydown', (event) => { if (event.key === 'Escape') { memoTab.cancelCompose(); } + // 내장 뷰어의 저장 단축키 대체 — 브라우저 "페이지 저장" 대화상자를 막는다. + if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 's') { + event.preventDefault(); + void downloadCurrentPdf(); + } }); window.addEventListener('pagehide', flushDocSave); diff --git a/src/viewer/viewer.css b/src/viewer/viewer.css index 4dfb8fc..b72522e 100644 --- a/src/viewer/viewer.css +++ b/src/viewer/viewer.css @@ -136,6 +136,7 @@ textarea { padding: 0 14px; background: var(--chrome); border-bottom: 1px solid var(--line); + overflow-x: auto; /* 극단적으로 좁은 창에서 버튼을 찌그러뜨리는 대신 스크롤 */ } .brand { @@ -183,6 +184,7 @@ textarea { .toolbar-button { padding: 6px 10px; font-weight: 500; + white-space: nowrap; } .icon-button { @@ -205,6 +207,7 @@ textarea { background: rgb(44 44 42 / .11); } +.toolbar-button:disabled, .icon-button:disabled { cursor: default; color: #c6c3b6; @@ -235,6 +238,8 @@ textarea { .zoom-label { width: 48px; + flex: none; + white-space: nowrap; color: var(--muted); font-size: 12px; text-align: center; @@ -422,6 +427,45 @@ textarea { display: none; } +.state-actions { + display: flex; + justify-content: center; + gap: 10px; + flex-wrap: wrap; + margin: 2px 0 12px; +} + +.secondary-button { + display: inline-flex; + align-items: center; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--paper); + color: #45433c; + cursor: pointer; + font-size: 12.5px; + font-weight: 600; + padding: 8px 16px; +} + +.secondary-button:hover { + background: #f4f2eb; +} + +.copy-url { + border: 1px solid var(--line-soft); + border-radius: 8px; + background: rgb(255 255 255 / .55); + color: #6b695f !important; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px !important; + line-height: 1.55 !important; + margin: 14px auto 0 !important; + max-width: 100%; + padding: 8px 10px; + user-select: text; +} + .hint { margin-top: 14px !important; color: var(--soft) !important; @@ -443,6 +487,16 @@ textarea { color: #8f3b3b; } +.file-access-state h1::before { + content: ""; + display: block; + width: 9px; + height: 9px; + margin: 0 auto 18px; + border-radius: 50%; + background: var(--blue); +} + /* ── 패널 ── */ #panel { diff --git a/test/pdf-url.test.ts b/test/pdf-url.test.ts index 46e20d8..41d51f0 100644 --- a/test/pdf-url.test.ts +++ b/test/pdf-url.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { isPdfContentType, isPdfLikeUrl, parseViewableUrl } from '../src/core/pdf-url'; +import { + isChromeNewTabUrl, + isLocalPdfUrl, + isPdfContentType, + isPdfLikeUrl, + parseViewableUrl +} from '../src/core/pdf-url'; describe('PDF URL helpers', () => { it('allows viewer-supported URL schemes', () => { @@ -14,6 +20,20 @@ describe('PDF URL helpers', () => { expect(isPdfLikeUrl('https://example.com/article')).toBe(false); }); + it('recognizes local PDF URLs including Windows UNC paths', () => { + expect(isLocalPdfUrl('file:///Users/me/paper.PDF?download=1')).toBe(true); + expect(isLocalPdfUrl('file://server/share/x.pdf')).toBe(true); + expect(isPdfLikeUrl('file://server/share/x.PDF')).toBe(true); + expect(isLocalPdfUrl('file:///Users/me/readme.txt')).toBe(false); + }); + + it('recognizes Chrome new-tab URLs', () => { + expect(isChromeNewTabUrl('chrome://newtab/')).toBe(true); + expect(isChromeNewTabUrl('chrome://new-tab-page/')).toBe(true); + expect(isChromeNewTabUrl('chrome://settings/')).toBe(false); + expect(isChromeNewTabUrl('not a url')).toBe(false); + }); + it('recognizes PDF content types', () => { expect(isPdfContentType('application/pdf')).toBe(true); expect(isPdfContentType('application/pdf; charset=binary')).toBe(true); diff --git a/viewer.html b/viewer.html index 84bfe4d..f700fb4 100644 --- a/viewer.html +++ b/viewer.html @@ -34,6 +34,7 @@
+
@@ -63,6 +64,24 @@

PDF를 불러오는 중입니다

PDF를 열 수 없습니다

+ +