diff --git a/docs/sw/sw-viewer-mode-2026-10-19.md b/docs/sw/sw-viewer-mode-2026-10-19.md new file mode 100644 index 0000000..a7928d4 --- /dev/null +++ b/docs/sw/sw-viewer-mode-2026-10-19.md @@ -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. diff --git a/docs/sw/viewer-qa/outline-states-2x.png b/docs/sw/viewer-qa/outline-states-2x.png new file mode 100644 index 0000000..1f6e915 Binary files /dev/null and b/docs/sw/viewer-qa/outline-states-2x.png differ diff --git a/docs/sw/viewer-qa/outline-swheader-3x.png b/docs/sw/viewer-qa/outline-swheader-3x.png new file mode 100644 index 0000000..0f2b304 Binary files /dev/null and b/docs/sw/viewer-qa/outline-swheader-3x.png differ diff --git a/docs/sw/viewer-qa/outline-viewer-3x.png b/docs/sw/viewer-qa/outline-viewer-3x.png new file mode 100644 index 0000000..a1da4e4 Binary files /dev/null and b/docs/sw/viewer-qa/outline-viewer-3x.png differ diff --git a/docs/sw/viewer-qa/outline-viewer-right-3x.png b/docs/sw/viewer-qa/outline-viewer-right-3x.png new file mode 100644 index 0000000..843d4a5 Binary files /dev/null and b/docs/sw/viewer-qa/outline-viewer-right-3x.png differ diff --git a/docs/sw/viewer-qa/package-1440-loaded.png b/docs/sw/viewer-qa/package-1440-loaded.png new file mode 100644 index 0000000..b524102 Binary files /dev/null and b/docs/sw/viewer-qa/package-1440-loaded.png differ diff --git a/docs/sw/viewer-qa/package-1440-panels.png b/docs/sw/viewer-qa/package-1440-panels.png new file mode 100644 index 0000000..eceb9aa Binary files /dev/null and b/docs/sw/viewer-qa/package-1440-panels.png differ diff --git a/docs/sw/viewer-qa/package-1440-sample-menu.png b/docs/sw/viewer-qa/package-1440-sample-menu.png new file mode 100644 index 0000000..f481346 Binary files /dev/null and b/docs/sw/viewer-qa/package-1440-sample-menu.png differ diff --git a/docs/sw/viewer-qa/package-logo-element.png b/docs/sw/viewer-qa/package-logo-element.png new file mode 100644 index 0000000..4087677 Binary files /dev/null and b/docs/sw/viewer-qa/package-logo-element.png differ diff --git a/docs/sw/viewer-qa/package-logo-none.png b/docs/sw/viewer-qa/package-logo-none.png new file mode 100644 index 0000000..ee4f59d Binary files /dev/null and b/docs/sw/viewer-qa/package-logo-none.png differ diff --git a/docs/sw/viewer-qa/package-logo-src.png b/docs/sw/viewer-qa/package-logo-src.png new file mode 100644 index 0000000..2a86285 Binary files /dev/null and b/docs/sw/viewer-qa/package-logo-src.png differ diff --git a/docs/sw/viewer-qa/package-responsive-1100.png b/docs/sw/viewer-qa/package-responsive-1100.png new file mode 100644 index 0000000..317a856 Binary files /dev/null and b/docs/sw/viewer-qa/package-responsive-1100.png differ diff --git a/docs/sw/viewer-qa/package-responsive-1440.png b/docs/sw/viewer-qa/package-responsive-1440.png new file mode 100644 index 0000000..e49cca9 Binary files /dev/null and b/docs/sw/viewer-qa/package-responsive-1440.png differ diff --git a/docs/sw/viewer-qa/package-responsive-520.png b/docs/sw/viewer-qa/package-responsive-520.png new file mode 100644 index 0000000..edd40dd Binary files /dev/null and b/docs/sw/viewer-qa/package-responsive-520.png differ diff --git a/docs/sw/viewer-qa/package-responsive-860.png b/docs/sw/viewer-qa/package-responsive-860.png new file mode 100644 index 0000000..cda99bf Binary files /dev/null and b/docs/sw/viewer-qa/package-responsive-860.png differ diff --git a/docs/sw/viewer-qa/package-responsive-more-open.png b/docs/sw/viewer-qa/package-responsive-more-open.png new file mode 100644 index 0000000..4c111b6 Binary files /dev/null and b/docs/sw/viewer-qa/package-responsive-more-open.png differ diff --git a/docs/sw/viewer-qa/reference-1440-idle.png b/docs/sw/viewer-qa/reference-1440-idle.png new file mode 100644 index 0000000..7f51570 Binary files /dev/null and b/docs/sw/viewer-qa/reference-1440-idle.png differ diff --git a/docs/sw/viewer-qa/reference-1440-loaded.png b/docs/sw/viewer-qa/reference-1440-loaded.png new file mode 100644 index 0000000..f10eb00 Binary files /dev/null and b/docs/sw/viewer-qa/reference-1440-loaded.png differ diff --git a/docs/sw/viewer-qa/reference-1440-panels.png b/docs/sw/viewer-qa/reference-1440-panels.png new file mode 100644 index 0000000..21f03f6 Binary files /dev/null and b/docs/sw/viewer-qa/reference-1440-panels.png differ diff --git a/docs/sw/viewer-qa/reference-1440-sample-menu.png b/docs/sw/viewer-qa/reference-1440-sample-menu.png new file mode 100644 index 0000000..e44670f Binary files /dev/null and b/docs/sw/viewer-qa/reference-1440-sample-menu.png differ diff --git a/docs/sw/viewer-qa/regression/regression-neutral-canvas.json b/docs/sw/viewer-qa/regression/regression-neutral-canvas.json new file mode 100644 index 0000000..ffa3b2b --- /dev/null +++ b/docs/sw/viewer-qa/regression/regression-neutral-canvas.json @@ -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" + } + ] +} diff --git a/docs/sw/viewer-qa/regression/regression.json b/docs/sw/viewer-qa/regression/regression.json new file mode 100644 index 0000000..c9c3b1a --- /dev/null +++ b/docs/sw/viewer-qa/regression/regression.json @@ -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" + } + ] +} diff --git a/docs/sw/viewer-qa/report.json b/docs/sw/viewer-qa/report.json new file mode 100644 index 0000000..c7cb7db --- /dev/null +++ b/docs/sw/viewer-qa/report.json @@ -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": [] +} diff --git a/docs/sw/viewer-qa/story-compare-with-sw-header.png b/docs/sw/viewer-qa/story-compare-with-sw-header.png new file mode 100644 index 0000000..76546fc Binary files /dev/null and b/docs/sw/viewer-qa/story-compare-with-sw-header.png differ diff --git a/docs/sw/viewer-qa/story-content-variants.png b/docs/sw/viewer-qa/story-content-variants.png new file mode 100644 index 0000000..35fe8dd Binary files /dev/null and b/docs/sw/viewer-qa/story-content-variants.png differ diff --git a/docs/sw/viewer-qa/story-dropdowns.png b/docs/sw/viewer-qa/story-dropdowns.png new file mode 100644 index 0000000..751fa33 Binary files /dev/null and b/docs/sw/viewer-qa/story-dropdowns.png differ diff --git a/docs/sw/viewer-qa/story-logo.png b/docs/sw/viewer-qa/story-logo.png new file mode 100644 index 0000000..4ef6877 Binary files /dev/null and b/docs/sw/viewer-qa/story-logo.png differ diff --git a/docs/sw/viewer-qa/story-menu-groups.png b/docs/sw/viewer-qa/story-menu-groups.png new file mode 100644 index 0000000..c4e5a35 Binary files /dev/null and b/docs/sw/viewer-qa/story-menu-groups.png differ diff --git a/docs/sw/viewer-qa/story-responsive.png b/docs/sw/viewer-qa/story-responsive.png new file mode 100644 index 0000000..634d727 Binary files /dev/null and b/docs/sw/viewer-qa/story-responsive.png differ diff --git a/docs/sw/viewer-qa/story-settings-panel.png b/docs/sw/viewer-qa/story-settings-panel.png new file mode 100644 index 0000000..ed6d041 Binary files /dev/null and b/docs/sw/viewer-qa/story-settings-panel.png differ diff --git a/docs/sw/viewer-qa/story-states.png b/docs/sw/viewer-qa/story-states.png new file mode 100644 index 0000000..86b9e88 Binary files /dev/null and b/docs/sw/viewer-qa/story-states.png differ diff --git a/docs/sw/viewer-qa/story-variable-size.png b/docs/sw/viewer-qa/story-variable-size.png new file mode 100644 index 0000000..c594a4d Binary files /dev/null and b/docs/sw/viewer-qa/story-variable-size.png differ diff --git a/docs/sw/viewer-qa/story-viewer-header.png b/docs/sw/viewer-qa/story-viewer-header.png new file mode 100644 index 0000000..20d5609 Binary files /dev/null and b/docs/sw/viewer-qa/story-viewer-header.png differ diff --git a/packages/sw/ai/AI_USAGE.md b/packages/sw/ai/AI_USAGE.md index 76b74b2..6d8dbf4 100644 --- a/packages/sw/ai/AI_USAGE.md +++ b/packages/sw/ai/AI_USAGE.md @@ -10,7 +10,8 @@ | [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) | 공식 공개 컴포넌트(Header·개별 컨트롤·Popup·도구 막대)·Props·Callback·크기·CSS 의존성 | | [DESIGN_RULES.md](./DESIGN_RULES.md) | SW 전용 토큰·Typography·간격·Hover/Focus/Selected·Overlay·Motion·반응형·Popup 외형·도구 버튼 | | [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) | 완성형·간소화·기능 중심·확장 Header, Popup(자유 콘텐츠), 파일 열기 + 도구 막대 | -| [examples/](./examples) | 공개 API만 쓰는 예제 A~F(E = 사용자 정의 Popup, F = 파일 열기 + 도구 막대. 독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증) | +| [examples/](./examples) | 공개 API만 쓰는 예제 A~F(E = 사용자 정의 Popup, F = 파일 열기 + 도구 막대. 독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증), H = 뷰어 모드 | +| [VIEWER.md](./VIEWER.md) | **뷰어 모드 전용**: `SWViewerHeader`(데이터 메뉴·More·외곽선·로고 규칙), `SWViewerMenu`(Dropdown·서브메뉴·우클릭), `SWViewerPanel`(팝업 프레임 + 자유 콘텐츠). 일반 SW 모드는 `SWHeader`·`SWPopup`을 그대로 씁니다 | ## 1. 작업 순서 (필수) diff --git a/packages/sw/ai/VIEWER.md b/packages/sw/ai/VIEWER.md new file mode 100644 index 0000000..f58cea3 --- /dev/null +++ b/packages/sw/ai/VIEWER.md @@ -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?`, `
` 속성, `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`의 직접 그린 아이콘은 ``으로 감쌉니다. 색·크기·굵기·간격은 그대로이고 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: , onSelect: fit }] }, + { id: "panels", label: "패널", placement: "end", items: [{ type: "toggle", id: "options", label: "설정", icon: , pressed: open, onPressedChange: setOpen, collapseBelow: 900 }] }, +] + + setOpen(false)}> + + {id => } + + +``` + +## 6. 잘못된 사용 + +| 하지 말 것 | 이유 / 대신 | +|---|---| +| ``처럼 로고가 없다고 임의 문자·아이콘 넣기 | 로고는 프로그램이 정합니다. 없으면 생략 | +| 뷰어 Header를 `div` + `button`으로 다시 만들기, `hm-sw-viewer-*` class를 소비 코드에 붙이기 | 데이터(`groups`)와 `custom` / `trailing`으로 구성 | +| Header 버튼 글자에 `-webkit-text-stroke`·`border`로 외곽선 만들기 | 외곽선은 패키지 토큰이 이미 적용합니다 | +| 메뉴 색·간격을 `#123456`, `margin` 등 임의 값으로 덮기 | 토큰 `--hm-sw-viewer-*` / `--hm-sw-header-*`만 `style`로 조정(기본값 유지 권장) | +| 존재하지 않는 Props 추측(`items`, `content`, `renderBody` …) | 위 표와 `dist/index.d.ts`가 전부 | +| 일반 모드 화면에서 `SWViewerHeader` 사용, 뷰어에서 `SWHeader` 재스타일 | 모드별 Header를 바꿔 끼웁니다 | +| 기능 없는 메뉴를 가짜 동작으로 연결 | 항목 제외 또는 `disabled` + 미연결 보고 | + +## 7. 디자인 변경 시 주의 + +- 크기(52px Header, 34px 버튼, 40px 토글·탭·스위치 칸, 41~42px field 면, 72px 로고 면)와 색은 참고 뷰어 실측값입니다. 파일명·객체 수정·속성 면 배경은 SWHeader와 같은 사용자 확정 토큰(`--hm-sw-header-document-*`, `object-*`, `property-*`)을 공유하므로 바꾸면 두 Header가 함께 바뀝니다. +- **높이:** 패키지는 뷰어(도면) 영역 높이를 정하지 않습니다. 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 닫기 추가, 로고 기본 표시 없음(사용자 결정). diff --git a/packages/sw/ai/examples/ExampleH-ViewerMode.tsx b/packages/sw/ai/examples/ExampleH-ViewerMode.tsx new file mode 100644 index 0000000..0fadf01 --- /dev/null +++ b/packages/sw/ai/examples/ExampleH-ViewerMode.tsx @@ -0,0 +1,85 @@ +// Example H — viewer mode: SWViewerHeader (data-driven menu) + SWViewerPanel popups with free content. +// Only public components. The program owns the state and the commands; the package owns the look and the +// open / close / focus / keyboard / drag behavior. Icons are the program's own (16px, currentColor). +import { useState } from "react" +import { + SWViewerButton, SWViewerField, SWViewerHeader, SWViewerHeaderButton, SWViewerHint, SWViewerInput, SWViewerPanel, + SWViewerSection, SWViewerSlider, SWViewerStatus, SWViewerSwitch, + type SWViewerHeaderGroup, +} from "@hm-design/sw" + +const Icon = ({ d }: { d: string }) => ( + +) +const icons = { + open: , + fit: , + layers: , + options: , + info: , +} + +export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => void }) { + const [fileName, setFileName] = useState(null) + const [space, setSpace] = useState("model") + const [depth, setDepth] = useState(false) + const [panels, setPanels] = useState({ layers: false, options: false }) + const [grid, setGrid] = useState(true) + const [zoomSpeed, setZoomSpeed] = useState(1) + const [about, setAbout] = useState(false) + + const groups: SWViewerHeaderGroup[] = [ + { id: "file", label: "파일", items: [ + { type: "button", id: "open", label: "파일 열기", icon: icons.open, iconOnlyBelow: 560 }, + { type: "menu", id: "samples", label: "샘플", moreLabel: "샘플 도면", collapseBelow: 1180, entries: [ + { type: "item", id: "sample-a", label: "기본 도면 · DWG", onSelect: () => setFileName("BasicSample.dwg") }, + { type: "submenu", id: "recent", label: "최근 도면", entries: [{ type: "item", id: "recent-1", label: "simple.dxf", onSelect: () => setFileName("simple.dxf") }] }, + ] }, + ] }, + { id: "space", label: "도면 공간", surface: "field", hidden: !fileName, items: [ + { type: "tabs", id: "space", label: "모델과 배치 선택", collapseBelow: 1180, value: space, onValueChange: setSpace, + options: [{ value: "model", label: "Model", count: 1200 }, { value: "layout", label: "Layout", count: 40 }] }, + ] }, + { id: "view", label: "화면 보기", surface: "field", items: [ + { type: "button", id: "fit", label: "전체 보기", icon: icons.fit, iconOnlyBelow: 560, disabled: !fileName }, + { type: "switch", id: "depth", label: "2.5D", checked: depth, collapseBelow: 1180, onCheckedChange: setDepth }, + ] }, + { id: "panels", label: "패널", placement: "end", items: [ + { type: "toggle", id: "layers", label: "레이어", icon: icons.layers, pressed: panels.layers, collapseBelow: 900, onPressedChange: layers => setPanels({ ...panels, layers }) }, + { type: "toggle", id: "options", label: "설정", icon: icons.options, pressed: panels.options, collapseBelow: 900, onPressedChange: options => setPanels({ ...panels, options }) }, + ] }, + ] + + return ( + <> + {/* No logo prop: nothing is drawn on the cap (no "HM" text). Pass logoSrc / logo to show the program's logo. */} + onEvent?.(`${command.itemId}/${command.id}${command.value === undefined ? "" : `=${String(command.value)}`}`)} + trailing={<> + + setAbout(true)} /> + } /> + + setPanels({ ...panels, layers: false })}> + + 프로그램의 레이어 목록을 여기에 둡니다. + + + + setPanels({ ...panels, options: false })} width={340} height="auto" inset={{ side: 356 }} + footer={ { onEvent?.("options:apply"); setPanels({ ...panels, options: false }) }}>적용}> + + {id => } + }> + {id => setZoomSpeed(Number(event.currentTarget.value))} />} + + + + + setAbout(false)} width={360} height="auto" resizable={false} collapsible={false} + footer={ setAbout(false)}>확인}> + 모듈 구성과 라이선스 고지는 프로그램이 제공합니다. + + + ) +} diff --git a/packages/sw/src/components/sw-popup/SWPopup.tsx b/packages/sw/src/components/sw-popup/SWPopup.tsx index 1945be5..8999472 100644 --- a/packages/sw/src/components/sw-popup/SWPopup.tsx +++ b/packages/sw/src/components/sw-popup/SWPopup.tsx @@ -17,6 +17,10 @@ export interface SWPopupProps extends Omit, "titl autoFocus?: boolean /** Anchor element: the popup is fixed below it and kept inside the viewport (as SWHeader does). */ anchorRef?: RefObject + /** 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, "titl * 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 outside SWHeader. */ -export function SWPopup({ closeLabel = "닫기", ...props }: SWPopupProps) { - return +export function SWPopup({ closeLabel = "닫기", footer, ...props }: SWPopupProps) { + return } export interface SWPopupSectionProps extends Omit, "title"> { diff --git a/packages/sw/src/components/sw-viewer/SWViewerControls.tsx b/packages/sw/src/components/sw-viewer/SWViewerControls.tsx new file mode 100644 index 0000000..d02d42b --- /dev/null +++ b/packages/sw/src/components/sw-viewer/SWViewerControls.tsx @@ -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, "onChange"> { + checked: boolean + onCheckedChange?: (checked: boolean) => void +} + +/** + * 36 x 20 switch. Its face follows where it is placed: dark header face (off #0A221D, on #FFC600) inside + * SWViewerHeader, beige panel face (off #9A9284, on #266651) inside SWViewerPanel. + */ +export const SWViewerSwitch = forwardRef(function SWViewerSwitch( + { checked, onCheckedChange, className, onClick, ...rest }, ref, +) { + return ( + + ) +}) + +export type SWViewerStatusState = "idle" | "loading" | "ready" | "error" + +export interface SWViewerStatusProps extends HTMLAttributes { + state: SWViewerStatusState + /** Short visible text. Default per state: 준비 / 불러오는 중 / 도면 열림 / 불러오기 실패. */ + label?: string + /** Longer detail for screen readers and the tooltip. */ + detail?: string +} + +const statusLabel: Record = { idle: "준비", loading: "불러오는 중", ready: "도면 열림", error: "불러오기 실패" } +const StatusIcon = ({ state }: { state: SWViewerStatusState }) => state === "idle" ? null : ( + +) + +/** Load state of the viewer header (reference status: muted 11px text, icon; text hides below 560px). */ +export function SWViewerStatus({ state, label, detail, className, ...rest }: SWViewerStatusProps) { + return ( +
+ {state !== "idle" && } + {label ?? statusLabel[state]} + {detail && : {detail}} +
+ ) +} + +export interface SWViewerButtonProps extends ButtonHTMLAttributes { + /** + * Panel buttons. `outline` (default): reference xs outline button (24px, white, #123328 border). + * `primary`: the same size filled with #123328 (footer confirm). `ghost`: icon button of the panel title bar. + */ + variant?: "outline" | "primary" | "ghost" + /** `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(function SWViewerButton( + { variant = "outline", tone, icon, children, className, type = "button", ...rest }, ref, +) { + return ( + + ) +}) + +export type SWViewerInputProps = InputHTMLAttributes + +/** 28px panel input (#F5F0E5 face, #9A9284 border, 12px). Any native input type. */ +export const SWViewerInput = forwardRef(function SWViewerInput({ className, ...rest }, ref) { + return +}) + +export interface SWViewerSliderProps extends Omit, "type" | "value" | "onChange"> { + value: number + min: number + max: number + step?: number + onValueChange?: (value: number) => void +} + +/** Panel slider (6px #E0DACE track, #266651 range, 18px white thumb with #266651 ring). Native range input. */ +export const SWViewerSlider = forwardRef(function SWViewerSlider( + { value, min, max, step = 1, onValueChange, className, style, ...rest }, ref, +) { + const percent = max > min ? ((Math.min(max, Math.max(min, value)) - min) / (max - min)) * 100 : 0 + return ( + onValueChange?.(Number(event.currentTarget.value))} {...rest} /> + ) +}) + +export interface SWViewerFieldProps extends Omit, "children"> { + label: string + /** Control id for the label. Generated when omitted and `children` is a function. */ + htmlFor?: string + /** + * `inline` (default): label left, control right (switch rows). `stacked`: label + control row, then `below` + * (slider rows: number input beside the label, slider underneath). + */ + layout?: "inline" | "stacked" + /** Unit after the control. */ + unit?: string + /** Stacked layout: content under the label row (a slider). */ + below?: ReactNode + disabled?: boolean + children: ReactNode | ((id: string) => ReactNode) +} + +/** Label + control row of the viewer panel (reference option rows). */ +export function SWViewerField({ label, htmlFor, layout = "inline", unit, below, disabled, children, className, ...rest }: SWViewerFieldProps) { + const generated = useId() + const id = htmlFor ?? generated + return ( +
+
+ +
+ {typeof children === "function" ? children(id) : children} + {unit && {unit}} +
+
+ {below} +
+ ) +} + +export interface SWViewerBadgeProps extends HTMLAttributes { children: ReactNode } + +/** Count badge of panel lists (12px 500, #F4F1EA face). */ +export function SWViewerBadge({ className, ...rest }: SWViewerBadgeProps) { + return +} + +export interface SWViewerSectionProps extends Omit, "title"> { + /** 12px 700 heading (reference panel h4). Also the section's accessible name. */ + title?: string + /** `card` (default): the inset #F5F0E5 card. `plain`: no face (lists). */ + surface?: "card" | "plain" + children?: ReactNode +} + +/** A content group of SWViewerPanel. Children are free (fields, lists, any component). */ +export function SWViewerSection({ title, surface = "card", children, className, ...rest }: SWViewerSectionProps) { + return ( +
+ {title &&

{title}

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

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

+ {item.options.map(option => { + const selected = option.value === item.value + return ( + + ) + })} +
+ ) +} + +function SwitchField({ item, onCommand }: { item: Extract; onCommand: (c: SWViewerCommand) => void }) { + const id = useId() + return ( +
+ + { item.onCheckedChange?.(next); onCommand({ id: item.id, itemId: item.id, value: next }) }} /> +
+ ) +} + +function Item({ item, width, onCommand }: { item: SWViewerHeaderItem; width: number; onCommand: (c: SWViewerCommand) => void }) { + if (item.type === "custom") return <>{item.render()} + const iconOnly = !!item.icon && (item.iconOnly ? "always" : item.iconOnlyBelow !== undefined && width < item.iconOnlyBelow) + if (item.type === "menu") return + if (item.type === "tabs") return + if (item.type === "switch") return + const toggle = item.type === "toggle" + return ( + { + if (toggle) { item.onPressedChange?.(!item.pressed); onCommand({ id: item.id, itemId: item.id, value: !item.pressed }) } + else { item.onSelect?.(); onCommand({ id: item.id, itemId: item.id }) } + }} /> + ) +} + +export interface SWViewerHeaderButtonProps extends Omit, "children"> { + /** Visible text, or the accessible name when `iconOnly`. */ + label: string + /** 16px icon supplied by the program. */ + icon?: ReactNode + /** + * `true`: always icon only, the 32 x 40 icon button (info, More). `"compact"`: the 34 x 34 command button shown + * without its label (what `iconOnlyBelow` does). Default false. + */ + iconOnly?: boolean | "compact" + /** Toggle state (aria-pressed, #FFC600 when on). Leave undefined for a command button. */ + pressed?: boolean +} + +/** + * The viewer header button for the `trailing` slot or a `custom` item (e.g. the info button that opens a modal + * SWViewerPanel). Same look as the data-driven items: transparent, #266651 hover, #FFC600 when pressed. + */ +export const SWViewerHeaderButton = forwardRef(function SWViewerHeaderButton( + { label, icon, iconOnly = false, pressed, className, type = "button", ...rest }, ref, +) { + const kind = pressed === undefined ? "button" : "toggle" + return ( + + ) +}) + +/** + * SW viewer-mode header (reference DWG/DXF viewer top menu): slanted brand cap → dark file-name well → one green + * object surface holding data-driven menu groups, with a trailing action area. Items move into the More dropdown + * by `collapseBelow` (measured header width, never horizontal scroll). Pure presentation: values are props, every + * action is a callback (`onCommand` plus each item's own). Use SWHeader for the normal SW mode. + */ +export const SWViewerHeader = forwardRef(function SWViewerHeader( + { + logo, logoSrc, logoAlt, onHomeClick, documentTitle, documentPlaceholder = "열린 도면 없음", documentTooltip, document: documentNode, + groups, trailing, onCommand, moreLabel = "추가 도구", toolbarLabel = "도면 작업 도구", className, ...rest + }, + forwardedRef, +) { + const rootRef = useRef(null) + const [width, setWidth] = useState(1920) + const gradientId = `hm-sw-viewer-brand-${useId().replace(/:/g, "")}` + useLayoutEffect(() => { + const root = rootRef.current + if (!root) return + const update = () => setWidth(root.getBoundingClientRect().width) + update() + const observer = new ResizeObserver(update) + observer.observe(root) + return () => observer.disconnect() + }, []) + const command = (value: SWViewerCommand) => onCommand?.(value) + const owners = new Map() + const more = moreEntries(groups, width, command, owners) + const moreRef = useRef(null) + const [moreOpen, setMoreOpen] = useState(false) + const [moreFocus, setMoreFocus] = useState<"first" | "menu">("menu") + const renderGroup = (group: SWViewerHeaderGroup) => { + const items = group.items.filter(item => isShown(item, width)) + if (group.hidden || !items.length) return null + return ( +
+ {items.map(item => )} +
+ ) + } + const start = groups.filter(group => group.placement !== "end") + const end = groups.filter(group => group.placement === "end") + const name = documentTitle || documentPlaceholder + const mark = logo ?? (logoSrc ? : null) + const brandContent = ( + <> + + {mark && } + + ) + return ( +
{ rootRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node }} + className={`hm-sw-theme hm-sw-viewer-header${className ? ` ${className}` : ""}`} {...rest}> + {onHomeClick + ? + :
{brandContent}
} +
+ {documentNode ?? {name}} +
+
+ {start.map(renderGroup)} + {more.length > 0 && <> + + { + const owner = owners.get(entry.id) + if (owner) command({ id: entry.id, itemId: owner, value: entry.type === "checkbox" ? !entry.checked : undefined }) + }} /> + } + {(end.length > 0 || trailing) &&
{end.map(renderGroup)}{trailing}
} +
+
+ ) +}) diff --git a/packages/sw/src/components/sw-viewer/SWViewerIcon.tsx b/packages/sw/src/components/sw-viewer/SWViewerIcon.tsx new file mode 100644 index 0000000..f027081 --- /dev/null +++ b/packages/sw/src/components/sw-viewer/SWViewerIcon.tsx @@ -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(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(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 ( + + ) +} diff --git a/packages/sw/src/components/sw-viewer/SWViewerMenu.tsx b/packages/sw/src/components/sw-viewer/SWViewerMenu.tsx new file mode 100644 index 0000000..958d043 --- /dev/null +++ b/packages/sw/src/components/sw-viewer/SWViewerMenu.tsx @@ -0,0 +1,226 @@ +import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react" +import type { SWViewerMenuEntry } from "./types" +import "../../theme/sw-viewer.css" + +export interface SWViewerMenuProps { + entries: SWViewerMenuEntry[] + open: boolean + onOpenChange: (open: boolean) => void + /** Element the menu opens below (a header button). Focus returns to it on close. */ + anchorRef?: RefObject + /** Opens at a point instead (context menu: the pointer position). */ + anchorPoint?: { x: number; y: number } + /** Edge aligned to the anchor. Default `start`. */ + align?: "start" | "end" + /** Width in px. Default: its content (min 160). */ + width?: number + /** `light` (default): the white dropdown face. `beige`: the More face of the viewer header. */ + surface?: "light" | "beige" + /** Accessible name of the menu. */ + label?: string + /** Called for every activated entry (after its own callback). */ + onEntrySelect?: (entry: SWViewerMenuEntry) => void + /** `first` (default): focus the first entry, as after Enter / ArrowDown. `menu`: focus the list, as after a click. */ + initialFocus?: "first" | "menu" + /** Id of the menu element (for the trigger's aria-controls). */ + id?: string + className?: string +} + +const ITEM_SELECTOR = ':scope > [role="none"] > [role^="menuitem"]:not([aria-disabled="true"])' + +function visible(entries: SWViewerMenuEntry[]) { + const shown = entries.filter(entry => !entry.hidden) + // No leading, trailing or doubled separators once hidden entries are gone. + return shown.filter((entry, index) => entry.type !== "separator" + || (index > 0 && index < shown.length - 1 && shown[index - 1].type !== "separator")) +} + +const CheckIcon = () => ( + +) +const ChevronRight = () => ( + +) + +interface ListProps { + entries: SWViewerMenuEntry[] + surface: "light" | "beige" + width?: number + label?: string + /** Fixed position computer: receives the list size, returns left/top. */ + place: (width: number, height: number) => { left: number; top: number } + /** Activate: run the entry and close everything (unless keepOpen). */ + activate: (entry: SWViewerMenuEntry) => void + /** Escape / Tab: close everything. */ + closeAll: (returnFocus: boolean) => void + /** ArrowLeft inside a submenu: close only this level. */ + closeLevel?: () => void + /** `first`: focus the first entry (keyboard open). `menu`: focus the list itself (pointer open). */ + autoFocus: "first" | "menu" | false + id?: string + className?: string +} + +function MenuList({ entries, surface, width, label, place, activate, closeAll, closeLevel, autoFocus, id, className }: ListProps) { + const ref = useRef(null) + const [position, setPosition] = useState<{ left: number; top: number } | null>(null) + const [openSub, setOpenSub] = useState(null) + const [focusSub, setFocusSub] = useState(false) + const placeRef = useRef(place) + placeRef.current = place + useLayoutEffect(() => { + const update = () => { + const list = ref.current + if (!list) return + const next = placeRef.current(list.offsetWidth, list.offsetHeight) + setPosition(current => (current && current.left === next.left && current.top === next.top ? current : next)) + } + update() + window.addEventListener("resize", update) + window.addEventListener("scroll", update, true) + return () => { window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true) } + }, []) + useEffect(() => { + if (!autoFocus || !position) return + const target = autoFocus === "first" ? ref.current?.querySelector(ITEM_SELECTOR) : ref.current + target?.focus({ preventScroll: true }) + }, [autoFocus, position !== null]) + + const items = () => Array.from(ref.current?.querySelectorAll(ITEM_SELECTOR) ?? []) + const onKeyDown = (event: KeyboardEvent) => { + // Only this level's own keys; a submenu inside handles (and stops) its own. + if (event.nativeEvent.isComposing) return + const list = items() + const index = list.indexOf(document.activeElement as HTMLElement) + const move = (next: number) => { event.preventDefault(); list[(next + list.length) % list.length]?.focus() } + switch (event.key) { + case "ArrowDown": move(index + 1); break + case "ArrowUp": move(index < 0 ? list.length - 1 : index - 1); break + case "Home": move(0); break + case "End": move(list.length - 1); break + case "ArrowLeft": + if (closeLevel) { event.preventDefault(); event.stopPropagation(); closeLevel() } + break + case "Escape": event.preventDefault(); event.stopPropagation(); closeAll(true); break + case "Tab": closeAll(false); break + default: return + } + event.stopPropagation() + } + + const rows: ReactNode[] = visible(entries).map(entry => { + if (entry.type === "separator") return
+ if (entry.type === "label") return
{entry.label}
+ const disabled = !!entry.disabled + const common = { + tabIndex: -1, + "aria-disabled": disabled || undefined, + "data-entry": entry.id, + onPointerMove: (event: { currentTarget: HTMLElement }) => { + if (disabled) return + if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true }) + if (entry.type !== "submenu" && openSub) setOpenSub(null) + }, + } + if (entry.type === "submenu") { + const expanded = openSub === entry.id + const openThis = (focus: boolean) => { if (disabled) return; setFocusSub(focus); setOpenSub(entry.id) } + return ( +
+ + {expanded && { setOpenSub(null); ref.current?.querySelector(`[data-entry="${CSS.escape(entry.id)}"]`)?.focus() }} + place={(w, h) => { + const row = ref.current?.querySelector(`[data-entry="${CSS.escape(entry.id)}"]`)?.getBoundingClientRect() + const vw = document.documentElement.clientWidth, vh = window.innerHeight + if (!row) return { left: 0, top: 0 } + // Beside the parent list (its outer edge), first entry level with the row; flips left at the screen edge. + const list = ref.current!.getBoundingClientRect() + const left = list.right + w <= vw - 4 ? list.right : Math.max(4, list.left - w) + return { left, top: Math.max(4, Math.min(row.top - 5, vh - h - 4)) } + }} />} +
+ ) + } + const role = entry.type === "checkbox" ? "menuitemcheckbox" : entry.type === "radio" ? "menuitemradio" : "menuitem" + const checked = entry.type === "item" ? undefined : entry.checked + return ( +
+ +
+ ) + }) + + return ( + + ) +} + +/** + * SW viewer dropdown (reference DWG/DXF viewer menu): fixed overlay below an anchor (or at a point), kept inside + * the viewport. Items, check / radio items, labels, separators and multi-level submenus come from `entries`. + * Keyboard: ArrowUp / ArrowDown / Home / End move, Enter / Space activate, ArrowRight / ArrowLeft open / close a + * submenu, Escape closes and returns focus to the anchor, Tab closes. A click outside closes. + */ +export function SWViewerMenu({ entries, open, onOpenChange, anchorRef, anchorPoint, align = "start", width, surface = "light", label, onEntrySelect, initialFocus = "first", id, className }: SWViewerMenuProps) { + const rootRef = useRef(null) + const changeRef = useRef(onOpenChange) + changeRef.current = onOpenChange + useEffect(() => { + if (!open) return + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node + if (rootRef.current?.contains(target) || anchorRef?.current?.contains(target)) return + changeRef.current(false) + } + document.addEventListener("pointerdown", onPointerDown, true) + return () => document.removeEventListener("pointerdown", onPointerDown, true) + }, [open, anchorRef]) + if (!open) return null + const closeAll = (returnFocus: boolean) => { + onOpenChange(false) + if (returnFocus) anchorRef?.current?.focus() + } + const activate = (entry: SWViewerMenuEntry) => { + if (entry.type === "item" || entry.type === "radio") entry.onSelect?.() + else if (entry.type === "checkbox") entry.onCheckedChange?.(!entry.checked) + onEntrySelect?.(entry) + if (!("keepOpen" in entry && entry.keepOpen)) closeAll(true) + } + const place = (w: number, h: number) => { + const vw = document.documentElement.clientWidth, vh = window.innerHeight + const box = anchorPoint ? { left: anchorPoint.x, right: anchorPoint.x, top: anchorPoint.y, bottom: anchorPoint.y } + : anchorRef?.current?.getBoundingClientRect() ?? { left: 0, right: 0, top: 0, bottom: 0 } + const start = align === "end" ? box.right - w : box.left + const left = Math.max(4, Math.min(start, vw - w - 4)) + // 4px under a button; exactly at the pointer for a context menu. + const gap = anchorPoint ? 0 : 4 + const below = box.bottom + gap + const top = below + h > vh - 4 && box.top - h - gap >= 4 ? box.top - h - gap : Math.max(4, Math.min(below, vh - h - 4)) + return { left, top } + } + return ( +
+ +
+ ) +} diff --git a/packages/sw/src/components/sw-viewer/SWViewerPanel.tsx b/packages/sw/src/components/sw-viewer/SWViewerPanel.tsx new file mode 100644 index 0000000..6988673 --- /dev/null +++ b/packages/sw/src/components/sw-viewer/SWViewerPanel.tsx @@ -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, "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 }) => ( + +) + +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) => { + 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) => { + 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) => { + onPointerUp?.(event) + if (!drag.current) return + drag.current = null + delete event.currentTarget.dataset.dragging + if (positionRef.current) onPositionChange?.(positionRef.current) + } + const toggleByTitle = (event: ReactMouseEvent) => { + 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) => { + 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) => { + 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) => { + 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) => { + onKeyDown?.(event) + const panel = element() + if (!modal || event.key !== "Tab" || event.defaultPrevented || !panel) return + const focusables = Array.from(panel.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])')) + if (!focusables.length) { event.preventDefault(); return } + const first = focusables[0], last = focusables[focusables.length - 1] + if (event.shiftKey && (document.activeElement === first || document.activeElement === 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 = ( + { raise(); onPointerDownCapture?.(event) }} + onFocusCapture={event => { raise(); onFocusCapture?.(event) }} + onPointerDown={startDrag} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={endDrag} onDoubleClick={toggleByTitle} + titleActions={(headerActions || collapsible) &&
+ {headerActions} + {collapsible && } +
} + footer={<> + {footer && !collapsed &&
{footer}
} + {resizable && !collapsed &&