sh-ui-cli 0.25.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -9
- package/data/changelog/versions.json +117 -1
- package/data/registry/react/components/code-editor/index.tsx +232 -0
- package/data/registry/react/components/code-editor/styles.css +76 -0
- package/data/registry/react/components/code-tabs/index.tsx +49 -0
- package/data/registry/react/components/header/index.tsx +632 -82
- package/data/registry/react/components/header/styles.css +169 -9
- package/data/registry/react/components/markdown-editor/index.tsx +121 -0
- package/data/registry/react/components/markdown-editor/styles.css +160 -0
- package/data/registry/react/components/page-toc/index.tsx +175 -0
- package/data/registry/react/components/page-toc/styles.css +82 -0
- package/data/registry/react/components/rich-text-editor/index.tsx +350 -0
- package/data/registry/react/components/rich-text-editor/styles.css +196 -0
- package/data/registry/react/registry.json +100 -0
- package/data/summaries/react.json +6 -1
- package/package.json +1 -1
- package/src/create/cli-args.js +1 -1
- package/src/create/index.mjs +1 -1
- package/src/create/plugins/authJwt.js +340 -0
- package/src/create/plugins/index.js +2 -1
- package/src/create/plugins/sentry.js +32 -280
- package/src/mcp.mjs +1 -2
- package/templates/flutter-standalone/README.md +2 -2
- package/templates/monorepo/README.md +1 -1
- package/templates/nextjs-app/app/api/proxy/[...path]/route.ts +31 -4
- package/templates/nextjs-app/package.json +0 -1
- package/templates/nextjs-app/src/app/providers/tanstack/QueryClientProvider.tsx +5 -18
- package/templates/nextjs-app/src/shared/api/clientFetch.ts +40 -0
- package/templates/nextjs-app/src/shared/api/http.ts +13 -56
- package/templates/nextjs-app/src/shared/api/observability.ts +20 -0
- package/templates/nextjs-app/src/shared/api/queryClient.ts +30 -0
- package/templates/nextjs-app/src/shared/api/serverFetch.ts +59 -0
- package/templates/nextjs-app/src/shared/hooks/useAppMutation.ts +52 -0
- package/templates/nextjs-standalone/README.md +3 -3
- package/templates/nextjs-standalone/app/api/proxy/[...path]/route.ts +31 -4
- package/templates/nextjs-standalone/package.json +0 -1
- package/templates/nextjs-standalone/src/app/providers/tanstack/QueryClientProvider.tsx +5 -18
- package/templates/nextjs-standalone/src/shared/api/clientFetch.ts +40 -0
- package/templates/nextjs-standalone/src/shared/api/http.ts +13 -56
- package/templates/nextjs-standalone/src/shared/api/observability.ts +20 -0
- package/templates/nextjs-standalone/src/shared/api/queryClient.ts +30 -0
- package/templates/nextjs-standalone/src/shared/api/serverFetch.ts +59 -0
- package/templates/nextjs-standalone/src/shared/hooks/useAppMutation.ts +52 -0
package/README.md
CHANGED
|
@@ -18,7 +18,6 @@ npx sh-ui-cli <command>
|
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
# 대화형
|
|
21
|
-
npm create sh-ui my-app
|
|
22
21
|
npx sh-ui-cli create
|
|
23
22
|
|
|
24
23
|
# 비대화형 (에이전트 / CI)
|
|
@@ -32,7 +31,7 @@ npx sh-ui-cli create my-app --platform flutter --yes
|
|
|
32
31
|
### init — 설정 파일 생성
|
|
33
32
|
|
|
34
33
|
```bash
|
|
35
|
-
npx sh-ui init
|
|
34
|
+
npx sh-ui-cli init
|
|
36
35
|
# 대화형 프롬프트:
|
|
37
36
|
# platform: react | flutter
|
|
38
37
|
# base: neutral | zinc | slate
|
|
@@ -43,27 +42,27 @@ npx sh-ui init
|
|
|
43
42
|
비대화형 예:
|
|
44
43
|
|
|
45
44
|
```bash
|
|
46
|
-
npx sh-ui init --platform react --base neutral --radius md --mode light-dark --yes
|
|
45
|
+
npx sh-ui-cli init --platform react --base neutral --radius md --mode light-dark --yes
|
|
47
46
|
```
|
|
48
47
|
|
|
49
48
|
### add — 컴포넌트 추가
|
|
50
49
|
|
|
51
50
|
```bash
|
|
52
|
-
npx sh-ui add button
|
|
53
|
-
npx sh-ui add card input
|
|
54
|
-
npx sh-ui add button --diff # 파일 변경 미리보기(실제 쓰지 않음)
|
|
51
|
+
npx sh-ui-cli add button
|
|
52
|
+
npx sh-ui-cli add card input
|
|
53
|
+
npx sh-ui-cli add button --diff # 파일 변경 미리보기(실제 쓰지 않음)
|
|
55
54
|
```
|
|
56
55
|
|
|
57
56
|
### list — 설치된 컴포넌트 목록
|
|
58
57
|
|
|
59
58
|
```bash
|
|
60
|
-
npx sh-ui list
|
|
59
|
+
npx sh-ui-cli list
|
|
61
60
|
```
|
|
62
61
|
|
|
63
62
|
### remove — 컴포넌트 제거
|
|
64
63
|
|
|
65
64
|
```bash
|
|
66
|
-
npx sh-ui remove button
|
|
65
|
+
npx sh-ui-cli remove button
|
|
67
66
|
```
|
|
68
67
|
|
|
69
68
|
### mcp — AI 에게 sh-ui 를 알려주기 (v0.21.0+)
|
|
@@ -85,7 +84,7 @@ npx -y sh-ui-cli mcp init --client claude-desktop # → 사용자 전역 (재
|
|
|
85
84
|
npx -y sh-ui-cli mcp init --client claude-code --scope user
|
|
86
85
|
```
|
|
87
86
|
|
|
88
|
-
> 참고: 위 다른 명령들의 `npx sh-ui ...` 표기는 `sh-ui-cli` 가 dev 의존성으로 설치된 상태를 가정한다. 빈 폴더에서 한 번에 쓰려면 동일하게 `npx -y sh-ui-cli ...` 로 호출.
|
|
87
|
+
> 참고: 위 다른 명령들의 `npx sh-ui-cli ...` 표기는 `sh-ui-cli` 가 dev 의존성으로 설치된 상태를 가정한다. 빈 폴더에서 한 번에 쓰려면 동일하게 `npx -y sh-ui-cli ...` 로 호출.
|
|
89
88
|
|
|
90
89
|
기존 설정 파일이 있으면 다른 MCP 서버 엔트리를 보존하며 `sh-ui` 만 머지·갱신.
|
|
91
90
|
|
|
@@ -2,6 +2,122 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
|
|
4
4
|
"versions": [
|
|
5
|
+
{
|
|
6
|
+
"version": "0.32.0",
|
|
7
|
+
"date": "2026-04-29",
|
|
8
|
+
"title": "auth-jwt 플러그인 신설 + isomorphic HTTP transport 재설계",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"신규 --plugins auth-jwt — Next 16 proxy.ts 미들웨어, refresh placeholder, withAuthRetry 헬퍼, refresh-aware BFF 를 한 번에 추가. 백엔드 refresh API 명세 확정 후 refreshSession.ts 본문만 채우면 자동 활성화",
|
|
12
|
+
"베이스 HTTP 레이어 재설계 — axios 단일 인스턴스를 isomorphic http() + serverFetch / clientFetch 두 갈래 transport 로 교체. RSC 는 백엔드 직통, 브라우저는 /api/proxy 경유 → API 함수는 한 번만 작성하고 RSC prefetch + hydration 패턴과 자연스럽게 어울림",
|
|
13
|
+
"Sentry 플러그인 슬림화 — HTTP/proxy 인프라 파일을 베이스로 이관하고, Sentry 는 observability.ts 를 Sentry-aware 버전으로 덮어써 5xx 캡처/로깅을 활성화. 두 플러그인이 독립적으로 조합됨",
|
|
14
|
+
"queryClient.ts 추가 — React cache() 기반 RSC 스코프 + 브라우저 싱글톤. 요청 간 캐시 누수 방지. apps/docs 의 auth / api-layer / testing 레시피도 새 설계에 맞춰 갱신"
|
|
15
|
+
],
|
|
16
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.32.0"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"version": "0.31.1",
|
|
20
|
+
"date": "2026-04-28",
|
|
21
|
+
"title": "Header drawer 그룹 라벨을 사이드바와 동일한 캡션 스타일로 정렬",
|
|
22
|
+
"type": "patch",
|
|
23
|
+
"highlights": [
|
|
24
|
+
"HeaderNavGroup 의 drawer 라벨이 uppercase + letter-spacing 으로 두드러져 같은 모바일 메뉴 안의 HeaderMenu 트리거(서브메뉴 헤더)와 시각적으로 헷갈리던 문제 해결",
|
|
25
|
+
"스타일을 Sidebar 의 group-label 과 동일하게 통일 — text-xs / weight-medium / foreground-muted, height 2rem, padding 0 var(--space-2) (uppercase·letter-spacing 제거)",
|
|
26
|
+
"그룹 라벨은 \"섹션 캡션\", 메뉴 트리거는 \"클릭 가능한 nav 항목\" 으로 역할이 명확히 갈라짐"
|
|
27
|
+
],
|
|
28
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.31.1"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"version": "0.31.0",
|
|
32
|
+
"date": "2026-04-28",
|
|
33
|
+
"title": "Editor 3종(Code/Markdown/RichText) uncontrolled 모드 추가",
|
|
34
|
+
"type": "minor",
|
|
35
|
+
"highlights": [
|
|
36
|
+
"CodeEditor / MarkdownEditor / RichTextEditor 모두 value 를 옵셔널로 만들고 defaultValue 를 추가 — 라우터·외부 상태와 동기화할 게 없는 폼이면 useState 없이 한 줄로 끝 (Tabs/RadioGroup/Header NavMatch 와 동일 패턴)",
|
|
37
|
+
"controlled (value 명시) 모드는 종전과 동일하게 동작 — 외부 동기화 effect 가 isControlled 일 때만 작동하도록 가드. uncontrolled 모드에서는 에디터(CodeMirror/Tiptap)가 자체 source-of-truth",
|
|
38
|
+
"onChange 는 controlled / uncontrolled 양 모드 모두에서 변경마다 호출 — 외부 관찰만 필요할 땐 onChange 만 받아도 OK",
|
|
39
|
+
"MarkdownEditor 는 미리보기 패널이 현재 값을 필요로 하므로 uncontrolled 모드에서도 자체 React state 로 트래킹"
|
|
40
|
+
],
|
|
41
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.31.0"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"version": "0.30.0",
|
|
45
|
+
"date": "2026-04-28",
|
|
46
|
+
"title": "PageTOC + CodeTabs registry 승격 — docs 인프라 컴포넌트 공개",
|
|
47
|
+
"type": "minor",
|
|
48
|
+
"highlights": [
|
|
49
|
+
"PageTOC 신규 — 페이지 안의 헤딩(기본 h2/h3)을 자동 스캔해 우측 레일에 목차(On this page)를 렌더. 자동 slugify·id 부여, IntersectionObserver 로 현재 보이는 섹션 active 표시(aria-current 자동), 클릭 시 smooth scroll. 라우터 비종속 — routeKey 로 라우트 변경 신호를 받아 재스캔하므로 Next.js / React Router / no-router 모두 지원. levels·containerSelector·excludeSelector·label·headerOffsetRem 으로 커스터마이즈",
|
|
50
|
+
"CodeTabs 신규 — 같은 예제의 여러 코드 뷰(예: React/Flutter, 또는 \"강조 / 전체 코드\")를 탭으로 전환. 각 탭은 그대로 CodePanel 로 렌더되어 shiki 하이라이팅·복사 버튼·파일명 헤더 등을 그대로 사용. registryDependencies = tabs · code-panel",
|
|
51
|
+
"두 컴포넌트 모두 그동안 docs 내부 전용으로 쓰이던 것을 일반화 — 이제 사용자 프로젝트에서 `npx sh-ui-cli add page-toc` / `npx sh-ui-cli add code-tabs` 로 설치 가능. /components 인덱스에도 등록"
|
|
52
|
+
],
|
|
53
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.30.0"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"version": "0.29.0",
|
|
57
|
+
"date": "2026-04-28",
|
|
58
|
+
"title": "Header — 서브메뉴·그룹·a11y·active 자동 매칭 보강 + variant/stickyHide 정식 도입",
|
|
59
|
+
"type": "minor",
|
|
60
|
+
"highlights": [
|
|
61
|
+
"a11y — drawer 가 열릴 때 focus trap · ESC 닫기 · 닫힐 때 트리거로 포커스 복원 자동화. drawer 패널에 role=dialog + aria-modal=true 부여",
|
|
62
|
+
"HeaderMenu / HeaderMenuTrigger / HeaderMenuContent — 데스크탑은 document.body 로 portal 된 dropdown(부모 overflow 클리핑 회피, 트리거 위치를 scroll/resize 마다 추종, 클릭 외부·ESC 닫기), 모바일 drawer 안에서는 collapsible 로 자동 전환. NavLocationContext 가 자식 위치를 전파해 한 자식 트리로 두 모드 처리",
|
|
63
|
+
"HeaderNavGroup(label) — drawer 안에서 nav 항목을 섹션으로 묶음(라벨 + 들여쓰기). inline 모드는 display:contents 로 자식만 평면 렌더, 데스크탑 레이아웃 무영향",
|
|
64
|
+
"HeaderDesktopOnly / HeaderMobileOnly — drawer 로 옮기지 않고 단순히 가시성만 토글하는 슬롯. display:contents 라 부모 flex 흐름 그대로 유지. 데스크탑 검색/로그인/마이페이지 같은 항목을 모바일에서 자체 drawer 트리거로 대체할 때 사용",
|
|
65
|
+
"active 자동 매칭 — HeaderNav 의 value(controlled) 또는 defaultValue+onValueChange(uncontrolled) 로 자식 HeaderItem 의 active 가 일괄 결정 (Tabs/RadioGroup 패턴). 라우터 앱은 value=pathname, 라우터 없는 위젯은 defaultValue 만으로 클릭 자동 추적. 기본 매칭은 exact 또는 prefix(root 제외), match 함수로 커스터마이즈, 개별 active prop 으로 override. 자동/수동 어느 쪽이든 data-active 와 aria-current=\"page\" 부여",
|
|
66
|
+
"variant=\"solid\"|\"transparent\"|\"blur\" 로 헤더 배경 표현 전환. blur 는 표준 글래스 헤더 수준의 85% opacity + saturate(180%) blur(16px), --sh-ui-header-blur-opacity / --sh-ui-header-blur-radius CSS 변수로 instance 별 조정. backdrop-filter 미지원 브라우저는 @supports 폴백으로 불투명 배경 자동 적용",
|
|
67
|
+
"stickyHide + stickyHideThreshold — 스크롤 다운 시 자동 숨김. 가장 가까운 스크롤 가능 조상을 자동 감지해 페이지 스크롤뿐 아니라 컨테이너 내부 스크롤에도 반응. prefers-reduced-motion: reduce 환경에서는 슬라이드 트랜지션 비활성화"
|
|
68
|
+
],
|
|
69
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.29.0"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"version": "0.28.1",
|
|
73
|
+
"date": "2026-04-28",
|
|
74
|
+
"title": "RichTextEditor — Link 버튼이 빈 선택에서 호출돼도 anchor 가 생기도록 수정",
|
|
75
|
+
"type": "patch",
|
|
76
|
+
"highlights": [
|
|
77
|
+
"Link 버튼: 텍스트 선택이 없고 기존 링크 위도 아닐 때 URL 자체를 anchor 텍스트로 삽입 — 빈 link 마크만 걸려 사용자가 추가로 타이핑해야 했던 어색한 상태 제거",
|
|
78
|
+
"선택이 있거나 기존 링크 위에서 호출하면 종전과 동일하게 selection·기존 링크에 setLink"
|
|
79
|
+
],
|
|
80
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.28.1"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"version": "0.28.0",
|
|
84
|
+
"date": "2026-04-28",
|
|
85
|
+
"title": "RichTextEditor 컴포넌트 — Tiptap 3 + sh-ui 토큰 테마 + 기본 toolbar",
|
|
86
|
+
"type": "minor",
|
|
87
|
+
"highlights": [
|
|
88
|
+
"RichTextEditor 신규 — Tiptap 3 (StarterKit + Placeholder + Link) 위에 sh-ui 토큰 테마와 기본 toolbar 를 얹은 controlled WYSIWYG. value 는 HTML 문자열로 입출력",
|
|
89
|
+
"툴바: 헤딩(H1~H3) · 리스트(불릿/숫자) · 인용 · 코드(인라인/블록) · 링크 · 강조(B/I/S) · 구분선 · undo/redo. 본문 포커스를 잃지 않도록 mousedown 가로채기",
|
|
90
|
+
"SSR 안전 — `immediatelyRender: false` 로 Next 15+ stricter SSR 환경에서 hydration mismatch 없음. readOnly·hideToolbar·minHeight/maxHeight prop 지원",
|
|
91
|
+
"추가 확장(이미지·테이블·멘션 등)은 registry 카피본을 직접 수정해 `extensions` 배열에 끼워 넣는 방식으로 확장"
|
|
92
|
+
],
|
|
93
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.28.0"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"version": "0.27.0",
|
|
97
|
+
"date": "2026-04-28",
|
|
98
|
+
"title": "MarkdownEditor 컴포넌트 — CodeEditor + react-markdown 라이브 프리뷰",
|
|
99
|
+
"type": "minor",
|
|
100
|
+
"highlights": [
|
|
101
|
+
"MarkdownEditor 신규 — CodeEditor(소스) + react-markdown(미리보기) 합성, GFM(테이블·체크리스트·strikethrough) 지원",
|
|
102
|
+
"raw HTML 은 react-markdown 기본 동작으로 차단 — 사용자 입력 마크다운으로부터의 XSS 자동 방어",
|
|
103
|
+
"previewPosition: right(기본) / bottom — 좁은 화면(<768px)에서는 자동으로 위·아래로 쌓임. preview={false} 로 미리보기를 끄면 단순 마크다운 입력기로 사용",
|
|
104
|
+
"registry dependency = code-editor — `npx sh-ui-cli add markdown-editor` 시 code-editor 도 함께 설치"
|
|
105
|
+
],
|
|
106
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.27.0"
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"version": "0.26.0",
|
|
110
|
+
"date": "2026-04-28",
|
|
111
|
+
"title": "CodeEditor 컴포넌트 — CodeMirror 6 + sh-ui 토큰 테마",
|
|
112
|
+
"type": "minor",
|
|
113
|
+
"highlights": [
|
|
114
|
+
"CodeEditor 신규 — CodeMirror 6 basicSetup 기반 controlled 인라인 코드 에디터 (value/onChange · placeholder · readOnly · showLineNumbers · minHeight/maxHeight)",
|
|
115
|
+
"지원 언어: text · javascript · typescript · jsx · tsx · json · css · html · markdown — 런타임 변경은 Compartment 로 hot-swap",
|
|
116
|
+
"컬러·여백을 sh-ui 토큰(--background/--foreground/--border/--background-muted 등)으로 매핑 — `.dark` 스코프에서 다크 테마 자동 추종",
|
|
117
|
+
"CLI 레지스트리에 `npx sh-ui-cli add code-editor` 등록, docs 사이드바 Components 섹션에 진입점 추가"
|
|
118
|
+
],
|
|
119
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.26.0"
|
|
120
|
+
},
|
|
5
121
|
{
|
|
6
122
|
"version": "0.25.0",
|
|
7
123
|
"date": "2026-04-28",
|
|
@@ -87,7 +203,7 @@
|
|
|
87
203
|
"sh-ui mcp init --client <claude-code|cursor|claude-desktop> 추가 — IDE 별 MCP 설정 파일을 자동으로 찾아 sh-ui 엔트리 머지",
|
|
88
204
|
"스코프 분기 — 기본 project(.mcp.json / .cursor/mcp.json), --scope user 로 전역(~/.claude/mcp.json 등) 선택 가능. claude-desktop 은 user 전용(OS 별 경로 자동 분기)",
|
|
89
205
|
"기존 JSON 보존 — 다른 MCP 서버 엔트리·기타 키를 건드리지 않고 sh-ui 만 머지·갱신",
|
|
90
|
-
"수동 JSON 편집 없이 npx sh-ui mcp init --client cursor 한 줄로 끝"
|
|
206
|
+
"수동 JSON 편집 없이 npx sh-ui-cli mcp init --client cursor 한 줄로 끝"
|
|
91
207
|
],
|
|
92
208
|
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.22.0"
|
|
93
209
|
},
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
4
|
+
import { Compartment, EditorState, type Extension } from "@codemirror/state";
|
|
5
|
+
import { EditorView, placeholder as placeholderExt } from "@codemirror/view";
|
|
6
|
+
import { basicSetup } from "codemirror";
|
|
7
|
+
import { javascript } from "@codemirror/lang-javascript";
|
|
8
|
+
import { json } from "@codemirror/lang-json";
|
|
9
|
+
import { css as cssLang } from "@codemirror/lang-css";
|
|
10
|
+
import { html } from "@codemirror/lang-html";
|
|
11
|
+
import { markdown } from "@codemirror/lang-markdown";
|
|
12
|
+
import "./styles.css";
|
|
13
|
+
|
|
14
|
+
export type CodeEditorLanguage =
|
|
15
|
+
| "text"
|
|
16
|
+
| "javascript"
|
|
17
|
+
| "typescript"
|
|
18
|
+
| "jsx"
|
|
19
|
+
| "tsx"
|
|
20
|
+
| "json"
|
|
21
|
+
| "css"
|
|
22
|
+
| "html"
|
|
23
|
+
| "markdown";
|
|
24
|
+
|
|
25
|
+
export interface CodeEditorProps {
|
|
26
|
+
/**
|
|
27
|
+
* Controlled — 현재 코드. 명시 시 value 가 진실원천이 되고 onChange 로 외부에서 갱신해야 한다.
|
|
28
|
+
* 미지정이면 uncontrolled — 에디터가 자체 내부 문서로 동작.
|
|
29
|
+
*/
|
|
30
|
+
value?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Uncontrolled 초기값. value 미지정 시에만 사용된다.
|
|
33
|
+
* @default ""
|
|
34
|
+
*/
|
|
35
|
+
defaultValue?: string;
|
|
36
|
+
/** 코드가 바뀔 때마다 호출 (controlled · uncontrolled 모두). */
|
|
37
|
+
onChange?: (value: string) => void;
|
|
38
|
+
/**
|
|
39
|
+
* 신택스 하이라이팅 언어.
|
|
40
|
+
* @default "text"
|
|
41
|
+
*/
|
|
42
|
+
language?: CodeEditorLanguage;
|
|
43
|
+
/** 비어 있을 때 표시할 placeholder. */
|
|
44
|
+
placeholder?: string;
|
|
45
|
+
/** 읽기 전용. 키 입력은 막지만 선택·복사는 가능. */
|
|
46
|
+
readOnly?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* 좌측 줄 번호 표시 여부.
|
|
49
|
+
* @default true
|
|
50
|
+
*/
|
|
51
|
+
showLineNumbers?: boolean;
|
|
52
|
+
/** 에디터 최소 높이 (CSS 길이 단위). */
|
|
53
|
+
minHeight?: string;
|
|
54
|
+
/** 에디터 최대 높이 (CSS 길이 단위). 초과 시 내부 스크롤. */
|
|
55
|
+
maxHeight?: string;
|
|
56
|
+
className?: string;
|
|
57
|
+
id?: string;
|
|
58
|
+
"aria-label"?: string;
|
|
59
|
+
"aria-labelledby"?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cx(...args: (string | undefined | false | null)[]) {
|
|
63
|
+
return args.filter(Boolean).join(" ");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function languageExtension(language: CodeEditorLanguage): Extension {
|
|
67
|
+
switch (language) {
|
|
68
|
+
case "javascript":
|
|
69
|
+
return javascript();
|
|
70
|
+
case "typescript":
|
|
71
|
+
return javascript({ typescript: true });
|
|
72
|
+
case "jsx":
|
|
73
|
+
return javascript({ jsx: true });
|
|
74
|
+
case "tsx":
|
|
75
|
+
return javascript({ jsx: true, typescript: true });
|
|
76
|
+
case "json":
|
|
77
|
+
return json();
|
|
78
|
+
case "css":
|
|
79
|
+
return cssLang();
|
|
80
|
+
case "html":
|
|
81
|
+
return html();
|
|
82
|
+
case "markdown":
|
|
83
|
+
return markdown();
|
|
84
|
+
case "text":
|
|
85
|
+
default:
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* CodeMirror 6 기반 인라인 코드 에디터.
|
|
92
|
+
*
|
|
93
|
+
* Controlled (value/onChange) · Uncontrolled (defaultValue + 선택 onChange) 모두 지원.
|
|
94
|
+
* 라우터·외부 상태와 동기화할 게 없는 경우 defaultValue 한 줄로 끝 — useState 불필요.
|
|
95
|
+
*
|
|
96
|
+
* 신택스 하이라이팅·자동 들여쓰기·괄호 매칭 등은 CodeMirror `basicSetup` 을 그대로 사용,
|
|
97
|
+
* 컬러·여백은 sh-ui 토큰(`--background`, `--foreground`, `--border` 등)으로 매핑돼 테마에 자동 추종.
|
|
98
|
+
*/
|
|
99
|
+
export function CodeEditor({
|
|
100
|
+
value: valueProp,
|
|
101
|
+
defaultValue,
|
|
102
|
+
onChange,
|
|
103
|
+
language = "text",
|
|
104
|
+
placeholder,
|
|
105
|
+
readOnly = false,
|
|
106
|
+
showLineNumbers = true,
|
|
107
|
+
minHeight,
|
|
108
|
+
maxHeight,
|
|
109
|
+
className,
|
|
110
|
+
id,
|
|
111
|
+
"aria-label": ariaLabel,
|
|
112
|
+
"aria-labelledby": ariaLabelledBy,
|
|
113
|
+
}: CodeEditorProps) {
|
|
114
|
+
const isControlled = valueProp !== undefined;
|
|
115
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
116
|
+
const viewRef = useRef<EditorView | null>(null);
|
|
117
|
+
const onChangeRef = useRef(onChange);
|
|
118
|
+
onChangeRef.current = onChange;
|
|
119
|
+
const initialDocRef = useRef(valueProp ?? defaultValue ?? "");
|
|
120
|
+
|
|
121
|
+
const compartments = useMemo(
|
|
122
|
+
() => ({
|
|
123
|
+
language: new Compartment(),
|
|
124
|
+
readOnly: new Compartment(),
|
|
125
|
+
lineNumbers: new Compartment(),
|
|
126
|
+
placeholder: new Compartment(),
|
|
127
|
+
}),
|
|
128
|
+
[],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!hostRef.current) return;
|
|
133
|
+
|
|
134
|
+
const extensions: Extension[] = [
|
|
135
|
+
basicSetup,
|
|
136
|
+
EditorView.lineWrapping,
|
|
137
|
+
EditorView.updateListener.of((update) => {
|
|
138
|
+
if (update.docChanged) {
|
|
139
|
+
onChangeRef.current?.(update.state.doc.toString());
|
|
140
|
+
}
|
|
141
|
+
}),
|
|
142
|
+
compartments.language.of(languageExtension(language)),
|
|
143
|
+
compartments.readOnly.of(EditorState.readOnly.of(readOnly)),
|
|
144
|
+
compartments.lineNumbers.of(
|
|
145
|
+
showLineNumbers
|
|
146
|
+
? []
|
|
147
|
+
: EditorView.theme({ ".cm-gutters": { display: "none" } }),
|
|
148
|
+
),
|
|
149
|
+
compartments.placeholder.of(placeholder ? placeholderExt(placeholder) : []),
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
const view = new EditorView({
|
|
153
|
+
state: EditorState.create({ doc: initialDocRef.current, extensions }),
|
|
154
|
+
parent: hostRef.current,
|
|
155
|
+
});
|
|
156
|
+
viewRef.current = view;
|
|
157
|
+
|
|
158
|
+
return () => {
|
|
159
|
+
view.destroy();
|
|
160
|
+
viewRef.current = null;
|
|
161
|
+
};
|
|
162
|
+
// 초기 마운트 1회만 — 후속 동기화는 별도 이펙트가 처리
|
|
163
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
164
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
// controlled 모드에서만 외부 value 를 에디터 doc 에 동기화. uncontrolled 면 에디터가 자체 source-of-truth.
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!isControlled) return;
|
|
169
|
+
const view = viewRef.current;
|
|
170
|
+
if (!view) return;
|
|
171
|
+
const current = view.state.doc.toString();
|
|
172
|
+
if (current === valueProp) return;
|
|
173
|
+
view.dispatch({
|
|
174
|
+
changes: { from: 0, to: current.length, insert: valueProp ?? "" },
|
|
175
|
+
});
|
|
176
|
+
}, [isControlled, valueProp]);
|
|
177
|
+
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
viewRef.current?.dispatch({
|
|
180
|
+
effects: compartments.language.reconfigure(languageExtension(language)),
|
|
181
|
+
});
|
|
182
|
+
}, [language, compartments.language]);
|
|
183
|
+
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
viewRef.current?.dispatch({
|
|
186
|
+
effects: compartments.readOnly.reconfigure(EditorState.readOnly.of(readOnly)),
|
|
187
|
+
});
|
|
188
|
+
}, [readOnly, compartments.readOnly]);
|
|
189
|
+
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
viewRef.current?.dispatch({
|
|
192
|
+
effects: compartments.lineNumbers.reconfigure(
|
|
193
|
+
showLineNumbers
|
|
194
|
+
? []
|
|
195
|
+
: EditorView.theme({ ".cm-gutters": { display: "none" } }),
|
|
196
|
+
),
|
|
197
|
+
});
|
|
198
|
+
}, [showLineNumbers, compartments.lineNumbers]);
|
|
199
|
+
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
viewRef.current?.dispatch({
|
|
202
|
+
effects: compartments.placeholder.reconfigure(
|
|
203
|
+
placeholder ? placeholderExt(placeholder) : [],
|
|
204
|
+
),
|
|
205
|
+
});
|
|
206
|
+
}, [placeholder, compartments.placeholder]);
|
|
207
|
+
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
const view = viewRef.current;
|
|
210
|
+
if (!view) return;
|
|
211
|
+
const node = view.contentDOM;
|
|
212
|
+
if (id) node.id = id;
|
|
213
|
+
if (ariaLabel) node.setAttribute("aria-label", ariaLabel);
|
|
214
|
+
else node.removeAttribute("aria-label");
|
|
215
|
+
if (ariaLabelledBy) node.setAttribute("aria-labelledby", ariaLabelledBy);
|
|
216
|
+
else node.removeAttribute("aria-labelledby");
|
|
217
|
+
}, [id, ariaLabel, ariaLabelledBy]);
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<div
|
|
221
|
+
ref={hostRef}
|
|
222
|
+
className={cx("sh-ui-code-editor", className)}
|
|
223
|
+
data-readonly={readOnly || undefined}
|
|
224
|
+
style={
|
|
225
|
+
{
|
|
226
|
+
"--sh-ui-code-editor-min-height": minHeight,
|
|
227
|
+
"--sh-ui-code-editor-max-height": maxHeight,
|
|
228
|
+
} as React.CSSProperties
|
|
229
|
+
}
|
|
230
|
+
/>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
.sh-ui-code-editor {
|
|
2
|
+
position: relative;
|
|
3
|
+
border: 1px solid var(--border);
|
|
4
|
+
border-radius: var(--radius);
|
|
5
|
+
background: var(--background);
|
|
6
|
+
font-size: 0.8125rem;
|
|
7
|
+
line-height: 1.6;
|
|
8
|
+
overflow: hidden;
|
|
9
|
+
transition: border-color var(--duration-fast);
|
|
10
|
+
}
|
|
11
|
+
.sh-ui-code-editor:focus-within {
|
|
12
|
+
border-color: var(--foreground);
|
|
13
|
+
outline: var(--border-width-strong) solid var(--foreground);
|
|
14
|
+
outline-offset: 2px;
|
|
15
|
+
}
|
|
16
|
+
.sh-ui-code-editor[data-readonly] {
|
|
17
|
+
background: var(--background-subtle);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.sh-ui-code-editor .cm-editor {
|
|
21
|
+
background: transparent;
|
|
22
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
23
|
+
}
|
|
24
|
+
.sh-ui-code-editor .cm-editor.cm-focused {
|
|
25
|
+
outline: none;
|
|
26
|
+
}
|
|
27
|
+
.sh-ui-code-editor .cm-scroller {
|
|
28
|
+
font-family: inherit;
|
|
29
|
+
min-height: var(--sh-ui-code-editor-min-height, 7.5rem);
|
|
30
|
+
max-height: var(--sh-ui-code-editor-max-height, 25rem);
|
|
31
|
+
}
|
|
32
|
+
.sh-ui-code-editor .cm-content {
|
|
33
|
+
caret-color: var(--foreground);
|
|
34
|
+
color: var(--foreground);
|
|
35
|
+
padding: var(--space-3) 0;
|
|
36
|
+
}
|
|
37
|
+
.sh-ui-code-editor .cm-line {
|
|
38
|
+
padding: 0 var(--space-3);
|
|
39
|
+
}
|
|
40
|
+
.sh-ui-code-editor .cm-gutters {
|
|
41
|
+
background: var(--background-subtle);
|
|
42
|
+
color: var(--foreground-muted);
|
|
43
|
+
border-right: 1px solid var(--border);
|
|
44
|
+
}
|
|
45
|
+
.sh-ui-code-editor .cm-activeLineGutter,
|
|
46
|
+
.sh-ui-code-editor .cm-activeLine {
|
|
47
|
+
background: var(--background-muted);
|
|
48
|
+
}
|
|
49
|
+
.sh-ui-code-editor .cm-cursor,
|
|
50
|
+
.sh-ui-code-editor .cm-dropCursor {
|
|
51
|
+
border-left-color: var(--foreground);
|
|
52
|
+
}
|
|
53
|
+
.sh-ui-code-editor .cm-selectionBackground,
|
|
54
|
+
.sh-ui-code-editor .cm-editor .cm-selectionBackground,
|
|
55
|
+
.sh-ui-code-editor .cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
|
|
56
|
+
.sh-ui-code-editor ::selection {
|
|
57
|
+
background: var(--background-muted) !important;
|
|
58
|
+
}
|
|
59
|
+
.sh-ui-code-editor .cm-placeholder {
|
|
60
|
+
color: var(--foreground-muted);
|
|
61
|
+
}
|
|
62
|
+
.sh-ui-code-editor .cm-tooltip {
|
|
63
|
+
background: var(--background);
|
|
64
|
+
border: 1px solid var(--border);
|
|
65
|
+
color: var(--foreground);
|
|
66
|
+
border-radius: calc(var(--radius) - 2px);
|
|
67
|
+
}
|
|
68
|
+
.sh-ui-code-editor .cm-tooltip-autocomplete > ul > li[aria-selected] {
|
|
69
|
+
background: var(--background-muted);
|
|
70
|
+
color: var(--foreground);
|
|
71
|
+
}
|
|
72
|
+
.sh-ui-code-editor .cm-matchingBracket,
|
|
73
|
+
.sh-ui-code-editor .cm-nonmatchingBracket {
|
|
74
|
+
background: var(--background-muted);
|
|
75
|
+
color: var(--foreground);
|
|
76
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { CodePanel, type CodePanelProps } from "../code-panel";
|
|
2
|
+
import {
|
|
3
|
+
Tabs,
|
|
4
|
+
TabsContent,
|
|
5
|
+
TabsIndicator,
|
|
6
|
+
TabsList,
|
|
7
|
+
TabsTrigger,
|
|
8
|
+
} from "../tabs";
|
|
9
|
+
|
|
10
|
+
export interface CodeTabsItem extends Omit<CodePanelProps, "code"> {
|
|
11
|
+
/** 탭 식별자 (Tabs 의 value 와 동일). */
|
|
12
|
+
value: string;
|
|
13
|
+
/** 탭 트리거에 표시될 라벨. */
|
|
14
|
+
label: string;
|
|
15
|
+
/** 표시할 코드. */
|
|
16
|
+
code: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CodeTabsProps {
|
|
20
|
+
items: CodeTabsItem[];
|
|
21
|
+
/** 초기 활성 탭 value. 미지정 시 첫 번째 항목. */
|
|
22
|
+
defaultValue?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 같은 예제의 여러 코드 뷰(예: React / Flutter, 또는 "강조 부분 / 전체 코드") 를 탭으로 전환.
|
|
27
|
+
* 각 탭의 내용은 그대로 `CodePanel` 로 렌더되므로 shiki 하이라이팅 · 복사 버튼 · 파일명 헤더
|
|
28
|
+
* 등을 그대로 사용할 수 있다.
|
|
29
|
+
*/
|
|
30
|
+
export function CodeTabs({ items, defaultValue }: CodeTabsProps) {
|
|
31
|
+
const initial = defaultValue ?? items[0]?.value;
|
|
32
|
+
return (
|
|
33
|
+
<Tabs defaultValue={initial}>
|
|
34
|
+
<TabsList>
|
|
35
|
+
<TabsIndicator />
|
|
36
|
+
{items.map((item) => (
|
|
37
|
+
<TabsTrigger key={item.value} value={item.value}>
|
|
38
|
+
{item.label}
|
|
39
|
+
</TabsTrigger>
|
|
40
|
+
))}
|
|
41
|
+
</TabsList>
|
|
42
|
+
{items.map(({ value, label: _label, ...panel }) => (
|
|
43
|
+
<TabsContent key={value} value={value}>
|
|
44
|
+
<CodePanel {...panel} />
|
|
45
|
+
</TabsContent>
|
|
46
|
+
))}
|
|
47
|
+
</Tabs>
|
|
48
|
+
);
|
|
49
|
+
}
|