Merge branch 'feature/sw-viewer-header' into feature/sw-package
Brings SW viewer mode (SWViewerHeader, SWViewerMenu, SWViewerPanel and panel controls, ai/VIEWER.md, example H) onto the packaged AI guides. ai/AI_USAGE.md keeps both the shared-guide link and the viewer row.
No files matched your search
@@ -0,0 +1,125 @@
|
|||||||
|
# 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는 승인 대기입니다.
|
||||||
|
|
||||||
|
## F. 후속 디자인 수정 (Push `7f42923` 이후, 미커밋)
|
||||||
|
|
||||||
|
| 항목 | 적용 | 검증 |
|
||||||
|
|---|---|---|
|
||||||
|
| A. 아이콘 외곽선 filter → SVG stroke | 신규 `SWViewerIcon`(공개). 아이콘 뒤 복사본의 도형을 검정 Stroke(원래 두께 + 화면 기준 양쪽 1px, `--hm-sw-viewer-icon-outline-width`), 채운 도형은 검정 채움. 위 아이콘은 그대로. CSS `filter`와 glow 제거 | 계산값: filter `none`, 복사본 stroke 검정 5 unit / 원본 2 unit(24 unit 아이콘을 16px로 그린 경우 1px = 1.5 unit). 3배 캡처에서 모양·선 두께 유지 |
|
||||||
|
| B. Viewer Popup = SW Popup | `SWViewerPanel`이 공통 `SWPopup`을 렌더링(같은 DOM·CSS). `SWPopup`에 선택 Props `titleActions`, `footer` 추가(기본 동작 동일). 제목 아이콘 Prop 제거, 기본 폭 314. 314px가 아닌 폭은 제목 아트를 오른쪽 정렬 + 왼쪽 평면 확장(이음매 없음). 패널 콘텐츠 토큰을 SW 속성창 토큰(`--hm-sw-property-*`)에 연결 | SW/Popup·Viewer 패널 계산값 동일(1px #A67D03, r4, inset 1px 검정, #E4DBC9, 제목 막대 30px, 제목 #FFC600 14px 700 + 1px 외곽선). 드래그·크기 조절·접기·Escape·모달·앞으로 가져오기 PASS |
|
||||||
|
| C. SW Header 메뉴 배경 그리드 | Story 공용 변수 `--sw-story-canvas`(Viewer 캔버스와 같은 40px 칸, 1px #266651 18%, #0A0A0B)를 SW Header Overview 작업 영역과 Dropdown 공간에 적용. SW Header 컴포넌트 자체는 변경 없음 | 캡처 확인. 회귀: 이전 배경을 테스트 CSS로 되돌리면 Header·Popup 요소 39/39 픽셀 동일, 실제 차이는 배경 투과 픽셀뿐 |
|
||||||
|
| D. 레이어 전체 켜기 / 끄기 | `SWViewerButton tone="green"`: 기본 유지, Hover = 확인(primary) 버튼 Hover와 같은 토큰 `--hm-sw-viewer-hover`, 누르는 동안 = primary 면 `--hm-sw-viewer-panel-primary`. 새 HEX 없음. 다른 버튼 영향 없음 | 기본 → Hover → Active → 놓은 뒤 Hover → 이탈 후 기본 계산값 PASS, 확인 버튼 Hover와 정확히 동일 |
|
||||||
|
|
||||||
|
- 해석(C): “Viewer Header의 검정 그리드”는 Header 컴포넌트가 아니라 Story 도면 영역 배경이라 Story 레벨 공용 변수로 적용했습니다.
|
||||||
|
- 높이(추가 요청): 패키지에 뷰어 영역 고정 높이가 없음을 확인했습니다(Header 52px 한 줄, 팝업 최대 90vh). Storybook 프레임 높이는 Story 전용 값이고, 프로그램 레이아웃 규칙은 `ai/VIEWER.md` §7에 적었습니다. 실제 테스트 프로젝트(`HMDesignSwTest-viewer`)는 다른 세션이 작업 중이라 이 세션에서는 수정하지 않았습니다. 읽기 확인 결과, 화면 높이는 `h-full` 세로 flex에 도면 영역 `flex-1 min-h-0`이고, 렌더러 크기 조정은 window `resize`에만 연결되어 있습니다(컨테이너 크기 변화에는 미대응).
|
||||||
|
- 검증: `viewer.browser.mjs` 76/76, 요소 회귀 39/39(`--neutral-canvas`), `npm run typecheck` 0 오류, `build-storybook` 성공, `ai-docs.check` 25/25.
|
||||||
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,636 @@
|
|||||||
|
{
|
||||||
|
"base": "http://127.0.0.1:6024",
|
||||||
|
"next": "http://127.0.0.1:6034",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "sw-header--overview",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "1 / 1",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--overview",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "1 / 1",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--overview",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "1 / 1",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--interactive-states",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "16 / 16",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--interactive-states",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "16 / 16",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--interactive-states",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "16 / 16",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--logo-slot",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "4 / 4",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--logo-slot",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "4 / 4",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--logo-slot",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "4 / 4",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--document-dropdown",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "18 / 18",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--document-dropdown",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "18 / 18",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--document-dropdown",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "18 / 18",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--responsive-more",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "19 / 19",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--responsive-more",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "19 / 19",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--responsive-more",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "19 / 19",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--k-guide",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "1 / 1",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--k-guide",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "1 / 1",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--k-guide",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "1 / 1",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--property-panel-compare",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "4 / 4",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--property-panel-compare",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "4 / 4",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--property-panel-compare",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "4 / 4",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--composed-controls",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "0 / 0",
|
||||||
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--composed-controls",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "0 / 0",
|
||||||
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--composed-controls",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "0 / 0",
|
||||||
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--document-open",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "23 / 23",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--document-open",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "23 / 23",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--document-open",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "23 / 23",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--hidden-sub-controls",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--hidden-sub-controls",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--hidden-sub-controls",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--detail-buttons",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "0 / 0",
|
||||||
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--detail-buttons",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "0 / 0",
|
||||||
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--detail-buttons",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "0 / 0",
|
||||||
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-popup--custom-content",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-popup--custom-content",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-popup--custom-content",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-popup--multiple-popups",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-popup--multiple-popups",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-popup--multiple-popups",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,636 @@
|
|||||||
|
{
|
||||||
|
"base": "http://127.0.0.1:6024",
|
||||||
|
"next": "http://127.0.0.1:6034",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "sw-header--overview",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "1 / 1",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
667
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 716941,
|
||||||
|
"result": "FAIL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--overview",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "1 / 1",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 606746,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--overview",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "1 / 1",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 237853,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--interactive-states",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "16 / 16",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--interactive-states",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "16 / 16",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--interactive-states",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "16 / 16",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--logo-slot",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "4 / 4",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--logo-slot",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "4 / 4",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--logo-slot",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "4 / 4",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--document-dropdown",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "18 / 18",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 6111124,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--document-dropdown",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "18 / 18",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
793,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1109,
|
||||||
|
793,
|
||||||
|
793,
|
||||||
|
793,
|
||||||
|
793,
|
||||||
|
793,
|
||||||
|
793,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
48
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 5602299,
|
||||||
|
"result": "FAIL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--document-dropdown",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "18 / 18",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
405,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
721,
|
||||||
|
405,
|
||||||
|
405,
|
||||||
|
405,
|
||||||
|
405,
|
||||||
|
405,
|
||||||
|
405,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
48
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 3410951,
|
||||||
|
"result": "FAIL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--responsive-more",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "19 / 19",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 8338418,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--responsive-more",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "19 / 19",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
818,
|
||||||
|
50,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
47,
|
||||||
|
48,
|
||||||
|
48,
|
||||||
|
48,
|
||||||
|
48,
|
||||||
|
47,
|
||||||
|
48,
|
||||||
|
47,
|
||||||
|
48,
|
||||||
|
48
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 7674707,
|
||||||
|
"result": "FAIL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--responsive-more",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "19 / 19",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
721,
|
||||||
|
50,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
47,
|
||||||
|
48,
|
||||||
|
47,
|
||||||
|
48,
|
||||||
|
48
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 4880711,
|
||||||
|
"result": "FAIL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--k-guide",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "1 / 1",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--k-guide",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "1 / 1",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--k-guide",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "1 / 1",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--property-panel-compare",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "4 / 4",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--property-panel-compare",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "4 / 4",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--property-panel-compare",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "4 / 4",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--composed-controls",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "0 / 0",
|
||||||
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--composed-controls",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "0 / 0",
|
||||||
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--composed-controls",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "0 / 0",
|
||||||
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--document-open",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "23 / 23",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1199,
|
||||||
|
1198,
|
||||||
|
1180,
|
||||||
|
1078,
|
||||||
|
899,
|
||||||
|
768,
|
||||||
|
640,
|
||||||
|
520,
|
||||||
|
388,
|
||||||
|
0,
|
||||||
|
1200,
|
||||||
|
1200,
|
||||||
|
1180,
|
||||||
|
1080,
|
||||||
|
900,
|
||||||
|
766,
|
||||||
|
640,
|
||||||
|
520,
|
||||||
|
390
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 10359147,
|
||||||
|
"result": "FAIL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--document-open",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "23 / 23",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1108,
|
||||||
|
1108,
|
||||||
|
1110,
|
||||||
|
1127,
|
||||||
|
948,
|
||||||
|
817,
|
||||||
|
689,
|
||||||
|
569,
|
||||||
|
437,
|
||||||
|
0,
|
||||||
|
1110,
|
||||||
|
1110,
|
||||||
|
1110,
|
||||||
|
1129,
|
||||||
|
949,
|
||||||
|
815,
|
||||||
|
689,
|
||||||
|
569,
|
||||||
|
439
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 9548042,
|
||||||
|
"result": "FAIL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--document-open",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "23 / 23",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
721,
|
||||||
|
721,
|
||||||
|
722,
|
||||||
|
722,
|
||||||
|
721,
|
||||||
|
722,
|
||||||
|
689,
|
||||||
|
569,
|
||||||
|
437,
|
||||||
|
0,
|
||||||
|
722,
|
||||||
|
722,
|
||||||
|
722,
|
||||||
|
722,
|
||||||
|
722,
|
||||||
|
720,
|
||||||
|
689,
|
||||||
|
569,
|
||||||
|
439
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 6118190,
|
||||||
|
"result": "FAIL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--hidden-sub-controls",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
1199,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 1178934,
|
||||||
|
"result": "FAIL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--hidden-sub-controls",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
1110,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 1084137,
|
||||||
|
"result": "FAIL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--hidden-sub-controls",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
722,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 676260,
|
||||||
|
"result": "FAIL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--detail-buttons",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "0 / 0",
|
||||||
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--detail-buttons",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "0 / 0",
|
||||||
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-header--detail-buttons",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "0 / 0",
|
||||||
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-popup--custom-content",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-popup--custom-content",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-popup--custom-content",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-popup--multiple-popups",
|
||||||
|
"width": 1920,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-popup--multiple-popups",
|
||||||
|
"width": 1180,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sw-popup--multiple-popups",
|
||||||
|
"width": 768,
|
||||||
|
"elements": "3 / 3",
|
||||||
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
|
"result": "PASS"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,631 @@
|
|||||||
|
{
|
||||||
|
"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": "314x420 → 257x323"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "전체 켜기: default kept, green hover / active, back after release",
|
||||||
|
"result": "PASS",
|
||||||
|
"detail": "rgb(255, 255, 255) rgb(18, 51, 40) | rgb(38, 102, 81) rgb(255, 255, 255) | rgb(18, 51, 40) rgb(255, 255, 255) | rgb(38, 102, 81) rgb(255, 255, 255) | rgb(255, 255, 255) rgb(18, 51, 40)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "panel content callback",
|
||||||
|
"result": "PASS",
|
||||||
|
"detail": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "close hover = SWPopup hover asset",
|
||||||
|
"result": "PASS",
|
||||||
|
"detail": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "close button closes",
|
||||||
|
"result": "PASS",
|
||||||
|
"detail": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "last touched panel on top",
|
||||||
|
"result": "PASS",
|
||||||
|
"detail": "layer z 47, options z 46"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "전체 켜기 hover = 라이선스 확인 hover (same token)",
|
||||||
|
"result": "PASS",
|
||||||
|
"detail": "rgb(38, 102, 81) rgb(255, 255, 255) / rgb(38, 102, 81) rgb(255, 255, 255)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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: SVG stroke copy, no CSS filter",
|
||||||
|
"result": "PASS",
|
||||||
|
"detail": "{\"filter\":\"none\",\"copyStroke\":\"rgb(0, 0, 0)\",\"copyWidth\":5,\"glyphStroke\":\"rgb(255, 255, 255)\",\"glyphWidth\":2}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 94 KiB |
@@ -10,7 +10,8 @@
|
|||||||
| [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) | 공식 공개 컴포넌트(Header·개별 컨트롤·Popup·도구 막대)·Props·Callback·크기·CSS 의존성 |
|
| [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 외형·도구 버튼 |
|
| [DESIGN_RULES.md](./DESIGN_RULES.md) | SW 전용 토큰·Typography·간격·Hover/Focus/Selected·Overlay·Motion·반응형·Popup 외형·도구 버튼 |
|
||||||
| [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) | 완성형·간소화·기능 중심·확장 Header, 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. 작업 순서 (필수)
|
## 1. 작업 순서 (필수)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# @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?`, `<header>` 속성, `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), 아이콘 `SWViewerIcon`(SWHeader 아이콘 자산의 1px 검정 바깥 Stroke를 **SVG stroke**로 재현: 아이콘 뒤 복사본의 모든 도형을 검정, 원래 선 두께 + 화면 기준 양쪽 1px(`--hm-sw-viewer-icon-outline-width`)로 그림. 위 아이콘은 그대로라 색·모양·선 두께 유지. CSS `filter` 미사용, glow는 생략). Header 항목·`SWViewerHeaderButton`의 `icon`은 자동 적용, `custom` / `trailing`의 직접 그린 아이콘은 `<SWViewerIcon>`으로 감쌉니다. 색·크기·굵기·간격은 그대로이고 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` — 팝업 프레임 + 자유 콘텐츠
|
||||||
|
|
||||||
|
프레임은 **공통 SW 팝업 `SWPopup`과 같은 컴포넌트·DOM**입니다(30px 제목 막대·Figma 제목 아트, #FFC600 700 제목 + 검정 1px 외곽선, SW 닫기 버튼, 1px #A67D03 테두리, 반경 4, #E4DBC9 면). 뷰어는 여기에 드래그·모서리 크기 조절·접기·경계 유지·앞으로 가져오기·모달을 더합니다. 314px가 아닌 폭에서도 제목 아트 곡선은 닫기 버튼에 붙어 있고 왼쪽 평면이 늘어납니다. 내용은 `children`·`footer`가 담당합니다. 일반 SW 팝업은 `SWPopup`(`titleActions?`, `footer?` 추가)을 그대로 씁니다.
|
||||||
|
|
||||||
|
| Props | 설명 |
|
||||||
|
|---|---|
|
||||||
|
| `title`, `open`, `onClose`, `closeLabel?` | 필수는 `title`·`open`·`onClose`. 제목 아이콘은 없습니다(SW 팝업 디자인) |
|
||||||
|
| `children` | 스크롤 본문. `SWViewerSection`(카드/plain, 12px 제목), `SWViewerField`(라벨 + 컨트롤, `inline` / `stacked` + `below`), `SWViewerSwitch`, `SWViewerSlider`, `SWViewerInput`, `SWViewerButton`, `SWViewerBadge`, `SWViewerHint`, 프로그램 목록·표 등 무엇이든 |
|
||||||
|
| `footer?` | 하단 버튼 영역(본문 스크롤과 무관하게 고정). `SWViewerButton variant="outline" \| "primary"`. `tone="green"` = 기본은 그대로, Hover는 primary(확인) 버튼 Hover와 같은 토큰 `--hm-sw-viewer-hover`, 누르는 동안 primary 면 `--hm-sw-viewer-panel-primary` (예: 레이어 전체 켜기 / 전체 끄기) |
|
||||||
|
| `headerActions?` | 제목 막대 추가 버튼: `SWViewerButton variant="ghost"` (26px, 글자색만 Hover #FFC600 — SW 제목 막대 방식) |
|
||||||
|
| `width?`(314 = SWPopup), `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: <FitIcon />, onSelect: fit }] },
|
||||||
|
{ id: "panels", label: "패널", placement: "end", items: [{ type: "toggle", id: "options", label: "설정", icon: <OptionsIcon />, pressed: open, onPressedChange: setOpen, collapseBelow: 900 }] },
|
||||||
|
]
|
||||||
|
<SWViewerHeader documentTitle={fileName} groups={groups} onCommand={runCommand} />
|
||||||
|
<SWViewerPanel title="옵션" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<SWViewerSection title="표시">
|
||||||
|
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={grid} onCheckedChange={setGrid} />}</SWViewerField>
|
||||||
|
</SWViewerSection>
|
||||||
|
</SWViewerPanel>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 잘못된 사용
|
||||||
|
|
||||||
|
| 하지 말 것 | 이유 / 대신 |
|
||||||
|
|---|---|
|
||||||
|
| `<SWViewerHeader logo="HM" />`처럼 로고가 없다고 임의 문자·아이콘 넣기 | 로고는 프로그램이 정합니다. 없으면 생략 |
|
||||||
|
| 뷰어 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가 함께 바뀝니다.
|
||||||
|
- **높이:** 패키지는 뷰어(도면) 영역 높이를 정하지 않습니다. Header는 52px 한 줄, 팝업만 최대 90vh입니다. 화면 높이는 앱의 상위 레이아웃이 정하고(예: `html, body, #root { height: 100% }` → 세로 flex, Header 다음 도면 영역 `flex: 1; min-height: 0`), 도면 영역 크기 변화에 맞춰 렌더러 크기를 바꾸는 것은 프로그램 담당입니다(ResizeObserver 권장). Storybook 예시의 고정 높이는 미리보기 프레임 값일 뿐 컴포넌트 값이 아닙니다.
|
||||||
|
- Light/Dark 분기는 없습니다. 참고 뷰어와의 알려진 차이: 도면 공간(tabs) field 면 높이 42px(참고 41px — 참고 Tabs 루트가 39px인 구현 부산물), 크기 조절은 네이티브 `resize` 대신 같은 위치의 모서리 Grip(모든 브라우저 동작), 패널 Escape 닫기 추가, 로고 기본 표시 없음(사용자 결정).
|
||||||
@@ -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 }) => (
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>
|
||||||
|
)
|
||||||
|
const icons = {
|
||||||
|
open: <Icon d="M3 7h6l2 2h10v10H3z" />,
|
||||||
|
fit: <Icon d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5" />,
|
||||||
|
layers: <Icon d="M12 3l9 5-9 5-9-5 9-5zM3 13l9 5 9-5" />,
|
||||||
|
options: <Icon d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0" />,
|
||||||
|
info: <Icon d="M12 8h.01M11 12h1v5h1M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => void }) {
|
||||||
|
const [fileName, setFileName] = useState<string | null>(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. */}
|
||||||
|
<SWViewerHeader documentTitle={fileName} groups={groups}
|
||||||
|
onCommand={command => onEvent?.(`${command.itemId}/${command.id}${command.value === undefined ? "" : `=${String(command.value)}`}`)}
|
||||||
|
trailing={<>
|
||||||
|
<SWViewerStatus state={fileName ? "ready" : "idle"} />
|
||||||
|
<SWViewerHeaderButton iconOnly icon={icons.info} label="정보" onClick={() => setAbout(true)} />
|
||||||
|
</>} />
|
||||||
|
|
||||||
|
<SWViewerPanel title="레이어" open={panels.layers} onClose={() => setPanels({ ...panels, layers: false })}>
|
||||||
|
<SWViewerSection surface="plain" aria-label="레이어 목록">
|
||||||
|
<SWViewerHint>프로그램의 레이어 목록을 여기에 둡니다.</SWViewerHint>
|
||||||
|
</SWViewerSection>
|
||||||
|
</SWViewerPanel>
|
||||||
|
|
||||||
|
<SWViewerPanel title="옵션" open={panels.options} onClose={() => setPanels({ ...panels, options: false })} width={340} height="auto" inset={{ side: 356 }}
|
||||||
|
footer={<SWViewerButton variant="primary" onClick={() => { onEvent?.("options:apply"); setPanels({ ...panels, options: false }) }}>적용</SWViewerButton>}>
|
||||||
|
<SWViewerSection title="표시">
|
||||||
|
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={grid} onCheckedChange={setGrid} />}</SWViewerField>
|
||||||
|
<SWViewerField label="줌 속도" layout="stacked" below={<SWViewerSlider aria-label="줌 속도" value={zoomSpeed} min={0.2} max={3} step={0.1} onValueChange={setZoomSpeed} />}>
|
||||||
|
{id => <SWViewerInput id={id} type="number" min={0.2} max={3} step={0.1} value={zoomSpeed} onChange={event => setZoomSpeed(Number(event.currentTarget.value))} />}
|
||||||
|
</SWViewerField>
|
||||||
|
</SWViewerSection>
|
||||||
|
</SWViewerPanel>
|
||||||
|
|
||||||
|
<SWViewerPanel modal title="정보" open={about} onClose={() => setAbout(false)} width={360} height="auto" resizable={false} collapsible={false}
|
||||||
|
footer={<SWViewerButton variant="primary" onClick={() => setAbout(false)}>확인</SWViewerButton>}>
|
||||||
|
<SWViewerHint>모듈 구성과 라이선스 고지는 프로그램이 제공합니다.</SWViewerHint>
|
||||||
|
</SWViewerPanel>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,6 +17,10 @@ export interface SWPopupProps extends Omit<HTMLAttributes<HTMLDivElement>, "titl
|
|||||||
autoFocus?: boolean
|
autoFocus?: boolean
|
||||||
/** Anchor element: the popup is fixed below it and kept inside the viewport (as SWHeader does). */
|
/** Anchor element: the popup is fixed below it and kept inside the viewport (as SWHeader does). */
|
||||||
anchorRef?: RefObject<HTMLElement | null>
|
anchorRef?: RefObject<HTMLElement | null>
|
||||||
|
/** Extra title-bar buttons, before the close button (e.g. collapse, reset). Default none. */
|
||||||
|
titleActions?: ReactNode
|
||||||
|
/** Area after the scrolling body (e.g. buttons). Stays visible while the body scrolls. Default none. */
|
||||||
|
footer?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,8 +28,8 @@ export interface SWPopupProps extends Omit<HTMLAttributes<HTMLDivElement>, "titl
|
|||||||
* content up to the viewport (the body scrolls). Non-modal (no dim, no portal, no focus trap); the host decides
|
* content up to the viewport (the body scrolls). Non-modal (no dim, no portal, no focus trap); the host decides
|
||||||
* when it is shown and where (`style`, or `anchorRef`). Wrap it in <SWTheme> outside SWHeader.
|
* when it is shown and where (`style`, or `anchorRef`). Wrap it in <SWTheme> outside SWHeader.
|
||||||
*/
|
*/
|
||||||
export function SWPopup({ closeLabel = "닫기", ...props }: SWPopupProps) {
|
export function SWPopup({ closeLabel = "닫기", footer, ...props }: SWPopupProps) {
|
||||||
return <SWPopupFrame closeLabel={closeLabel} titleArt="single" kind="single" {...props} />
|
return <SWPopupFrame closeLabel={closeLabel} titleArt="single" kind="single" after={footer} {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SWPopupSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
export interface SWPopupSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { forwardRef, useId, type ButtonHTMLAttributes, type HTMLAttributes, type InputHTMLAttributes, type ReactNode } from "react"
|
||||||
|
import { SWViewerIcon } from "./SWViewerIcon"
|
||||||
|
import "../../theme/sw-viewer.css"
|
||||||
|
|
||||||
|
const cx = (...names: (string | false | undefined)[]) => names.filter(Boolean).join(" ")
|
||||||
|
|
||||||
|
export interface SWViewerSwitchProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "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<HTMLButtonElement, SWViewerSwitchProps>(function SWViewerSwitch(
|
||||||
|
{ checked, onCheckedChange, className, onClick, ...rest }, ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<button ref={ref} type="button" role="switch" aria-checked={checked} data-state={checked ? "checked" : "unchecked"}
|
||||||
|
className={cx("hm-sw-viewer-switch", className)}
|
||||||
|
onClick={event => { onClick?.(event); if (!event.defaultPrevented) onCheckedChange?.(!checked) }} {...rest}>
|
||||||
|
<span className="hm-sw-viewer-switch__thumb" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SWViewerStatusState = "idle" | "loading" | "ready" | "error"
|
||||||
|
|
||||||
|
export interface SWViewerStatusProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
state: SWViewerStatusState
|
||||||
|
/** Short visible text. Default per state: 준비 / 불러오는 중 / 도면 열림 / 불러오기 실패. */
|
||||||
|
label?: string
|
||||||
|
/** Longer detail for screen readers and the tooltip. */
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel: Record<SWViewerStatusState, string> = { idle: "준비", loading: "불러오는 중", ready: "도면 열림", error: "불러오기 실패" }
|
||||||
|
const StatusIcon = ({ state }: { state: SWViewerStatusState }) => state === "idle" ? null : (
|
||||||
|
<svg className={state === "loading" ? "hm-sw-viewer-status__spin" : undefined} viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{state === "loading" && <path d="M14 8a6 6 0 1 1-6-6" />}
|
||||||
|
{state === "ready" && <path d="M3.5 8.5l3 3 6-7" />}
|
||||||
|
{state === "error" && <><circle cx="8" cy="8" r="6" /><path d="M8 5v3.5M8 11h.01" /></>}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div role="status" className={cx("hm-sw-viewer-status", className)} data-state={state} title={detail} {...rest}>
|
||||||
|
{state !== "idle" && <span className="hm-sw-viewer-status__icon"><SWViewerIcon><StatusIcon state={state} /></SWViewerIcon></span>}
|
||||||
|
<span className="hm-sw-viewer-status__label">{label ?? statusLabel[state]}</span>
|
||||||
|
{detail && <span className="hm-sw-viewer-sr-only">: {detail}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SWViewerButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
/**
|
||||||
|
* 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"
|
||||||
|
/** `green`: the default look stays; hover and press turn the face HM green with white text. Default none. */
|
||||||
|
tone?: "green"
|
||||||
|
icon?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SWViewerButton = forwardRef<HTMLButtonElement, SWViewerButtonProps>(function SWViewerButton(
|
||||||
|
{ variant = "outline", tone, icon, children, className, type = "button", ...rest }, ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<button ref={ref} type={type} className={cx("hm-sw-viewer-button", className)} data-variant={variant} data-tone={tone} {...rest}>
|
||||||
|
{icon && <span className="hm-sw-viewer-button__icon" aria-hidden="true">{icon}</span>}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SWViewerInputProps = InputHTMLAttributes<HTMLInputElement>
|
||||||
|
|
||||||
|
/** 28px panel input (#F5F0E5 face, #9A9284 border, 12px). Any native input type. */
|
||||||
|
export const SWViewerInput = forwardRef<HTMLInputElement, SWViewerInputProps>(function SWViewerInput({ className, ...rest }, ref) {
|
||||||
|
return <input ref={ref} className={cx("hm-sw-viewer-input", className)} {...rest} />
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface SWViewerSliderProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "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<HTMLInputElement, SWViewerSliderProps>(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 (
|
||||||
|
<input ref={ref} type="range" className={cx("hm-sw-viewer-slider", className)} value={value} min={min} max={max} step={step}
|
||||||
|
style={{ ...style, ["--hm-sw-viewer-slider-percent" as string]: `${percent}%` }}
|
||||||
|
onChange={event => onValueChange?.(Number(event.currentTarget.value))} {...rest} />
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface SWViewerFieldProps extends Omit<HTMLAttributes<HTMLDivElement>, "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 (
|
||||||
|
<div className={cx("hm-sw-viewer-field", className)} data-layout={layout} data-disabled={disabled || undefined} {...rest}>
|
||||||
|
<div className="hm-sw-viewer-field__row">
|
||||||
|
<label className="hm-sw-viewer-field__label" htmlFor={id}>{label}</label>
|
||||||
|
<div className="hm-sw-viewer-field__control">
|
||||||
|
{typeof children === "function" ? children(id) : children}
|
||||||
|
{unit && <span className="hm-sw-viewer-field__unit">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{below}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SWViewerBadgeProps extends HTMLAttributes<HTMLSpanElement> { children: ReactNode }
|
||||||
|
|
||||||
|
/** Count badge of panel lists (12px 500, #F4F1EA face). */
|
||||||
|
export function SWViewerBadge({ className, ...rest }: SWViewerBadgeProps) {
|
||||||
|
return <span className={cx("hm-sw-viewer-badge", className)} {...rest} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SWViewerSectionProps extends Omit<HTMLAttributes<HTMLElement>, "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 (
|
||||||
|
<section className={cx("hm-sw-viewer-section", className)} data-surface={surface} aria-label={rest["aria-label"] ?? title} {...rest}>
|
||||||
|
{title && <h4 className="hm-sw-viewer-section__title">{title}</h4>}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Muted 12px help text of a panel section. */
|
||||||
|
export function SWViewerHint({ className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return <p className={cx("hm-sw-viewer-hint", className)} {...rest} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
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 { SWViewerIcon } from "./SWViewerIcon"
|
||||||
|
import type { SWViewerCommand, SWViewerHeaderGroup, SWViewerHeaderItem, SWViewerMenuEntry } from "./types"
|
||||||
|
import "../../theme/sw-viewer.css"
|
||||||
|
|
||||||
|
export interface SWViewerHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "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, <img>, ...). Takes precedence over `logoSrc` (same prop as SWHeader). */
|
||||||
|
logo?: ReactNode
|
||||||
|
/** Accessible product name (same prop as SWHeader). With `onHomeClick` the button is "<logoAlt> 홈". */
|
||||||
|
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 = () => (
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor"><circle cx="3" cy="8" r="1.4" /><circle cx="8" cy="8" r="1.4" /><circle cx="13" cy="8" r="1.4" /></svg>
|
||||||
|
)
|
||||||
|
const ChevronDown = () => (
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
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<string, string>): 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<SWViewerHeaderItem, { type: "menu" }>; iconOnly: boolean | "always"; onCommand: (c: SWViewerCommand) => void }) {
|
||||||
|
const ref = useRef<HTMLButtonElement>(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 (
|
||||||
|
<>
|
||||||
|
<button ref={ref} type="button" className="hm-sw-viewer-header__button" data-kind="menu" data-icon-only={iconOnly === "always" ? "always" : iconOnly ? "" : undefined} data-state={open ? "open" : undefined}
|
||||||
|
aria-haspopup="menu" aria-expanded={open} aria-controls={open ? menuId : undefined} aria-label={iconOnly ? item.label : undefined} title={item.title} disabled={item.disabled}
|
||||||
|
onClick={() => (open ? setOpen(false) : show("menu"))}
|
||||||
|
onKeyDown={event => { if (event.key === "ArrowDown" || ((event.key === "Enter" || event.key === " ") && !open)) { event.preventDefault(); show("first") } }}>
|
||||||
|
{item.icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true"><SWViewerIcon>{item.icon}</SWViewerIcon></span>}
|
||||||
|
{!iconOnly && <span className="hm-sw-viewer-header__label">{item.label}</span>}
|
||||||
|
<span className="hm-sw-viewer-header__chevron"><SWViewerIcon><ChevronDown /></SWViewerIcon></span>
|
||||||
|
</button>
|
||||||
|
<SWViewerMenu id={menuId} entries={item.entries} open={open} onOpenChange={setOpen} anchorRef={ref} align={item.menuAlign} width={item.menuWidth} label={item.label} initialFocus={focus}
|
||||||
|
onEntrySelect={entry => onCommand({ id: entry.id, itemId: item.id, value: entry.type === "checkbox" ? !entry.checked : undefined })} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tabs({ item, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "tabs" }>; onCommand: (c: SWViewerCommand) => void }) {
|
||||||
|
const select = (value: string) => { item.onValueChange?.(value); onCommand({ id: item.id, itemId: item.id, value }) }
|
||||||
|
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const keys = ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"]
|
||||||
|
if (!keys.includes(event.key)) return
|
||||||
|
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[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 (
|
||||||
|
<div role="radiogroup" aria-label={item.label} className="hm-sw-viewer-header__tabs" onKeyDown={onKeyDown}>
|
||||||
|
{item.options.map(option => {
|
||||||
|
const selected = option.value === item.value
|
||||||
|
return (
|
||||||
|
<button key={option.value} type="button" role="radio" aria-checked={selected} tabIndex={option.value === current ? 0 : -1}
|
||||||
|
className="hm-sw-viewer-header__tab" data-state={selected ? "active" : undefined} title={option.title} disabled={item.disabled || option.disabled}
|
||||||
|
onClick={() => { if (!selected) select(option.value) }}>
|
||||||
|
{option.label}
|
||||||
|
{option.count !== undefined && option.count > 0 && <span className="hm-sw-viewer-header__count">{option.count.toLocaleString()}</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SwitchField({ item, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "switch" }>; onCommand: (c: SWViewerCommand) => void }) {
|
||||||
|
const id = useId()
|
||||||
|
return (
|
||||||
|
<div className="hm-sw-viewer-header__switch-field" title={item.title}>
|
||||||
|
<label htmlFor={id} className="hm-sw-viewer-header__switch-label">{item.label}</label>
|
||||||
|
<SWViewerSwitch id={id} checked={item.checked} disabled={item.disabled}
|
||||||
|
onCheckedChange={next => { item.onCheckedChange?.(next); onCommand({ id: item.id, itemId: item.id, value: next }) }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <MenuButton item={item} iconOnly={iconOnly} onCommand={onCommand} />
|
||||||
|
if (item.type === "tabs") return <Tabs item={item} onCommand={onCommand} />
|
||||||
|
if (item.type === "switch") return <SwitchField item={item} onCommand={onCommand} />
|
||||||
|
const toggle = item.type === "toggle"
|
||||||
|
return (
|
||||||
|
<SWViewerHeaderButton label={item.label} icon={item.icon} iconOnly={iconOnly === "always" ? true : iconOnly ? "compact" : false} pressed={toggle ? item.pressed : undefined} title={item.title} disabled={item.disabled}
|
||||||
|
onClick={() => {
|
||||||
|
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<ButtonHTMLAttributes<HTMLButtonElement>, "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<HTMLButtonElement, SWViewerHeaderButtonProps>(function SWViewerHeaderButton(
|
||||||
|
{ label, icon, iconOnly = false, pressed, className, type = "button", ...rest }, ref,
|
||||||
|
) {
|
||||||
|
const kind = pressed === undefined ? "button" : "toggle"
|
||||||
|
return (
|
||||||
|
<button ref={ref} type={type} className={`hm-sw-viewer-header__button${className ? ` ${className}` : ""}`} data-kind={kind} data-icon-only={iconOnly === true ? "always" : iconOnly ? "" : undefined}
|
||||||
|
data-state={pressed === undefined ? undefined : pressed ? "on" : "off"} aria-pressed={pressed} aria-label={iconOnly ? label : undefined} {...rest}>
|
||||||
|
{icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true"><SWViewerIcon>{icon}</SWViewerIcon></span>}
|
||||||
|
{!iconOnly && <span className="hm-sw-viewer-header__label">{label}</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<HTMLElement, SWViewerHeaderProps>(function SWViewerHeader(
|
||||||
|
{
|
||||||
|
logo, logoSrc, logoAlt, onHomeClick, documentTitle, documentPlaceholder = "열린 도면 없음", documentTooltip, document: documentNode,
|
||||||
|
groups, trailing, onCommand, moreLabel = "추가 도구", toolbarLabel = "도면 작업 도구", className, ...rest
|
||||||
|
},
|
||||||
|
forwardedRef,
|
||||||
|
) {
|
||||||
|
const rootRef = useRef<HTMLElement | null>(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<string, string>()
|
||||||
|
const more = moreEntries(groups, width, command, owners)
|
||||||
|
const moreRef = useRef<HTMLButtonElement>(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 (
|
||||||
|
<div key={group.id} role="group" aria-label={group.label} className="hm-sw-viewer-header__group" data-surface={group.surface ?? "plain"} data-group={group.id}>
|
||||||
|
{items.map(item => <Item key={item.id} item={item} width={width} onCommand={command} />)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const start = groups.filter(group => group.placement !== "end")
|
||||||
|
const end = groups.filter(group => group.placement === "end")
|
||||||
|
const name = documentTitle || documentPlaceholder
|
||||||
|
const mark = logo ?? (logoSrc ? <img src={logoSrc} alt="" /> : null)
|
||||||
|
const brandContent = (
|
||||||
|
<>
|
||||||
|
<svg className="hm-sw-viewer-header__brand-shape" viewBox="0 0 88 52" preserveAspectRatio="xMaxYMid slice" aria-hidden="true" focusable="false">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="var(--hm-sw-viewer-brand-top)" />
|
||||||
|
<stop offset="45%" stopColor="var(--hm-sw-viewer-brand-middle)" />
|
||||||
|
<stop offset="100%" stopColor="var(--hm-sw-viewer-brand-bottom)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M 0 0 H 88 L 73 44 Q 70 52 62 52 H 0 Z" fill={`url(#${gradientId})`} />
|
||||||
|
<path d="M 88 0 L 73 44 Q 70 52 62 52 H 0" fill="none" stroke="var(--hm-sw-viewer-brand-middle)" vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
{mark && <span className="hm-sw-viewer-header__brand-mark" aria-hidden="true">{mark}</span>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<header ref={node => { 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
|
||||||
|
? <button type="button" className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} title={logoAlt} onClick={onHomeClick}>{brandContent}</button>
|
||||||
|
: <div className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} role={logoAlt ? "img" : undefined} aria-label={logoAlt}>{brandContent}</div>}
|
||||||
|
<div className="hm-sw-viewer-header__document" role="group" aria-label="현재 도면">
|
||||||
|
{documentNode ?? <span className="hm-sw-viewer-header__document-name" data-empty={!documentTitle || undefined} title={documentTooltip ?? (documentTitle || "파일 열기 또는 샘플을 선택하세요")}>{name}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="hm-sw-viewer-header__object" role="toolbar" aria-label={toolbarLabel}>
|
||||||
|
{start.map(renderGroup)}
|
||||||
|
{more.length > 0 && <>
|
||||||
|
<button ref={moreRef} type="button" className="hm-sw-viewer-header__button hm-sw-viewer-header__more" data-icon-only="always" data-state={moreOpen ? "open" : undefined}
|
||||||
|
aria-label={moreLabel} title="더보기" aria-haspopup="menu" aria-expanded={moreOpen}
|
||||||
|
onClick={() => { setMoreFocus("menu"); setMoreOpen(!moreOpen) }}
|
||||||
|
onKeyDown={event => { if (event.key === "ArrowDown") { event.preventDefault(); setMoreFocus("first"); setMoreOpen(true) } }}>
|
||||||
|
<span className="hm-sw-viewer-header__icon"><SWViewerIcon><MoreIcon /></SWViewerIcon></span>
|
||||||
|
</button>
|
||||||
|
<SWViewerMenu entries={more} open={moreOpen} onOpenChange={setMoreOpen} anchorRef={moreRef} align="end" width={260} surface="beige" label={moreLabel} initialFocus={moreFocus}
|
||||||
|
onEntrySelect={entry => {
|
||||||
|
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) && <div className="hm-sw-viewer-header__actions">{end.map(renderGroup)}{trailing}</div>}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useLayoutEffect, useRef, type ReactNode } from "react"
|
||||||
|
import "../../theme/sw-viewer.css"
|
||||||
|
|
||||||
|
const SHAPES = "path, line, polyline, polygon, circle, ellipse, rect"
|
||||||
|
|
||||||
|
export interface SWViewerIconProps {
|
||||||
|
/** The program's SVG icon (any size; stroke and / or fill, currentColor recommended). */
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon with the SWHeader black outline drawn as SVG stroke (SWHeader icon assets: 1px black OUTSIDE stroke).
|
||||||
|
* A copy of the icon sits behind it: every shape of the copy is stroked black with its own stroke width plus
|
||||||
|
* 2 x `--hm-sw-viewer-icon-outline-width` (1px each side, in screen pixels at the rendered size), and filled
|
||||||
|
* shapes are filled black. The icon on top is untouched, so its color, shape, size and line width stay the same.
|
||||||
|
*/
|
||||||
|
export function SWViewerIcon({ children, className }: SWViewerIconProps) {
|
||||||
|
const outlineRef = useRef<HTMLSpanElement>(null)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const root = outlineRef.current
|
||||||
|
if (!root) return
|
||||||
|
const outline = parseFloat(getComputedStyle(root).getPropertyValue("--hm-sw-viewer-icon-outline-width")) || 1
|
||||||
|
for (const svg of Array.from(root.querySelectorAll("svg"))) {
|
||||||
|
const view = svg.viewBox?.baseVal
|
||||||
|
const rendered = svg.getBoundingClientRect().width
|
||||||
|
// User units per screen pixel, so the outline is 1px on screen whatever the viewBox (e.g. 24 drawn at 16px).
|
||||||
|
const unit = view && view.width && rendered ? view.width / rendered : 1
|
||||||
|
for (const shape of Array.from(svg.querySelectorAll<SVGElement>(SHAPES))) {
|
||||||
|
shape.style.removeProperty("stroke-width")
|
||||||
|
const style = getComputedStyle(shape)
|
||||||
|
const strokeWidth = style.stroke !== "none" ? parseFloat(style.strokeWidth) || 0 : 0
|
||||||
|
const filled = style.fill !== "none" && Number(style.fillOpacity) !== 0
|
||||||
|
if (!strokeWidth && !filled) { shape.style.visibility = "hidden"; continue }
|
||||||
|
shape.style.stroke = "var(--hm-sw-viewer-icon-outline-color)"
|
||||||
|
shape.style.strokeWidth = `${strokeWidth + 2 * outline * unit}`
|
||||||
|
shape.style.strokeLinejoin = "round"
|
||||||
|
if (!strokeWidth) shape.style.strokeLinecap = "round"
|
||||||
|
if (filled) shape.style.fill = "var(--hm-sw-viewer-icon-outline-color)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<span className={`hm-sw-viewer-icon${className ? ` ${className}` : ""}`} aria-hidden="true">
|
||||||
|
<span ref={outlineRef} className="hm-sw-viewer-icon__outline">{children}</span>
|
||||||
|
<span className="hm-sw-viewer-icon__glyph">{children}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<HTMLElement | null>
|
||||||
|
/** 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 = () => (
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
|
)
|
||||||
|
const ChevronRight = () => (
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M6 3.5l4.5 4.5L6 12.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
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<HTMLDivElement>(null)
|
||||||
|
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
||||||
|
const [openSub, setOpenSub] = useState<string | null>(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<HTMLElement>(ITEM_SELECTOR) : ref.current
|
||||||
|
target?.focus({ preventScroll: true })
|
||||||
|
}, [autoFocus, position !== null])
|
||||||
|
|
||||||
|
const items = () => Array.from(ref.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
|
||||||
|
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
// 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 <div key={entry.id} role="separator" className="hm-sw-viewer-menu__separator" />
|
||||||
|
if (entry.type === "label") return <div key={entry.id} role="presentation" className="hm-sw-viewer-menu__label">{entry.label}</div>
|
||||||
|
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 (
|
||||||
|
<div key={entry.id} role="none" className="hm-sw-viewer-menu__entry">
|
||||||
|
<button type="button" role="menuitem" className="hm-sw-viewer-menu__item" aria-haspopup="menu" aria-expanded={expanded} data-state={expanded ? "open" : undefined} {...common}
|
||||||
|
onPointerEnter={() => openThis(false)}
|
||||||
|
onClick={() => openThis(true)}
|
||||||
|
onKeyDown={event => { if (event.key === "ArrowRight" || event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); openThis(true) } }}>
|
||||||
|
<span className="hm-sw-viewer-menu__icon" aria-hidden="true">{entry.icon}</span>
|
||||||
|
<span className="hm-sw-viewer-menu__text">{entry.label}</span>
|
||||||
|
<span className="hm-sw-viewer-menu__chevron"><ChevronRight /></span>
|
||||||
|
</button>
|
||||||
|
{expanded && <MenuList entries={entry.entries} surface={surface} label={entry.label} autoFocus={focusSub ? "first" : false}activate={activate} closeAll={closeAll}
|
||||||
|
closeLevel={() => { setOpenSub(null); ref.current?.querySelector<HTMLElement>(`[data-entry="${CSS.escape(entry.id)}"]`)?.focus() }}
|
||||||
|
place={(w, h) => {
|
||||||
|
const row = ref.current?.querySelector<HTMLElement>(`[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)) }
|
||||||
|
}} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const role = entry.type === "checkbox" ? "menuitemcheckbox" : entry.type === "radio" ? "menuitemradio" : "menuitem"
|
||||||
|
const checked = entry.type === "item" ? undefined : entry.checked
|
||||||
|
return (
|
||||||
|
<div key={entry.id} role="none" className="hm-sw-viewer-menu__entry">
|
||||||
|
<button type="button" role={role} className="hm-sw-viewer-menu__item" aria-checked={checked} data-kind={entry.type} {...common}
|
||||||
|
onClick={() => { if (!disabled) activate(entry) }}>
|
||||||
|
<span className="hm-sw-viewer-menu__icon" aria-hidden="true">
|
||||||
|
{entry.type === "item" ? entry.icon : checked ? entry.type === "radio" ? <span className="hm-sw-viewer-menu__dot" /> : <CheckIcon /> : null}
|
||||||
|
</span>
|
||||||
|
<span className="hm-sw-viewer-menu__text">{entry.label}</span>
|
||||||
|
{entry.type === "item" && entry.shortcut && <span className="hm-sw-viewer-menu__shortcut">{entry.shortcut}</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} id={id} role="menu" tabIndex={-1} aria-label={label} aria-orientation="vertical" className={`hm-sw-theme hm-sw-viewer-menu${className ? ` ${className}` : ""}`} data-surface={surface}
|
||||||
|
style={{ left: position?.left ?? 0, top: position?.top ?? 0, width, visibility: position ? undefined : "hidden" }}
|
||||||
|
onKeyDown={onKeyDown}>
|
||||||
|
{rows.length ? rows : <div role="presentation" className="hm-sw-viewer-menu__label">항목 없음</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<HTMLDivElement>(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 (
|
||||||
|
<div ref={rootRef} className="hm-sw-viewer-menu-root">
|
||||||
|
<MenuList entries={entries} surface={surface} width={width} label={label} place={place} activate={activate} closeAll={closeAll} autoFocus={initialFocus} id={id} className={className} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"
|
||||||
|
import { SWPopup } from "../sw-popup/SWPopup"
|
||||||
|
import "../../theme/sw-viewer.css"
|
||||||
|
|
||||||
|
export type SWViewerPanelPlacement = "top-end" | "top-start" | "center"
|
||||||
|
|
||||||
|
export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||||
|
/** Title (SWPopup title: 700, #FFC600, black 1px outline, ellipsis). Also the accessible name. */
|
||||||
|
title: string
|
||||||
|
/** Shown when true. Unmounted otherwise (no hidden DOM). */
|
||||||
|
open: boolean
|
||||||
|
/** Close button, and Escape (inside the panel, or anywhere while it is the top SW popup — SWPopup behavior). */
|
||||||
|
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 314 (the SWPopup width) x 420. `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 = 40
|
||||||
|
const EDGE = 10
|
||||||
|
const INTERACTIVE = "button, input, select, textarea, a, label, [data-panel-no-drag]"
|
||||||
|
|
||||||
|
const Chevron = ({ down }: { down: boolean }) => (
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d={down ? "M4 6l4 4 4-4" : "M6 4l4 4-4 4"} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
function bounds(element: HTMLElement, 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. The frame is the common SW popup (`SWPopup`: title bar, title, close,
|
||||||
|
* border, radius, shadow — the same component and DOM as every SW popup); this adds the viewer window behavior:
|
||||||
|
* drag by the title bar, corner resize, collapse, placement inside the bounds, bring to front, optional modal.
|
||||||
|
* `children` / `footer` are free content.
|
||||||
|
*/
|
||||||
|
export function SWViewerPanel({
|
||||||
|
title, open, onClose, closeLabel = "닫기", headerActions, children, footer,
|
||||||
|
width = 314, 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, id: idProp, className, style, onKeyDown,
|
||||||
|
onPointerDownCapture, onFocusCapture, onPointerDown, onPointerMove, onPointerUp, onDoubleClick, ...rest
|
||||||
|
}: SWViewerPanelProps) {
|
||||||
|
const generatedId = useId()
|
||||||
|
const id = idProp ?? `hm-sw-viewer-panel-${generatedId.replace(/:/g, "")}`
|
||||||
|
const element = () => document.getElementById(id) as HTMLDivElement | null
|
||||||
|
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 (before paint); then kept inside the bounds when they change.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) { setPosition(null); setSize(null); return }
|
||||||
|
const panel = element()
|
||||||
|
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, node = element(); if (current && node) setPosition(clamp(current.left, current.top, node, 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])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
const raise = () => { if (layer !== topLayer) setLayer(++topLayer) }
|
||||||
|
const setCollapsed = (next: boolean) => { if (collapsedProp === undefined) setInternalCollapsed(next); onCollapsedChange?.(next) }
|
||||||
|
const onTitlebar = (target: EventTarget) => {
|
||||||
|
const node = target as HTMLElement
|
||||||
|
return !!node.closest(".hm-sw-property__titlebar") && !node.closest(INTERACTIVE)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
onPointerDown?.(event)
|
||||||
|
if (!draggable || event.button !== 0 || !position || !onTitlebar(event.target)) 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 moveDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
onPointerMove?.(event)
|
||||||
|
const start = drag.current, panel = element()
|
||||||
|
if (!start || !panel) return
|
||||||
|
setPosition(clamp(start.left + event.clientX - start.x, start.top + event.clientY - start.y, panel, strategy))
|
||||||
|
}
|
||||||
|
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
onPointerUp?.(event)
|
||||||
|
if (!drag.current) return
|
||||||
|
drag.current = null
|
||||||
|
delete event.currentTarget.dataset.dragging
|
||||||
|
if (positionRef.current) onPositionChange?.(positionRef.current)
|
||||||
|
}
|
||||||
|
const toggleByTitle = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
|
onDoubleClick?.(event)
|
||||||
|
// Pointer capture (drag) retargets the click to the panel root, so test what is under the pointer.
|
||||||
|
const under = document.elementFromPoint(event.clientX, event.clientY)
|
||||||
|
if (collapsible && under && onTitlebar(under)) setCollapsed(!collapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGripPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
const panel = element()
|
||||||
|
if (event.button !== 0 || !panel) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
resize.current = { x: event.clientX, y: event.clientY, width: panel.offsetWidth, height: panel.offsetHeight }
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
const onGripPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
const start = resize.current, panel = element()
|
||||||
|
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 = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!resize.current) return
|
||||||
|
resize.current = null
|
||||||
|
const panel = element()
|
||||||
|
if (panel) onSizeChange?.({ width: panel.offsetWidth, height: panel.offsetHeight })
|
||||||
|
}
|
||||||
|
const trapFocus = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
onKeyDown?.(event)
|
||||||
|
const panel = element()
|
||||||
|
if (!modal || event.key !== "Tab" || event.defaultPrevented || !panel) return
|
||||||
|
const focusables = Array.from(panel.querySelectorAll<HTMLElement>('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 === panel)) { event.preventDefault(); last.focus() }
|
||||||
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentWidth = size?.width ?? width
|
||||||
|
const frameStyle: CSSProperties = {
|
||||||
|
position: strategy,
|
||||||
|
left: position?.left ?? 0,
|
||||||
|
top: position?.top ?? 0,
|
||||||
|
zIndex: layer,
|
||||||
|
width: currentWidth,
|
||||||
|
height: collapsed || (height === "auto" && !size) ? "auto" : size?.height ?? height,
|
||||||
|
minWidth,
|
||||||
|
minHeight: collapsed ? undefined : Math.min(minHeight, typeof height === "number" ? height : minHeight),
|
||||||
|
maxWidth: "90vw",
|
||||||
|
maxHeight: "90vh",
|
||||||
|
["--hm-sw-viewer-panel-width" as string]: `${currentWidth}px`,
|
||||||
|
...style,
|
||||||
|
}
|
||||||
|
const panel = (
|
||||||
|
<SWPopup {...rest} id={id} title={title} onClose={onClose} closeLabel={closeLabel} autoFocus={autoFocus}
|
||||||
|
className={`hm-sw-theme hm-sw-viewer-panel${className ? ` ${className}` : ""}`} style={frameStyle}
|
||||||
|
aria-modal={modal}
|
||||||
|
data-collapsed={collapsed || undefined} data-modal={modal || undefined} data-draggable={draggable || undefined}
|
||||||
|
onKeyDown={trapFocus}
|
||||||
|
onPointerDownCapture={event => { raise(); onPointerDownCapture?.(event) }}
|
||||||
|
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}
|
||||||
|
onPointerDown={startDrag} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={endDrag} onDoubleClick={toggleByTitle}
|
||||||
|
titleActions={(headerActions || collapsible) && <div className="hm-sw-viewer-panel__actions">
|
||||||
|
{headerActions}
|
||||||
|
{collapsible && <button type="button" className="hm-sw-viewer-panel__icon-button" aria-label={collapsed ? "펼치기" : "접기"} title={collapsed ? "펼치기" : "접기"} aria-expanded={!collapsed} onClick={() => setCollapsed(!collapsed)}><Chevron down={!collapsed} /></button>}
|
||||||
|
</div>}
|
||||||
|
footer={<>
|
||||||
|
{footer && !collapsed && <div className="hm-sw-viewer-panel__footer">{footer}</div>}
|
||||||
|
{resizable && !collapsed && <div className="hm-sw-viewer-panel__resize" aria-hidden="true" data-panel-no-drag=""
|
||||||
|
onPointerDown={onGripPointerDown} onPointerMove={onGripPointerMove} onPointerUp={onGripPointerUp} onPointerCancel={onGripPointerUp} />}
|
||||||
|
</>}>
|
||||||
|
{collapsed ? null : children}
|
||||||
|
</SWPopup>
|
||||||
|
)
|
||||||
|
return modal ? <><div className="hm-sw-viewer-panel-backdrop" style={{ position: strategy, zIndex: layer }} aria-hidden="true" />{panel}</> : panel
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { SWViewerHeader, SWViewerHeaderButton, type SWViewerHeaderProps, type SWViewerHeaderButtonProps } from "./SWViewerHeader"
|
||||||
|
export { SWViewerMenu, type SWViewerMenuProps } from "./SWViewerMenu"
|
||||||
|
export { SWViewerIcon, type SWViewerIconProps } from "./SWViewerIcon"
|
||||||
|
export { SWViewerPanel, type SWViewerPanelProps, type SWViewerPanelPlacement } from "./SWViewerPanel"
|
||||||
|
export {
|
||||||
|
SWViewerSection, SWViewerField, SWViewerHint, SWViewerSwitch, SWViewerSlider, SWViewerInput, SWViewerButton, SWViewerBadge, SWViewerStatus,
|
||||||
|
type SWViewerSectionProps, type SWViewerFieldProps, type SWViewerSwitchProps, type SWViewerSliderProps, type SWViewerInputProps,
|
||||||
|
type SWViewerButtonProps, type SWViewerBadgeProps, type SWViewerStatusProps, type SWViewerStatusState,
|
||||||
|
} from "./SWViewerControls"
|
||||||
|
export type { SWViewerHeaderItem, SWViewerHeaderGroup, SWViewerMenuEntry, SWViewerCommand } from "./types"
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
/** One entry of an SW viewer dropdown (header menu, More, context menu). Data only: the host runs the command. */
|
||||||
|
export type SWViewerMenuEntry =
|
||||||
|
| {
|
||||||
|
type: "item"
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
/** 16px icon supplied by the program. */
|
||||||
|
icon?: ReactNode
|
||||||
|
/** Right-aligned hint, e.g. `F`. */
|
||||||
|
shortcut?: string
|
||||||
|
disabled?: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
/** Keep the menu open after selecting. Default false. */
|
||||||
|
keepOpen?: boolean
|
||||||
|
onSelect?: () => void
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "checkbox"
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
checked: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
keepOpen?: boolean
|
||||||
|
onCheckedChange?: (checked: boolean) => void
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "radio"
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
checked: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
keepOpen?: boolean
|
||||||
|
onSelect?: () => void
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Multi-level menu: opens to the side (hover, Enter / Space / ArrowRight; ArrowLeft closes). */
|
||||||
|
type: "submenu"
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
entries: SWViewerMenuEntry[]
|
||||||
|
}
|
||||||
|
| { type: "label"; id: string; label: string; hidden?: boolean }
|
||||||
|
| { type: "separator"; id: string; hidden?: boolean }
|
||||||
|
|
||||||
|
/** Command event of SWViewerHeader / SWViewerMenu: the one place a program wires every menu action. */
|
||||||
|
export interface SWViewerCommand {
|
||||||
|
/** Header item id, or menu entry id for dropdown entries. */
|
||||||
|
id: string
|
||||||
|
/** Header item that owns the command (equals `id` for header buttons, toggles, tabs, switches). */
|
||||||
|
itemId: string
|
||||||
|
/** New state: `pressed` / `checked` (boolean) or the selected tab value (string). */
|
||||||
|
value?: boolean | string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SWViewerHeaderItemBase {
|
||||||
|
id: string
|
||||||
|
/** Visible text; also the accessible name (icon-only buttons keep it as aria-label). */
|
||||||
|
label: string
|
||||||
|
/** 16px icon supplied by the program. */
|
||||||
|
icon?: ReactNode
|
||||||
|
/** Tooltip (title attribute). */
|
||||||
|
title?: string
|
||||||
|
disabled?: boolean
|
||||||
|
/** Removed from the header and from More. */
|
||||||
|
hidden?: boolean
|
||||||
|
/** Header width (px) below which the item moves into the More dropdown. */
|
||||||
|
collapseBelow?: number
|
||||||
|
/** Header width (px) below which only the icon is shown (label kept as the accessible name). */
|
||||||
|
iconOnlyBelow?: number
|
||||||
|
/** Always icon only (e.g. the info button). Needs `icon`. */
|
||||||
|
iconOnly?: boolean
|
||||||
|
/** Section title of this item inside More (menu and tabs items only). Default `label`. */
|
||||||
|
moreLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SWViewerHeaderItem =
|
||||||
|
| (SWViewerHeaderItemBase & { type: "button"; onSelect?: () => void })
|
||||||
|
| (SWViewerHeaderItemBase & { type: "toggle"; pressed: boolean; onPressedChange?: (pressed: boolean) => void })
|
||||||
|
| (SWViewerHeaderItemBase & {
|
||||||
|
/** Button with a dropdown (chevron). */
|
||||||
|
type: "menu"
|
||||||
|
entries: SWViewerMenuEntry[]
|
||||||
|
/** Dropdown width in px. Default: its content (min 160). */
|
||||||
|
menuWidth?: number
|
||||||
|
/** Dropdown edge aligned to the button. Default `start`. */
|
||||||
|
menuAlign?: "start" | "end"
|
||||||
|
})
|
||||||
|
| (SWViewerHeaderItemBase & {
|
||||||
|
/** Single selection such as Model / Layout (radiogroup, arrow keys move). */
|
||||||
|
type: "tabs"
|
||||||
|
value: string | null
|
||||||
|
options: { value: string; label: string; count?: number; title?: string; disabled?: boolean }[]
|
||||||
|
onValueChange?: (value: string) => void
|
||||||
|
})
|
||||||
|
| (SWViewerHeaderItemBase & {
|
||||||
|
/** Caption above a switch, e.g. 2.5D. */
|
||||||
|
type: "switch"
|
||||||
|
checked: boolean
|
||||||
|
onCheckedChange?: (checked: boolean) => void
|
||||||
|
})
|
||||||
|
| {
|
||||||
|
/** Free content in the header (program-specific control). */
|
||||||
|
type: "custom"
|
||||||
|
id: string
|
||||||
|
hidden?: boolean
|
||||||
|
collapseBelow?: number
|
||||||
|
render: () => ReactNode
|
||||||
|
/** How the item appears in More once collapsed. Default: not shown there. */
|
||||||
|
menuEntries?: SWViewerMenuEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SWViewerHeaderGroup {
|
||||||
|
id: string
|
||||||
|
/** Accessible group name. Also the More section title of its buttons, toggles and switches. */
|
||||||
|
label: string
|
||||||
|
/** More section title when it differs from `label`. */
|
||||||
|
moreLabel?: string
|
||||||
|
/** `plain` (default): no face. `field`: the inset dark property surface (user-confirmed values). */
|
||||||
|
surface?: "plain" | "field"
|
||||||
|
/** `end`: the trailing action area at the right end of the object surface. Default `start`. */
|
||||||
|
placement?: "start" | "end"
|
||||||
|
hidden?: boolean
|
||||||
|
items: SWViewerHeaderItem[]
|
||||||
|
}
|
||||||
@@ -10,3 +10,6 @@ export {
|
|||||||
SWPopup, SWPopupSection, SWPopupRow,
|
SWPopup, SWPopupSection, SWPopupRow,
|
||||||
type SWPopupProps, type SWPopupSectionProps, type SWPopupRowProps,
|
type SWPopupProps, type SWPopupSectionProps, type SWPopupRowProps,
|
||||||
} from "./components/sw-popup/SWPopup"
|
} from "./components/sw-popup/SWPopup"
|
||||||
|
|
||||||
|
// SW viewer mode (2026-10-19): viewer header, dropdown, popup panel and panel controls.
|
||||||
|
export * from "./components/sw-viewer"
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
/* @hm-design/sw — SW viewer mode (reference: DWG/DXF viewer top menu and floating panels, measured 2026-10-19).
|
||||||
|
Viewer-only tokens. The three header surfaces reuse the user-confirmed SW tokens of sw.css
|
||||||
|
(--hm-sw-header-document-* / object-* / property-*). Never touches global selectors or the SWHeader. */
|
||||||
|
|
||||||
|
.hm-sw-header,
|
||||||
|
.hm-sw-theme {
|
||||||
|
/* Header */
|
||||||
|
--hm-sw-viewer-height: 52px;
|
||||||
|
--hm-sw-viewer-background: linear-gradient(180deg, #266651 0%, #123328 45%, #0a221d 100%);
|
||||||
|
--hm-sw-viewer-border-color: #071e18;
|
||||||
|
--hm-sw-viewer-brand-width: 72px;
|
||||||
|
/* No logo: no text or icon is drawn; the cap keeps only its slanted edge (no wide empty space). */
|
||||||
|
--hm-sw-viewer-brand-empty-width: 24px;
|
||||||
|
--hm-sw-viewer-brand-top: #123328;
|
||||||
|
--hm-sw-viewer-brand-middle: #205241;
|
||||||
|
--hm-sw-viewer-brand-bottom: #071e18;
|
||||||
|
--hm-sw-viewer-text: #fff;
|
||||||
|
--hm-sw-viewer-muted: #bddfd4;
|
||||||
|
--hm-sw-viewer-accent: #ffc600;
|
||||||
|
--hm-sw-viewer-hover: #266651;
|
||||||
|
--hm-sw-viewer-gap: 4px;
|
||||||
|
--hm-sw-viewer-radius: 4px;
|
||||||
|
--hm-sw-viewer-control-height: 40px;
|
||||||
|
--hm-sw-viewer-button-height: 34px;
|
||||||
|
--hm-sw-viewer-font-size: 14px;
|
||||||
|
--hm-sw-viewer-label-size: 11px;
|
||||||
|
--hm-sw-viewer-icon-size: 16px;
|
||||||
|
--hm-sw-viewer-disabled-opacity: 0.6;
|
||||||
|
--hm-sw-viewer-switch-off: #0a221d;
|
||||||
|
--hm-sw-viewer-switch-bevel: inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 -1px 1px rgba(0, 0, 0, 0.25);
|
||||||
|
/* Icon stroke: the SWHeader icon assets (print / K guide / layer icons) are a fill + a 1px black OUTSIDE stroke
|
||||||
|
(Figma mask "outside" path). SWViewerIcon draws it as SVG stroke on a copy behind the icon (see SWViewerIcon). */
|
||||||
|
--hm-sw-viewer-icon-outline-color: var(--hm-sw-header-text-outline-color);
|
||||||
|
--hm-sw-viewer-icon-outline-width: 1px;
|
||||||
|
|
||||||
|
/* Dropdown (light) and More (beige) */
|
||||||
|
--hm-sw-viewer-menu-background: #fff;
|
||||||
|
--hm-sw-viewer-menu-border-color: #dedbd2;
|
||||||
|
--hm-sw-viewer-menu-text: #0a221d;
|
||||||
|
--hm-sw-viewer-menu-muted: #6e6759;
|
||||||
|
--hm-sw-viewer-menu-highlight: rgba(189, 223, 212, 0.45);
|
||||||
|
--hm-sw-viewer-menu-highlight-text: #123328;
|
||||||
|
--hm-sw-viewer-menu-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
|
||||||
|
--hm-sw-viewer-menu-item-height: 36px;
|
||||||
|
--hm-sw-viewer-more-background: #f5f0e5;
|
||||||
|
--hm-sw-viewer-more-border-color: #897445;
|
||||||
|
|
||||||
|
/* Panel: the frame is SWPopup (title bar, title, close, border, radius, shadow). Content tokens map to the
|
||||||
|
SW property window tokens so viewer content sits on the same surface as every SW popup. */
|
||||||
|
--hm-sw-viewer-panel-background: var(--hm-sw-property-surface);
|
||||||
|
--hm-sw-viewer-panel-field-background: var(--hm-sw-property-content);
|
||||||
|
--hm-sw-viewer-panel-inner-border: var(--hm-sw-property-inner-border);
|
||||||
|
--hm-sw-viewer-panel-text: var(--hm-sw-property-label);
|
||||||
|
--hm-sw-viewer-panel-heading: #3e3523;
|
||||||
|
--hm-sw-viewer-panel-label: #6e6759;
|
||||||
|
--hm-sw-viewer-panel-primary: #123328;
|
||||||
|
--hm-sw-viewer-panel-switch-off: var(--hm-sw-property-inner-border);
|
||||||
|
--hm-sw-viewer-panel-switch-on: #266651;
|
||||||
|
--hm-sw-viewer-panel-badge-background: var(--hm-sw-property-table);
|
||||||
|
--hm-sw-viewer-panel-overlay: rgba(0, 0, 0, 0.1);
|
||||||
|
--hm-sw-viewer-panel-spacing: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hm-sw-viewer-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ---------- Header ---------- */
|
||||||
|
.hm-sw-viewer-header {
|
||||||
|
container: hm-sw-viewer / inline-size;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: var(--hm-sw-viewer-height);
|
||||||
|
border: 1px solid var(--hm-sw-viewer-border-color);
|
||||||
|
background: var(--hm-sw-viewer-background);
|
||||||
|
color: var(--hm-sw-viewer-text);
|
||||||
|
font-size: var(--hm-sw-viewer-font-size);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hm-sw-viewer-header__brand {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: var(--hm-sw-viewer-brand-empty-width);
|
||||||
|
height: calc(var(--hm-sw-viewer-height) - 2px);
|
||||||
|
margin-right: -2px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
/* With a logo: the reference 72px cap (logo at 24px), wider only for a wider logo. Without: the slanted edge only. */
|
||||||
|
.hm-sw-viewer-header__brand[data-logo] { width: auto; min-width: var(--hm-sw-viewer-brand-width); padding: 0 25px 0 24px; }
|
||||||
|
button.hm-sw-viewer-header__brand { text-align: left; }
|
||||||
|
.hm-sw-viewer-header__brand-shape { position: absolute; inset: 0 auto 0 0; width: calc(100% + 16px); height: 100%; pointer-events: none; }
|
||||||
|
.hm-sw-viewer-header__brand-mark { position: relative; display: inline-flex; align-items: center; }
|
||||||
|
.hm-sw-viewer-header__brand-mark > :is(img, svg) { display: block; max-height: 24px; width: auto; }
|
||||||
|
|
||||||
|
.hm-sw-viewer-header__document {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 1 clamp(140px, 25cqw, 360px);
|
||||||
|
width: clamp(220px, 25cqw, 360px);
|
||||||
|
min-width: 56px;
|
||||||
|
height: calc(var(--hm-sw-viewer-height) - 2px);
|
||||||
|
padding: 0 16px 0 24px;
|
||||||
|
border: 1px solid var(--hm-sw-header-document-border-color);
|
||||||
|
border-left: 0;
|
||||||
|
background: var(--hm-sw-header-document-background);
|
||||||
|
}
|
||||||
|
.hm-sw-viewer-header__document-name { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.hm-sw-viewer-header__object {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: calc(var(--hm-sw-viewer-height) - 2px);
|
||||||
|
gap: var(--hm-sw-viewer-gap);
|
||||||
|
padding-inline: var(--hm-sw-viewer-gap);
|
||||||
|
border: 1px solid var(--hm-sw-header-object-border-color);
|
||||||
|
border-radius: var(--hm-sw-header-object-radius);
|
||||||
|
background: var(--hm-sw-header-object-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hm-sw-viewer-header__group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--hm-sw-viewer-gap);
|
||||||
|
min-height: var(--hm-sw-viewer-control-height);
|
||||||
|
padding-inline: var(--hm-sw-viewer-gap);
|
||||||
|
}
|
||||||
|
.hm-sw-viewer-header__group[data-surface="field"] {
|
||||||
|
border: 1px solid var(--hm-sw-header-property-border-color);
|
||||||
|
border-radius: var(--hm-sw-header-property-radius);
|
||||||
|
background: var(--hm-sw-header-property-background);
|
||||||
|
box-shadow: var(--hm-sw-header-property-shadow);
|
||||||
|
}
|
||||||
|
.hm-sw-viewer-header__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
gap: var(--hm-sw-viewer-gap);
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons, menu buttons and toggles: transparent, #266651 hover, #FFC600 pressed / open / on (no selected box). */
|
||||||
|
.hm-sw-viewer-header .hm-sw-viewer-header__button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
height: var(--hm-sw-viewer-button-height);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--hm-sw-viewer-radius);
|
||||||
|
color: var(--hm-sw-viewer-text);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hm-sw-viewer-header .hm-sw-viewer-header__button[data-kind="toggle"] { height: var(--hm-sw-viewer-control-height); padding: 0 8px; }
|
||||||
|
.hm-sw-viewer-header .hm-sw-viewer-header__button[data-icon-only] { min-width: var(--hm-sw-viewer-button-height); padding: 0 8px; }
|
||||||
|
/* Always-icon buttons (info, More): the reference 32 x 40 icon button. */
|
||||||
|
.hm-sw-viewer-header .hm-sw-viewer-header__button[data-icon-only="always"] { min-width: 32px; width: 32px; height: var(--hm-sw-viewer-control-height); padding: 0 8px; }
|
||||||
|
.hm-sw-viewer-header .hm-sw-viewer-header__button:hover:not(:disabled) { background: var(--hm-sw-viewer-hover); }
|
||||||
|
.hm-sw-viewer-header .hm-sw-viewer-header__button:active:not(:disabled),
|
||||||
|
.hm-sw-viewer-header .hm-sw-viewer-header__button:is([data-state="open"], [data-state="on"]) { color: var(--hm-sw-viewer-accent); }
|
||||||
|
.hm-sw-viewer-header .hm-sw-viewer-header__button:is([data-state="open"], [data-state="on"]):not(:hover) { background: transparent; }
|
||||||
|
.hm-sw-viewer-header :is(.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab):disabled { opacity: var(--hm-sw-viewer-disabled-opacity); cursor: not-allowed; }
|
||||||
|
.hm-sw-viewer-header__icon, .hm-sw-viewer-header__chevron { display: inline-flex; width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
|
||||||
|
.hm-sw-viewer-header__icon > svg, .hm-sw-viewer-header__chevron > svg { width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
/* SWViewerIcon: the outline copy (black SVG stroke) right behind the untouched icon. */
|
||||||
|
.hm-sw-viewer-icon { position: relative; display: inline-flex; width: 100%; height: 100%; }
|
||||||
|
.hm-sw-viewer-icon__outline, .hm-sw-viewer-icon__glyph { display: inline-flex; width: 100%; height: 100%; }
|
||||||
|
.hm-sw-viewer-icon__outline { position: absolute; inset: 0; overflow: visible; }
|
||||||
|
.hm-sw-viewer-icon__glyph { position: relative; }
|
||||||
|
.hm-sw-viewer-icon svg { width: 100%; height: 100%; overflow: visible; }
|
||||||
|
|
||||||
|
/* Tabs (Model / Layout): 40px triggers with an 11px count; the active one turns #FFC600. */
|
||||||
|
.hm-sw-viewer-header__tabs { display: flex; align-items: center; gap: var(--hm-sw-viewer-gap); }
|
||||||
|
.hm-sw-viewer-header .hm-sw-viewer-header__tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--hm-sw-viewer-gap);
|
||||||
|
height: var(--hm-sw-viewer-control-height);
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--hm-sw-viewer-radius);
|
||||||
|
color: var(--hm-sw-viewer-text);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hm-sw-viewer-header .hm-sw-viewer-header__tab:hover:not(:disabled) { background: var(--hm-sw-viewer-hover); }
|
||||||
|
.hm-sw-viewer-header .hm-sw-viewer-header__tab[data-state="active"] { color: var(--hm-sw-viewer-accent); }
|
||||||
|
.hm-sw-viewer-header__count { font-size: var(--hm-sw-viewer-label-size); font-weight: 400; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* Switch fields (2.5D, 어두운 배경): 11px muted caption above the switch. */
|
||||||
|
.hm-sw-viewer-header__switch-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--hm-sw-viewer-gap);
|
||||||
|
min-height: var(--hm-sw-viewer-control-height);
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
.hm-sw-viewer-header__switch-label { color: var(--hm-sw-viewer-muted); font-size: var(--hm-sw-viewer-label-size); font-weight: 400; line-height: 1; white-space: nowrap; }
|
||||||
|
|
||||||
|
.hm-sw-viewer-header :is(.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab, .hm-sw-viewer-header__brand, .hm-sw-viewer-switch):focus-visible {
|
||||||
|
outline: 2px solid var(--hm-sw-viewer-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text and icon stroke (2026-10-19 request): the SWHeader black outline on the viewer header, same tokens and
|
||||||
|
method — an outside outline of the glyph / icon shape, never a box border.
|
||||||
|
Text: 14px = --hm-sw-header-text-outline (1.3px black), 11px captions / counts / status = --hm-sw-header-label-outline
|
||||||
|
(1px black), as SWHeader's 12px labels. Icons: SWViewerIcon (SVG stroke, see the token). Fill color, size,
|
||||||
|
weight and spacing are unchanged; hover / active / open change only the fill, disabled fades the whole button
|
||||||
|
(opacity) like SWHeader. Dropdown faces (light / beige) and panels get no outline, like SWHeader's More menu. */
|
||||||
|
.hm-sw-viewer-header :is(
|
||||||
|
.hm-sw-viewer-header__brand-mark, .hm-sw-viewer-header__document-name, .hm-sw-viewer-header__label, .hm-sw-viewer-header__tab
|
||||||
|
) {
|
||||||
|
text-shadow: var(--hm-sw-header-text-outline);
|
||||||
|
}
|
||||||
|
.hm-sw-viewer-header :is(.hm-sw-viewer-header__count, .hm-sw-viewer-header__switch-label, .hm-sw-viewer-status__label) {
|
||||||
|
text-shadow: var(--hm-sw-header-label-outline);
|
||||||
|
}
|
||||||
|
/* Clipped (ellipsis) file name: the outline may paint 2px outside the box instead of being cut. */
|
||||||
|
.hm-sw-viewer-header .hm-sw-viewer-header__document-name {
|
||||||
|
overflow: clip;
|
||||||
|
overflow-clip-margin: var(--hm-sw-header-text-outline-room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
.hm-sw-viewer-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--hm-sw-viewer-gap);
|
||||||
|
padding-inline: 8px;
|
||||||
|
color: var(--hm-sw-viewer-muted);
|
||||||
|
font-size: var(--hm-sw-viewer-label-size);
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hm-sw-viewer-status__icon { display: inline-flex; width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
|
||||||
|
.hm-sw-viewer-status[data-state="error"] { color: var(--hm-sw-viewer-accent); }
|
||||||
|
.hm-sw-viewer-status__spin { animation: hm-sw-viewer-spin 1s linear infinite; }
|
||||||
|
@keyframes hm-sw-viewer-spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .hm-sw-viewer-status__spin { animation: none; } }
|
||||||
|
|
||||||
|
/* Narrow header (reference breakpoints, measured on the header itself). */
|
||||||
|
@container hm-sw-viewer (max-width: 1179.98px) {
|
||||||
|
.hm-sw-viewer-header__object { gap: 2px; padding-inline: 2px; }
|
||||||
|
.hm-sw-viewer-header__group { gap: 2px; padding-inline: 2px; }
|
||||||
|
.hm-sw-viewer-header__actions { padding-left: 2px; gap: 2px; }
|
||||||
|
.hm-sw-viewer-status { padding-inline: 2px; }
|
||||||
|
}
|
||||||
|
@container hm-sw-viewer (max-width: 559.98px) {
|
||||||
|
.hm-sw-viewer-header__document { padding-right: 8px; }
|
||||||
|
.hm-sw-viewer-status__label { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Switch (header / panel faces) ---------- */
|
||||||
|
.hm-sw-theme .hm-sw-viewer-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--hm-sw-viewer-switch-off);
|
||||||
|
box-shadow: var(--hm-sw-viewer-switch-bevel);
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
.hm-sw-viewer-switch__thumb { display: block; width: 16px; height: 16px; border-radius: 9999px; background: var(--hm-sw-viewer-text); transition: transform 0.15s; }
|
||||||
|
.hm-sw-viewer-switch[data-state="checked"] { background: var(--hm-sw-viewer-accent); }
|
||||||
|
.hm-sw-viewer-switch[data-state="checked"] .hm-sw-viewer-switch__thumb { transform: translateX(16px); background: var(--hm-sw-viewer-switch-off); }
|
||||||
|
.hm-sw-theme .hm-sw-viewer-switch:disabled { opacity: var(--hm-sw-viewer-disabled-opacity); cursor: not-allowed; }
|
||||||
|
.hm-sw-viewer-panel .hm-sw-viewer-switch { background: var(--hm-sw-viewer-panel-switch-off); box-shadow: none; }
|
||||||
|
.hm-sw-viewer-panel .hm-sw-viewer-switch .hm-sw-viewer-switch__thumb { background: #fff; }
|
||||||
|
.hm-sw-viewer-panel .hm-sw-viewer-switch[data-state="checked"] { background: var(--hm-sw-viewer-panel-switch-on); }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .hm-sw-viewer-switch, .hm-sw-viewer-switch__thumb { transition: none; } }
|
||||||
|
|
||||||
|
/* ---------- Dropdown ---------- */
|
||||||
|
.hm-sw-viewer-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: calc(100vw - 8px);
|
||||||
|
max-height: min(640px, calc(100dvh - 8px));
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid var(--hm-sw-viewer-menu-border-color);
|
||||||
|
border-radius: var(--hm-sw-viewer-radius);
|
||||||
|
background: var(--hm-sw-viewer-menu-background);
|
||||||
|
box-shadow: var(--hm-sw-viewer-menu-shadow);
|
||||||
|
color: var(--hm-sw-viewer-menu-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 20px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.hm-sw-viewer-menu[data-surface="beige"] { background: var(--hm-sw-viewer-more-background); border-color: var(--hm-sw-viewer-more-border-color); }
|
||||||
|
.hm-sw-viewer-menu .hm-sw-viewer-menu__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--hm-sw-viewer-menu-item-height);
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.hm-sw-viewer-menu .hm-sw-viewer-menu__item:is(:focus, :hover, [data-state="open"]):not([aria-disabled]) { background: var(--hm-sw-viewer-menu-highlight); color: var(--hm-sw-viewer-menu-highlight-text); }
|
||||||
|
.hm-sw-viewer-menu .hm-sw-viewer-menu__item[aria-disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.hm-sw-viewer-menu .hm-sw-viewer-menu__item:focus-visible { outline: none; }
|
||||||
|
.hm-sw-viewer-menu__icon { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 16px; width: 16px; height: 16px; }
|
||||||
|
.hm-sw-viewer-menu__icon:empty { display: none; }
|
||||||
|
.hm-sw-viewer-menu__item[role="menuitemcheckbox"] .hm-sw-viewer-menu__icon,
|
||||||
|
.hm-sw-viewer-menu__item[role="menuitemradio"] .hm-sw-viewer-menu__icon { display: inline-flex; }
|
||||||
|
.hm-sw-viewer-menu__icon > svg { width: 16px; height: 16px; }
|
||||||
|
.hm-sw-viewer-menu__dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; }
|
||||||
|
.hm-sw-viewer-menu__text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.hm-sw-viewer-menu__shortcut { margin-left: auto; color: var(--hm-sw-viewer-menu-muted); font-size: 12px; letter-spacing: 0.05em; }
|
||||||
|
.hm-sw-viewer-menu__chevron { display: inline-flex; margin-left: auto; color: var(--hm-sw-viewer-menu-muted); }
|
||||||
|
.hm-sw-viewer-menu__label { padding: 6px 12px; color: var(--hm-sw-viewer-menu-muted); font-size: 12px; font-weight: 500; line-height: 18px; }
|
||||||
|
.hm-sw-viewer-menu__separator { flex: 0 0 1px; height: 1px; margin: 4px -4px; background: var(--hm-sw-viewer-menu-border-color); }
|
||||||
|
.hm-sw-viewer-menu[data-surface="beige"] .hm-sw-viewer-menu__separator { background: var(--hm-sw-viewer-panel-inner-border); }
|
||||||
|
|
||||||
|
/* ---------- Panel (SWPopup frame + viewer window behavior) ---------- */
|
||||||
|
.hm-sw-viewer-panel-backdrop { inset: 0; background: var(--hm-sw-viewer-panel-overlay); }
|
||||||
|
.hm-sw-viewer-panel[data-collapsed] .hm-sw-property__body { display: none; }
|
||||||
|
.hm-sw-viewer-panel[data-draggable] .hm-sw-property__titlebar { cursor: grab; touch-action: none; user-select: none; }
|
||||||
|
.hm-sw-viewer-panel[data-dragging] .hm-sw-property__titlebar { cursor: grabbing; }
|
||||||
|
/* Any width: the Figma title art keeps its 308px curve at the close button (right-aligned; at 314px this is exactly
|
||||||
|
the SWPopup position), and a stretched copy of the same art fills the flat part on the left of wider panels.
|
||||||
|
The right one hides its own left 20px (left edge + corner) and the left copy ends exactly there: no seam, no
|
||||||
|
doubled shadow. */
|
||||||
|
.hm-sw-viewer-panel .hm-sw-property__title-art { left: auto; right: 4px; clip-path: inset(-4px -4px -4px 20px); }
|
||||||
|
.hm-sw-viewer-panel .hm-sw-property__titlebar::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
left: 0;
|
||||||
|
top: -2px;
|
||||||
|
width: max(0px, calc(100% - 292px));
|
||||||
|
height: 38px;
|
||||||
|
background: url("../assets/property-title-single.svg") left top / calc(100% + 288px) 38px no-repeat;
|
||||||
|
transform: scaleY(-1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.hm-sw-viewer-panel__actions { position: relative; z-index: 2; display: flex; flex-shrink: 0; align-items: center; height: 30px; }
|
||||||
|
/* Title-bar buttons: SW text-only look (no face), #FFC600 on hover like the SW title-bar controls. */
|
||||||
|
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--hm-sw-viewer-muted);
|
||||||
|
}
|
||||||
|
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]):hover:not(:disabled) { background: transparent; color: var(--hm-sw-header-selected-color); }
|
||||||
|
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]):focus-visible { outline: 2px solid var(--hm-sw-header-selected-color); outline-offset: -2px; }
|
||||||
|
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]) svg { width: 16px; height: 16px; }
|
||||||
|
.hm-sw-viewer-panel__footer {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px;
|
||||||
|
border-top: 1px solid var(--hm-sw-viewer-panel-inner-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel content */
|
||||||
|
.hm-sw-viewer-section { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.hm-sw-viewer-section[data-surface="card"] {
|
||||||
|
padding: var(--hm-sw-viewer-panel-spacing);
|
||||||
|
border: 1px solid var(--hm-sw-viewer-panel-inner-border);
|
||||||
|
border-radius: var(--hm-sw-viewer-radius);
|
||||||
|
background: var(--hm-sw-viewer-panel-field-background);
|
||||||
|
box-shadow: inset 0 1px 0 #fff;
|
||||||
|
}
|
||||||
|
.hm-sw-viewer-section__title { margin: 0; color: var(--hm-sw-viewer-panel-heading); font-size: 12px; font-weight: 700; line-height: 16px; letter-spacing: 0.025em; text-transform: uppercase; }
|
||||||
|
.hm-sw-viewer-hint { margin: 0; color: var(--hm-sw-viewer-panel-heading); font-size: 12px; line-height: 16px; }
|
||||||
|
.hm-sw-viewer-field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.hm-sw-viewer-field[data-disabled] { opacity: 0.5; pointer-events: none; }
|
||||||
|
.hm-sw-viewer-field__row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 20px; }
|
||||||
|
.hm-sw-viewer-field__label { min-width: 0; color: var(--hm-sw-viewer-panel-label); font-size: 14px; font-weight: 500; line-height: 20px; }
|
||||||
|
.hm-sw-viewer-field__control { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||||
|
.hm-sw-viewer-field__unit { color: var(--hm-sw-viewer-panel-label); font-size: 12px; }
|
||||||
|
|
||||||
|
.hm-sw-theme .hm-sw-viewer-input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 80px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--hm-sw-viewer-panel-inner-border);
|
||||||
|
border-radius: var(--hm-sw-viewer-radius);
|
||||||
|
background: var(--hm-sw-viewer-panel-field-background);
|
||||||
|
color: var(--hm-sw-viewer-panel-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
.hm-sw-theme .hm-sw-viewer-input:focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-switch-on); outline-offset: -1px; }
|
||||||
|
.hm-sw-theme .hm-sw-viewer-input:disabled { opacity: 0.5; }
|
||||||
|
|
||||||
|
.hm-sw-theme .hm-sw-viewer-slider {
|
||||||
|
--hm-sw-viewer-slider-percent: 0%;
|
||||||
|
width: 100%;
|
||||||
|
height: 18px;
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.hm-sw-viewer-slider::-webkit-slider-runnable-track { height: 6px; border-radius: 9999px; background: linear-gradient(90deg, var(--hm-sw-viewer-panel-switch-on) var(--hm-sw-viewer-slider-percent), var(--hm-sw-viewer-panel-background) var(--hm-sw-viewer-slider-percent)); }
|
||||||
|
.hm-sw-viewer-slider::-moz-range-track { height: 6px; border-radius: 9999px; background: var(--hm-sw-viewer-panel-background); }
|
||||||
|
.hm-sw-viewer-slider::-moz-range-progress { height: 6px; border-radius: 9999px; background: var(--hm-sw-viewer-panel-switch-on); }
|
||||||
|
.hm-sw-viewer-slider::-webkit-slider-thumb { width: 18px; height: 18px; margin-top: -6px; border: 2px solid var(--hm-sw-viewer-panel-switch-on); border-radius: 9999px; background: #fff; appearance: none; }
|
||||||
|
.hm-sw-viewer-slider::-moz-range-thumb { box-sizing: border-box; width: 18px; height: 18px; border: 2px solid var(--hm-sw-viewer-panel-switch-on); border-radius: 9999px; background: #fff; }
|
||||||
|
.hm-sw-theme .hm-sw-viewer-slider:focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-switch-on); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.hm-sw-theme .hm-sw-viewer-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--hm-sw-viewer-panel-primary);
|
||||||
|
border-radius: var(--hm-sw-viewer-radius);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--hm-sw-viewer-panel-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hm-sw-theme .hm-sw-viewer-button[data-variant="outline"]:hover:not(:disabled) { background: var(--hm-sw-viewer-panel-field-background); }
|
||||||
|
.hm-sw-theme .hm-sw-viewer-button[data-variant="primary"] { background: var(--hm-sw-viewer-panel-primary); color: #fff; }
|
||||||
|
/* tone="green" (e.g. 전체 켜기 / 전체 끄기): default unchanged. Hover = the primary (확인) button hover, the same token;
|
||||||
|
pressed = the primary button face. White text as on the primary button. Back to default / hover after the press. */
|
||||||
|
.hm-sw-theme .hm-sw-viewer-button[data-tone="green"]:hover:not(:disabled) { border-color: var(--hm-sw-viewer-hover); background: var(--hm-sw-viewer-hover); color: #fff; }
|
||||||
|
.hm-sw-theme .hm-sw-viewer-button[data-tone="green"]:active:not(:disabled) { border-color: var(--hm-sw-viewer-panel-primary); background: var(--hm-sw-viewer-panel-primary); color: #fff; }
|
||||||
|
.hm-sw-theme .hm-sw-viewer-button[data-variant="primary"]:hover:not(:disabled) { background: var(--hm-sw-viewer-hover); border-color: var(--hm-sw-viewer-hover); }
|
||||||
|
.hm-sw-theme .hm-sw-viewer-button:focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-switch-on); outline-offset: 1px; }
|
||||||
|
.hm-sw-theme .hm-sw-viewer-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.hm-sw-viewer-button__icon { display: inline-flex; width: 16px; height: 16px; }
|
||||||
|
.hm-sw-viewer-button__icon > svg { width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
.hm-sw-viewer-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: var(--hm-sw-viewer-radius); background: var(--hm-sw-viewer-panel-badge-background); color: var(--hm-sw-viewer-panel-label); font-size: 12px; font-weight: 500; line-height: 18px; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* Corner resize grip (replaces native resize: same corner and glyph idea, works in every browser and with
|
||||||
|
pointer capture; the size stays within min / 90% of the bounds / the bounds edge). */
|
||||||
|
.hm-sw-viewer-panel__resize {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
touch-action: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, transparent 0 55%, var(--hm-sw-viewer-panel-inner-border) 55% 62%, transparent 62% 72%, var(--hm-sw-viewer-panel-inner-border) 72% 79%, transparent 79%);
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ export const inventorySignature = entries => entries.map(entry => [entry.id, ent
|
|||||||
// SW/Header next to its 10 retained Stories, the rest under SW/Tool and SW/Popup), and the only package files it changed.
|
// SW/Header next to its 10 retained Stories, the rest under SW/Tool and SW/Popup), and the only package files it changed.
|
||||||
// The two Header Stories sit directly under SW/Header (no separate Options menu), in src/sw/sw-header-open.stories.tsx.
|
// The two Header Stories sit directly under SW/Header (no separate Options menu), in src/sw/sw-header-open.stories.tsx.
|
||||||
export const integratedStoryIds = ['sw-header--document-open', 'sw-header--hidden-sub-controls', 'sw-tool--tool-buttons', 'sw-popup--custom-content', 'sw-popup--multiple-popups']
|
export const integratedStoryIds = ['sw-header--document-open', 'sw-header--hidden-sub-controls', 'sw-tool--tool-buttons', 'sw-popup--custom-content', 'sw-popup--multiple-popups']
|
||||||
|
// 2026-10-19 (viewer worktree, user request): the SW/Tool Story menu was removed; SW/Popup (the common SWPopup
|
||||||
|
// examples) stays. SWToolButton / SWToolGroup stay public; integrated-improvement.browser.mjs opening this id is historical evidence only.
|
||||||
|
export const retiredToolIds = ["sw-tool--tool-buttons"]
|
||||||
export const integratedChangedFiles = [
|
export const integratedChangedFiles = [
|
||||||
'packages/sw/README.md', 'packages/sw/package.json', 'packages/sw/build.mjs',
|
'packages/sw/README.md', 'packages/sw/package.json', 'packages/sw/build.mjs',
|
||||||
'packages/sw/ai/AI_USAGE.md', 'packages/sw/ai/COMPONENT_CATALOG.md', 'packages/sw/ai/COMPOSITION_PATTERNS.md', 'packages/sw/ai/DESIGN_RULES.md',
|
'packages/sw/ai/AI_USAGE.md', 'packages/sw/ai/COMPONENT_CATALOG.md', 'packages/sw/ai/COMPOSITION_PATTERNS.md', 'packages/sw/ai/DESIGN_RULES.md',
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Regression for the SW viewer work (2026-10-19): the existing SW/Header and SW/Popup Stories must render their
|
||||||
|
// headers and popups pixel-identical before (base Storybook) and after (this branch) at several widths.
|
||||||
|
// Element mode compares every .hm-sw-header / .hm-sw-property element; the full page is reported for information
|
||||||
|
// (the 2026-10-19 follow-up changed the Story work-area background to the shared grid canvas on purpose).
|
||||||
|
// Usage: node packages/sw/tests/viewer-regression.browser.mjs <base-storybook-url> <new-storybook-url> <out-dir>
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
|
||||||
|
const require = createRequire(path.join(runtime, 'index.js'))
|
||||||
|
const { chromium } = require('playwright')
|
||||||
|
const { PNG } = require('pngjs')
|
||||||
|
const pixelmatch = require('pixelmatch').default ?? require('pixelmatch')
|
||||||
|
const [base, next, outArg, mode] = process.argv.slice(2)
|
||||||
|
// --neutral-canvas: on the new side only, put back the pre-grid Story backgrounds (white work area, page color under
|
||||||
|
// dropdowns), so semi-transparent header / dropdown edge pixels compare against the same backdrop.
|
||||||
|
const neutral = mode === "--neutral-canvas"
|
||||||
|
const out = path.resolve(outArg ?? 'docs/sw/viewer-qa/regression')
|
||||||
|
await mkdir(out, { recursive: true })
|
||||||
|
const ids = ['overview', 'interactive-states', 'logo-slot', 'document-dropdown', 'responsive-more', 'k-guide', 'property-panel-compare', 'composed-controls', 'document-open', 'hidden-sub-controls', 'detail-buttons'].map(id => `sw-header--${id}`)
|
||||||
|
.concat(['sw-popup--custom-content', 'sw-popup--multiple-popups'])
|
||||||
|
const widths = [1920, 1180, 768]
|
||||||
|
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||||
|
const shot = async (url, width, restore = false) => {
|
||||||
|
const page = await browser.newPage({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' })
|
||||||
|
await page.goto(url)
|
||||||
|
await page.waitForSelector('#storybook-root > *')
|
||||||
|
if (restore) await page.addStyleTag({ content: '.sw-overview-stage { background: var(--hm-sem-bg-surface) !important; } .sw-story-scroller--menu { background: none !important; }' })
|
||||||
|
await page.evaluate(() => document.fonts.ready)
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
const full = PNG.sync.read(await page.screenshot({ fullPage: true, animations: 'disabled' }))
|
||||||
|
const elements = []
|
||||||
|
for (const element of await page.locator('.hm-sw-header, .hm-sw-property').all()) {
|
||||||
|
if (!(await element.isVisible())) continue
|
||||||
|
elements.push(PNG.sync.read(await element.screenshot({ animations: 'disabled' })))
|
||||||
|
}
|
||||||
|
await page.close()
|
||||||
|
return { full, elements }
|
||||||
|
}
|
||||||
|
const compare = (a, b, out) => {
|
||||||
|
if (a.width !== b.width || a.height !== b.height) return -1
|
||||||
|
const image = new PNG({ width: a.width, height: a.height })
|
||||||
|
const diff = pixelmatch(a.data, b.data, image.data, a.width, a.height, { threshold: 0 })
|
||||||
|
if (diff && out) return { diff, image }
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
const results = []
|
||||||
|
for (const id of ids) for (const width of widths) {
|
||||||
|
const url = root => `${root}/iframe.html?id=${id}&viewMode=story`
|
||||||
|
const [a, b] = [await shot(url(base), width), await shot(url(next), width, neutral)]
|
||||||
|
const elementDiffs = a.elements.length === b.elements.length ? a.elements.map((item, index) => compare(item, b.elements[index])) : [-1]
|
||||||
|
const fullDiff = compare(a.full, b.full)
|
||||||
|
const pass = elementDiffs.every(diff => diff === 0)
|
||||||
|
results.push({ id, width, elements: a.elements.length + ' / ' + b.elements.length, elementDiffPixels: elementDiffs, fullPageDiffPixels: fullDiff, result: pass ? 'PASS' : 'FAIL' })
|
||||||
|
console.log(`${pass ? 'PASS' : 'FAIL'} ${id} @${width} elements ${a.elements.length}/${b.elements.length} diff [${elementDiffs.join(',')}] full-page diff ${fullDiff}`)
|
||||||
|
}
|
||||||
|
await browser.close()
|
||||||
|
await writeFile(path.join(out, neutral ? 'regression-neutral-canvas.json' : 'regression.json'), JSON.stringify({ base, next, results }, null, 2) + '\n')
|
||||||
|
const failed = results.filter(item => item.result !== 'PASS').length
|
||||||
|
console.log(`${results.length - failed}/${results.length} PASS`)
|
||||||
|
process.exit(failed ? 1 : 0)
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
// SW viewer mode verification (2026-10-19).
|
||||||
|
// 1) Captures the reference DWG/DXF viewer and the SW/Viewer Stories at the same resolution (1440 x 900).
|
||||||
|
// 2) Runs interaction checks on the Stories (menus, keyboard, Escape, outside click, panels, More, modal).
|
||||||
|
// Usage: node packages/sw/tests/viewer.browser.mjs <storybook-url> <reference-url|-> <out-dir>
|
||||||
|
// e.g. node packages/sw/tests/viewer.browser.mjs http://127.0.0.1:6034 http://127.0.0.1:5182 docs/sw/viewer-qa
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
|
||||||
|
const require = createRequire(path.join(runtime, 'index.js'))
|
||||||
|
const { chromium } = require('playwright')
|
||||||
|
const [storybook, reference, outArg] = process.argv.slice(2)
|
||||||
|
const out = path.resolve(outArg ?? 'docs/sw/viewer-qa')
|
||||||
|
await mkdir(out, { recursive: true })
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||||
|
const results = []
|
||||||
|
const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) }
|
||||||
|
const errors = []
|
||||||
|
const story = id => `${storybook}/iframe.html?id=sw-viewer--${id}&viewMode=story`
|
||||||
|
const newPage = async (width = 1440, height = 900) => {
|
||||||
|
const page = await browser.newPage({ viewport: { width, height }, reducedMotion: 'reduce' })
|
||||||
|
page.on('pageerror', error => errors.push(`${page.url()}: ${error.message}`))
|
||||||
|
page.on('console', message => { if (message.type() === 'error' && !/favicon|404/.test(message.text())) errors.push(`${page.url()}: ${message.text()}`) })
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
// Test-only: lift the 1280px Story page envelope so the 1440px frames are measured at full width.
|
||||||
|
const open = async (page, id) => { await page.goto(story(id)); await page.waitForSelector('.hm-sw-viewer-header'); await page.addStyleTag({ content: '.sb-page { --sb-page-max-width: 100vw; }' }); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(200) }
|
||||||
|
const log = page => page.locator('[data-callback-log]').first().innerText()
|
||||||
|
const box = locator => locator.boundingBox()
|
||||||
|
|
||||||
|
// ---------- 1. Same-resolution captures ----------
|
||||||
|
const geometry = async (page, selectors) => page.evaluate(selectors => {
|
||||||
|
const header = document.querySelector(selectors.header)
|
||||||
|
const base = header.getBoundingClientRect()
|
||||||
|
return Object.fromEntries(Object.entries(selectors.parts).map(([name, selector]) => [name, [...document.querySelectorAll(selector)].map(element => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return { x: Math.round(rect.x - base.x), y: Math.round(rect.y - base.y), w: Math.round(rect.width), h: Math.round(rect.height), text: element.textContent.trim().slice(0, 12) }
|
||||||
|
})]))
|
||||||
|
}, selectors)
|
||||||
|
const parts = {
|
||||||
|
reference: { header: 'header', parts: { document: '.cad-toolbar-document', object: '.cad-toolbar-object-area', field: '.cad-toolbar-property-field', button: '.cad-toolbar [data-slot=button]', toggle: '.cad-toolbar [data-slot=toggle]', tab: '.cad-toolbar [data-slot=tabs-trigger]', switchField: '.cad-toolbar-switch-field' } },
|
||||||
|
package: { header: '.hm-sw-viewer-header', parts: { document: '.hm-sw-viewer-header__document', object: '.hm-sw-viewer-header__object', field: '.hm-sw-viewer-header__group[data-surface=field]', button: '.hm-sw-viewer-header__button:not([data-kind=toggle])', toggle: '.hm-sw-viewer-header__button[data-kind=toggle]', tab: '.hm-sw-viewer-header__tab', switchField: '.hm-sw-viewer-header__switch-field' } },
|
||||||
|
}
|
||||||
|
const comparison = {}
|
||||||
|
if (reference && reference !== '-') {
|
||||||
|
const page = await newPage()
|
||||||
|
await page.goto(reference)
|
||||||
|
await page.waitForSelector('header')
|
||||||
|
await page.evaluate(() => document.fonts.ready)
|
||||||
|
await page.screenshot({ path: path.join(out, 'reference-1440-idle.png'), clip: { x: 0, y: 0, width: 1440, height: 56 } })
|
||||||
|
await page.getByRole('button', { name: '샘플' }).click()
|
||||||
|
await page.screenshot({ path: path.join(out, 'reference-1440-sample-menu.png'), clip: { x: 0, y: 0, width: 1440, height: 200 } })
|
||||||
|
await page.getByRole('menuitem', { name: /평면 및 종단면도/ }).click()
|
||||||
|
await page.waitForFunction(() => document.querySelector('.cad-toolbar-filename')?.textContent?.includes('C0060202'), null, { timeout: 30000 })
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
comparison.reference = await geometry(page, parts.reference)
|
||||||
|
await page.screenshot({ path: path.join(out, 'reference-1440-loaded.png'), clip: { x: 0, y: 0, width: 1440, height: 56 } })
|
||||||
|
await page.getByRole('button', { name: '레이어 패널' }).click()
|
||||||
|
await page.getByRole('button', { name: '옵션 패널' }).click()
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
await page.screenshot({ path: path.join(out, 'reference-1440-panels.png') })
|
||||||
|
await page.close()
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const page = await newPage(1920, 1000)
|
||||||
|
await open(page, 'viewer-header')
|
||||||
|
const frame = page.locator('[data-viewer-frame]')
|
||||||
|
const origin = await box(frame)
|
||||||
|
comparison.package = await geometry(page, parts.package)
|
||||||
|
await page.screenshot({ path: path.join(out, 'package-1440-loaded.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 56 } })
|
||||||
|
await page.getByRole('button', { name: '샘플' }).click()
|
||||||
|
await page.screenshot({ path: path.join(out, 'package-1440-sample-menu.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 200 } })
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await page.getByRole('button', { name: '레이어' }).click()
|
||||||
|
await page.getByRole('button', { name: '설정' }).click()
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
await page.screenshot({ path: path.join(out, 'package-1440-panels.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 620 } })
|
||||||
|
await page.close()
|
||||||
|
}
|
||||||
|
if (comparison.reference) {
|
||||||
|
// Same item = same size (positions shift with the file-name text width, which differs by sample data).
|
||||||
|
for (const key of ['button', 'toggle', 'tab', 'switchField', 'field']) {
|
||||||
|
const a = comparison.reference[key].map(item => `${item.w}x${item.h}`), b = comparison.package[key].map(item => `${item.w}x${item.h}`)
|
||||||
|
// field: the reference Tabs root is 39px inside its 40px frame, so its tab field is 41px; the package keeps both fields 42px (documented 1px difference).
|
||||||
|
const within = key === 'field' ? comparison.reference[key].every((item, index) => comparison.package[key][index] && item.w === comparison.package[key][index].w && Math.abs(item.h - comparison.package[key][index].h) <= 1) : JSON.stringify(a) === JSON.stringify(b)
|
||||||
|
check(`geometry ${key} sizes equal to reference${key === 'field' ? ' (±1px height, known)' : ''}`, within, `reference ${a.join(' ')} / package ${b.join(' ')}`)
|
||||||
|
}
|
||||||
|
const [r, p] = [comparison.reference.document[0], comparison.package.document[0]]
|
||||||
|
// x differs by design: the reference always draws "HM" (72px cap); the package draws no logo unless given (24px cap).
|
||||||
|
check('geometry document well height / top (x: logo rule)', r.h === p.h && r.y === p.y && p.x === r.x - 48, `reference x${r.x} y${r.y} h${r.h} / package x${p.x} y${p.y} h${p.h}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 2. Interactions ----------
|
||||||
|
{
|
||||||
|
// Wide enough for the 1440px story frame plus the story page padding (panels at the frame right edge stay on screen).
|
||||||
|
const page = await newPage(1920, 1000)
|
||||||
|
await open(page, 'viewer-header')
|
||||||
|
// Dropdown: click opens, entries listed, Escape closes and returns focus.
|
||||||
|
const samples = page.getByRole('button', { name: '샘플' })
|
||||||
|
await samples.click()
|
||||||
|
const menu = page.getByRole('menu', { name: '샘플' })
|
||||||
|
check('menu opens on click', await menu.isVisible())
|
||||||
|
check('menu has 3 entries', (await menu.getByRole('menuitem').count()) === 3)
|
||||||
|
const menuBox = await box(menu), triggerBox = await box(samples)
|
||||||
|
check('menu placed under its button', Math.abs(menuBox.x - triggerBox.x) <= 1 && menuBox.y >= triggerBox.y + triggerBox.height, `menu ${Math.round(menuBox.x)},${Math.round(menuBox.y)} button ${Math.round(triggerBox.x)},${Math.round(triggerBox.y + triggerBox.height)}`)
|
||||||
|
check('trigger shows open state', (await samples.getAttribute('aria-expanded')) === 'true' && (await samples.getAttribute('data-state')) === 'open')
|
||||||
|
await page.keyboard.press('ArrowDown')
|
||||||
|
await page.keyboard.press('ArrowDown')
|
||||||
|
check('ArrowDown moves focus in menu', (await page.evaluate(() => document.activeElement?.textContent)).includes('평면'))
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
check('Escape closes menu', !(await menu.isVisible().catch(() => false)))
|
||||||
|
check('Escape returns focus to trigger', await samples.evaluate(element => element === document.activeElement))
|
||||||
|
// Keyboard open + Enter select → file name changes, command logged.
|
||||||
|
await samples.press('Enter')
|
||||||
|
check('Enter opens with first entry focused', (await page.evaluate(() => document.activeElement?.getAttribute('role'))) === 'menuitem')
|
||||||
|
await page.keyboard.press('End')
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
check('Enter selects entry (document changes)', (await page.locator('.hm-sw-viewer-header__document-name').innerText()) === 'simple.dxf')
|
||||||
|
check('onCommand reports menu entry', (await log(page)).includes('samples/sample-dxf'), await log(page))
|
||||||
|
// Outside click closes.
|
||||||
|
await samples.click()
|
||||||
|
await page.mouse.click(700, 600)
|
||||||
|
check('outside click closes menu', (await page.getByRole('menu').count()) === 0)
|
||||||
|
// Tabs: arrow keys select.
|
||||||
|
const model = page.getByRole('radio', { name: /Model/ })
|
||||||
|
await model.focus()
|
||||||
|
await page.keyboard.press('ArrowRight')
|
||||||
|
check('tabs ArrowRight selects Layout', (await page.getByRole('radio', { name: /Layout/ }).getAttribute('aria-checked')) === 'true')
|
||||||
|
// Switch
|
||||||
|
const depth = page.getByRole('switch', { name: '2.5D' })
|
||||||
|
await depth.click()
|
||||||
|
check('header switch toggles', (await depth.getAttribute('aria-checked')) === 'true' && (await log(page)).includes('2.5d=true'))
|
||||||
|
// Disabled fit button is enabled once a document is open; hover face.
|
||||||
|
const fit = page.getByRole('button', { name: '전체 보기' })
|
||||||
|
await fit.hover()
|
||||||
|
check('hover face #266651', (await fit.evaluate(element => getComputedStyle(element).backgroundColor)) === 'rgb(38, 102, 81)')
|
||||||
|
// Toggle opens the panel; Escape inside closes it and focus goes back.
|
||||||
|
const layers = page.getByRole('button', { name: '레이어', exact: true })
|
||||||
|
await layers.click()
|
||||||
|
const layerPanel = page.getByRole('dialog', { name: /레이어 \(/ })
|
||||||
|
check('toggle opens panel', await layerPanel.isVisible())
|
||||||
|
check('toggle pressed state + #FFC600', (await layers.getAttribute('aria-pressed')) === 'true' && (await layers.evaluate(element => getComputedStyle(element).color)) === 'rgb(255, 198, 0)')
|
||||||
|
check('panel focused on open', await layerPanel.evaluate(element => element.contains(document.activeElement)))
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
check('Escape closes panel', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
|
||||||
|
check('focus returns to toggle', await layers.evaluate(element => element === document.activeElement))
|
||||||
|
// Resize by the native corner (right after opening, top-end placement).
|
||||||
|
await layers.click()
|
||||||
|
const sized = await box(layerPanel)
|
||||||
|
await page.mouse.move(sized.x + sized.width - 6, sized.y + sized.height - 6)
|
||||||
|
await page.mouse.down()
|
||||||
|
await page.mouse.move(sized.x + sized.width - 63, sized.y + sized.height - 103, { steps: 6 })
|
||||||
|
await page.mouse.up()
|
||||||
|
const resized = await box(layerPanel)
|
||||||
|
check("corner resize changes size", resized.width < sized.width - 40 && resized.height < sized.height - 80, `${Math.round(sized.width)}x${Math.round(sized.height)} → ${Math.round(resized.width)}x${Math.round(resized.height)}`)
|
||||||
|
// Drag: title bar moves the panel and it stays inside the frame.
|
||||||
|
const before = await box(layerPanel)
|
||||||
|
const title = layerPanel.locator('.hm-sw-property__titlebar')
|
||||||
|
const titleBox = await box(title)
|
||||||
|
await page.mouse.move(titleBox.x + 40, titleBox.y + 15)
|
||||||
|
await page.mouse.down()
|
||||||
|
await page.mouse.move(titleBox.x - 260, titleBox.y + 45, { steps: 8 })
|
||||||
|
await page.mouse.up()
|
||||||
|
const after = await box(layerPanel)
|
||||||
|
check('drag moves panel', Math.round(before.x - after.x) === 300 && Math.round(after.y - before.y) === 30, `dx ${Math.round(after.x - before.x)} dy ${Math.round(after.y - before.y)}`)
|
||||||
|
await page.mouse.move(after.x + 40, after.y + 15)
|
||||||
|
await page.mouse.down()
|
||||||
|
await page.mouse.move(after.x + 3000, after.y + 3000, { steps: 6 })
|
||||||
|
await page.mouse.up()
|
||||||
|
const frame = await box(page.locator('.sw-viewer-story-canvas'))
|
||||||
|
const clamped = await box(layerPanel)
|
||||||
|
check('drag stays inside bounds (10px edge)', Math.round(frame.x + frame.width - (clamped.x + clamped.width)) === 10 && Math.round(frame.y + frame.height - (clamped.y + clamped.height)) === 10,
|
||||||
|
`right gap ${Math.round(frame.x + frame.width - (clamped.x + clamped.width))}, bottom gap ${Math.round(frame.y + frame.height - (clamped.y + clamped.height))}`)
|
||||||
|
// Collapse by button and double click.
|
||||||
|
await layerPanel.getByRole('button', { name: '접기' }).click()
|
||||||
|
check('collapse hides body', !(await layerPanel.locator('.hm-sw-property__body').isVisible()))
|
||||||
|
await title.dblclick({ position: { x: 30, y: 15 } })
|
||||||
|
check('title double click expands', await layerPanel.locator('.hm-sw-property__body').isVisible())
|
||||||
|
// Panel content works (layer eye).
|
||||||
|
// 전체 켜기 / 전체 끄기: green on hover and while pressed, default otherwise; other panel buttons unchanged.
|
||||||
|
const allOn = layerPanel.getByRole('button', { name: '전체 켜기' })
|
||||||
|
const face = locator => locator.evaluate(element => [getComputedStyle(element).backgroundColor, getComputedStyle(element).color].join(' '))
|
||||||
|
const restFace = await face(allOn)
|
||||||
|
await allOn.hover()
|
||||||
|
const hoverFace = await face(allOn)
|
||||||
|
await page.mouse.down()
|
||||||
|
const activeFace = await face(allOn)
|
||||||
|
await page.mouse.up()
|
||||||
|
const afterFace = await face(allOn)
|
||||||
|
await page.mouse.move(5, 5)
|
||||||
|
const leftFace = await face(allOn)
|
||||||
|
check('전체 켜기: default kept, green hover / active, back after release', restFace === 'rgb(255, 255, 255) rgb(18, 51, 40)' && hoverFace === 'rgb(38, 102, 81) rgb(255, 255, 255)' && activeFace === 'rgb(18, 51, 40) rgb(255, 255, 255)' && afterFace === hoverFace && leftFace === restFace, [restFace, hoverFace, activeFace, afterFace, leftFace].join(' | '))
|
||||||
|
await layerPanel.getByRole('button', { name: 'CR-TEXT 숨기기' }).click()
|
||||||
|
check('panel content callback', (await log(page)).includes('layer:CR-TEXT=false'))
|
||||||
|
// Close button hover face.
|
||||||
|
const close = layerPanel.getByRole('button', { name: '닫기' })
|
||||||
|
await close.hover()
|
||||||
|
check('close hover = SWPopup hover asset', await close.evaluate(element => getComputedStyle(element.querySelector('.hm-sw-property__close-hover')).visibility === 'visible' && getComputedStyle(element.querySelector('.hm-sw-property__close-default')).visibility === 'hidden'))
|
||||||
|
await close.click()
|
||||||
|
check('close button closes', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
|
||||||
|
// Two panels: the last touched comes to the front.
|
||||||
|
await layers.click()
|
||||||
|
await page.getByRole('button', { name: '설정', exact: true }).click()
|
||||||
|
const options = page.getByRole('dialog', { name: '옵션' })
|
||||||
|
const z = async () => Promise.all([layerPanel, options].map(item => item.evaluate(element => Number(getComputedStyle(element).zIndex))))
|
||||||
|
await layerPanel.locator('.hm-sw-property__titlebar').click({ position: { x: 30, y: 15 } })
|
||||||
|
const [zl, zo] = await z()
|
||||||
|
check('last touched panel on top', zl > zo, `layer z ${zl}, options z ${zo}`)
|
||||||
|
// Modal popup: backdrop, focus trap, Escape.
|
||||||
|
await page.getByRole('button', { name: '정보 및 라이선스' }).click()
|
||||||
|
const about = page.getByRole('dialog', { name: /라이선스/ })
|
||||||
|
const confirm = about.getByRole('button', { name: '확인' })
|
||||||
|
await confirm.hover()
|
||||||
|
const confirmHover = await face(confirm)
|
||||||
|
check('전체 켜기 hover = 라이선스 확인 hover (same token)', confirmHover === hoverFace, `${confirmHover} / ${hoverFace}`)
|
||||||
|
await about.locator('.hm-sw-property__title').hover()
|
||||||
|
check('modal popup opens with backdrop', (await about.getAttribute('aria-modal')) === 'true' && (await page.locator('.hm-sw-viewer-panel-backdrop').count()) === 1)
|
||||||
|
for (let index = 0; index < 4; index++) await page.keyboard.press('Tab')
|
||||||
|
check('modal focus stays inside', await about.evaluate(element => element.contains(document.activeElement)))
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
check('modal Escape closes', (await page.getByRole('dialog', { name: /라이선스/ }).count()) === 0)
|
||||||
|
await page.close()
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Submenus and the context menu.
|
||||||
|
const page = await newPage(1920, 1100)
|
||||||
|
await open(page, 'dropdowns')
|
||||||
|
await page.getByRole('button', { name: '도면' }).press('Enter')
|
||||||
|
const recent = page.getByRole('menuitem', { name: '최근 도면' })
|
||||||
|
await recent.focus()
|
||||||
|
await page.keyboard.press('ArrowRight')
|
||||||
|
const sub = page.getByRole('menu', { name: '최근 도면' })
|
||||||
|
check('ArrowRight opens submenu with focus', (await sub.isVisible()) && await sub.evaluate(element => element.contains(document.activeElement)))
|
||||||
|
const parentBox = await box(page.getByRole('menu', { name: '도면', exact: true })), subBox = await box(sub)
|
||||||
|
check('submenu opens to the side', subBox.x >= parentBox.x + parentBox.width, `parent right ${Math.round(parentBox.x + parentBox.width)}, sub left ${Math.round(subBox.x)}`)
|
||||||
|
await page.getByRole('menuitem', { name: '보관 도면' }).hover()
|
||||||
|
check('third level opens on hover', await page.getByRole('menu', { name: '보관 도면' }).isVisible())
|
||||||
|
await page.getByRole('menuitem', { name: '보관 도면' }).focus()
|
||||||
|
await page.keyboard.press('ArrowLeft')
|
||||||
|
check('ArrowLeft closes one level', (await page.getByRole('menu', { name: '최근 도면' }).count()) === 0 && (await page.getByRole('menu', { name: '도면', exact: true }).count()) === 1)
|
||||||
|
await page.getByRole('menuitemcheckbox', { name: '선가중치 표시' }).click()
|
||||||
|
check('checkbox entry toggles via onCommand', (await log(page)).includes('file/lineweight=true'), await log(page))
|
||||||
|
await page.getByRole('button', { name: '도면' }).click()
|
||||||
|
check('checkbox state reflected', (await page.getByRole('menuitemcheckbox', { name: '선가중치 표시' }).getAttribute('aria-checked')) === 'true')
|
||||||
|
await page.getByRole('menuitem', { name: '인쇄 (미연결)' }).click({ force: true })
|
||||||
|
check('disabled entry does nothing', (await page.getByRole('menu', { name: '도면', exact: true }).count()) === 1)
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
const right = page.getByRole('button', { name: '오른쪽 정렬 메뉴' })
|
||||||
|
await right.click()
|
||||||
|
const rightMenu = page.getByRole('menu', { name: '오른쪽 정렬 메뉴' })
|
||||||
|
const [rb, rm] = [await box(right), await box(rightMenu)]
|
||||||
|
check('menuAlign end + menuWidth', Math.abs(rb.x + rb.width - (rm.x + rm.width)) <= 1 && Math.round(rm.width) === 220, `width ${Math.round(rm.width)}`)
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
const area = page.locator('[data-context-area]')
|
||||||
|
const areaBox = await box(area)
|
||||||
|
await page.mouse.click(areaBox.x + 50, areaBox.y + 40, { button: 'right' })
|
||||||
|
const context = page.getByRole('menu', { name: '도면 메뉴' })
|
||||||
|
const contextBox = await box(context)
|
||||||
|
check('context menu opens at pointer', Math.abs(contextBox.x - (areaBox.x + 50)) <= 1 && Math.abs(contextBox.y - (areaBox.y + 40)) <= 1, `${Math.round(contextBox.x)},${Math.round(contextBox.y)}`)
|
||||||
|
await page.getByRole('menuitem', { name: '선택 확대' }).click()
|
||||||
|
check('context entry reported', (await log(page)).includes('context/zoom-selection'))
|
||||||
|
// Menu kept inside the viewport near the right edge.
|
||||||
|
await page.setViewportSize({ width: 900, height: 700 })
|
||||||
|
await page.mouse.click(0, 0)
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 0))
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
await page.locator('.sw-story-scroller').first().evaluate(element => { element.scrollLeft = element.scrollWidth })
|
||||||
|
await right.click()
|
||||||
|
const edge = await box(page.getByRole('menu', { name: '오른쪽 정렬 메뉴' }))
|
||||||
|
check('menu stays inside the viewport', edge.x >= 4 && edge.x + edge.width <= 900 - 4, `left ${Math.round(edge.x)}, right ${Math.round(edge.x + edge.width)}`)
|
||||||
|
await page.close()
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Responsive: More collects the collapsed items, no horizontal overflow, icon-only below 560.
|
||||||
|
const page = await newPage(1920, 1200)
|
||||||
|
await open(page, 'responsive')
|
||||||
|
const headers = page.locator('[data-width] .hm-sw-viewer-header')
|
||||||
|
const report = []
|
||||||
|
for (const [index, width] of [1440, 1100, 860, 520].entries()) {
|
||||||
|
const header = headers.nth(index)
|
||||||
|
const info = await header.evaluate(element => ({
|
||||||
|
overflow: element.scrollWidth - element.clientWidth,
|
||||||
|
objectOverflow: (() => { const object = element.querySelector('.hm-sw-viewer-header__object'); return object.scrollWidth - object.clientWidth })(),
|
||||||
|
more: !!element.querySelector('.hm-sw-viewer-header__more'),
|
||||||
|
labels: [...element.querySelectorAll('.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab, .hm-sw-viewer-header__switch-label')].map(item => item.textContent.trim() || item.getAttribute('aria-label')),
|
||||||
|
}))
|
||||||
|
report.push({ width, ...info })
|
||||||
|
const box2 = await box(header)
|
||||||
|
await page.screenshot({ path: path.join(out, `package-responsive-${width}.png`), clip: { x: box2.x, y: box2.y, width: box2.width, height: box2.height } })
|
||||||
|
}
|
||||||
|
check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.every(item => item.overflow <= 0 && item.objectOverflow <= 1), JSON.stringify(report.map(item => [item.width, item.overflow, item.objectOverflow])))
|
||||||
|
check('1440: no More', !report[0].more)
|
||||||
|
check('1100: More, samples/tabs/switches moved', report[1].more && !report[1].labels.includes('샘플') && !report[1].labels.includes('2.5D') && report[1].labels.includes('레이어'))
|
||||||
|
check('860: panel toggles moved too', report[2].more && !report[2].labels.includes('레이어'))
|
||||||
|
check('520: 파일 열기 / 전체 보기 icon only', report[3].labels.includes('파일 열기') && !(await headers.nth(3).locator('.hm-sw-viewer-header__label', { hasText: '파일 열기' }).count()))
|
||||||
|
const more = headers.nth(2).getByRole('button', { name: '추가 도구' })
|
||||||
|
await more.click()
|
||||||
|
const moreMenu = page.getByRole('menu', { name: '추가 도구' })
|
||||||
|
const items = await moreMenu.locator('.hm-sw-viewer-menu__label, [role^=menuitem]').allInnerTexts()
|
||||||
|
check('More sections like the reference', ['샘플 도면', '도면 공간', '화면 보기', '패널'].every(label => items.includes(label)), items.join(' | '))
|
||||||
|
check('More face beige 260px', (await moreMenu.evaluate(element => `${getComputedStyle(element).backgroundColor} ${element.offsetWidth}`)) === 'rgb(245, 240, 229) 260')
|
||||||
|
await moreMenu.getByRole('menuitemcheckbox', { name: '레이어' }).click()
|
||||||
|
check('More checkbox reports command', (await log(page)).includes('layers=true'), await log(page))
|
||||||
|
await more.click()
|
||||||
|
await page.getByRole('menuitemradio', { name: /Layout/ }).click()
|
||||||
|
check('More radio selects tab value', (await log(page)).includes('space=layout1'))
|
||||||
|
await page.screenshot({ path: path.join(out, 'package-responsive-more-open.png') })
|
||||||
|
await page.close()
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Story captures (full page) for the report.
|
||||||
|
const page = await newPage(1920, 1000)
|
||||||
|
for (const id of ['viewer-header', 'menu-groups', 'states', 'dropdowns', 'settings-panel', 'content-variants', 'variable-size', 'responsive', 'logo', 'compare-with-sw-header']) {
|
||||||
|
await open(page, id)
|
||||||
|
await page.screenshot({ path: path.join(out, `story-${id}.png`), fullPage: true })
|
||||||
|
}
|
||||||
|
// Mode switch keeps state.
|
||||||
|
await open(page, 'compare-with-sw-header')
|
||||||
|
await page.getByLabel('뷰어 모드 (SWViewerHeader)').check()
|
||||||
|
const frame = page.locator('[data-viewer-frame]').last()
|
||||||
|
check('mode switch shows viewer header', (await frame.locator('.hm-sw-viewer-header').count()) === 1 && (await frame.locator('.hm-sw-header').count()) === 0)
|
||||||
|
await frame.getByRole('button', { name: '레이어', exact: true }).click()
|
||||||
|
await frame.getByRole('button', { name: '임시 로고 (일반 모드로) 홈' }).click()
|
||||||
|
check('brand click returns to normal SWHeader', (await frame.locator('.hm-sw-header').count()) === 1 && (await frame.locator('.hm-sw-viewer-header').count()) === 0)
|
||||||
|
await page.getByLabel('뷰어 모드 (SWViewerHeader)').check()
|
||||||
|
check('state kept across mode switch (layer panel still open)', (await frame.getByRole('dialog', { name: /레이어 \(/ }).count()) === 1)
|
||||||
|
await page.close()
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Logo: nothing drawn without one, image / element when given; layout unchanged.
|
||||||
|
const page = await newPage(1920, 1000)
|
||||||
|
await open(page, 'logo')
|
||||||
|
const cases = page.locator('[data-logo-case]')
|
||||||
|
const read = index => cases.nth(index).evaluate(element => {
|
||||||
|
const brand = element.querySelector('.hm-sw-viewer-header__brand'), doc = element.querySelector('.hm-sw-viewer-header__document')
|
||||||
|
const base = element.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
text: brand.textContent.trim(), img: !!brand.querySelector('img, .hm-sw-viewer-header__brand-mark svg'),
|
||||||
|
width: Math.round(brand.getBoundingClientRect().width), docX: Math.round(doc.getBoundingClientRect().x - base.x),
|
||||||
|
height: Math.round(element.querySelector('.hm-sw-viewer-header').getBoundingClientRect().height),
|
||||||
|
fileOpenY: Math.round(element.querySelector('.hm-sw-viewer-header__button').getBoundingClientRect().y - base.y),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const [none, src, node] = [await read(0), await read(1), await read(2)]
|
||||||
|
check('logo none: no HM / text / icon', none.text === '' && !none.img, JSON.stringify(none))
|
||||||
|
check('logo none: cap shrinks to its edge (no wide gap)', none.width === 24, JSON.stringify(none))
|
||||||
|
const logoWidth = await cases.nth(1).locator('.hm-sw-viewer-header__brand-mark').evaluate(element => Math.round(element.getBoundingClientRect().width))
|
||||||
|
check('logoSrc shows the image (cap = 24 + logo + 25, min 72)', src.img && src.width === Math.max(72, 24 + logoWidth + 25), JSON.stringify({ ...src, logoWidth }))
|
||||||
|
check('logo element shown', node.img && node.width >= 72, JSON.stringify(node))
|
||||||
|
check('header height and menu row unchanged with / without logo', [none, src, node].every(item => item.height === 52 && item.fileOpenY === none.fileOpenY), [none, src, node].map(item => item.height + '/' + item.fileOpenY).join(' '))
|
||||||
|
for (const [index, name] of [[0, 'none'], [1, 'src'], [2, 'element']]) {
|
||||||
|
const b = await box(cases.nth(index))
|
||||||
|
await page.screenshot({ path: path.join(out, 'package-logo-' + name + '.png'), clip: { x: b.x, y: b.y, width: 520, height: 52 } })
|
||||||
|
}
|
||||||
|
// Text / icon outline: same tokens as SWHeader, fill unchanged.
|
||||||
|
const outline = await page.evaluate(() => {
|
||||||
|
const header = document.querySelector('[data-logo-case] .hm-sw-viewer-header')
|
||||||
|
const style = selector => getComputedStyle(header.querySelector(selector))
|
||||||
|
return {
|
||||||
|
label: style('.hm-sw-viewer-header__label').textShadow, document: style('.hm-sw-viewer-header__document-name').textShadow,
|
||||||
|
tab: style('.hm-sw-viewer-header__tab').textShadow, caption: style('.hm-sw-viewer-header__switch-label').textShadow,
|
||||||
|
count: style('.hm-sw-viewer-header__count').textShadow,
|
||||||
|
icon: (() => {
|
||||||
|
const icon = header.querySelector('.hm-sw-viewer-header__icon')
|
||||||
|
const copy = getComputedStyle(icon.querySelector('.hm-sw-viewer-icon__outline svg *')), glyph = getComputedStyle(icon.querySelector('.hm-sw-viewer-icon__glyph svg *'))
|
||||||
|
// lucide 24-unit icon drawn at 16px: 1px each side = 1.5 units, so the copy is 2 + 3 units.
|
||||||
|
return { filter: getComputedStyle(icon).filter, copyStroke: copy.stroke, copyWidth: parseFloat(copy.strokeWidth), glyphStroke: glyph.stroke, glyphWidth: parseFloat(glyph.strokeWidth) }
|
||||||
|
})(),
|
||||||
|
labelColor: style('.hm-sw-viewer-header__label').color,
|
||||||
|
labelFont: [style('.hm-sw-viewer-header__label').fontSize, style('.hm-sw-viewer-header__label').fontWeight, style('.hm-sw-viewer-header__label').letterSpacing].join(' '),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const blacks = value => value.split('rgb(0, 0, 0)').length - 1
|
||||||
|
check('text outline 1.3px black on 14px text (label / file name / tab)', [outline.label, outline.document, outline.tab].every(value => value.includes('rgb(0, 0, 0) 1.3px 0px 0px') && blacks(value) === 16), outline.label.slice(0, 60))
|
||||||
|
check('label outline 1px black on 11px captions / counts', [outline.caption, outline.count].every(value => value.includes('rgb(0, 0, 0) 1px 0px 0px') && blacks(value) === 16), outline.caption.slice(0, 60))
|
||||||
|
check('icon outline: SVG stroke copy, no CSS filter', outline.icon.filter === 'none' && outline.icon.copyStroke === 'rgb(0, 0, 0)' && outline.icon.copyWidth === outline.icon.glyphWidth + 3 && outline.icon.glyphStroke !== 'rgb(0, 0, 0)', JSON.stringify(outline.icon))
|
||||||
|
check('fill / size / weight / spacing kept', outline.labelColor === 'rgb(255, 255, 255)' && outline.labelFont === '14px 500 normal', outline.labelColor + ' ' + outline.labelFont)
|
||||||
|
await page.close()
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// SWHeader vs SWViewerHeader text / icon outline at 3x for the report.
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1920, height: 1000 }, deviceScaleFactor: 3 })
|
||||||
|
await open(page, 'compare-with-sw-header')
|
||||||
|
const sw = await box(page.locator('.hm-sw-header').first()), vw = await box(page.locator('.hm-sw-viewer-header').first())
|
||||||
|
await page.screenshot({ path: path.join(out, 'outline-swheader-3x.png'), clip: { x: sw.x, y: sw.y, width: 760, height: sw.height } })
|
||||||
|
await page.screenshot({ path: path.join(out, 'outline-viewer-3x.png'), clip: { x: vw.x, y: vw.y, width: 760, height: vw.height } })
|
||||||
|
await page.screenshot({ path: path.join(out, 'outline-viewer-right-3x.png'), clip: { x: vw.x + 1000, y: vw.y, width: 440, height: vw.height } })
|
||||||
|
const hovered = page.locator('.hm-sw-viewer-header').first().getByRole('button', { name: '레이어', exact: true })
|
||||||
|
await hovered.hover()
|
||||||
|
check('hover changes fill only (outline kept)', (await hovered.locator('.hm-sw-viewer-header__label').evaluate(element => getComputedStyle(element).textShadow)).includes('1.3px'))
|
||||||
|
await page.close()
|
||||||
|
const states = await browser.newPage({ viewport: { width: 1920, height: 900 }, deviceScaleFactor: 2 })
|
||||||
|
await open(states, 'states')
|
||||||
|
const header = await box(states.locator('.hm-sw-viewer-header'))
|
||||||
|
await states.getByRole('button', { name: '기본' }).hover()
|
||||||
|
await states.screenshot({ path: path.join(out, 'outline-states-2x.png'), clip: { x: header.x, y: header.y, width: 1440, height: header.height } })
|
||||||
|
const disabled = await states.getByRole('button', { name: 'Disabled', exact: true }).evaluate(element => [getComputedStyle(element).opacity, getComputedStyle(element.querySelector('.hm-sw-viewer-header__label')).textShadow.includes('1.3px')])
|
||||||
|
check('disabled fades text + outline together', disabled[0] === '0.6' && disabled[1] === true, JSON.stringify(disabled))
|
||||||
|
await states.close()
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Storybook sidebar: SW/Tool removed; SW/Popup (common SWPopup examples), SW/Header and SW/Viewer remain.
|
||||||
|
const index = await (await fetch(storybook + '/index.json')).json()
|
||||||
|
const titles = [...new Set(Object.values(index.entries).map(entry => entry.title).filter(title => title.startsWith('SW')))]
|
||||||
|
check('SW/Tool removed from the sidebar', !titles.includes('SW/Tool'), titles.join(', '))
|
||||||
|
check('SW/Popup, SW/Header and SW/Viewer remain', ['SW/Popup', 'SW/Header', 'SW/Viewer'].every(title => titles.includes(title)))
|
||||||
|
}
|
||||||
|
check('no page errors', errors.length === 0, errors.join(' | '))
|
||||||
|
await browser.close()
|
||||||
|
await writeFile(path.join(out, 'report.json'), JSON.stringify({ storybook, reference, comparison, results, errors }, null, 2) + '\n')
|
||||||
|
const failed = results.filter(item => item.result === 'FAIL').length
|
||||||
|
console.log(`\n${results.length - failed}/${results.length} PASS`)
|
||||||
|
process.exit(failed ? 1 : 0)
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
/* Story canvas only: frames that simulate a header width. Never styles the header itself. */
|
/* Story canvas only: frames that simulate a header width. Never styles the header itself. */
|
||||||
.sw-story-scroller { max-width: 100%; overflow-x: auto; }
|
.sw-story-scroller { max-width: 100%; overflow-x: auto; }
|
||||||
.sw-story-frame { background: #060606; }
|
.sw-story-frame { background: #060606; }
|
||||||
|
/* Shared drawing-area canvas of the SW Stories (2026-10-19): the SW/Viewer black grid — 40px cells, 1px lines of
|
||||||
|
HM green #266651 at 18%, on #0A0A0B. Used behind the SW Header work area and dropdown room and the viewer frames. */
|
||||||
|
.sb-page { --sw-story-canvas: linear-gradient(rgba(38, 102, 81, 0.18) 1px, transparent 1px) 0 0 / 40px 40px, linear-gradient(90deg, rgba(38, 102, 81, 0.18) 1px, transparent 1px) 0 0 / 40px 40px, #0a0a0b; }
|
||||||
.sw-story-composed {
|
.sw-story-composed {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -10,7 +13,7 @@
|
|||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
background: var(--hm-sw-header-object-background);
|
background: var(--hm-sw-header-object-background);
|
||||||
}
|
}
|
||||||
.sw-story-scroller--menu { padding-bottom: 360px; }
|
.sw-story-scroller--menu { padding-bottom: 360px; background: var(--sw-story-canvas); }
|
||||||
/* Only the two fixed dimension specimens use the full 1920px frame as their anchor. The shared
|
/* Only the two fixed dimension specimens use the full 1920px frame as their anchor. The shared
|
||||||
runtime dropdown still clamps to the real viewport. Scroll the specimen to see its right edge. */
|
runtime dropdown still clamps to the real viewport. Scroll the specimen to see its right edge. */
|
||||||
.sw-story-fixed-dimension .hm-sw-header__list-menu--dimension-style { left: auto !important; right: 0; }
|
.sw-story-fixed-dimension .hm-sw-header__list-menu--dimension-style { left: auto !important; right: 0; }
|
||||||
@@ -58,7 +61,7 @@
|
|||||||
.sw-overview-data { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding-block: 8px; }
|
.sw-overview-data { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding-block: 8px; }
|
||||||
.sw-overview-data label { display: inline-flex; align-items: center; gap: 4px; }
|
.sw-overview-data label { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
.sw-overview-slot { flex: 1 1 0; min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; }
|
.sw-overview-slot { flex: 1 1 0; min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; }
|
||||||
.sw-overview-stage { flex: 0 0 auto; overflow: auto; overscroll-behavior: contain; background: var(--hm-sem-bg-surface); outline: 1px solid var(--hm-sem-stroke-input-default); }
|
.sw-overview-stage { flex: 0 0 auto; overflow: auto; overscroll-behavior: contain; background: var(--sw-story-canvas); outline: 1px solid var(--hm-sem-stroke-input-default); }
|
||||||
.sw-overview-world { min-width: 390px; min-height: 100%; position: relative; }
|
.sw-overview-world { min-width: 390px; min-height: 100%; position: relative; }
|
||||||
/* Placement-only Storybook exception: keep the original panel size/content in the Stage scroll area.
|
/* Placement-only Storybook exception: keep the original panel size/content in the Stage scroll area.
|
||||||
Header's public API deliberately excludes panel style; package CSS and other Stories stay untouched. */
|
Header's public API deliberately excludes panel style; package CSS and other Stories stay untouched. */
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
import { useState, type ReactNode } from "react"
|
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
|
||||||
import { SWHeader, SWHeaderHome, SWTheme, SWToolButton, SWToolGroup } from "@hm-design/sw"
|
|
||||||
import "@hm-design/sw/styles.css"
|
|
||||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
|
||||||
import "./sw-stories.css"
|
|
||||||
import "./sw-integrated-stories.css"
|
|
||||||
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
|
|
||||||
|
|
||||||
// SWToolButton / SWToolGroup (2026-10-19): the existing SW toggle, pill and file-name looks as public buttons
|
|
||||||
// for viewer tool bars (Model / Layout, Fit, 2.5D, ...). Public API only, like a consuming project.
|
|
||||||
const KICKER = "HM DESIGN SYSTEM / SW"
|
|
||||||
|
|
||||||
// Sample host icon (the program supplies its own): 24 x 23 box like the print icon, default #B6D0C9 / hover #FFC600.
|
|
||||||
const FitIcon = ({ color }: { color: string }) => (
|
|
||||||
<svg viewBox="0 0 24 23" width="24" height="23" aria-hidden="true">
|
|
||||||
<path d="M2 8V2h6M16 2h6v6M22 15v6h-6M8 21H2v-6" fill="none" stroke={color} strokeWidth="2" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: "SW/Tool",
|
|
||||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
|
||||||
} satisfies Meta
|
|
||||||
export default meta
|
|
||||||
type Story = StoryObj<typeof meta>
|
|
||||||
|
|
||||||
function Bar({ children }: { children: ReactNode }) {
|
|
||||||
return <div className="sw-story-scroller"><div className="sw-story-frame" style={{ width: 1440 }}>{children}</div></div>
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolPage() {
|
|
||||||
const [view, setView] = useState<"model" | "layout">("model")
|
|
||||||
const [depth, setDepth] = useState(false)
|
|
||||||
const [log, setLog] = useState<string[]>([])
|
|
||||||
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
|
|
||||||
const viewGroup = (
|
|
||||||
<SWToolGroup label="보기" selection="single" data-view-group="">
|
|
||||||
<SWToolButton pressed={view === "model"} onClick={() => { setView("model"); record("view(model)") }}>Model</SWToolButton>
|
|
||||||
<SWToolButton pressed={view === "layout"} onClick={() => { setView("layout"); record("view(layout)") }}>Layout</SWToolButton>
|
|
||||||
</SWToolGroup>
|
|
||||||
)
|
|
||||||
const displayGroup = (
|
|
||||||
<SWToolGroup label="표시" data-display-group="">
|
|
||||||
<SWToolButton pressed={depth} onClick={() => { setDepth(!depth); record(`2.5D(${!depth})`) }}>2.5D</SWToolButton>
|
|
||||||
</SWToolGroup>
|
|
||||||
)
|
|
||||||
const fit = (
|
|
||||||
<SWToolGroup appearance="pill" aria-label="화면" data-fit-group="">
|
|
||||||
<SWToolButton appearance="pill" icon={<FitIcon color="#B6D0C9" />} hoverIcon={<FitIcon color="var(--hm-sw-header-selected-color)" />} onClick={() => record("fit")}>화면 맞춤</SWToolButton>
|
|
||||||
</SWToolGroup>
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<StoryPage>
|
|
||||||
<StoryPageHeader kicker={KICKER} title="SW Tool — SWToolButton / SWToolGroup"
|
|
||||||
description="새 디자인 값 없이 기존 SW 버튼 3종을 공개 버튼으로 씁니다. text = 속성Bar·속성창 글자 토글, pill = 인쇄·K 버튼, document = 파일명 면. SWToolGroup은 상세보기 묶음(text) 또는 인쇄·K pill(pill)이며 selection="single"이면 Model / Layout 같은 단일 선택(radiogroup, 화살표 키)입니다." />
|
|
||||||
<p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {log.length ? log.join(" → ") : "없음"}</p>
|
|
||||||
<StorySection title="SWHeader tools 슬롯 안" description="객체 수정 영역의 메뉴 뒤에 도구 막대를 둡니다.">
|
|
||||||
<Bar><SWHeader documentTitle="C0020101-006-평면및종단면도(06)" logoSrc={placeholderLogo} logoAlt="임시 로고"
|
|
||||||
hiddenControls={["lineStyle", "textStyle", "dimensionStyle", "details", "propertyBar", "propertyPanel"]}
|
|
||||||
tools={<>{viewGroup}{displayGroup}{fit}</>} /></Bar>
|
|
||||||
</StorySection>
|
|
||||||
<StorySection title="조합형 막대 (SWTheme)" description="Header 없이 같은 부품을 SWTheme 안에 배치합니다. 배치 CSS만 소비 프로젝트 소유입니다.">
|
|
||||||
<Bar>
|
|
||||||
<SWTheme className="sw-story-composed-bar" data-tool-bar="">
|
|
||||||
<SWHeaderHome logoSrc={placeholderLogo} logoAlt="임시 로고" />
|
|
||||||
<SWToolButton appearance="document" onClick={() => record("open")}>Open</SWToolButton>
|
|
||||||
<div className="sw-story-composed-surface">{viewGroup}{displayGroup}{fit}</div>
|
|
||||||
</SWTheme>
|
|
||||||
</Bar>
|
|
||||||
</StorySection>
|
|
||||||
<StorySection title="상태" description="text: Hover·Selected 글자 #FFC600, Disabled 40%. pill: Hover 노란 테두리, hoverIcon 교체. document: 글자만 Hover #FFC600.">
|
|
||||||
<Bar>
|
|
||||||
<SWTheme className="sw-story-composed-bar" data-tool-states="">
|
|
||||||
<SWHeaderHome logoSrc={placeholderLogo} logoAlt="임시 로고" interactive={false} />
|
|
||||||
<SWToolButton appearance="document" disabled>Disabled</SWToolButton>
|
|
||||||
<div className="sw-story-composed-surface">
|
|
||||||
<SWToolGroup label="text"><SWToolButton>Default</SWToolButton><SWToolButton pressed>Pressed</SWToolButton><SWToolButton disabled>Disabled</SWToolButton></SWToolGroup>
|
|
||||||
<SWToolGroup appearance="pill" aria-label="pill">
|
|
||||||
<SWToolButton appearance="pill" icon={<FitIcon color="#B6D0C9" />} hoverIcon={<FitIcon color="var(--hm-sw-header-selected-color)" />}>Fit</SWToolButton>
|
|
||||||
<SWToolButton appearance="pill" icon={<FitIcon color="#B6D0C9" />} disabled>Fit disabled</SWToolButton>
|
|
||||||
</SWToolGroup>
|
|
||||||
</div>
|
|
||||||
</SWTheme>
|
|
||||||
</Bar>
|
|
||||||
</StorySection>
|
|
||||||
</StoryPage>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ToolButtons: Story = { name: "Tool Buttons", render: () => <ToolPage /> }
|
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Story fixture for SW/Viewer: the reference DWG/DXF viewer menu rebuilt from the public @hm-design/sw API only.
|
||||||
|
// UI state lives here (like a consuming program); no DWG/DXF engine — commands are logged, not executed.
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
SWViewerBadge, SWViewerButton, SWViewerHeaderButton, SWViewerStatus, SWViewerField, SWViewerHint, SWViewerInput, SWViewerPanel, SWViewerSection, SWViewerSlider, SWViewerSwitch,
|
||||||
|
type SWViewerCommand, type SWViewerHeaderGroup, type SWViewerPanelProps,
|
||||||
|
} from "@hm-design/sw"
|
||||||
|
import { Eye, EyeOff, FolderOpen, Info, Layers, List, MousePointerClick, RotateCcw, Scan, SlidersHorizontal, ZoomIn } from "lucide-react"
|
||||||
|
|
||||||
|
export const SAMPLES = [
|
||||||
|
{ id: "sample-basic", label: "기본 도면 · DWG", name: "BasicSample.dwg" },
|
||||||
|
{ id: "sample-civil", label: "평면 및 종단면도 · DWG", name: "C0060202-001-평면및종단면도.dwg" },
|
||||||
|
{ id: "sample-dxf", label: "기본 도형 · DXF", name: "simple.dxf" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const SPACES = [
|
||||||
|
{ value: "model", label: "Model", count: 17377 },
|
||||||
|
{ value: "layout1", label: "Layout", count: 237 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const LAYERS = [
|
||||||
|
{ name: "0", color: "#ffffff", count: 1 },
|
||||||
|
{ name: "CX-BORD-LIN1", color: "#00ffff", count: 30 },
|
||||||
|
{ name: "CX-BORD-LIN2", color: "#00ff00", count: 1 },
|
||||||
|
{ name: "CX-BORD-TEX3", color: "#ff00ff", count: 17 },
|
||||||
|
{ name: "CX-BORD-TEX2", color: "#000000", count: 4 },
|
||||||
|
{ name: "CX-MISC", color: "#ffff00", count: 0 },
|
||||||
|
{ name: "CR-LEAT", color: "#00ff00", count: 4 },
|
||||||
|
{ name: "CR-GRND", color: "#00ff00", count: 6 },
|
||||||
|
{ name: "CR-TEXT", color: "#ff0000", count: 52 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export type ViewerPanelId = "layers" | "options" | "properties"
|
||||||
|
|
||||||
|
export interface ViewerOptions { lineweightOn: boolean; lineweightScale: number; grid: boolean; zoomSpeed: number; zScale: number }
|
||||||
|
export const DEFAULT_OPTIONS: ViewerOptions = { lineweightOn: false, lineweightScale: 11, grid: false, zoomSpeed: 1, zScale: 10 }
|
||||||
|
|
||||||
|
/** All UI state of the demo viewer screen + the header menu data built from it. */
|
||||||
|
export function useViewerDemo({ initialDocument = null as string | null, initialPanels = [] as ViewerPanelId[] } = {}) {
|
||||||
|
const [documentName, setDocumentName] = useState<string | null>(initialDocument)
|
||||||
|
const [space, setSpace] = useState("model")
|
||||||
|
const [is25D, set25D] = useState(false)
|
||||||
|
const [dark, setDark] = useState(true)
|
||||||
|
const [panels, setPanels] = useState<Record<ViewerPanelId, boolean>>(() => ({ layers: initialPanels.includes("layers"), options: initialPanels.includes("options"), properties: initialPanels.includes("properties") }))
|
||||||
|
const [options, setOptions] = useState<ViewerOptions>(DEFAULT_OPTIONS)
|
||||||
|
const [hidden, setHidden] = useState<Set<string>>(new Set())
|
||||||
|
const [aboutOpen, setAboutOpen] = useState(false)
|
||||||
|
const [log, setLog] = useState<string[]>([])
|
||||||
|
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
|
||||||
|
const toggle = (id: ViewerPanelId, next = !panels[id]) => setPanels(current => ({ ...current, [id]: next }))
|
||||||
|
const loaded = documentName !== null
|
||||||
|
|
||||||
|
const groups = useMemo<SWViewerHeaderGroup[]>(() => [
|
||||||
|
{
|
||||||
|
id: "file", label: "파일", items: [
|
||||||
|
{ type: "button", id: "open", label: "파일 열기", icon: <FolderOpen />, title: "파일 열기", iconOnlyBelow: 560, onSelect: () => record("파일 선택창 (미연결)") },
|
||||||
|
{
|
||||||
|
type: "menu", id: "samples", label: "샘플", moreLabel: "샘플 도면", collapseBelow: 1180,
|
||||||
|
entries: SAMPLES.map(sample => ({ type: "item" as const, id: sample.id, label: sample.label, onSelect: () => setDocumentName(sample.name) })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "space", label: "도면 공간", surface: "field", hidden: !loaded, items: [
|
||||||
|
{ type: "tabs", id: "space", label: "모델과 배치 선택", moreLabel: "도면 공간", collapseBelow: 1180, value: space, options: SPACES, onValueChange: setSpace },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "view", label: "화면 보기", surface: "field", items: [
|
||||||
|
{ type: "button", id: "fit", label: "전체 보기", icon: <Scan />, title: "전체 도면 맞춤 (F)", iconOnlyBelow: 560, disabled: !loaded },
|
||||||
|
{ type: "switch", id: "2.5d", label: "2.5D", checked: is25D, collapseBelow: 1180, onCheckedChange: set25D },
|
||||||
|
{ type: "switch", id: "dark", label: "어두운 배경", checked: dark, collapseBelow: 1180, onCheckedChange: setDark },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "panels", label: "패널", placement: "end", items: [
|
||||||
|
{ type: "toggle", id: "layers", label: "레이어", icon: <Layers />, title: "레이어 패널 열기 / 닫기", pressed: panels.layers, collapseBelow: 900, onPressedChange: next => toggle("layers", next) },
|
||||||
|
{ type: "toggle", id: "options", label: "설정", icon: <SlidersHorizontal />, title: "옵션 패널 열기 / 닫기", pressed: panels.options, collapseBelow: 900, onPressedChange: next => toggle("options", next) },
|
||||||
|
{ type: "toggle", id: "properties", label: "속성", icon: <List />, title: "속성 패널 열기 / 닫기", pressed: panels.properties, collapseBelow: 900, onPressedChange: next => toggle("properties", next) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
], [loaded, space, is25D, dark, panels])
|
||||||
|
|
||||||
|
/** Trailing slot: load state + the info button that opens the modal about popup (reference LicenseNotice). */
|
||||||
|
const trailing = (
|
||||||
|
<>
|
||||||
|
<SWViewerStatus state={loaded ? "ready" : "idle"} detail={loaded ? `${documentName} 열림` : "deselected"} />
|
||||||
|
<SWViewerHeaderButton iconOnly icon={<Info />} label="정보 및 라이선스" title="정보 및 라이선스" onClick={() => { setAboutOpen(true); record("about") }} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const onCommand = (command: SWViewerCommand) => record(`${command.itemId}${command.id !== command.itemId ? `/${command.id}` : ""}${command.value !== undefined ? `=${String(command.value)}` : ""}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
documentName, setDocumentName, space, is25D, set25D, dark, setDark, panels, toggle, options, setOptions, hidden, setHidden,
|
||||||
|
log, record, groups, trailing, onCommand, loaded, aboutOpen, setAboutOpen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ViewerDemo = ReturnType<typeof useViewerDemo>
|
||||||
|
|
||||||
|
type PanelPlacement = Pick<SWViewerPanelProps, "strategy" | "defaultPosition" | "inset">
|
||||||
|
|
||||||
|
export function LayerPanel({ demo, ...placement }: { demo: ViewerDemo } & PanelPlacement) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const filtered = LAYERS.filter(layer => layer.name.toLowerCase().includes(query.trim().toLowerCase()))
|
||||||
|
const set = (names: string[]) => demo.setHidden(new Set(names))
|
||||||
|
return (
|
||||||
|
<SWViewerPanel title={`레이어 (${LAYERS.length}개)`} open={demo.panels.layers} onClose={() => demo.toggle("layers", false)} {...placement}>
|
||||||
|
<SWViewerSection surface="plain" aria-label="레이어 목록">
|
||||||
|
<div className="sw-viewer-story-toolbar">
|
||||||
|
<SWViewerInput type="search" placeholder="레이어 검색" aria-label="레이어 검색" value={query} onChange={event => setQuery(event.currentTarget.value)} />
|
||||||
|
<SWViewerButton tone="green" onClick={() => set([])}>전체 켜기</SWViewerButton>
|
||||||
|
<SWViewerButton tone="green" onClick={() => set(LAYERS.map(layer => layer.name))}>전체 끄기</SWViewerButton>
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 ? <SWViewerHint>검색 결과가 없습니다 ("{query}")</SWViewerHint> : (
|
||||||
|
<ul className="sw-viewer-story-list">
|
||||||
|
{filtered.map(layer => {
|
||||||
|
const visible = !demo.hidden.has(layer.name)
|
||||||
|
return (
|
||||||
|
<li key={layer.name} data-visible={visible}>
|
||||||
|
<span className="sw-viewer-story-swatch" style={{ background: layer.color }} aria-hidden="true" />
|
||||||
|
<span className="sw-viewer-story-name" title={layer.name}>{layer.name}</span>
|
||||||
|
<SWViewerBadge>{layer.count}</SWViewerBadge>
|
||||||
|
<button type="button" className="sw-viewer-story-eye" aria-label={`${layer.name} ${visible ? "숨기기" : "표시하기"}`} title={visible ? "숨기기" : "표시하기"}
|
||||||
|
onClick={() => {
|
||||||
|
const next = new Set(demo.hidden)
|
||||||
|
if (visible) next.add(layer.name); else next.delete(layer.name)
|
||||||
|
demo.setHidden(next)
|
||||||
|
demo.record(`layer:${layer.name}=${!visible}`)
|
||||||
|
}}>{visible ? <Eye size={16} /> : <EyeOff size={16} />}</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</SWViewerSection>
|
||||||
|
</SWViewerPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SliderRow({ label, value, min, max, step, disabled, onChange }: { label: string; value: number; min: number; max: number; step: number; disabled?: boolean; onChange: (value: number) => void }) {
|
||||||
|
return (
|
||||||
|
<SWViewerField label={label} layout="stacked" disabled={disabled}
|
||||||
|
below={<SWViewerSlider aria-label={label} value={value} min={min} max={max} step={step} onValueChange={onChange} />}>
|
||||||
|
{id => <SWViewerInput id={id} type="number" min={min} max={max} step={step} value={value} onChange={event => { const next = Number(event.currentTarget.value); if (Number.isFinite(next)) onChange(next) }} />}
|
||||||
|
</SWViewerField>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OptionsPanel({ demo, footer, ...placement }: { demo: ViewerDemo; footer?: boolean } & PanelPlacement) {
|
||||||
|
const { options, setOptions } = demo
|
||||||
|
const set = (patch: Partial<ViewerOptions>) => setOptions({ ...options, ...patch })
|
||||||
|
return (
|
||||||
|
<SWViewerPanel title="옵션" open={demo.panels.options} onClose={() => demo.toggle("options", false)} width={340} height={520} {...placement}
|
||||||
|
headerActions={<SWViewerButton variant="ghost" aria-label="기본값으로" title="기본값으로" onClick={() => { setOptions(DEFAULT_OPTIONS); demo.record("options:reset") }}><RotateCcw size={16} /></SWViewerButton>}
|
||||||
|
footer={footer ? <><SWViewerButton onClick={() => demo.toggle("options", false)}>취소</SWViewerButton><SWViewerButton variant="primary" onClick={() => { demo.record("options:apply"); demo.toggle("options", false) }}>적용</SWViewerButton></> : undefined}>
|
||||||
|
<SWViewerSection title="선가중치">
|
||||||
|
<SWViewerField label="표시 사용">{id => <SWViewerSwitch id={id} checked={options.lineweightOn} onCheckedChange={lineweightOn => set({ lineweightOn })} />}</SWViewerField>
|
||||||
|
<SliderRow label="표시 배율 (px/mm)" value={options.lineweightScale} min={2} max={60} step={0.5} onChange={lineweightScale => set({ lineweightScale })} />
|
||||||
|
<SWViewerHint>이 도면에는 굵은 선이 없습니다 (0.50mm 였다면 {(0.5 * options.lineweightScale).toFixed(1)}px)</SWViewerHint>
|
||||||
|
<SWViewerHint>AutoCAD의 "선가중치 설정 → 표시 배율"과 동일합니다.</SWViewerHint>
|
||||||
|
</SWViewerSection>
|
||||||
|
<SWViewerSection title="표시">
|
||||||
|
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={options.grid} onCheckedChange={grid => set({ grid })} />}</SWViewerField>
|
||||||
|
<SliderRow label="줌 속도" value={options.zoomSpeed} min={0.2} max={3} step={0.1} onChange={zoomSpeed => set({ zoomSpeed })} />
|
||||||
|
</SWViewerSection>
|
||||||
|
<SWViewerSection title="2.5D">
|
||||||
|
<SWViewerField label="2.5D 보기">{id => <SWViewerSwitch id={id} checked={demo.is25D} onCheckedChange={demo.set25D} />}</SWViewerField>
|
||||||
|
<SliderRow label="Z 높이 배율" value={options.zScale} min={1} max={50} step={0.5} disabled={!demo.is25D} onChange={zScale => set({ zScale })} />
|
||||||
|
<SWViewerHint>좌클릭 드래그로 회전, 중간/우클릭 드래그로 이동합니다.</SWViewerHint>
|
||||||
|
</SWViewerSection>
|
||||||
|
</SWViewerPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITY_ROWS: [string, string][] = [["유형", "LINE"], ["레이어", "CX-BORD-LIN1"], ["색상", "ByLayer"], ["선종류", "Continuous"]]
|
||||||
|
const GEOMETRY_ROWS: [string, string][] = [["시작 X", "18432.512"], ["시작 Y", "2210.004"], ["끝 X", "18992.040"], ["끝 Y", "2210.004"], ["길이", "559.528"]]
|
||||||
|
|
||||||
|
export function PropertiesPanel({ demo, selected = true, ...placement }: { demo: ViewerDemo; selected?: boolean } & PanelPlacement) {
|
||||||
|
const rows = (items: [string, string][]) => (
|
||||||
|
<dl className="sw-viewer-story-props">{items.map(([key, value]) => <div key={key}><dt>{key}</dt><dd title={value}>{value}</dd></div>)}</dl>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<SWViewerPanel title="속성" open={demo.panels.properties} onClose={() => demo.toggle("properties", false)} {...placement}
|
||||||
|
headerActions={selected ? <SWViewerButton variant="ghost" aria-label="선택 확대" title="선택 확대" onClick={() => demo.record("zoomToSelection (미연결)")}><ZoomIn size={16} /></SWViewerButton> : undefined}>
|
||||||
|
{selected ? <>
|
||||||
|
<SWViewerSection surface="plain" title="일반 정보 (General)"><SWViewerBadge>LINE · h=2F1A</SWViewerBadge>{rows(ENTITY_ROWS)}</SWViewerSection>
|
||||||
|
<SWViewerSection surface="plain" title="기하 속성 (Geometry)">{rows(GEOMETRY_ROWS)}</SWViewerSection>
|
||||||
|
</> : (
|
||||||
|
<div className="sw-viewer-story-empty"><MousePointerClick size={24} aria-hidden="true" />도면에서 객체(엔티티)를 클릭하면<br />상세 속성이 표시됩니다</div>
|
||||||
|
)}
|
||||||
|
</SWViewerPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/* Story-owned layout for SW/Viewer. Like a consuming program it arranges public components and its own list
|
||||||
|
content; it never styles hm-sw-* classes. Panel content colors read the public --hm-sw-viewer-* tokens. */
|
||||||
|
.sw-viewer-story-frame { position: relative; display: flex; flex-direction: column; overflow: hidden; background: #0a0a0b; }
|
||||||
|
.sw-viewer-story-canvas {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--sw-story-canvas);
|
||||||
|
}
|
||||||
|
.sw-viewer-story-room { padding-bottom: 320px; }
|
||||||
|
.sw-viewer-story-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 12px 4px; }
|
||||||
|
.sw-viewer-story-toolbar > input { flex: 1 0 100%; }
|
||||||
|
.sw-viewer-story-list { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.sw-viewer-story-list li { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border-radius: 3px; }
|
||||||
|
.sw-viewer-story-list li:hover { background: rgba(154, 146, 132, 0.25); }
|
||||||
|
.sw-viewer-story-list li[data-visible="false"] { opacity: 0.45; }
|
||||||
|
.sw-viewer-story-swatch { flex: 0 0 10px; width: 10px; height: 10px; border: 1px solid var(--hm-sw-viewer-panel-inner-border); border-radius: 9999px; }
|
||||||
|
.sw-viewer-story-name { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-family: ui-monospace, monospace; font-size: 12px; }
|
||||||
|
.sw-viewer-story-eye { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 4px; color: var(--hm-sw-viewer-panel-heading); }
|
||||||
|
.sw-viewer-story-eye:hover { background: rgba(154, 146, 132, 0.3); }
|
||||||
|
.sw-viewer-story-props { margin: 0; }
|
||||||
|
.sw-viewer-story-props > div { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--hm-sw-viewer-panel-inner-border); font-size: 12px; }
|
||||||
|
.sw-viewer-story-props dt { color: var(--hm-sw-viewer-panel-label); }
|
||||||
|
.sw-viewer-story-props dd { margin: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-family: ui-monospace, monospace; user-select: all; }
|
||||||
|
.sw-viewer-story-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 40px 0; text-align: center; font-size: 12px; color: var(--hm-sw-viewer-panel-label); }
|
||||||
|
.sw-viewer-story-steps { margin: 0; padding-left: 20px; font-size: 14px; }
|
||||||
|
.sw-viewer-story-select { height: 28px; padding: 0 8px; border: 1px solid var(--hm-sw-viewer-panel-inner-border); border-radius: 4px; background: var(--hm-sw-viewer-panel-field-background); font: inherit; font-size: 12px; }
|
||||||
|
.sw-viewer-story-reopen { position: absolute; left: 712px; top: 16px; padding: 6px 10px; border-radius: 4px; background: #f5f0e5; font-size: 12px; }
|
||||||
|
.sw-viewer-story-context { display: flex; align-items: center; justify-content: center; height: 160px; border: 1px dashed #9a9284; border-radius: 4px; background: #f5f0e5; color: #3e3523; font-size: 13px; }
|
||||||
|
.sw-viewer-story-mode { display: flex; gap: 16px; margin-bottom: 8px; font-size: 13px; }
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { useRef, useState, type ReactNode } from "react"
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||||
|
import {
|
||||||
|
SWHeader, SWViewerButton, SWViewerField, SWViewerHeader, SWViewerHint, SWViewerInput, SWViewerMenu, SWViewerPanel, SWViewerSection, SWViewerSwitch,
|
||||||
|
type SWViewerHeaderGroup, type SWViewerMenuEntry,
|
||||||
|
} from "@hm-design/sw"
|
||||||
|
import "@hm-design/sw/styles.css"
|
||||||
|
import { Copy, Download, FileText, FolderOpen, Layers, Printer, Ruler, Scan, Search } from "lucide-react"
|
||||||
|
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||||
|
import { LAYERS, LayerPanel, OptionsPanel, PropertiesPanel, SAMPLES, useViewerDemo, type ViewerDemo } from "./sw-viewer-fixture"
|
||||||
|
import "./sw-stories.css"
|
||||||
|
import "./sw-viewer-stories.css"
|
||||||
|
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
|
||||||
|
|
||||||
|
// SW viewer mode (2026-10-19): the reference DWG/DXF viewer top menu and popups as package components.
|
||||||
|
// Stories use the public @hm-design/sw API only, like a consuming program. No DWG/DXF engine: commands are logged.
|
||||||
|
const KICKER = "HM DESIGN SYSTEM / SW"
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "SW/Viewer",
|
||||||
|
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||||
|
} satisfies Meta
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
function Log({ lines }: { lines: string[] }) {
|
||||||
|
return <p className="sw-story-log" aria-live="polite" data-callback-log="">Command: {lines.length ? lines.join(" → ") : "없음"}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A viewer screen: header on top, the drawing area below it (panels are placed inside this frame). */
|
||||||
|
function ViewerFrame({ width = 1440, height = 620, children, header }: { width?: number; height?: number; header: ReactNode; children?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="sw-story-scroller">
|
||||||
|
{/* Fills the Story column up to `width`, so panels placed at the right edge stay visible. */}
|
||||||
|
<div className="sw-viewer-story-frame" style={{ width: "100%", maxWidth: width, height }} data-viewer-frame="">
|
||||||
|
{header}
|
||||||
|
<div className="sw-viewer-story-canvas" aria-label="도면 영역 (예시)">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AboutPanel({ demo }: { demo: ViewerDemo }) {
|
||||||
|
return (
|
||||||
|
<SWViewerPanel modal strategy="absolute" title="모듈 구성 · 라이선스 고지" open={demo.aboutOpen} onClose={() => demo.setAboutOpen(false)} width={420} height="auto" resizable={false} collapsible={false}
|
||||||
|
footer={<SWViewerButton variant="primary" onClick={() => demo.setAboutOpen(false)}>확인</SWViewerButton>}>
|
||||||
|
<SWViewerSection title="처리 파이프라인">
|
||||||
|
<ol className="sw-viewer-story-steps">
|
||||||
|
<li>파일 로드 (.dwg → acadrust WASM, .dxf → dxf-parser)</li>
|
||||||
|
<li>CAD 파싱 결과 정규화 (레이어 / 스페이스 / 엔티티)</li>
|
||||||
|
<li>Three.js 씬 구성 및 렌더링</li>
|
||||||
|
</ol>
|
||||||
|
</SWViewerSection>
|
||||||
|
<SWViewerHint>Storybook 예시: 링크와 실제 고지문은 프로그램이 제공합니다.</SWViewerHint>
|
||||||
|
</SWViewerPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewerScreen({ width = 1440, initialDocument = SAMPLES[1].name as string | null, open = [] as ("layers" | "options" | "properties")[] }) {
|
||||||
|
const demo = useViewerDemo({ initialDocument, initialPanels: open })
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Log lines={demo.log} />
|
||||||
|
<ViewerFrame width={width} header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
|
||||||
|
<LayerPanel demo={demo} strategy="absolute" />
|
||||||
|
<OptionsPanel demo={demo} strategy="absolute" inset={{ side: 356 }} />
|
||||||
|
<PropertiesPanel demo={demo} strategy="absolute" inset={{ side: 24, top: 488 }} />
|
||||||
|
<AboutPanel demo={demo} />
|
||||||
|
</ViewerFrame>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ViewerHeader: Story = {
|
||||||
|
name: "Viewer Header",
|
||||||
|
render: () => (
|
||||||
|
<StoryPage>
|
||||||
|
<StoryPageHeader kicker={KICKER} title="SW Viewer — SWViewerHeader"
|
||||||
|
description="참고 DWG/DXF 뷰어의 상단 메뉴를 패키지 컴포넌트로 옮긴 뷰어 모드 전용 Header입니다. 로고 면 → 파일명 면 → 객체 수정 면(메뉴 그룹) → 오른쪽 패널·상태·정보 영역이며, 메뉴는 groups 데이터로 구성합니다. 레이어·설정·속성 토글은 팝업을 열고, 정보 버튼은 모달 팝업을 엽니다." />
|
||||||
|
<StorySection title="기본형 (1440px, 도면 열림)" description="샘플▾ Dropdown, Model/Layout 탭, 2.5D·어두운 배경 스위치, 패널 토글을 직접 눌러 보세요. Command 줄에 onCommand 결과가 표시됩니다.">
|
||||||
|
<ViewerScreen />
|
||||||
|
</StorySection>
|
||||||
|
</StoryPage>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuGroups: Story = {
|
||||||
|
name: "Menu Groups",
|
||||||
|
render: () => {
|
||||||
|
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
|
||||||
|
const pick = (ids: string[]): SWViewerHeaderGroup[] => demo.groups.map(group => ({ ...group, hidden: group.hidden || !ids.includes(group.id) }))
|
||||||
|
const rows: { title: string; ids: string[] }[] = [
|
||||||
|
{ title: "파일 그룹만 (파일 열기 · 샘플)", ids: ["file"] },
|
||||||
|
{ title: "도면 공간 (field 면 · tabs)", ids: ["space"] },
|
||||||
|
{ title: "화면 보기 (field 면 · button + switch)", ids: ["view"] },
|
||||||
|
{ title: "오른쪽 영역 (placement=end 패널 토글 + trailing 상태 · 정보)", ids: ["panels"] },
|
||||||
|
{ title: "전체", ids: demo.groups.map(group => group.id) },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<StoryPage>
|
||||||
|
<StoryPageHeader kicker={KICKER} title="SW Viewer — 메뉴 그룹" description="그룹은 groups 배열의 순서대로 놓입니다. surface="field"는 사용자 확정 속성 입력 면(어두운 inset), placement="end"는 오른쪽 끝 영역입니다. hidden으로 그룹·항목을 제거합니다." />
|
||||||
|
<Log lines={demo.log} />
|
||||||
|
<div className="sw-story-stack">
|
||||||
|
{rows.map(row => (
|
||||||
|
<StorySection key={row.title} title={row.title}>
|
||||||
|
<div className="sw-story-scroller"><div style={{ width: 1440 }}><SWViewerHeader documentTitle={demo.documentName} groups={pick(row.ids)} trailing={row.ids.includes("panels") ? demo.trailing : undefined} onCommand={demo.onCommand} /></div></div>
|
||||||
|
</StorySection>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</StoryPage>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const States: Story = {
|
||||||
|
name: "Item States",
|
||||||
|
render: () => {
|
||||||
|
const [log, setLog] = useState<string[]>([])
|
||||||
|
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
|
||||||
|
const groups: SWViewerHeaderGroup[] = [
|
||||||
|
{
|
||||||
|
id: "states", label: "상태 예시", items: [
|
||||||
|
{ type: "button", id: "default", label: "기본", icon: <FolderOpen /> },
|
||||||
|
{ type: "toggle", id: "active", label: "Active (on)", icon: <Layers />, pressed: true },
|
||||||
|
{ type: "toggle", id: "inactive", label: "Toggle (off)", icon: <Layers />, pressed: false },
|
||||||
|
{ type: "button", id: "disabled", label: "Disabled", icon: <Scan />, disabled: true },
|
||||||
|
{ type: "menu", id: "menu", label: "Dropdown", entries: [{ type: "item", id: "a", label: "항목 A" }, { type: "item", id: "b", label: "항목 B (disabled)", disabled: true }] },
|
||||||
|
{ type: "menu", id: "menu-disabled", label: "Dropdown (disabled)", disabled: true, entries: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "field", label: "field 면", surface: "field", items: [
|
||||||
|
{ type: "tabs", id: "tabs", label: "탭", value: "model", options: [{ value: "model", label: "Model", count: 17377 }, { value: "layout", label: "Layout", count: 237 }, { value: "off", label: "Disabled", disabled: true }] },
|
||||||
|
{ type: "switch", id: "on", label: "Switch on", checked: true },
|
||||||
|
{ type: "switch", id: "off", label: "Switch off", checked: false },
|
||||||
|
{ type: "switch", id: "sw-disabled", label: "Disabled", checked: false, disabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<StoryPage>
|
||||||
|
<StoryPageHeader kicker={KICKER} title="SW Viewer — Active / Hover / Disabled"
|
||||||
|
description="Hover: #266651 면, Active·열림·선택 탭: 글자와 아이콘 #FFC600(선택 박스 없음), Disabled: 60% 불투명, Focus: 2px #FFC600 외곽선(키보드). Hover와 Focus는 마우스·Tab으로 확인합니다." />
|
||||||
|
<Log lines={log} />
|
||||||
|
<div className="sw-story-scroller"><div style={{ width: 1440 }}>
|
||||||
|
<SWViewerHeader documentTitle="상태 확인용.dwg" groups={groups} onCommand={command => record(`${command.id}${command.value !== undefined ? `=${String(command.value)}` : ""}`)} />
|
||||||
|
</div></div>
|
||||||
|
</StoryPage>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU_ENTRIES: SWViewerMenuEntry[] = [
|
||||||
|
{ type: "label", id: "l-file", label: "파일" },
|
||||||
|
{ type: "item", id: "open", label: "파일 열기", icon: <FolderOpen size={16} />, shortcut: "Ctrl+O" },
|
||||||
|
{
|
||||||
|
type: "submenu", id: "recent", label: "최근 도면", icon: <FileText size={16} />, entries: [
|
||||||
|
...SAMPLES.map(sample => ({ type: "item" as const, id: `recent:${sample.id}`, label: sample.name })),
|
||||||
|
{ type: "separator", id: "s-recent" },
|
||||||
|
{ type: "submenu", id: "recent-more", label: "보관 도면", entries: [{ type: "item", id: "archive:2025", label: "2025-평면도.dwg" }, { type: "item", id: "archive:2024", label: "2024-종단면도.dwg" }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "item", id: "export", label: "PNG로 내보내기", icon: <Download size={16} /> },
|
||||||
|
{ type: "item", id: "print", label: "인쇄 (미연결)", icon: <Printer size={16} />, disabled: true },
|
||||||
|
{ type: "separator", id: "s1" },
|
||||||
|
{ type: "label", id: "l-view", label: "화면 보기" },
|
||||||
|
{ type: "checkbox", id: "grid", label: "그리드", checked: true },
|
||||||
|
{ type: "checkbox", id: "lineweight", label: "선가중치 표시", checked: false },
|
||||||
|
{ type: "separator", id: "s2" },
|
||||||
|
{ type: "label", id: "l-unit", label: "단위" },
|
||||||
|
{ type: "radio", id: "unit:mm", label: "mm", checked: true },
|
||||||
|
{ type: "radio", id: "unit:m", label: "m", checked: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const Dropdowns: Story = {
|
||||||
|
name: "Header + Dropdown",
|
||||||
|
render: () => {
|
||||||
|
const [entries, setEntries] = useState(MENU_ENTRIES)
|
||||||
|
const [log, setLog] = useState<string[]>([])
|
||||||
|
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
|
||||||
|
const apply = (entry: SWViewerMenuEntry) => setEntries(list => list.map(item => {
|
||||||
|
if (entry.type === "checkbox" && item.id === entry.id && item.type === "checkbox") return { ...item, checked: !item.checked }
|
||||||
|
if (entry.type === "radio" && item.type === "radio" && item.id.startsWith("unit:")) return { ...item, checked: item.id === entry.id }
|
||||||
|
return item
|
||||||
|
}))
|
||||||
|
const groups: SWViewerHeaderGroup[] = [
|
||||||
|
{ id: "file", label: "파일", items: [{ type: "menu", id: "file", label: "도면", icon: <FileText />, entries: entries.map(entry => ({ ...entry, ...(entry.type === "checkbox" || entry.type === "radio" ? { keepOpen: false } : {}) })) }] },
|
||||||
|
{ id: "tools", label: "도구", items: [
|
||||||
|
{ type: "menu", id: "measure", label: "측정", icon: <Ruler />, entries: [{ type: "item", id: "distance", label: "거리" }, { type: "item", id: "area", label: "면적" }, { type: "submenu", id: "angle", label: "각도", entries: [{ type: "item", id: "angle:2", label: "두 선 사이" }, { type: "item", id: "angle:3", label: "세 점" }] }] },
|
||||||
|
{ type: "menu", id: "align-end", label: "오른쪽 정렬 메뉴", menuAlign: "end", menuWidth: 220, entries: [{ type: "item", id: "copy", label: "좌표 복사", icon: <Copy size={16} /> }, { type: "item", id: "find", label: "객체 찾기", icon: <Search size={16} /> }] },
|
||||||
|
] },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<StoryPage>
|
||||||
|
<StoryPageHeader kicker={KICKER} title="SW Viewer — 상단 메뉴 + Dropdown"
|
||||||
|
description="type="menu" 항목의 entries가 Dropdown이 됩니다. 일반 항목·체크·라디오·라벨·구분선·다단계 서브메뉴(submenu)를 지원합니다. 키보드: Enter/↓ 열기, ↑↓ 이동, → 서브메뉴, ← 닫기, Esc 닫고 버튼으로 복귀, 바깥 클릭 닫기." />
|
||||||
|
<Log lines={log} />
|
||||||
|
<div className="sw-story-scroller sw-viewer-story-room"><div style={{ width: 1440 }}>
|
||||||
|
<SWViewerHeader documentTitle="C0060202-001-평면및종단면도.dwg" groups={groups}
|
||||||
|
onCommand={command => { const entry = findEntry(entries, command.id); if (entry) apply(entry); record(`${command.itemId}/${command.id}${command.value !== undefined ? `=${String(command.value)}` : ""}`) }} />
|
||||||
|
</div></div>
|
||||||
|
<StorySection title="SWViewerMenu 단독 (Context Menu)" description="anchorPoint로 지정 위치에 엽니다. 아래 영역을 마우스 오른쪽 버튼으로 클릭하세요.">
|
||||||
|
<ContextMenuDemo onSelect={line => record(line)} />
|
||||||
|
</StorySection>
|
||||||
|
</StoryPage>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function findEntry(entries: SWViewerMenuEntry[], id: string): SWViewerMenuEntry | undefined {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.id === id) return entry
|
||||||
|
if (entry.type === "submenu") { const found = findEntry(entry.entries, id); if (found) return found }
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuDemo({ onSelect }: { onSelect: (line: string) => void }) {
|
||||||
|
const [point, setPoint] = useState<{ x: number; y: number } | null>(null)
|
||||||
|
return (
|
||||||
|
<div className="sw-viewer-story-context" onContextMenu={event => { event.preventDefault(); setPoint({ x: event.clientX, y: event.clientY }) }} data-context-area="">
|
||||||
|
도면 영역 (오른쪽 클릭)
|
||||||
|
<SWViewerMenu open={point !== null} onOpenChange={open => { if (!open) setPoint(null) }} anchorPoint={point ?? undefined} label="도면 메뉴" initialFocus="menu"
|
||||||
|
entries={[
|
||||||
|
{ type: "item", id: "fit", label: "전체 보기", shortcut: "F" },
|
||||||
|
{ type: "item", id: "zoom-selection", label: "선택 확대" },
|
||||||
|
{ type: "separator", id: "s" },
|
||||||
|
{ type: "submenu", id: "layer", label: "레이어", entries: LAYERS.slice(0, 4).map(layer => ({ type: "item" as const, id: `hide:${layer.name}`, label: `${layer.name} 숨기기` })) },
|
||||||
|
{ type: "item", id: "props", label: "속성 보기" },
|
||||||
|
]}
|
||||||
|
onEntrySelect={entry => onSelect(`context/${entry.id}`)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsPanel: Story = {
|
||||||
|
name: "Settings Popup",
|
||||||
|
render: () => {
|
||||||
|
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["options"] })
|
||||||
|
return (
|
||||||
|
<StoryPage>
|
||||||
|
<StoryPageHeader kicker={KICKER} title="SW Viewer — 설정창 (SWViewerPanel)"
|
||||||
|
description="설정 토글로 여는 옵션 팝업입니다. 제목 막대를 끌어 이동, 오른쪽 아래 모서리로 크기 조절, 제목 더블클릭·접기 버튼으로 접기, 닫기·Esc(팝업 안 Focus)로 닫습니다. 하단 버튼 영역(footer)을 포함한 예시입니다." />
|
||||||
|
<Log lines={demo.log} />
|
||||||
|
<ViewerFrame header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
|
||||||
|
<OptionsPanel demo={demo} strategy="absolute" footer />
|
||||||
|
</ViewerFrame>
|
||||||
|
</StoryPage>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContentVariants: Story = {
|
||||||
|
name: "Popup Content Variants",
|
||||||
|
render: () => {
|
||||||
|
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["layers", "properties"] })
|
||||||
|
const [inputOpen, setInputOpen] = useState(true)
|
||||||
|
const [scale, setScale] = useState("1:500")
|
||||||
|
const [snap, setSnap] = useState(true)
|
||||||
|
return (
|
||||||
|
<StoryPage>
|
||||||
|
<StoryPageHeader kicker={KICKER} title="SW Viewer — 같은 프레임, 다른 내부 콘텐츠"
|
||||||
|
description="SWViewerPanel의 외형(제목 막대·닫기·테두리·스크롤 본문)은 같고 children / footer만 다릅니다: 검색 + 목록(레이어), 읽기 전용 정의 목록(속성), 입력 폼 + 하단 버튼(입력 팝업)." />
|
||||||
|
<Log lines={demo.log} />
|
||||||
|
<ViewerFrame height={640} header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
|
||||||
|
<LayerPanel demo={demo} strategy="absolute" defaultPosition={{ left: 24, top: 16 }} />
|
||||||
|
<PropertiesPanel demo={demo} strategy="absolute" defaultPosition={{ left: 368, top: 16 }} />
|
||||||
|
<SWViewerPanel strategy="absolute" defaultPosition={{ left: 712, top: 16 }} title="축척 지정" open={inputOpen} onClose={() => setInputOpen(false)} width={300} height="auto" resizable={false}
|
||||||
|
footer={<><SWViewerButton onClick={() => setInputOpen(false)}>취소</SWViewerButton><SWViewerButton variant="primary" onClick={() => { demo.record(`scale=${scale}, snap=${snap}`); setInputOpen(false) }}>적용</SWViewerButton></>}>
|
||||||
|
<SWViewerSection title="입력">
|
||||||
|
<SWViewerField label="축척">{id => <SWViewerInput id={id} value={scale} onChange={event => setScale(event.currentTarget.value)} style={{ width: 120 }} />}</SWViewerField>
|
||||||
|
<SWViewerField label="스냅 사용">{id => <SWViewerSwitch id={id} checked={snap} onCheckedChange={setSnap} />}</SWViewerField>
|
||||||
|
<SWViewerField label="단위">{id => <select id={id} className="sw-viewer-story-select" defaultValue="mm"><option value="mm">mm</option><option value="m">m</option></select>}</SWViewerField>
|
||||||
|
</SWViewerSection>
|
||||||
|
</SWViewerPanel>
|
||||||
|
{!inputOpen && <button type="button" className="sw-viewer-story-reopen" onClick={() => setInputOpen(true)}>축척 지정 다시 열기</button>}
|
||||||
|
</ViewerFrame>
|
||||||
|
</StoryPage>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VariableSize: Story = {
|
||||||
|
name: "Variable Size Popups",
|
||||||
|
render: () => {
|
||||||
|
const sizes: { id: string; title: string; width: number; height: number | "auto"; left: number; note: string }[] = [
|
||||||
|
{ id: "s", title: "250 × auto (최소 폭)", width: 250, height: "auto", left: 16, note: "내용 높이" },
|
||||||
|
{ id: "m", title: "320 × 420 (기본)", width: 320, height: 420, left: 282, note: "본문 스크롤" },
|
||||||
|
{ id: "l", title: "480 × 300 (가로형)", width: 480, height: 300, left: 618, note: "resize: both" },
|
||||||
|
]
|
||||||
|
const [open, setOpen] = useState<Record<string, boolean>>({ s: true, m: true, l: true })
|
||||||
|
const lines = Array.from({ length: 24 }, (_, index) => `행 ${index + 1}`)
|
||||||
|
return (
|
||||||
|
<StoryPage>
|
||||||
|
<StoryPageHeader kicker={KICKER} title="SW Viewer — 가변 크기 팝업"
|
||||||
|
description="width / height("auto" 가능) / minWidth / minHeight로 메뉴별 크기를 정합니다. 최대 90vw × 90vh, 오른쪽 아래 모서리로 크기 조절, 화면(또는 strategy="absolute"의 부모) 경계 안에서만 이동합니다." />
|
||||||
|
<ViewerFrame height={520} header={<SWViewerHeader documentTitle="크기 비교.dwg" groups={[]} />}>
|
||||||
|
{sizes.map(size => (
|
||||||
|
<SWViewerPanel key={size.id} strategy="absolute" defaultPosition={{ left: size.left, top: 16 }} title={size.title} open={open[size.id]} onClose={() => setOpen({ ...open, [size.id]: false })} width={size.width} height={size.height}>
|
||||||
|
<SWViewerHint>{size.note}</SWViewerHint>
|
||||||
|
<SWViewerSection surface="plain">{(size.height === "auto" ? lines.slice(0, 3) : lines).map(line => <div key={line}>{line}</div>)}</SWViewerSection>
|
||||||
|
</SWViewerPanel>
|
||||||
|
))}
|
||||||
|
</ViewerFrame>
|
||||||
|
</StoryPage>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Responsive: Story = {
|
||||||
|
name: "Responsive Widths",
|
||||||
|
render: () => {
|
||||||
|
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
|
||||||
|
return (
|
||||||
|
<StoryPage>
|
||||||
|
<StoryPageHeader kicker={KICKER} title="SW Viewer — 화면 너비"
|
||||||
|
description="참고 뷰어와 같은 기준입니다. Header 폭 1180px 미만: 샘플·도면 공간·2.5D·어두운 배경이 더보기(⋯)로 이동, 900px 미만: 패널 토글도 이동, 560px 미만: 파일 열기·전체 보기는 아이콘만, 상태 글자 숨김. 가로 스크롤은 만들지 않습니다." />
|
||||||
|
<Log lines={demo.log} />
|
||||||
|
<div className="sw-story-stack">
|
||||||
|
{[1440, 1100, 860, 520].map(width => (
|
||||||
|
<StorySection key={width} title={`${width}px`}>
|
||||||
|
<div className="sw-story-scroller"><div style={{ width }} data-width={width}><SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} /></div></div>
|
||||||
|
</StorySection>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</StoryPage>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeSwitchDemo() {
|
||||||
|
const [mode, setMode] = useState<"normal" | "viewer">("normal")
|
||||||
|
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
|
||||||
|
const headerRef = useRef<HTMLElement>(null)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="sw-viewer-story-mode" role="radiogroup" aria-label="모드">
|
||||||
|
{(["normal", "viewer"] as const).map(value => (
|
||||||
|
<label key={value}><input type="radio" name="sw-mode" checked={mode === value} onChange={() => setMode(value)} /> {value === "normal" ? "일반 SW 모드 (SWHeader)" : "뷰어 모드 (SWViewerHeader)"}</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Log lines={demo.log} />
|
||||||
|
<ViewerFrame height={480} header={mode === "normal"
|
||||||
|
? <SWHeader ref={headerRef} documentTitle={demo.documentName ?? ""} logoSrc={placeholderLogo} logoAlt="임시 로고 (뷰어 모드로)" onHomeClick={() => setMode("viewer")} />
|
||||||
|
: <SWViewerHeader ref={headerRef} documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} logoSrc={placeholderLogo} logoAlt="임시 로고 (일반 모드로)" onHomeClick={() => setMode("normal")} />}>
|
||||||
|
{mode === "viewer" && <><LayerPanel demo={demo} strategy="absolute" /><OptionsPanel demo={demo} strategy="absolute" inset={{ side: 356 }} /></>}
|
||||||
|
</ViewerFrame>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CompareWithSWHeader: Story = {
|
||||||
|
name: "SWHeader vs Viewer Header",
|
||||||
|
render: () => (
|
||||||
|
<StoryPage>
|
||||||
|
<StoryPageHeader kicker={KICKER} title="SW Viewer — 일반 SW Header와 비교"
|
||||||
|
description="일반 SW 모드는 기존 SWHeader(49px, Figma 26:8944)를 그대로 쓰고, 뷰어 모드만 SWViewerHeader(52px, 참고 뷰어)를 씁니다. 두 Header는 파일명·객체 수정·속성 면 토큰을 공유합니다. 아래 전환 예시는 화면 상태(파일명·패널 상태)를 유지한 채 Header만 바꿉니다. 예시에서는 SWHeader 로고(임시)를 누르면 뷰어 모드, 뷰어 로고(임시)를 누르면 일반 모드로 돌아갑니다." />
|
||||||
|
<StorySection title="나란히 비교 (1440px)">
|
||||||
|
<div className="sw-story-stack">
|
||||||
|
<div className="sw-story-scroller"><div style={{ width: 1440 }}><SWHeader documentTitle="C0060202-001-평면및종단면도" /></div></div>
|
||||||
|
<div className="sw-story-scroller"><div style={{ width: 1440 }}><ViewerOnly /></div></div>
|
||||||
|
</div>
|
||||||
|
</StorySection>
|
||||||
|
<StorySection title="일반 ↔ 뷰어 모드 전환" description="Header 교체만 보여 주는 UI 예시입니다. 실제 모드 진입·종료와 데이터 처리는 SW 프로그램 담당입니다.">
|
||||||
|
<ModeSwitchDemo />
|
||||||
|
</StorySection>
|
||||||
|
</StoryPage>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Logo: Story = {
|
||||||
|
name: "Logo",
|
||||||
|
render: () => {
|
||||||
|
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
|
||||||
|
const rows: { title: string; description: string; props: { logoSrc?: string; logo?: ReactNode; logoAlt?: string } }[] = [
|
||||||
|
{ title: "로고 없음 (기본)", description: "logo / logoSrc를 넘기지 않으면 아무것도 그리지 않습니다(HM 문자·기본 아이콘 없음). 로고 면은 사선 끝(24px)만 남아 넓은 빈 공간이 생기지 않습니다.", props: {} },
|
||||||
|
{ title: "logoSrc (이미지 URL)", description: "SWHeader와 같은 Prop. 로고 면 72px(로고가 더 넓으면 그만큼), 높이 최대 24px.", props: { logoSrc: placeholderLogo, logoAlt: "임시 로고" } },
|
||||||
|
{ title: "logo (요소)", description: "logo가 logoSrc보다 우선합니다.", props: { logo: <svg viewBox="0 0 32 17" width="32" height="17" aria-hidden="true"><rect x="0.5" y="0.5" width="31" height="16" rx="3" fill="none" stroke="#fff" /><text x="16" y="12.5" fill="#fff" fontSize="10" fontWeight="700" textAnchor="middle">LOGO</text></svg>, logoAlt: "예시 로고" } },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<StoryPage>
|
||||||
|
<StoryPageHeader kicker={KICKER} title="SW Viewer — 로고" description="로고는 각 프로그램이 정합니다. 제공된 경우에만 표시하고, Header 높이(52px)와 메뉴 정렬은 그대로입니다. SWHeader의 로고 동작은 바뀌지 않습니다." />
|
||||||
|
<div className="sw-story-stack">
|
||||||
|
{rows.map(row => (
|
||||||
|
<StorySection key={row.title} title={row.title} description={row.description}>
|
||||||
|
<div className="sw-story-scroller"><div style={{ width: 1440 }} data-logo-case={row.title}>
|
||||||
|
<SWViewerHeader {...row.props} documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
|
||||||
|
</div></div>
|
||||||
|
</StorySection>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</StoryPage>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewerOnly() {
|
||||||
|
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
|
||||||
|
return <SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
|
||||||
|
}
|
||||||