diff --git a/docs/sw/sw-viewer-mode-2026-10-19.md b/docs/sw/sw-viewer-mode-2026-10-19.md new file mode 100644 index 0000000..e1413ab --- /dev/null +++ b/docs/sw/sw-viewer-mode-2026-10-19.md @@ -0,0 +1,112 @@ +# SW 뷰어 모드 상단 메뉴·팝업 (2026-10-19) + +- 작업 위치: worktree `D:\디자인시스템\HMDesign-SW-Viewer`, 브랜치 `feature/sw-viewer-header`(기준 `feature/sw-package` e8409ef). Task A+B 통합 worktree(`HMDesign-SW-Integration`)는 변경하지 않았습니다. Commit·Push 없음. +- 범위(사용자 확정): HMDesign Storybook 데모 기준. 실제 SW 프로그램 연결과 DWG/DXF 기능은 제외했습니다. 참고 프로젝트 `D:\디자인시스템\테스트\dwg-dxf-viewer-hm`은 읽기만 했고, 최신 소스는 scratchpad로 빌드해 `vite preview`로 띄웠습니다(참고 폴더에 쓰기 없음). +- 추가 요청 반영: ① Viewer Header 텍스트·아이콘에 SW Header와 같은 검정 외곽선 ② Storybook `Tool Popup` 메뉴 정리: `SW/Tool` 삭제. `SW/Popup`은 공통 `SWPopup` 예시이므로 Commit 승인 시 지시에 따라 복원·유지 ③ 로고 미제공 시 `HM` 등 아무것도 표시하지 않음. +- 증거: `docs/sw/viewer-qa/`(같은 해상도 캡처, `report.json` 74항목), `docs/sw/viewer-qa/regression/regression.json`(기존 SW Header 33장 픽셀 비교). + +## A. 분석 결과 + +**참고 뷰어 상단 메뉴**(`Toolbar.tsx`, `sw-header.css` — `@hm-design/ui` SWHeader 조합, 1440px 실측) +- 52px Header(그라디언트 #266651 → #123328 → #0A221D, 1px #071E18 테두리), 72px 사선 로고 면(HM), 파일명 면 `clamp(220px, 25vw, 360px)`, 객체 수정 면(반경 6, 테두리 #135643). +- 그룹: 파일(파일 열기 109×34, 샘플▾ 80×34) / 도면 공간 field 면(Model·Layout 탭 97·87×40, 개수 11px) / 화면 보기 field 면(전체 보기, 2.5D·어두운 배경 스위치 칸 52·70×40) / 오른쪽 끝(레이어·설정·속성 토글 81·68·68×40, 상태 11px, 정보 32×40). +- 상태: Hover = #266651 면, On·Open·선택 탭 = #FFC600 글자, Disabled = 60%, 스위치 off #0A221D / on #FFC600. +- 반응형: 1180px 미만 = 샘플·탭·스위치가 더보기(베이지 #F5F0E5, 260px)로, 900px 미만 = 패널 토글도 더보기로, 560px 미만 = 아이콘만 표시하고 상태 글자 숨김. + +**참고 팝업·설정창** +- 샘플 Dropdown(흰 면, #DEDBD2 테두리, 반경 4, 항목 36px, 강조 rgba(189,223,212,.45))과 더보기 Dropdown(라벨, 체크, 라디오 구획). +- DraggablePanel 3종(레이어 320×420, 옵션 340×520, 속성 320×420): 베이지 #E0DACE, 2px #897445, 제목 막대 #221D13·#FFC600 700, 곡선 녹색 닫기(Hover #E40000), 접기, 드래그(경계 10px), 네이티브 resize, 본문 12px 패딩, 카드 섹션 #F5F0E5. +- 라이선스 모달 Dialog. +- Context Menu와 다단계 서브메뉴는 참고 뷰어에 **없습니다**. 요청에 따라 공통 Dropdown 기능으로 추가했습니다. + +**기존 SW 패키지와의 차이** +- 기존 `SWHeader`는 Figma 26:8944 기준(49px, 고정 필드 구성)입니다. 데이터 기반 메뉴, 탭, 스위치, 드래그 패널이 없습니다. +- `SWPopup`은 314px 고정 Figma 속성창 프레임으로, 이동·크기 조절·접기를 지원하지 않습니다. +- 재사용: 사용자 확정 면 토큰 3종(`--hm-sw-header-document-*`, `object-*`, `property-*`, 참고 뷰어와 같은 값), SW 외곽선 토큰(`--hm-sw-header-text-outline`, `label-outline`, `text-outline-room`), `SWTheme` 범위·버튼 초기화, `SWHeader` 로고 Props 규칙. + +**분류** + +| 항목 | 분류 | 근거 | +|---|---|---| +| 일반 모드 Header | 재사용(변경 없음) | `SWHeader` | +| 면 배경·외곽선·글꼴 | 재사용 | 기존 토큰 | +| 뷰어 Header | 신규 | 기존 SWHeader는 Figma 고정 구성이고, 뷰어 메뉴 구조·크기가 다름 | +| Dropdown·서브메뉴·Context | 신규 | SW 패키지에 범용 Dropdown 없음(필드 전용 Dropdown만 있음) | +| 뷰어 팝업 | 신규 | `SWPopup` 프레임(314px Figma art)과 외형·동작이 다름. Variant로 만들면 기존 속성창 DOM·CSS를 바꿔야 해서 분리 | + +## B. 패키지 변경 결과 + +**신규 파일** +- `packages/sw/src/components/sw-viewer/`: `types.ts`, `SWViewerHeader.tsx`, `SWViewerMenu.tsx`, `SWViewerPanel.tsx`, `SWViewerControls.tsx`, `index.ts` +- `packages/sw/src/theme/sw-viewer.css` +- `packages/sw/ai/VIEWER.md`, `packages/sw/ai/examples/ExampleH-ViewerMode.tsx` +- `packages/sw/tests/viewer.browser.mjs`, `viewer-regression.browser.mjs` + +**수정 파일** +- `packages/sw/src/index.ts`: 끝에 Export 1블록 +- `packages/sw/ai/AI_USAGE.md`: 문서 표 1행 +- `packages/sw/tests/storybook-inventory.mjs`: 삭제한 Story id 기록 + +기존 컴포넌트·`sw.css`·`SWHeaderPropertyPanel.tsx`는 수정하지 않았습니다. A+B 통합 작업과 충돌 가능성이 있는 곳은 `index.ts`·`AI_USAGE.md` 각 1군데뿐입니다. + +**공개 API**(`@hm-design/sw`) +- `SWViewerHeader`: `groups`·`trailing`·`onCommand`·`documentTitle`·`logo`/`logoSrc`/`logoAlt`/`onHomeClick` +- `SWViewerHeaderButton` +- `SWViewerMenu`: `entries`·`anchorRef`/`anchorPoint`·`surface`·`initialFocus` +- `SWViewerPanel`: `children`·`footer`·`headerActions`·크기·위치·`strategy`·드래그·크기 조절·접기·`modal` +- 패널 콘텐츠: `SWViewerSection`, `SWViewerField`, `SWViewerHint`, `SWViewerSwitch`, `SWViewerSlider`, `SWViewerInput`, `SWViewerButton`, `SWViewerBadge`, `SWViewerStatus` +- 타입: `SWViewerHeaderItem`, `SWViewerHeaderGroup`, `SWViewerMenuEntry`, `SWViewerCommand`, `SWViewerPanelPlacement` 및 각 Props + +**외곽선**(SW Header 분석 → 같은 기준 적용) +- 텍스트: SW Header는 14px 글자에 검정 1.3px(16 text-shadow), 12px 라벨에 1px을 씁니다. 뷰어의 14px 글자(파일명·버튼·탭·로고)는 `--hm-sw-header-text-outline`, 11px(스위치 캡션·개수·상태)는 `--hm-sw-header-label-outline`을 그대로 사용합니다. 말줄임 파일명에는 `overflow-clip-margin`을 적용했습니다. +- 아이콘: SW Header 아이콘 자산(print·K·레이어)은 채움 + 1px 검정 바깥 Stroke(Figma mask outside) + glow(stdDeviation 1.615, #061A14)입니다. 프로그램 아이콘은 자유 SVG라서 같은 효과를 `drop-shadow` 체인(1px × 4방향 + glow)으로 재현합니다. 토큰 `--hm-sw-viewer-icon-outline`이며 아이콘 윤곽을 따라갑니다. +- 색·크기·굵기·간격은 유지합니다. Hover·Active는 채움 색만 바뀌고, Disabled는 버튼 전체 opacity로 함께 흐려집니다. Dropdown과 패널에는 외곽선이 없습니다(SW Header More 메뉴와 같음). + +**로고** +- `SWHeader`와 같은 Props(`logo`, `logoSrc`, `logoAlt`, `onHomeClick`)를 씁니다. +- 로고를 주지 않으면 사선 끝 24px만 남고 문자·아이콘은 없습니다. +- 로고를 주면 72px 면이 되고, 로고가 더 넓으면 `24 + 로고 + 25`px입니다. Header 높이 52px와 메뉴 위치는 변하지 않습니다. + +**Storybook** +- `SW/Viewer` 10개: Viewer Header(로고 없음 기본), Menu Groups, Item States, Header + Dropdown(서브메뉴·체크·라디오·Context Menu), Settings Popup(footer), Popup Content Variants, Variable Size Popups, Responsive Widths, SWHeader vs Viewer Header(모드 전환), Logo +- 공개 API만 사용합니다. Story CSS는 배치와 Story 자체 목록에만 쓰고 `hm-sw-*`는 사용하지 않습니다. +- `SW/Tool`(`sw-tool.stories.tsx`)만 삭제했습니다. `SW/Popup`(`sw-popup.stories.tsx`, 공통 `SWPopup` 예시)과 `sw-integrated-stories.css`는 원본 그대로 유지합니다. `SWToolButton`/`SWToolGroup`/`SWPopup`과 그 API도 유지합니다. +- 삭제한 `sw-tool--tool-buttons`를 여는 기존 `integrated-improvement.browser.mjs`는 과거 검증 기록용으로만 남습니다. 지금 다시 실행하면 해당 단계가 실패합니다. + +**AI 지침**: `ai/VIEWER.md`(역할·사용 환경·Props·이벤트·Import·Viewer 적용 조건·콘텐츠 교체·외곽선·로고·주의사항·올바른 예/잘못된 예), 예제 H, `AI_USAGE.md` 문서 표 반영. + +## C. SW 프로그램 통합 결과 + +이번 범위에서 제외(사용자 결정)했으므로 **NOT TESTED**입니다. Storybook `SWHeader vs Viewer Header`에서 Header 교체만 UI로 시연했고, 화면 상태(파일명·패널)는 유지됩니다. + +메뉴 명령은 모두 `onCommand` 로그로만 표시하며 실제 기능은 미연결입니다: 파일 열기, 전체 보기, 선택 확대, 인쇄, 내보내기, 측정. + +## D. 검증 결과 + +| 항목 | 판정 | 근거 | +|---|---|---| +| Typecheck | PASS | `npm run typecheck`(전체 패키지 build + `tsc -b` + 5개 workspace) exit 0 | +| Package Build | PASS | `build:packages` / `build:sw` 성공 | +| Storybook Build | PASS | `npm run build-storybook` exit 0. CSS 경고 1건은 기존 docs의 `bg-[var(...)]` 문구(기존부터 있음) | +| 공개 Export | PASS | `ai-docs.check.mjs` 25/25(문서 식별자·예제 Import·링크) | +| 독립 소비 프로젝트 | PASS (일부) | `ai-template-consumer.mjs`: npm pack → 새 프로젝트 설치 → 예제 A~H `tsc` + `vite build` 통과. 예제 H의 브라우저 렌더링은 소비 프로젝트에서 미실행(Storybook에서만 확인) | +| 실제 화면 비교 | PASS (알려진 차이 있음) | 1440×900 같은 해상도 캡처와 수치 비교. 버튼·토글·탭·스위치 칸 크기가 참고와 같음(px 단위). 도면 공간 field 면은 42px로 참고(41px)와 1px 차이. 원인은 참고 Tabs 루트 39px. 파일명 면 x는 로고 규칙 때문에 −48px(의도된 차이) | +| 사용자 인터랙션 | PASS | `viewer.browser.mjs` 74/74 PASS (아래 항목 참조) | +| 외곽선 | PASS | 텍스트 1.3px/1px 검정 각 16 shadow, 아이콘 drop-shadow 5개, 색·크기·굵기 유지, Hover·Disabled에서 유지, 3배 확대 캡처(`outline-*.png`) | +| 로고 | PASS | 없음: 문자·이미지 0, 면 24px. logoSrc·logo 표시. 높이 52px, 메뉴 y 동일 | +| `SW/Tool` 삭제·`SW/Popup` 유지 | PASS | dev `index.json`: SW 제목은 `SW/Header`, `SW/Popup`, `SW/Viewer` (`viewer.browser.mjs` 재실행) | +| 기존 기능 회귀 | PASS | 기존 SW/Header Story 11개 × 3폭(1920/1180/768) 픽셀 차이 0(33/33). 비교 대상은 기준 Storybook(6024, e8409ef)과 이 브랜치. `test:components` 9/9, `test:guard` 94/94, `git diff --check` 통과 | +| Window Move Motion Story | NOT TESTED | 애니메이션이라 픽셀 비교에서 제외 | +| Firefox·Safari, 실제 창 크기 변경 | NOT TESTED | Chrome headless만 실행. 폭은 viewport와 Story 폭으로 재현 | + +`viewer.browser.mjs` 74개 항목: 메뉴 열기·위치·Enter/Arrow/Escape(Focus 복귀)·바깥 클릭, 탭 화살표, 스위치, Hover 면, 토글 → 팝업, 패널 Focus·Escape·Focus 복귀, 모서리 크기 조절, 드래그·경계 10px, 접기·더블클릭, 닫기 Hover #E40000, 앞으로 가져오기, 모달 배경·Focus Trap·Escape, 서브메뉴 →/← 3단계, 체크·라디오·비활성 항목, `menuAlign`·`menuWidth`, Context Menu 포인터 위치, 화면 경계, 1440/1100/860/520 가로 넘침 0, More 구획(샘플 도면/도면 공간/화면 보기/패널), More 체크·라디오 명령, 모드 전환과 상태 유지, 로고, 외곽선, 메뉴 삭제, 페이지 오류 0. + +**참고 뷰어와 의도적으로 다른 점** +- 모서리 크기 조절: 네이티브 `resize` 대신 같은 위치의 Grip을 씁니다. Firefox 등 모든 브라우저에서 동작하고 자동 검증할 수 있습니다. +- 패널 Escape 닫기를 추가했습니다(Focus가 패널 안에 있을 때). +- 로고 기본 표시를 없앴습니다(사용자 결정). +- 서브메뉴와 Context Menu를 추가했습니다(요청 범위). + +## E. 최종 판정 + +**PARTIAL**: 패키지 컴포넌트 구현, Storybook, AI 지침, 주요 UI·인터랙션 검증은 끝났습니다. PASS가 아닌 이유는 요청 원문의 “SW 프로그램 뷰어 모드 통합”이 이번 범위에서 제외되어(사용자 결정) 진행하지 않았기 때문입니다. Commit·Push는 승인 대기입니다. diff --git a/docs/sw/viewer-qa/outline-states-2x.png b/docs/sw/viewer-qa/outline-states-2x.png new file mode 100644 index 0000000..0934c13 Binary files /dev/null and b/docs/sw/viewer-qa/outline-states-2x.png differ diff --git a/docs/sw/viewer-qa/outline-swheader-3x.png b/docs/sw/viewer-qa/outline-swheader-3x.png new file mode 100644 index 0000000..0f2b304 Binary files /dev/null and b/docs/sw/viewer-qa/outline-swheader-3x.png differ diff --git a/docs/sw/viewer-qa/outline-viewer-3x.png b/docs/sw/viewer-qa/outline-viewer-3x.png new file mode 100644 index 0000000..4b02a50 Binary files /dev/null and b/docs/sw/viewer-qa/outline-viewer-3x.png differ diff --git a/docs/sw/viewer-qa/outline-viewer-right-3x.png b/docs/sw/viewer-qa/outline-viewer-right-3x.png new file mode 100644 index 0000000..35b93a4 Binary files /dev/null and b/docs/sw/viewer-qa/outline-viewer-right-3x.png differ diff --git a/docs/sw/viewer-qa/package-1440-loaded.png b/docs/sw/viewer-qa/package-1440-loaded.png new file mode 100644 index 0000000..4f7685c Binary files /dev/null and b/docs/sw/viewer-qa/package-1440-loaded.png differ diff --git a/docs/sw/viewer-qa/package-1440-panels.png b/docs/sw/viewer-qa/package-1440-panels.png new file mode 100644 index 0000000..b95284c Binary files /dev/null and b/docs/sw/viewer-qa/package-1440-panels.png differ diff --git a/docs/sw/viewer-qa/package-1440-sample-menu.png b/docs/sw/viewer-qa/package-1440-sample-menu.png new file mode 100644 index 0000000..b633a3c Binary files /dev/null and b/docs/sw/viewer-qa/package-1440-sample-menu.png differ diff --git a/docs/sw/viewer-qa/package-logo-element.png b/docs/sw/viewer-qa/package-logo-element.png new file mode 100644 index 0000000..3a90202 Binary files /dev/null and b/docs/sw/viewer-qa/package-logo-element.png differ diff --git a/docs/sw/viewer-qa/package-logo-none.png b/docs/sw/viewer-qa/package-logo-none.png new file mode 100644 index 0000000..2eb1b1b Binary files /dev/null and b/docs/sw/viewer-qa/package-logo-none.png differ diff --git a/docs/sw/viewer-qa/package-logo-src.png b/docs/sw/viewer-qa/package-logo-src.png new file mode 100644 index 0000000..24cdcc2 Binary files /dev/null and b/docs/sw/viewer-qa/package-logo-src.png differ diff --git a/docs/sw/viewer-qa/package-responsive-1100.png b/docs/sw/viewer-qa/package-responsive-1100.png new file mode 100644 index 0000000..fa0c0f2 Binary files /dev/null and b/docs/sw/viewer-qa/package-responsive-1100.png differ diff --git a/docs/sw/viewer-qa/package-responsive-1440.png b/docs/sw/viewer-qa/package-responsive-1440.png new file mode 100644 index 0000000..fcfc112 Binary files /dev/null and b/docs/sw/viewer-qa/package-responsive-1440.png differ diff --git a/docs/sw/viewer-qa/package-responsive-520.png b/docs/sw/viewer-qa/package-responsive-520.png new file mode 100644 index 0000000..d0969e4 Binary files /dev/null and b/docs/sw/viewer-qa/package-responsive-520.png differ diff --git a/docs/sw/viewer-qa/package-responsive-860.png b/docs/sw/viewer-qa/package-responsive-860.png new file mode 100644 index 0000000..e0b3c1b Binary files /dev/null and b/docs/sw/viewer-qa/package-responsive-860.png differ diff --git a/docs/sw/viewer-qa/package-responsive-more-open.png b/docs/sw/viewer-qa/package-responsive-more-open.png new file mode 100644 index 0000000..1e4cefb Binary files /dev/null and b/docs/sw/viewer-qa/package-responsive-more-open.png differ diff --git a/docs/sw/viewer-qa/reference-1440-idle.png b/docs/sw/viewer-qa/reference-1440-idle.png new file mode 100644 index 0000000..7f51570 Binary files /dev/null and b/docs/sw/viewer-qa/reference-1440-idle.png differ diff --git a/docs/sw/viewer-qa/reference-1440-loaded.png b/docs/sw/viewer-qa/reference-1440-loaded.png new file mode 100644 index 0000000..c1af8bd Binary files /dev/null and b/docs/sw/viewer-qa/reference-1440-loaded.png differ diff --git a/docs/sw/viewer-qa/reference-1440-panels.png b/docs/sw/viewer-qa/reference-1440-panels.png new file mode 100644 index 0000000..21f03f6 Binary files /dev/null and b/docs/sw/viewer-qa/reference-1440-panels.png differ diff --git a/docs/sw/viewer-qa/reference-1440-sample-menu.png b/docs/sw/viewer-qa/reference-1440-sample-menu.png new file mode 100644 index 0000000..d210e6c Binary files /dev/null and b/docs/sw/viewer-qa/reference-1440-sample-menu.png differ diff --git a/docs/sw/viewer-qa/regression/regression.json b/docs/sw/viewer-qa/regression/regression.json new file mode 100644 index 0000000..5b25aa3 --- /dev/null +++ b/docs/sw/viewer-qa/regression/regression.json @@ -0,0 +1,237 @@ +{ + "base": "http://127.0.0.1:6024", + "next": "http://127.0.0.1:6034", + "results": [ + { + "id": "overview", + "width": 1920, + "size": "1920x1000 / 1920x1000", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "overview", + "width": 1180, + "size": "1180x1000 / 1180x1000", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "overview", + "width": 768, + "size": "768x1000 / 768x1000", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "interactive-states", + "width": 1920, + "size": "1920x2517 / 1920x2517", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "interactive-states", + "width": 1180, + "size": "1180x2527 / 1180x2527", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "interactive-states", + "width": 768, + "size": "768x2623 / 768x2623", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "logo-slot", + "width": 1920, + "size": "1920x1000 / 1920x1000", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "logo-slot", + "width": 1180, + "size": "1180x1000 / 1180x1000", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "logo-slot", + "width": 768, + "size": "768x1000 / 768x1000", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "document-dropdown", + "width": 1920, + "size": "1920x9112 / 1920x9112", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "document-dropdown", + "width": 1180, + "size": "1180x9122 / 1180x9122", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "document-dropdown", + "width": 768, + "size": "768x9298 / 768x9298", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "responsive-more", + "width": 1920, + "size": "1920x10042 / 1920x10042", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "responsive-more", + "width": 1180, + "size": "1180x10052 / 1180x10052", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "responsive-more", + "width": 768, + "size": "768x10048 / 768x10048", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "k-guide", + "width": 1920, + "size": "1920x1037 / 1920x1037", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "k-guide", + "width": 1180, + "size": "1180x1028 / 1180x1028", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "k-guide", + "width": 768, + "size": "768x1023 / 768x1023", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "property-panel-compare", + "width": 1920, + "size": "1920x1000 / 1920x1000", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "property-panel-compare", + "width": 1180, + "size": "1180x1000 / 1180x1000", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "property-panel-compare", + "width": 768, + "size": "768x1000 / 768x1000", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "composed-controls", + "width": 1920, + "size": "1920x1000 / 1920x1000", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "composed-controls", + "width": 1180, + "size": "1180x1000 / 1180x1000", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "composed-controls", + "width": 768, + "size": "768x1000 / 768x1000", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "document-open", + "width": 1920, + "size": "1920x10771 / 1920x10771", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "document-open", + "width": 1180, + "size": "1180x10761 / 1180x10761", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "document-open", + "width": 768, + "size": "768x10797 / 768x10797", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "hidden-sub-controls", + "width": 1920, + "size": "1920x1710 / 1920x1710", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "hidden-sub-controls", + "width": 1180, + "size": "1180x1700 / 1180x1700", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "hidden-sub-controls", + "width": 768, + "size": "768x1676 / 768x1676", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "detail-buttons", + "width": 1920, + "size": "1920x1000 / 1920x1000", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "detail-buttons", + "width": 1180, + "size": "1180x1000 / 1180x1000", + "diffPixels": 0, + "result": "PASS" + }, + { + "id": "detail-buttons", + "width": 768, + "size": "768x1000 / 768x1000", + "diffPixels": 0, + "result": "PASS" + } + ] +} diff --git a/docs/sw/viewer-qa/report.json b/docs/sw/viewer-qa/report.json new file mode 100644 index 0000000..ffa63c7 --- /dev/null +++ b/docs/sw/viewer-qa/report.json @@ -0,0 +1,621 @@ +{ + "storybook": "http://127.0.0.1:6034", + "reference": "http://127.0.0.1:5182", + "comparison": { + "reference": { + "document": [ + { + "x": 73, + "y": 1, + "w": 337, + "h": 50, + "text": "C0060202-001" + } + ], + "object": [ + { + "x": 412, + "y": 1, + "w": 1027, + "h": 50, + "text": "파일 열기샘플 Mode" + } + ], + "field": [ + { + "x": 622, + "y": 6, + "w": 197, + "h": 41, + "text": "Model17,377L" + }, + { + "x": 823, + "y": 5, + "w": 249, + "h": 42, + "text": "전체 보기2.5D어두운" + } + ], + "button": [ + { + "x": 421, + "y": 9, + "w": 109, + "h": 34, + "text": "파일 열기" + }, + { + "x": 534, + "y": 9, + "w": 80, + "h": 34, + "text": "샘플" + }, + { + "x": 828, + "y": 9, + "w": 109, + "h": 34, + "text": "전체 보기" + }, + { + "x": 1402, + "y": 6, + "w": 32, + "h": 40, + "text": "" + } + ], + "toggle": [ + { + "x": 1086, + "y": 6, + "w": 81, + "h": 40, + "text": "레이어" + }, + { + "x": 1171, + "y": 6, + "w": 68, + "h": 40, + "text": "설정" + }, + { + "x": 1243, + "y": 6, + "w": 68, + "h": 40, + "text": "속성" + } + ], + "tab": [ + { + "x": 627, + "y": 7, + "w": 97, + "h": 40, + "text": "Model17,377" + }, + { + "x": 728, + "y": 7, + "w": 87, + "h": 40, + "text": "Layout237" + } + ], + "switchField": [ + { + "x": 942, + "y": 6, + "w": 52, + "h": 40, + "text": "2.5D" + }, + { + "x": 998, + "y": 6, + "w": 70, + "h": 40, + "text": "어두운 배경" + } + ] + }, + "package": { + "document": [ + { + "x": 25, + "y": 1, + "w": 360, + "h": 50, + "text": "C0060202-001" + } + ], + "object": [ + { + "x": 387, + "y": 1, + "w": 1053, + "h": 50, + "text": "파일 열기샘플Model" + } + ], + "field": [ + { + "x": 597, + "y": 5, + "w": 197, + "h": 42, + "text": "Model17,377L" + }, + { + "x": 798, + "y": 5, + "w": 249, + "h": 42, + "text": "전체 보기2.5D어두운" + } + ], + "button": [ + { + "x": 396, + "y": 9, + "w": 109, + "h": 34, + "text": "파일 열기" + }, + { + "x": 509, + "y": 9, + "w": 80, + "h": 34, + "text": "샘플" + }, + { + "x": 803, + "y": 9, + "w": 109, + "h": 34, + "text": "전체 보기" + }, + { + "x": 1402, + "y": 6, + "w": 32, + "h": 40, + "text": "" + } + ], + "toggle": [ + { + "x": 1086, + "y": 6, + "w": 81, + "h": 40, + "text": "레이어" + }, + { + "x": 1171, + "y": 6, + "w": 68, + "h": 40, + "text": "설정" + }, + { + "x": 1243, + "y": 6, + "w": 68, + "h": 40, + "text": "속성" + } + ], + "tab": [ + { + "x": 602, + "y": 6, + "w": 97, + "h": 40, + "text": "Model17,377" + }, + { + "x": 702, + "y": 6, + "w": 87, + "h": 40, + "text": "Layout237" + } + ], + "switchField": [ + { + "x": 916, + "y": 6, + "w": 52, + "h": 40, + "text": "2.5D" + }, + { + "x": 972, + "y": 6, + "w": 70, + "h": 40, + "text": "어두운 배경" + } + ] + } + }, + "results": [ + { + "name": "geometry button sizes equal to reference", + "result": "PASS", + "detail": "reference 109x34 80x34 109x34 32x40 / package 109x34 80x34 109x34 32x40" + }, + { + "name": "geometry toggle sizes equal to reference", + "result": "PASS", + "detail": "reference 81x40 68x40 68x40 / package 81x40 68x40 68x40" + }, + { + "name": "geometry tab sizes equal to reference", + "result": "PASS", + "detail": "reference 97x40 87x40 / package 97x40 87x40" + }, + { + "name": "geometry switchField sizes equal to reference", + "result": "PASS", + "detail": "reference 52x40 70x40 / package 52x40 70x40" + }, + { + "name": "geometry field sizes equal to reference (±1px height, known)", + "result": "PASS", + "detail": "reference 197x41 249x42 / package 197x42 249x42" + }, + { + "name": "geometry document well height / top (x: logo rule)", + "result": "PASS", + "detail": "reference x73 y1 h50 / package x25 y1 h50" + }, + { + "name": "menu opens on click", + "result": "PASS", + "detail": "" + }, + { + "name": "menu has 3 entries", + "result": "PASS", + "detail": "" + }, + { + "name": "menu placed under its button", + "result": "PASS", + "detail": "menu 549,365 button 549,361" + }, + { + "name": "trigger shows open state", + "result": "PASS", + "detail": "" + }, + { + "name": "ArrowDown moves focus in menu", + "result": "PASS", + "detail": "" + }, + { + "name": "Escape closes menu", + "result": "PASS", + "detail": "" + }, + { + "name": "Escape returns focus to trigger", + "result": "PASS", + "detail": "" + }, + { + "name": "Enter opens with first entry focused", + "result": "PASS", + "detail": "" + }, + { + "name": "Enter selects entry (document changes)", + "result": "PASS", + "detail": "" + }, + { + "name": "onCommand reports menu entry", + "result": "PASS", + "detail": "Command: samples/sample-dxf" + }, + { + "name": "outside click closes menu", + "result": "PASS", + "detail": "" + }, + { + "name": "tabs ArrowRight selects Layout", + "result": "PASS", + "detail": "" + }, + { + "name": "header switch toggles", + "result": "PASS", + "detail": "" + }, + { + "name": "hover face #266651", + "result": "PASS", + "detail": "" + }, + { + "name": "toggle opens panel", + "result": "PASS", + "detail": "" + }, + { + "name": "toggle pressed state + #FFC600", + "result": "PASS", + "detail": "" + }, + { + "name": "panel focused on open", + "result": "PASS", + "detail": "" + }, + { + "name": "Escape closes panel", + "result": "PASS", + "detail": "" + }, + { + "name": "focus returns to toggle", + "result": "PASS", + "detail": "" + }, + { + "name": "corner resize changes size", + "result": "PASS", + "detail": "320x420 → 263x323" + }, + { + "name": "drag moves panel", + "result": "PASS", + "detail": "dx -300 dy 30" + }, + { + "name": "drag stays inside bounds (10px edge)", + "result": "PASS", + "detail": "right gap 10, bottom gap 10" + }, + { + "name": "collapse hides body", + "result": "PASS", + "detail": "" + }, + { + "name": "title double click expands", + "result": "PASS", + "detail": "" + }, + { + "name": "panel content callback", + "result": "PASS", + "detail": "" + }, + { + "name": "close hover #E40000 / white X", + "result": "PASS", + "detail": "" + }, + { + "name": "close button closes", + "result": "PASS", + "detail": "" + }, + { + "name": "last touched panel on top", + "result": "PASS", + "detail": "layer z 37, options z 36" + }, + { + "name": "modal popup opens with backdrop", + "result": "PASS", + "detail": "" + }, + { + "name": "modal focus stays inside", + "result": "PASS", + "detail": "" + }, + { + "name": "modal Escape closes", + "result": "PASS", + "detail": "" + }, + { + "name": "ArrowRight opens submenu with focus", + "result": "PASS", + "detail": "" + }, + { + "name": "submenu opens to the side", + "result": "PASS", + "detail": "parent right 596, sub left 596" + }, + { + "name": "third level opens on hover", + "result": "PASS", + "detail": "" + }, + { + "name": "ArrowLeft closes one level", + "result": "PASS", + "detail": "" + }, + { + "name": "checkbox entry toggles via onCommand", + "result": "PASS", + "detail": "Command: file/lineweight=true" + }, + { + "name": "checkbox state reflected", + "result": "PASS", + "detail": "" + }, + { + "name": "disabled entry does nothing", + "result": "PASS", + "detail": "" + }, + { + "name": "menuAlign end + menuWidth", + "result": "PASS", + "detail": "width 220" + }, + { + "name": "context menu opens at pointer", + "result": "PASS", + "detail": "90,729" + }, + { + "name": "context entry reported", + "result": "PASS", + "detail": "" + }, + { + "name": "menu stays inside the viewport", + "result": "PASS", + "detail": "left 4, right 224" + }, + { + "name": "no horizontal overflow at 1440 / 1100 / 860 / 520", + "result": "PASS", + "detail": "[[1440,0,0],[1100,0,0],[860,0,0],[520,0,0]]" + }, + { + "name": "1440: no More", + "result": "PASS", + "detail": "" + }, + { + "name": "1100: More, samples/tabs/switches moved", + "result": "PASS", + "detail": "" + }, + { + "name": "860: panel toggles moved too", + "result": "PASS", + "detail": "" + }, + { + "name": "520: 파일 열기 / 전체 보기 icon only", + "result": "PASS", + "detail": "" + }, + { + "name": "More sections like the reference", + "result": "PASS", + "detail": "샘플 도면 | 기본 도면 · DWG | 평면 및 종단면도 · DWG | 기본 도형 · DXF | 도면 공간 | Model · 17,377개 | Layout · 237개 | 화면 보기 | 2.5D | 어두운 배경 | 패널 | 레이어 | 설정 | 속성" + }, + { + "name": "More face beige 260px", + "result": "PASS", + "detail": "" + }, + { + "name": "More checkbox reports command", + "result": "PASS", + "detail": "Command: layers=true" + }, + { + "name": "More radio selects tab value", + "result": "PASS", + "detail": "" + }, + { + "name": "mode switch shows viewer header", + "result": "PASS", + "detail": "" + }, + { + "name": "brand click returns to normal SWHeader", + "result": "PASS", + "detail": "" + }, + { + "name": "state kept across mode switch (layer panel still open)", + "result": "PASS", + "detail": "" + }, + { + "name": "logo none: no HM / text / icon", + "result": "PASS", + "detail": "{\"text\":\"\",\"img\":false,\"width\":24,\"docX\":25,\"height\":52,\"fileOpenY\":9}" + }, + { + "name": "logo none: cap shrinks to its edge (no wide gap)", + "result": "PASS", + "detail": "{\"text\":\"\",\"img\":false,\"width\":24,\"docX\":25,\"height\":52,\"fileOpenY\":9}" + }, + { + "name": "logoSrc shows the image (cap = 24 + logo + 25, min 72)", + "result": "PASS", + "detail": "{\"text\":\"\",\"img\":true,\"width\":94,\"docX\":95,\"height\":52,\"fileOpenY\":9,\"logoWidth\":45}" + }, + { + "name": "logo element shown", + "result": "PASS", + "detail": "{\"text\":\"LOGO\",\"img\":true,\"width\":81,\"docX\":82,\"height\":52,\"fileOpenY\":9}" + }, + { + "name": "header height and menu row unchanged with / without logo", + "result": "PASS", + "detail": "52/9 52/9 52/9" + }, + { + "name": "text outline 1.3px black on 14px text (label / file name / tab)", + "result": "PASS", + "detail": "rgb(0, 0, 0) 1.3px 0px 0px, rgb(0, 0, 0) 1.2px 0.5px 0px, rg" + }, + { + "name": "label outline 1px black on 11px captions / counts", + "result": "PASS", + "detail": "rgb(0, 0, 0) 1px 0px 0px, rgb(0, 0, 0) 0.92px 0.38px 0px, rg" + }, + { + "name": "icon outline: 4 x 1px black drop-shadows + #061A14 glow", + "result": "PASS", + "detail": "drop-shadow(rgb(0, 0, 0) 1px 0px 0px) drop-shadow(rgb(0, 0, 0) -1px 0px 0px) drop-shadow(r" + }, + { + "name": "fill / size / weight / spacing kept", + "result": "PASS", + "detail": "rgb(255, 255, 255) 14px 500 normal" + }, + { + "name": "hover changes fill only (outline kept)", + "result": "PASS", + "detail": "" + }, + { + "name": "disabled fades text + outline together", + "result": "PASS", + "detail": "[\"0.6\",true]" + }, + { + "name": "SW/Tool removed from the sidebar", + "result": "PASS", + "detail": "SW/Header, SW/Viewer, SW/Popup" + }, + { + "name": "SW/Popup, SW/Header and SW/Viewer remain", + "result": "PASS", + "detail": "" + }, + { + "name": "no page errors", + "result": "PASS", + "detail": "" + } + ], + "errors": [] +} diff --git a/docs/sw/viewer-qa/story-compare-with-sw-header.png b/docs/sw/viewer-qa/story-compare-with-sw-header.png new file mode 100644 index 0000000..19d5de2 Binary files /dev/null and b/docs/sw/viewer-qa/story-compare-with-sw-header.png differ diff --git a/docs/sw/viewer-qa/story-content-variants.png b/docs/sw/viewer-qa/story-content-variants.png new file mode 100644 index 0000000..767dee5 Binary files /dev/null and b/docs/sw/viewer-qa/story-content-variants.png differ diff --git a/docs/sw/viewer-qa/story-dropdowns.png b/docs/sw/viewer-qa/story-dropdowns.png new file mode 100644 index 0000000..993b5ca Binary files /dev/null and b/docs/sw/viewer-qa/story-dropdowns.png differ diff --git a/docs/sw/viewer-qa/story-logo.png b/docs/sw/viewer-qa/story-logo.png new file mode 100644 index 0000000..7cf6af1 Binary files /dev/null and b/docs/sw/viewer-qa/story-logo.png differ diff --git a/docs/sw/viewer-qa/story-menu-groups.png b/docs/sw/viewer-qa/story-menu-groups.png new file mode 100644 index 0000000..4e078fb Binary files /dev/null and b/docs/sw/viewer-qa/story-menu-groups.png differ diff --git a/docs/sw/viewer-qa/story-responsive.png b/docs/sw/viewer-qa/story-responsive.png new file mode 100644 index 0000000..1556b8c Binary files /dev/null and b/docs/sw/viewer-qa/story-responsive.png differ diff --git a/docs/sw/viewer-qa/story-settings-panel.png b/docs/sw/viewer-qa/story-settings-panel.png new file mode 100644 index 0000000..80a14d0 Binary files /dev/null and b/docs/sw/viewer-qa/story-settings-panel.png differ diff --git a/docs/sw/viewer-qa/story-states.png b/docs/sw/viewer-qa/story-states.png new file mode 100644 index 0000000..4746e6f Binary files /dev/null and b/docs/sw/viewer-qa/story-states.png differ diff --git a/docs/sw/viewer-qa/story-variable-size.png b/docs/sw/viewer-qa/story-variable-size.png new file mode 100644 index 0000000..24e55ff Binary files /dev/null and b/docs/sw/viewer-qa/story-variable-size.png differ diff --git a/docs/sw/viewer-qa/story-viewer-header.png b/docs/sw/viewer-qa/story-viewer-header.png new file mode 100644 index 0000000..346a620 Binary files /dev/null and b/docs/sw/viewer-qa/story-viewer-header.png differ diff --git a/packages/sw/ai/AI_USAGE.md b/packages/sw/ai/AI_USAGE.md index ab53b6f..e26b459 100644 --- a/packages/sw/ai/AI_USAGE.md +++ b/packages/sw/ai/AI_USAGE.md @@ -7,7 +7,8 @@ | [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) | 공식 공개 컴포넌트(Header·개별 컨트롤·Popup·도구 막대)·Props·Callback·크기·CSS 의존성 | | [DESIGN_RULES.md](./DESIGN_RULES.md) | SW 전용 토큰·Typography·간격·Hover/Focus/Selected·Overlay·Motion·반응형·Popup 외형·도구 버튼 | | [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) | 완성형·간소화·기능 중심·확장 Header, Popup(자유 콘텐츠), 파일 열기 + 도구 막대 | -| [examples/](./examples) | 공개 API만 쓰는 예제 A~F(E = 사용자 정의 Popup, F = 파일 열기 + 도구 막대. 독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증) | +| [examples/](./examples) | 공개 API만 쓰는 예제 A~F(E = 사용자 정의 Popup, F = 파일 열기 + 도구 막대. 독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증), H = 뷰어 모드 | +| [VIEWER.md](./VIEWER.md) | **뷰어 모드 전용**: `SWViewerHeader`(데이터 메뉴·More·외곽선·로고 규칙), `SWViewerMenu`(Dropdown·서브메뉴·우클릭), `SWViewerPanel`(팝업 프레임 + 자유 콘텐츠). 일반 SW 모드는 `SWHeader`·`SWPopup`을 그대로 씁니다 | ## 1. 작업 순서 (필수) diff --git a/packages/sw/ai/VIEWER.md b/packages/sw/ai/VIEWER.md new file mode 100644 index 0000000..288f314 --- /dev/null +++ b/packages/sw/ai/VIEWER.md @@ -0,0 +1,104 @@ +# @hm-design/sw — 뷰어 모드 (Viewer Header · Popup) 사용 지침 + +기준: 2026-10-19, 참고 DWG/DXF 뷰어(`dwg-dxf-viewer-hm`) 상단 메뉴·팝업을 같은 해상도(1440 × 900)에서 실측해 패키지 컴포넌트로 옮긴 결과입니다. 아래 이름은 모두 `from "@hm-design/sw"` 공개 Export이고 CSS는 `@hm-design/sw/styles.css` 한 개입니다. 예제: [examples/ExampleH-ViewerMode.tsx](./examples/ExampleH-ViewerMode.tsx). + +## 1. 언제 무엇을 쓰나 + +| 화면 | 사용 | 쓰지 않음 | +|---|---|---| +| SW 일반 모드 Header | `SWHeader` (49px, Figma 26:8944) | `SWViewerHeader` | +| SW 뷰어 모드 Header | `SWViewerHeader` (52px, 참고 뷰어) | `SWHeader`에 뷰어 메뉴를 억지로 넣기, 직접 만든 툴바 | +| 뷰어 메뉴 Dropdown·서브메뉴·우클릭 메뉴 | `type: "menu"` 항목의 `entries`, 단독은 `SWViewerMenu` | `@hm-design/ui` DropdownMenu를 Header 안에 직접 배치 | +| 뷰어 팝업·설정창·도구 패널·입력 팝업 | `SWViewerPanel` (+ `modal`) | 새 프레임 작성, `SWPopup`(일반 SW 속성창 프레임)을 뷰어 디자인으로 재스타일 | +| 일반 SW 팝업·속성창 | `SWPopup`, `SWHeaderPropertyPanel` (변경 없음) | `SWViewerPanel` | + +- 한 화면에는 Header 하나만 둡니다. 모드 전환은 프로그램이 Header 컴포넌트를 바꾸는 방식입니다(Storybook `SW/Viewer › SWHeader vs Viewer Header`). 파일·패널 상태는 프로그램 상태라 Header를 바꿔도 유지됩니다. +- 뷰어 모드 진입·종료, 파일 처리, DWG/DXF 렌더링, 메뉴 명령 실행은 **프로그램 담당**입니다. 패키지는 UI와 공통 인터랙션(열기/닫기, Focus, 키보드, 위치·크기)만 담당합니다. +- 기능이 없는 메뉴는 만들지 않습니다. 연결할 명령이 없으면 항목을 빼거나 `disabled`로 두고 “미연결”로 보고합니다. + +## 2. `SWViewerHeader` + +로고 면(사선) → 파일명 면 → 객체 수정 면(메뉴 그룹) → 오른쪽 끝 영역(`placement: "end"` 그룹 + `trailing`). + +| Props | 설명 | +|---|---| +| `groups: SWViewerHeaderGroup[]` | 메뉴 데이터(순서대로 배치). 그룹: `id`, `label`(접근성 이름·More 구획 제목), `moreLabel?`, `surface?: "plain" \| "field"`(field = 사용자 확정 속성 입력 면), `placement?: "start" \| "end"`, `hidden?`, `items` | +| 항목 `SWViewerHeaderItem` | 공통: `id`, `label`, `icon?`(16px, 프로그램 제공), `title?`, `disabled?`, `hidden?`, `collapseBelow?`(Header 폭 미만이면 More로 이동), `iconOnlyBelow?`(미만이면 아이콘만), `iconOnly?`, `moreLabel?` | +| `type: "button"` | `onSelect` | +| `type: "toggle"` | `pressed`, `onPressedChange` (패널 열기/닫기 등) | +| `type: "menu"` | `entries: SWViewerMenuEntry[]`, `menuWidth?`, `menuAlign?: "start" \| "end"` | +| `type: "tabs"` | `value`, `options: { value, label, count?, title?, disabled? }[]`, `onValueChange` (Model / Layout) | +| `type: "switch"` | `checked`, `onCheckedChange` (2.5D 등, 위 11px 캡션) | +| `type: "custom"` | `render()`, `menuEntries?`(More에 넣을 항목) | +| `onCommand(command)` | 모든 명령을 한 곳에서 받습니다: `{ id, itemId, value? }`. 항목 자체 Callback 다음에 호출 | +| `documentTitle`, `documentPlaceholder`, `documentTooltip`, `document?` | 파일명 면 | +| `logo?`, `logoSrc?`, `logoAlt?`, `onHomeClick?` | `SWHeader`와 같은 로고 Props. **주지 않으면 아무것도 그리지 않습니다**(“HM” 문자·기본 아이콘 없음, 로고 면은 사선 끝 24px만). 주면 72px 면(로고가 더 넓으면 그만큼), 높이 최대 24px | +| `trailing?: ReactNode` | 오른쪽 끝 자유 영역: `SWViewerStatus`, `SWViewerHeaderButton` | +| `moreLabel?`, `toolbarLabel?`, `
` 속성, `ref` | | + +- **반응형(참고 뷰어와 동일):** 항목별 `collapseBelow`로 More(⋯, 베이지 260px Dropdown)로 이동합니다. 참고 뷰어 기준값: 샘플·도면 공간·2.5D·어두운 배경 `1180`, 패널 토글 `900`, 파일 열기·전체 보기 `iconOnlyBelow: 560`. 1180px 미만은 간격 2px, 560px 미만은 상태 글자 숨김(CSS container). 가로 스크롤은 만들지 않습니다. +- More는 그룹/항목 단위 구획으로 자동 구성됩니다: menu·tabs는 자기 구획(`moreLabel`), button·toggle·switch는 그룹 구획(`group.moreLabel ?? group.label`). toggle·switch는 체크 항목, tabs는 라디오 항목이 됩니다. +- **상태 표현:** Hover = #266651 면, Active·Pressed·열림·선택 탭 = 글자·아이콘 #FFC600(선택 박스 없음), Disabled = 60% 불투명, Focus = 2px #FFC600 외곽선. +- **글자·아이콘 외곽선(SWHeader와 동일):** 14px 글자 `--hm-sw-header-text-outline`(검정 1.3px), 11px 캡션·개수·상태 `--hm-sw-header-label-outline`(검정 1px), 아이콘 `--hm-sw-viewer-icon-outline`(SWHeader 아이콘 자산의 1px 검정 바깥 Stroke + #061A14 glow를 `drop-shadow`로 재현, 아이콘 윤곽을 따름). 색·크기·굵기·간격은 그대로이고 Hover/Active는 채움 색만 바뀝니다. Dropdown·패널 안에는 외곽선이 없습니다(SWHeader More 메뉴와 같음). 프로그램 아이콘은 `currentColor`로 그리면 상태 색과 외곽선이 자동 적용됩니다. + +## 3. `SWViewerMenu` / `SWViewerMenuEntry` + +`entries`: `item`(`icon?`, `shortcut?`, `onSelect`), `checkbox`(`checked`, `onCheckedChange`), `radio`(`checked`, `onSelect`), `submenu`(`entries` — 다단계), `label`, `separator`. 공통 `disabled?`, `hidden?`, `keepOpen?`. + +- Header의 `type: "menu"`가 이 Dropdown을 엽니다. 다른 위치(도면 우클릭 등)는 `SWViewerMenu`를 `open` / `onOpenChange` + `anchorRef`(버튼 아래 4px) 또는 `anchorPoint`(포인터 위치)로 씁니다. `surface="beige"`는 More 면, `initialFocus`는 `first`(키보드 열기) / `menu`(클릭 열기). +- 키보드: Enter·Space·ArrowDown 열기, ↑↓ Home End 이동, → 서브메뉴 열기, ← 한 단계 닫기, Escape 닫고 Trigger로 Focus 복귀, Tab·바깥 클릭 닫기. 화면 끝에서는 위/왼쪽으로 뒤집혀 화면 안에 남습니다. + +## 4. `SWViewerPanel` — 팝업 프레임 + 자유 콘텐츠 + +외형(베이지 #E0DACE 본문, 2px #897445 테두리, #221D13 제목 막대, #FFC600 제목, 접기, 곡선 녹색 닫기 → Hover #E40000)과 공통 동작은 패키지가, 내용은 `children`·`footer`가 담당합니다. + +| Props | 설명 | +|---|---| +| `title`, `icon?`, `open`, `onClose`, `closeLabel?` | 필수는 `title`·`open`·`onClose` | +| `children` | 스크롤 본문. `SWViewerSection`(카드/plain, 12px 제목), `SWViewerField`(라벨 + 컨트롤, `inline` / `stacked` + `below`), `SWViewerSwitch`, `SWViewerSlider`, `SWViewerInput`, `SWViewerButton`, `SWViewerBadge`, `SWViewerHint`, 프로그램 목록·표 등 무엇이든 | +| `footer?` | 하단 버튼 영역(본문 스크롤과 무관하게 고정). `SWViewerButton variant="outline" \| "primary"` | +| `headerActions?` | 제목 막대 추가 버튼: `SWViewerButton variant="ghost"` (기본값 복원, 선택 확대 등) | +| `width?`(320), `height?`(420, `"auto"` 가능), `minWidth?`(250), `minHeight?`(140) | 메뉴별 크기. 최대 90vw × 90vh | +| `defaultPosition?` | `{ left, top }` 또는 `"top-end"`(기본) / `"top-start"` / `"center"`(modal 기본), `inset?: { top?, side? }`(기본 56 / 24 = 52px Header 아래) | +| `strategy?` | `fixed`(기본, 화면) / `absolute`(가장 가까운 positioned 조상 안 — 미리보기·임베드) | +| `draggable?`, `resizable?`, `collapsible?`, `defaultCollapsed?`, `collapsed?`, `onCollapsedChange?`, `onPositionChange?`, `onSizeChange?` | 제목 막대 드래그, 오른쪽 아래 모서리 크기 조절, 접기(버튼·제목 더블클릭). 이동·크기는 경계 10px 안 | +| `modal?` | 10% 검정 배경, Focus Trap, 가운데 배치(입력·확인 팝업) | +| `autoFocus?` | 열 때 패널로 Focus, 닫을 때 원래 위치로 복귀(기본 true) | + +- Escape는 Focus가 패널 안에 있을 때 그 패널만 닫습니다. 비모달 패널은 바깥 클릭으로 닫히지 않습니다(참고 뷰어와 동일, 도구 창). 마지막으로 누른 패널이 맨 앞으로 옵니다. +- **콘텐츠 교체:** 같은 프레임에 다른 `children`을 넣습니다(레이어 목록 / 옵션 / 속성 / 입력 폼 — Storybook `Popup Content Variants`). 프레임을 복사하거나 `hm-sw-viewer-*` class로 새 팝업을 만들지 않습니다. + +## 5. 올바른 사용 + +```tsx +import { SWViewerHeader, SWViewerPanel, SWViewerSection, SWViewerField, SWViewerSwitch, type SWViewerHeaderGroup } from "@hm-design/sw" +import "@hm-design/sw/styles.css" + +const groups: SWViewerHeaderGroup[] = [ + { id: "view", label: "화면 보기", surface: "field", items: [{ type: "button", id: "fit", label: "전체 보기", icon: , onSelect: fit }] }, + { id: "panels", label: "패널", placement: "end", items: [{ type: "toggle", id: "options", label: "설정", icon: , pressed: open, onPressedChange: setOpen, collapseBelow: 900 }] }, +] + + setOpen(false)}> + + {id => } + + +``` + +## 6. 잘못된 사용 + +| 하지 말 것 | 이유 / 대신 | +|---|---| +| ``처럼 로고가 없다고 임의 문자·아이콘 넣기 | 로고는 프로그램이 정합니다. 없으면 생략 | +| 뷰어 Header를 `div` + `button`으로 다시 만들기, `hm-sw-viewer-*` class를 소비 코드에 붙이기 | 데이터(`groups`)와 `custom` / `trailing`으로 구성 | +| Header 버튼 글자에 `-webkit-text-stroke`·`border`로 외곽선 만들기 | 외곽선은 패키지 토큰이 이미 적용합니다 | +| 메뉴 색·간격을 `#123456`, `margin` 등 임의 값으로 덮기 | 토큰 `--hm-sw-viewer-*` / `--hm-sw-header-*`만 `style`로 조정(기본값 유지 권장) | +| 존재하지 않는 Props 추측(`items`, `content`, `renderBody` …) | 위 표와 `dist/index.d.ts`가 전부 | +| 일반 모드 화면에서 `SWViewerHeader` 사용, 뷰어에서 `SWHeader` 재스타일 | 모드별 Header를 바꿔 끼웁니다 | +| 기능 없는 메뉴를 가짜 동작으로 연결 | 항목 제외 또는 `disabled` + 미연결 보고 | + +## 7. 디자인 변경 시 주의 + +- 크기(52px Header, 34px 버튼, 40px 토글·탭·스위치 칸, 41~42px field 면, 72px 로고 면)와 색은 참고 뷰어 실측값입니다. 파일명·객체 수정·속성 면 배경은 SWHeader와 같은 사용자 확정 토큰(`--hm-sw-header-document-*`, `object-*`, `property-*`)을 공유하므로 바꾸면 두 Header가 함께 바뀝니다. +- Light/Dark 분기는 없습니다. 참고 뷰어와의 알려진 차이: 도면 공간(tabs) field 면 높이 42px(참고 41px — 참고 Tabs 루트가 39px인 구현 부산물), 크기 조절은 네이티브 `resize` 대신 같은 위치의 모서리 Grip(모든 브라우저 동작), 패널 Escape 닫기 추가, 로고 기본 표시 없음(사용자 결정). diff --git a/packages/sw/ai/examples/ExampleH-ViewerMode.tsx b/packages/sw/ai/examples/ExampleH-ViewerMode.tsx new file mode 100644 index 0000000..623c9e9 --- /dev/null +++ b/packages/sw/ai/examples/ExampleH-ViewerMode.tsx @@ -0,0 +1,85 @@ +// Example H — viewer mode: SWViewerHeader (data-driven menu) + SWViewerPanel popups with free content. +// Only public components. The program owns the state and the commands; the package owns the look and the +// open / close / focus / keyboard / drag behavior. Icons are the program's own (16px, currentColor). +import { useState } from "react" +import { + SWViewerButton, SWViewerField, SWViewerHeader, SWViewerHeaderButton, SWViewerHint, SWViewerInput, SWViewerPanel, + SWViewerSection, SWViewerSlider, SWViewerStatus, SWViewerSwitch, + type SWViewerHeaderGroup, +} from "@hm-design/sw" + +const Icon = ({ d }: { d: string }) => ( + +) +const icons = { + open: , + fit: , + layers: , + options: , + info: , +} + +export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => void }) { + const [fileName, setFileName] = useState(null) + const [space, setSpace] = useState("model") + const [depth, setDepth] = useState(false) + const [panels, setPanels] = useState({ layers: false, options: false }) + const [grid, setGrid] = useState(true) + const [zoomSpeed, setZoomSpeed] = useState(1) + const [about, setAbout] = useState(false) + + const groups: SWViewerHeaderGroup[] = [ + { id: "file", label: "파일", items: [ + { type: "button", id: "open", label: "파일 열기", icon: icons.open, iconOnlyBelow: 560 }, + { type: "menu", id: "samples", label: "샘플", moreLabel: "샘플 도면", collapseBelow: 1180, entries: [ + { type: "item", id: "sample-a", label: "기본 도면 · DWG", onSelect: () => setFileName("BasicSample.dwg") }, + { type: "submenu", id: "recent", label: "최근 도면", entries: [{ type: "item", id: "recent-1", label: "simple.dxf", onSelect: () => setFileName("simple.dxf") }] }, + ] }, + ] }, + { id: "space", label: "도면 공간", surface: "field", hidden: !fileName, items: [ + { type: "tabs", id: "space", label: "모델과 배치 선택", collapseBelow: 1180, value: space, onValueChange: setSpace, + options: [{ value: "model", label: "Model", count: 1200 }, { value: "layout", label: "Layout", count: 40 }] }, + ] }, + { id: "view", label: "화면 보기", surface: "field", items: [ + { type: "button", id: "fit", label: "전체 보기", icon: icons.fit, iconOnlyBelow: 560, disabled: !fileName }, + { type: "switch", id: "depth", label: "2.5D", checked: depth, collapseBelow: 1180, onCheckedChange: setDepth }, + ] }, + { id: "panels", label: "패널", placement: "end", items: [ + { type: "toggle", id: "layers", label: "레이어", icon: icons.layers, pressed: panels.layers, collapseBelow: 900, onPressedChange: layers => setPanels({ ...panels, layers }) }, + { type: "toggle", id: "options", label: "설정", icon: icons.options, pressed: panels.options, collapseBelow: 900, onPressedChange: options => setPanels({ ...panels, options }) }, + ] }, + ] + + return ( + <> + {/* No logo prop: nothing is drawn on the cap (no "HM" text). Pass logoSrc / logo to show the program's logo. */} + onEvent?.(`${command.itemId}/${command.id}${command.value === undefined ? "" : `=${String(command.value)}`}`)} + trailing={<> + + setAbout(true)} /> + } /> + + setPanels({ ...panels, layers: false })}> + + 프로그램의 레이어 목록을 여기에 둡니다. + + + + setPanels({ ...panels, options: false })} width={340} height="auto" inset={{ side: 356 }} + footer={ { onEvent?.("options:apply"); setPanels({ ...panels, options: false }) }}>적용}> + + {id => } + }> + {id => setZoomSpeed(Number(event.currentTarget.value))} />} + + + + + setAbout(false)} width={360} height="auto" resizable={false} collapsible={false} + footer={ setAbout(false)}>확인}> + 모듈 구성과 라이선스 고지는 프로그램이 제공합니다. + + + ) +} diff --git a/packages/sw/src/components/sw-viewer/SWViewerControls.tsx b/packages/sw/src/components/sw-viewer/SWViewerControls.tsx new file mode 100644 index 0000000..7fb5828 --- /dev/null +++ b/packages/sw/src/components/sw-viewer/SWViewerControls.tsx @@ -0,0 +1,167 @@ +import { forwardRef, useId, type ButtonHTMLAttributes, type HTMLAttributes, type InputHTMLAttributes, type ReactNode } from "react" +import "../../theme/sw-viewer.css" + +const cx = (...names: (string | false | undefined)[]) => names.filter(Boolean).join(" ") + +export interface SWViewerSwitchProps extends Omit, "onChange"> { + checked: boolean + onCheckedChange?: (checked: boolean) => void +} + +/** + * 36 x 20 switch. Its face follows where it is placed: dark header face (off #0A221D, on #FFC600) inside + * SWViewerHeader, beige panel face (off #9A9284, on #266651) inside SWViewerPanel. + */ +export const SWViewerSwitch = forwardRef(function SWViewerSwitch( + { checked, onCheckedChange, className, onClick, ...rest }, ref, +) { + return ( + + ) +}) + +export type SWViewerStatusState = "idle" | "loading" | "ready" | "error" + +export interface SWViewerStatusProps extends HTMLAttributes { + state: SWViewerStatusState + /** Short visible text. Default per state: 준비 / 불러오는 중 / 도면 열림 / 불러오기 실패. */ + label?: string + /** Longer detail for screen readers and the tooltip. */ + detail?: string +} + +const statusLabel: Record = { idle: "준비", loading: "불러오는 중", ready: "도면 열림", error: "불러오기 실패" } +const StatusIcon = ({ state }: { state: SWViewerStatusState }) => state === "idle" ? null : ( + +) + +/** Load state of the viewer header (reference status: muted 11px text, icon; text hides below 560px). */ +export function SWViewerStatus({ state, label, detail, className, ...rest }: SWViewerStatusProps) { + return ( +
+ + {label ?? statusLabel[state]} + {detail && : {detail}} +
+ ) +} + +export interface SWViewerButtonProps extends ButtonHTMLAttributes { + /** + * Panel buttons. `outline` (default): reference xs outline button (24px, white, #123328 border). + * `primary`: the same size filled with #123328 (footer confirm). `ghost`: icon button of the panel title bar. + */ + variant?: "outline" | "primary" | "ghost" + icon?: ReactNode +} + +export const SWViewerButton = forwardRef(function SWViewerButton( + { variant = "outline", icon, children, className, type = "button", ...rest }, ref, +) { + return ( + + ) +}) + +export type SWViewerInputProps = InputHTMLAttributes + +/** 28px panel input (#F5F0E5 face, #9A9284 border, 12px). Any native input type. */ +export const SWViewerInput = forwardRef(function SWViewerInput({ className, ...rest }, ref) { + return +}) + +export interface SWViewerSliderProps extends Omit, "type" | "value" | "onChange"> { + value: number + min: number + max: number + step?: number + onValueChange?: (value: number) => void +} + +/** Panel slider (6px #E0DACE track, #266651 range, 18px white thumb with #266651 ring). Native range input. */ +export const SWViewerSlider = forwardRef(function SWViewerSlider( + { value, min, max, step = 1, onValueChange, className, style, ...rest }, ref, +) { + const percent = max > min ? ((Math.min(max, Math.max(min, value)) - min) / (max - min)) * 100 : 0 + return ( + onValueChange?.(Number(event.currentTarget.value))} {...rest} /> + ) +}) + +export interface SWViewerFieldProps extends Omit, "children"> { + label: string + /** Control id for the label. Generated when omitted and `children` is a function. */ + htmlFor?: string + /** + * `inline` (default): label left, control right (switch rows). `stacked`: label + control row, then `below` + * (slider rows: number input beside the label, slider underneath). + */ + layout?: "inline" | "stacked" + /** Unit after the control. */ + unit?: string + /** Stacked layout: content under the label row (a slider). */ + below?: ReactNode + disabled?: boolean + children: ReactNode | ((id: string) => ReactNode) +} + +/** Label + control row of the viewer panel (reference option rows). */ +export function SWViewerField({ label, htmlFor, layout = "inline", unit, below, disabled, children, className, ...rest }: SWViewerFieldProps) { + const generated = useId() + const id = htmlFor ?? generated + return ( +
+
+ +
+ {typeof children === "function" ? children(id) : children} + {unit && {unit}} +
+
+ {below} +
+ ) +} + +export interface SWViewerBadgeProps extends HTMLAttributes { children: ReactNode } + +/** Count badge of panel lists (12px 500, #F4F1EA face). */ +export function SWViewerBadge({ className, ...rest }: SWViewerBadgeProps) { + return +} + +export interface SWViewerSectionProps extends Omit, "title"> { + /** 12px 700 heading (reference panel h4). Also the section's accessible name. */ + title?: string + /** `card` (default): the inset #F5F0E5 card. `plain`: no face (lists). */ + surface?: "card" | "plain" + children?: ReactNode +} + +/** A content group of SWViewerPanel. Children are free (fields, lists, any component). */ +export function SWViewerSection({ title, surface = "card", children, className, ...rest }: SWViewerSectionProps) { + return ( +
+ {title &&

{title}

} + {children} +
+ ) +} + +/** Muted 12px help text of a panel section. */ +export function SWViewerHint({ className, ...rest }: HTMLAttributes) { + return

+} diff --git a/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx b/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx new file mode 100644 index 0000000..69dc9f1 --- /dev/null +++ b/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx @@ -0,0 +1,287 @@ +import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react" +import { SWViewerMenu } from "./SWViewerMenu" +import { SWViewerSwitch } from "./SWViewerControls" +import type { SWViewerCommand, SWViewerHeaderGroup, SWViewerHeaderItem, SWViewerMenuEntry } from "./types" +import "../../theme/sw-viewer.css" + +export interface SWViewerHeaderProps extends Omit, "title"> { + /** + * Logo image URL of the program (same prop as SWHeader), max 24px high on the slanted green cap. + * The logo belongs to each program: nothing is rendered when omitted (no default text or icon) and the cap + * shrinks to its slanted edge. + */ + logoSrc?: string + /** Logo element (inline SVG, , ...). Takes precedence over `logoSrc` (same prop as SWHeader). */ + logo?: ReactNode + /** Accessible product name (same prop as SWHeader). With `onHomeClick` the button is " 홈". */ + logoAlt?: string + /** Makes the logo cap a button (e.g. back to the normal SW mode). Same prop as SWHeader. */ + onHomeClick?: () => void + /** Open file name. `null` / empty shows `documentPlaceholder`. */ + documentTitle?: string | null + /** Default `열린 도면 없음`. */ + documentPlaceholder?: string + /** Tooltip of the file name. Default: the name, or `파일 열기 또는 샘플을 선택하세요`. */ + documentTooltip?: string + /** Replaces the file-name text (the dark document well stays). */ + document?: ReactNode + /** Menu data, in order. Groups with `placement="end"` form the trailing action area. */ + groups: SWViewerHeaderGroup[] + /** Free content after the trailing groups (status, info button, ...). */ + trailing?: ReactNode + /** Every command of the header and its dropdowns (after the item's own callback). */ + onCommand?: (command: SWViewerCommand) => void + /** Accessible name of the More button. Default `추가 도구`. */ + moreLabel?: string + /** Accessible name of the object surface. Default `도면 작업 도구`. */ + toolbarLabel?: string +} + +const MoreIcon = () => ( + +) +const ChevronDown = () => ( + +) + +const isShown = (item: { hidden?: boolean; collapseBelow?: number }, width: number) => !item.hidden && !(item.collapseBelow !== undefined && width < item.collapseBelow) + +/** More entries of the collapsed items, one section per group (menus and tabs get their own section). */ +function moreEntries(groups: SWViewerHeaderGroup[], width: number, command: (c: SWViewerCommand) => void, owners: Map): SWViewerMenuEntry[] { + const sections: SWViewerMenuEntry[][] = [] + // Dropdown entries keep their own callbacks; the More menu reports them to onCommand with their owner item. + const own = (entries: SWViewerMenuEntry[], itemId: string) => entries.forEach(entry => { owners.set(entry.id, itemId); if (entry.type === "submenu") own(entry.entries, itemId) }) + for (const group of groups) { + if (group.hidden) continue + const collapsed = group.items.filter(item => !item.hidden && item.collapseBelow !== undefined && width < item.collapseBelow) + let plain: SWViewerMenuEntry[] = [] + const flush = () => { if (plain.length) sections.push([{ type: "label", id: `${group.id}:more`, label: group.moreLabel ?? group.label }, ...plain]); plain = [] } + for (const item of collapsed) { + if (item.type === "custom") { if (item.menuEntries?.length) { flush(); own(item.menuEntries, item.id); sections.push(item.menuEntries) } continue } + if (item.type === "menu") { + flush() + own(item.entries, item.id) + sections.push([{ type: "label", id: `${item.id}:more`, label: item.moreLabel ?? item.label }, + ...item.entries.map(entry => (entry.type === "label" || entry.type === "separator" ? entry : { ...entry, disabled: entry.disabled || item.disabled }))]) + } else if (item.type === "tabs") { + flush() + sections.push([{ type: "label", id: `${item.id}:more`, label: item.moreLabel ?? item.label }, ...item.options.map(option => ({ + type: "radio" as const, id: `${item.id}:${option.value}`, checked: option.value === item.value, disabled: option.disabled || item.disabled, + label: option.count !== undefined && option.count > 0 ? `${option.label} · ${option.count.toLocaleString()}개` : option.label, + onSelect: () => { item.onValueChange?.(option.value); command({ id: item.id, itemId: item.id, value: option.value }) }, + }))]) + } else if (item.type === "button") { + plain.push({ type: "item", id: item.id, label: item.label, icon: item.icon, disabled: item.disabled, onSelect: () => { item.onSelect?.(); command({ id: item.id, itemId: item.id }) } }) + } else { + const checked = item.type === "toggle" ? item.pressed : item.checked + plain.push({ type: "checkbox", id: item.id, label: item.label, checked, disabled: item.disabled, onCheckedChange: next => { + if (item.type === "toggle") item.onPressedChange?.(next); else item.onCheckedChange?.(next) + command({ id: item.id, itemId: item.id, value: next }) + } }) + } + } + flush() + } + return sections.flatMap((section, index) => (index ? [{ type: "separator" as const, id: `more-sep-${index}` }, ...section] : section)) +} + +function MenuButton({ item, iconOnly, onCommand }: { item: Extract; iconOnly: boolean | "always"; onCommand: (c: SWViewerCommand) => void }) { + const ref = useRef(null) + const [open, setOpen] = useState(false) + const [focus, setFocus] = useState<"first" | "menu">("menu") + const menuId = useId() + const show = (how: "first" | "menu") => { setFocus(how); setOpen(true) } + return ( + <> + + onCommand({ id: entry.id, itemId: item.id, value: entry.type === "checkbox" ? !entry.checked : undefined })} /> + + ) +} + +function Tabs({ item, onCommand }: { item: Extract; onCommand: (c: SWViewerCommand) => void }) { + const select = (value: string) => { item.onValueChange?.(value); onCommand({ id: item.id, itemId: item.id, value }) } + const onKeyDown = (event: KeyboardEvent) => { + const keys = ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"] + if (!keys.includes(event.key)) return + const buttons = Array.from(event.currentTarget.querySelectorAll('[role="radio"]:not(:disabled)')) + const index = buttons.indexOf(document.activeElement as HTMLButtonElement) + if (index < 0) return + event.preventDefault() + const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + (event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length + buttons[next].focus() + buttons[next].click() + } + const current = item.options.some(option => option.value === item.value) ? item.value : item.options.find(option => !option.disabled)?.value + return ( +

+ {item.options.map(option => { + const selected = option.value === item.value + return ( + + ) + })} +
+ ) +} + +function SwitchField({ item, onCommand }: { item: Extract; onCommand: (c: SWViewerCommand) => void }) { + const id = useId() + return ( +
+ + { item.onCheckedChange?.(next); onCommand({ id: item.id, itemId: item.id, value: next }) }} /> +
+ ) +} + +function Item({ item, width, onCommand }: { item: SWViewerHeaderItem; width: number; onCommand: (c: SWViewerCommand) => void }) { + if (item.type === "custom") return <>{item.render()} + const iconOnly = !!item.icon && (item.iconOnly ? "always" : item.iconOnlyBelow !== undefined && width < item.iconOnlyBelow) + if (item.type === "menu") return + if (item.type === "tabs") return + if (item.type === "switch") return + const toggle = item.type === "toggle" + return ( + { + if (toggle) { item.onPressedChange?.(!item.pressed); onCommand({ id: item.id, itemId: item.id, value: !item.pressed }) } + else { item.onSelect?.(); onCommand({ id: item.id, itemId: item.id }) } + }} /> + ) +} + +export interface SWViewerHeaderButtonProps extends Omit, "children"> { + /** Visible text, or the accessible name when `iconOnly`. */ + label: string + /** 16px icon supplied by the program. */ + icon?: ReactNode + /** + * `true`: always icon only, the 32 x 40 icon button (info, More). `"compact"`: the 34 x 34 command button shown + * without its label (what `iconOnlyBelow` does). Default false. + */ + iconOnly?: boolean | "compact" + /** Toggle state (aria-pressed, #FFC600 when on). Leave undefined for a command button. */ + pressed?: boolean +} + +/** + * The viewer header button for the `trailing` slot or a `custom` item (e.g. the info button that opens a modal + * SWViewerPanel). Same look as the data-driven items: transparent, #266651 hover, #FFC600 when pressed. + */ +export const SWViewerHeaderButton = forwardRef(function SWViewerHeaderButton( + { label, icon, iconOnly = false, pressed, className, type = "button", ...rest }, ref, +) { + const kind = pressed === undefined ? "button" : "toggle" + return ( + + ) +}) + +/** + * SW viewer-mode header (reference DWG/DXF viewer top menu): slanted brand cap → dark file-name well → one green + * object surface holding data-driven menu groups, with a trailing action area. Items move into the More dropdown + * by `collapseBelow` (measured header width, never horizontal scroll). Pure presentation: values are props, every + * action is a callback (`onCommand` plus each item's own). Use SWHeader for the normal SW mode. + */ +export const SWViewerHeader = forwardRef(function SWViewerHeader( + { + logo, logoSrc, logoAlt, onHomeClick, documentTitle, documentPlaceholder = "열린 도면 없음", documentTooltip, document: documentNode, + groups, trailing, onCommand, moreLabel = "추가 도구", toolbarLabel = "도면 작업 도구", className, ...rest + }, + forwardedRef, +) { + const rootRef = useRef(null) + const [width, setWidth] = useState(1920) + const gradientId = `hm-sw-viewer-brand-${useId().replace(/:/g, "")}` + useLayoutEffect(() => { + const root = rootRef.current + if (!root) return + const update = () => setWidth(root.getBoundingClientRect().width) + update() + const observer = new ResizeObserver(update) + observer.observe(root) + return () => observer.disconnect() + }, []) + const command = (value: SWViewerCommand) => onCommand?.(value) + const owners = new Map() + const more = moreEntries(groups, width, command, owners) + const moreRef = useRef(null) + const [moreOpen, setMoreOpen] = useState(false) + const [moreFocus, setMoreFocus] = useState<"first" | "menu">("menu") + const renderGroup = (group: SWViewerHeaderGroup) => { + const items = group.items.filter(item => isShown(item, width)) + if (group.hidden || !items.length) return null + return ( +
+ {items.map(item => )} +
+ ) + } + const start = groups.filter(group => group.placement !== "end") + const end = groups.filter(group => group.placement === "end") + const name = documentTitle || documentPlaceholder + const mark = logo ?? (logoSrc ? : null) + const brandContent = ( + <> + + {mark && } + + ) + return ( +
{ rootRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node }} + className={`hm-sw-theme hm-sw-viewer-header${className ? ` ${className}` : ""}`} {...rest}> + {onHomeClick + ? + :
{brandContent}
} +
+ {documentNode ?? {name}} +
+
+ {start.map(renderGroup)} + {more.length > 0 && <> + + { + const owner = owners.get(entry.id) + if (owner) command({ id: entry.id, itemId: owner, value: entry.type === "checkbox" ? !entry.checked : undefined }) + }} /> + } + {(end.length > 0 || trailing) &&
{end.map(renderGroup)}{trailing}
} +
+
+ ) +}) diff --git a/packages/sw/src/components/sw-viewer/SWViewerMenu.tsx b/packages/sw/src/components/sw-viewer/SWViewerMenu.tsx new file mode 100644 index 0000000..958d043 --- /dev/null +++ b/packages/sw/src/components/sw-viewer/SWViewerMenu.tsx @@ -0,0 +1,226 @@ +import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react" +import type { SWViewerMenuEntry } from "./types" +import "../../theme/sw-viewer.css" + +export interface SWViewerMenuProps { + entries: SWViewerMenuEntry[] + open: boolean + onOpenChange: (open: boolean) => void + /** Element the menu opens below (a header button). Focus returns to it on close. */ + anchorRef?: RefObject + /** Opens at a point instead (context menu: the pointer position). */ + anchorPoint?: { x: number; y: number } + /** Edge aligned to the anchor. Default `start`. */ + align?: "start" | "end" + /** Width in px. Default: its content (min 160). */ + width?: number + /** `light` (default): the white dropdown face. `beige`: the More face of the viewer header. */ + surface?: "light" | "beige" + /** Accessible name of the menu. */ + label?: string + /** Called for every activated entry (after its own callback). */ + onEntrySelect?: (entry: SWViewerMenuEntry) => void + /** `first` (default): focus the first entry, as after Enter / ArrowDown. `menu`: focus the list, as after a click. */ + initialFocus?: "first" | "menu" + /** Id of the menu element (for the trigger's aria-controls). */ + id?: string + className?: string +} + +const ITEM_SELECTOR = ':scope > [role="none"] > [role^="menuitem"]:not([aria-disabled="true"])' + +function visible(entries: SWViewerMenuEntry[]) { + const shown = entries.filter(entry => !entry.hidden) + // No leading, trailing or doubled separators once hidden entries are gone. + return shown.filter((entry, index) => entry.type !== "separator" + || (index > 0 && index < shown.length - 1 && shown[index - 1].type !== "separator")) +} + +const CheckIcon = () => ( + +) +const ChevronRight = () => ( + +) + +interface ListProps { + entries: SWViewerMenuEntry[] + surface: "light" | "beige" + width?: number + label?: string + /** Fixed position computer: receives the list size, returns left/top. */ + place: (width: number, height: number) => { left: number; top: number } + /** Activate: run the entry and close everything (unless keepOpen). */ + activate: (entry: SWViewerMenuEntry) => void + /** Escape / Tab: close everything. */ + closeAll: (returnFocus: boolean) => void + /** ArrowLeft inside a submenu: close only this level. */ + closeLevel?: () => void + /** `first`: focus the first entry (keyboard open). `menu`: focus the list itself (pointer open). */ + autoFocus: "first" | "menu" | false + id?: string + className?: string +} + +function MenuList({ entries, surface, width, label, place, activate, closeAll, closeLevel, autoFocus, id, className }: ListProps) { + const ref = useRef(null) + const [position, setPosition] = useState<{ left: number; top: number } | null>(null) + const [openSub, setOpenSub] = useState(null) + const [focusSub, setFocusSub] = useState(false) + const placeRef = useRef(place) + placeRef.current = place + useLayoutEffect(() => { + const update = () => { + const list = ref.current + if (!list) return + const next = placeRef.current(list.offsetWidth, list.offsetHeight) + setPosition(current => (current && current.left === next.left && current.top === next.top ? current : next)) + } + update() + window.addEventListener("resize", update) + window.addEventListener("scroll", update, true) + return () => { window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true) } + }, []) + useEffect(() => { + if (!autoFocus || !position) return + const target = autoFocus === "first" ? ref.current?.querySelector(ITEM_SELECTOR) : ref.current + target?.focus({ preventScroll: true }) + }, [autoFocus, position !== null]) + + const items = () => Array.from(ref.current?.querySelectorAll(ITEM_SELECTOR) ?? []) + const onKeyDown = (event: KeyboardEvent) => { + // Only this level's own keys; a submenu inside handles (and stops) its own. + if (event.nativeEvent.isComposing) return + const list = items() + const index = list.indexOf(document.activeElement as HTMLElement) + const move = (next: number) => { event.preventDefault(); list[(next + list.length) % list.length]?.focus() } + switch (event.key) { + case "ArrowDown": move(index + 1); break + case "ArrowUp": move(index < 0 ? list.length - 1 : index - 1); break + case "Home": move(0); break + case "End": move(list.length - 1); break + case "ArrowLeft": + if (closeLevel) { event.preventDefault(); event.stopPropagation(); closeLevel() } + break + case "Escape": event.preventDefault(); event.stopPropagation(); closeAll(true); break + case "Tab": closeAll(false); break + default: return + } + event.stopPropagation() + } + + const rows: ReactNode[] = visible(entries).map(entry => { + if (entry.type === "separator") return
+ if (entry.type === "label") return
{entry.label}
+ const disabled = !!entry.disabled + const common = { + tabIndex: -1, + "aria-disabled": disabled || undefined, + "data-entry": entry.id, + onPointerMove: (event: { currentTarget: HTMLElement }) => { + if (disabled) return + if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true }) + if (entry.type !== "submenu" && openSub) setOpenSub(null) + }, + } + if (entry.type === "submenu") { + const expanded = openSub === entry.id + const openThis = (focus: boolean) => { if (disabled) return; setFocusSub(focus); setOpenSub(entry.id) } + return ( +
+ + {expanded && { setOpenSub(null); ref.current?.querySelector(`[data-entry="${CSS.escape(entry.id)}"]`)?.focus() }} + place={(w, h) => { + const row = ref.current?.querySelector(`[data-entry="${CSS.escape(entry.id)}"]`)?.getBoundingClientRect() + const vw = document.documentElement.clientWidth, vh = window.innerHeight + if (!row) return { left: 0, top: 0 } + // Beside the parent list (its outer edge), first entry level with the row; flips left at the screen edge. + const list = ref.current!.getBoundingClientRect() + const left = list.right + w <= vw - 4 ? list.right : Math.max(4, list.left - w) + return { left, top: Math.max(4, Math.min(row.top - 5, vh - h - 4)) } + }} />} +
+ ) + } + const role = entry.type === "checkbox" ? "menuitemcheckbox" : entry.type === "radio" ? "menuitemradio" : "menuitem" + const checked = entry.type === "item" ? undefined : entry.checked + return ( +
+ +
+ ) + }) + + return ( + + ) +} + +/** + * SW viewer dropdown (reference DWG/DXF viewer menu): fixed overlay below an anchor (or at a point), kept inside + * the viewport. Items, check / radio items, labels, separators and multi-level submenus come from `entries`. + * Keyboard: ArrowUp / ArrowDown / Home / End move, Enter / Space activate, ArrowRight / ArrowLeft open / close a + * submenu, Escape closes and returns focus to the anchor, Tab closes. A click outside closes. + */ +export function SWViewerMenu({ entries, open, onOpenChange, anchorRef, anchorPoint, align = "start", width, surface = "light", label, onEntrySelect, initialFocus = "first", id, className }: SWViewerMenuProps) { + const rootRef = useRef(null) + const changeRef = useRef(onOpenChange) + changeRef.current = onOpenChange + useEffect(() => { + if (!open) return + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node + if (rootRef.current?.contains(target) || anchorRef?.current?.contains(target)) return + changeRef.current(false) + } + document.addEventListener("pointerdown", onPointerDown, true) + return () => document.removeEventListener("pointerdown", onPointerDown, true) + }, [open, anchorRef]) + if (!open) return null + const closeAll = (returnFocus: boolean) => { + onOpenChange(false) + if (returnFocus) anchorRef?.current?.focus() + } + const activate = (entry: SWViewerMenuEntry) => { + if (entry.type === "item" || entry.type === "radio") entry.onSelect?.() + else if (entry.type === "checkbox") entry.onCheckedChange?.(!entry.checked) + onEntrySelect?.(entry) + if (!("keepOpen" in entry && entry.keepOpen)) closeAll(true) + } + const place = (w: number, h: number) => { + const vw = document.documentElement.clientWidth, vh = window.innerHeight + const box = anchorPoint ? { left: anchorPoint.x, right: anchorPoint.x, top: anchorPoint.y, bottom: anchorPoint.y } + : anchorRef?.current?.getBoundingClientRect() ?? { left: 0, right: 0, top: 0, bottom: 0 } + const start = align === "end" ? box.right - w : box.left + const left = Math.max(4, Math.min(start, vw - w - 4)) + // 4px under a button; exactly at the pointer for a context menu. + const gap = anchorPoint ? 0 : 4 + const below = box.bottom + gap + const top = below + h > vh - 4 && box.top - h - gap >= 4 ? box.top - h - gap : Math.max(4, Math.min(below, vh - h - 4)) + return { left, top } + } + return ( +
+ +
+ ) +} diff --git a/packages/sw/src/components/sw-viewer/SWViewerPanel.tsx b/packages/sw/src/components/sw-viewer/SWViewerPanel.tsx new file mode 100644 index 0000000..8555c3d --- /dev/null +++ b/packages/sw/src/components/sw-viewer/SWViewerPanel.tsx @@ -0,0 +1,236 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react" +import "../../theme/sw-viewer.css" + +export type SWViewerPanelPlacement = "top-end" | "top-start" | "center" + +export interface SWViewerPanelProps extends Omit, "title"> { + /** Title (14px 700 #FFC600, ellipsis). Also the accessible name. */ + title: string + /** 16px title icon supplied by the program. */ + icon?: ReactNode + /** Shown when true. Unmounted otherwise (no hidden DOM). */ + open: boolean + /** Close button, and Escape while focus is inside the panel. */ + onClose: () => void + closeLabel?: string + /** Extra title-bar buttons before collapse / close (use `SWViewerButton variant="ghost"`). */ + headerActions?: ReactNode + /** Free body content: SWViewerSection / SWViewerField / lists / any component. The body scrolls. */ + children?: ReactNode + /** Bottom button area (e.g. 취소 / 적용). Stays visible while the body scrolls. */ + footer?: ReactNode + /** Initial size. Default 320 x 420 (reference panel). `height: "auto"` follows the content. */ + width?: number + height?: number | "auto" + minWidth?: number + minHeight?: number + /** Initial position: a point, or a placement inside the bounds. Default `top-end`. */ + defaultPosition?: { left: number; top: number } | SWViewerPanelPlacement + /** Placement insets in px. Default `{ top: 56, side: 24 }` (below the 52px viewer header). */ + inset?: { top?: number; side?: number } + /** `fixed` (default): the viewport. `absolute`: the nearest positioned ancestor (embedded preview). */ + strategy?: "fixed" | "absolute" + /** Drag by the title bar. Default true. */ + draggable?: boolean + /** Corner grip resize (min / max size, kept inside the bounds). Default true. */ + resizable?: boolean + /** Collapse button and title double click. Default true. */ + collapsible?: boolean + defaultCollapsed?: boolean + collapsed?: boolean + onCollapsedChange?: (collapsed: boolean) => void + /** Dim backdrop, focus trap and centered default (input / confirm popups). Default false. */ + modal?: boolean + /** Focus the panel when it opens; focus returns to the previous element when it closes. Default true. */ + autoFocus?: boolean + onPositionChange?: (position: { left: number; top: number }) => void + /** After a corner resize. */ + onSizeChange?: (size: { width: number; height: number }) => void +} + +let topLayer = 30 +const EDGE = 10 +const INTERACTIVE = "button, input, select, textarea, a, label, [data-panel-no-drag]" + +const Chevron = ({ down }: { down: boolean }) => ( + +) + +function bounds(element: HTMLElement | null, strategy: "fixed" | "absolute") { + const parent = strategy === "absolute" ? element?.offsetParent as HTMLElement | null : null + return parent ? { width: parent.clientWidth, height: parent.clientHeight } : { width: document.documentElement.clientWidth, height: window.innerHeight } +} + +function clamp(left: number, top: number, element: HTMLElement, strategy: "fixed" | "absolute") { + const box = bounds(element, strategy) + return { + left: Math.round(Math.min(Math.max(left, EDGE), Math.max(EDGE, box.width - element.offsetWidth - EDGE))), + top: Math.round(Math.min(Math.max(top, EDGE), Math.max(EDGE, box.height - element.offsetHeight - EDGE))), + } +} + +/** + * SW viewer popup / settings window (reference DWG/DXF viewer floating panel): #E0DACE body, 2px #897445 border, + * #221D13 title bar with #FFC600 title, collapse and the curved green close (#E40000 on hover). Non-modal by + * default: drag by the title bar, corner resize, collapse, stays inside the bounds, Escape inside closes, the + * last touched panel comes to the front. The frame is fixed; `children` / `footer` are free. + */ +export function SWViewerPanel({ + title, icon, open, onClose, closeLabel = "닫기", headerActions, children, footer, + width = 320, height = 420, minWidth = 250, minHeight = 140, defaultPosition, inset, strategy = "fixed", + draggable = true, resizable = true, collapsible = true, defaultCollapsed = false, collapsed: collapsedProp, onCollapsedChange, + modal = false, autoFocus = true, onPositionChange, onSizeChange, className, style, onKeyDown, onPointerDownCapture, onFocusCapture, ...rest +}: SWViewerPanelProps) { + const ref = useRef(null) + const titleId = useId() + const closeGradient = `hm-sw-viewer-close-${useId().replace(/:/g, "")}` + const [position, setPosition] = useState<{ left: number; top: number } | null>(null) + const [layer, setLayer] = useState(() => ++topLayer) + const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed) + const collapsed = collapsible && (collapsedProp ?? internalCollapsed) + const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null) + // Size after a corner resize (null: the width / height props). + const [size, setSize] = useState<{ width: number; height: number } | null>(null) + const resize = useRef<{ x: number; y: number; width: number; height: number } | null>(null) + const positionRef = useRef(position) + positionRef.current = position + const placement = defaultPosition ?? (modal ? "center" : "top-end") + + // Initial position, measured once per opening; then kept inside the bounds when they change. + useLayoutEffect(() => { + if (!open) { setPosition(null); setSize(null); return } + const panel = ref.current + if (!panel) return + const box = bounds(panel, strategy) + const top = inset?.top ?? 56, side = inset?.side ?? 24 + const start = typeof placement === "object" ? placement + : placement === "center" ? { left: (box.width - panel.offsetWidth) / 2, top: (box.height - panel.offsetHeight) / 2 } + : { left: placement === "top-end" ? box.width - panel.offsetWidth - side : side, top } + setPosition(clamp(start.left, start.top, panel, strategy)) + const keepInside = () => { const current = positionRef.current; if (current && ref.current) setPosition(clamp(current.left, current.top, ref.current, strategy)) } + window.addEventListener("resize", keepInside) + const parent = strategy === "absolute" ? panel.offsetParent : null + const observer = parent ? new ResizeObserver(keepInside) : null + if (parent) observer?.observe(parent) + return () => { window.removeEventListener("resize", keepInside); observer?.disconnect() } + }, [open]) + + // Focus in once positioned (a hidden panel cannot take focus), back to the opener on close. + const placed = position !== null + useEffect(() => { + if (!open || !autoFocus) return + const previous = document.activeElement as HTMLElement | null + return () => { if (previous?.isConnected) previous.focus({ preventScroll: true }) } + }, [open, autoFocus]) + useEffect(() => { + if (open && autoFocus && placed && !ref.current?.contains(document.activeElement)) ref.current?.focus({ preventScroll: true }) + }, [open, autoFocus, placed]) + + if (!open) return null + const raise = () => { if (layer !== topLayer) setLayer(++topLayer) } + const setCollapsed = (next: boolean) => { if (collapsedProp === undefined) setInternalCollapsed(next); onCollapsedChange?.(next) } + + const onTitlePointerDown = (event: ReactPointerEvent) => { + if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest(INTERACTIVE) || !ref.current || !position) return + drag.current = { x: event.clientX, y: event.clientY, left: position.left, top: position.top } + event.currentTarget.setPointerCapture(event.pointerId) + event.currentTarget.dataset.dragging = "" + } + const onTitlePointerMove = (event: ReactPointerEvent) => { + const start = drag.current + if (!start || !ref.current) return + setPosition(clamp(start.left + event.clientX - start.x, start.top + event.clientY - start.y, ref.current, strategy)) + } + const onTitlePointerUp = (event: ReactPointerEvent) => { + if (!drag.current) return + drag.current = null + delete event.currentTarget.dataset.dragging + if (positionRef.current) onPositionChange?.(positionRef.current) + } + const onGripPointerDown = (event: ReactPointerEvent) => { + if (event.button !== 0 || !ref.current) return + event.preventDefault() + resize.current = { x: event.clientX, y: event.clientY, width: ref.current.offsetWidth, height: ref.current.offsetHeight } + event.currentTarget.setPointerCapture(event.pointerId) + } + const onGripPointerMove = (event: ReactPointerEvent) => { + const start = resize.current, panel = ref.current + if (!start || !panel || !position) return + const box = bounds(panel, strategy) + // Min size, 90% of the bounds, and never past the bounds edge (10px) from the current corner. + const maxWidth = Math.min(box.width * 0.9, box.width - position.left - EDGE), maxHeight = Math.min(box.height * 0.9, box.height - position.top - EDGE) + setSize({ + width: Math.round(Math.max(minWidth, Math.min(maxWidth, start.width + event.clientX - start.x))), + height: Math.round(Math.max(minHeight, Math.min(maxHeight, start.height + event.clientY - start.y))), + }) + } + const onGripPointerUp = () => { + if (!resize.current) return + resize.current = null + if (ref.current) onSizeChange?.({ width: ref.current.offsetWidth, height: ref.current.offsetHeight }) + } + const onPanelKeyDown = (event: KeyboardEvent) => { + onKeyDown?.(event) + if (event.defaultPrevented || event.nativeEvent.isComposing) return + if (event.key === "Escape") { event.stopPropagation(); onClose(); return } + if (modal && event.key === "Tab" && ref.current) { + const focusables = Array.from(ref.current.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])')) + if (!focusables.length) { event.preventDefault(); return } + const first = focusables[0], last = focusables[focusables.length - 1] + if (event.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) { event.preventDefault(); last.focus() } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() } + } + } + + const frameStyle: CSSProperties = { + position: strategy, + left: position?.left ?? 0, + top: position?.top ?? 0, + zIndex: layer, + width: size?.width ?? width, + height: collapsed || (height === "auto" && !size) ? "auto" : size?.height ?? height, + minWidth, + minHeight: collapsed ? undefined : Math.min(minHeight, typeof height === "number" ? height : minHeight), + visibility: position ? undefined : "hidden", + ...style, + } + const panel = ( +
{ raise(); onPointerDownCapture?.(event) }} + onFocusCapture={event => { raise(); onFocusCapture?.(event) }}> +
{ if (collapsible && !(event.target as HTMLElement).closest(INTERACTIVE)) setCollapsed(!collapsed) }}> +

+ {icon && } + {title} +

+
+ {headerActions} + {collapsible && } + +
+
+ {!collapsed &&
{children}
} + {!collapsed && footer &&
{footer}
} + {resizable && !collapsed && + ) + return modal ? <>