sh-ui-cli 0.116.0 → 0.117.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/data/changelog/versions.json +12 -0
- package/data/registry/flutter/registry.json +9 -0
- package/data/registry/flutter/widgets/sh_ui_tree.dart +428 -0
- package/data/registry/react/components/rich-text-editor/index.module.tsx +98 -8
- package/data/registry/react/components/rich-text-editor/index.tailwind.tsx +87 -4
- package/data/registry/react/components/rich-text-editor/index.tsx +98 -8
- package/data/registry/react/components/rich-text-editor/rich-text-editor.test.tsx +283 -0
- package/data/registry/react/components/tree/flatten.test.ts +72 -0
- package/data/registry/react/components/tree/flatten.ts +69 -0
- package/data/registry/react/components/tree/index.module.tsx +215 -0
- package/data/registry/react/components/tree/index.tailwind.tsx +224 -0
- package/data/registry/react/components/tree/index.tsx +215 -0
- package/data/registry/react/components/tree/styles.css +69 -0
- package/data/registry/react/components/tree/styles.module.css +69 -0
- package/data/registry/react/components/tree/tree.test.tsx +110 -0
- package/data/registry/react/components/tree/types.ts +36 -0
- package/data/registry/react/peer-versions.json +9 -1
- package/data/registry/react/registry.json +45 -0
- package/data/registry/react/tokens-used.json +40 -1
- package/data/summaries/react.json +2 -1
- package/package.json +3 -3
|
@@ -2,6 +2,18 @@
|
|
|
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.117.0",
|
|
7
|
+
"date": "2026-06-18",
|
|
8
|
+
"title": "Tree 컴포넌트 — 계층 데이터 확장·선택·키보드 네비",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"신규 Tree — 데이터 주도(nodes 배열) 계층 트리. 확장/축소 + 단일 선택(제어·비제어)",
|
|
12
|
+
"WAI-ARIA Tree 패턴 — role=tree/treeitem/group, 화살표·Home/End·Enter·typeahead 키보드 네비",
|
|
13
|
+
"React(plain·tailwind·css-modules) + Flutter(ShUiTree) 동시 제공. 풀 기능(다중선택·DnD·lazy·인라인편집)은 후속"
|
|
14
|
+
],
|
|
15
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.117.0"
|
|
16
|
+
},
|
|
5
17
|
{
|
|
6
18
|
"version": "0.116.0",
|
|
7
19
|
"date": "2026-06-17",
|
|
@@ -331,6 +331,15 @@
|
|
|
331
331
|
],
|
|
332
332
|
"dependencies": [],
|
|
333
333
|
"registryDependencies": ["tokens"]
|
|
334
|
+
},
|
|
335
|
+
"tree": {
|
|
336
|
+
"name": "tree",
|
|
337
|
+
"type": "widget",
|
|
338
|
+
"files": [
|
|
339
|
+
{ "src": "widgets/sh_ui_tree.dart", "dest": "{widgets}/sh_ui_tree.dart" }
|
|
340
|
+
],
|
|
341
|
+
"dependencies": [],
|
|
342
|
+
"registryDependencies": ["tokens"]
|
|
334
343
|
}
|
|
335
344
|
}
|
|
336
345
|
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import 'package:flutter/material.dart';
|
|
2
|
+
import 'package:flutter/services.dart';
|
|
3
|
+
import '../foundation/sh_ui_tokens.dart';
|
|
4
|
+
|
|
5
|
+
/// Tree 밀도. [sm]은 좁은 사이드바용 컴팩트 변형.
|
|
6
|
+
enum ShUiTreeSize { sm, md }
|
|
7
|
+
|
|
8
|
+
/// Tree 노드 모델.
|
|
9
|
+
class ShUiTreeNode {
|
|
10
|
+
/// 고유 식별자. 확장/선택 상태 추적에 사용된다.
|
|
11
|
+
final String id;
|
|
12
|
+
|
|
13
|
+
/// 행에 표시될 라벨.
|
|
14
|
+
final String label;
|
|
15
|
+
|
|
16
|
+
/// 하위 노드 목록 (선택). 비어있지 않으면 확장 가능한 부모로 취급된다.
|
|
17
|
+
final List<ShUiTreeNode>? children;
|
|
18
|
+
|
|
19
|
+
/// 라벨 왼쪽에 붙는 아이콘 (선택).
|
|
20
|
+
final IconData? icon;
|
|
21
|
+
|
|
22
|
+
/// 비활성화 상태. 선택·확장되지 않고 키보드 네비에서도 건너뛴다.
|
|
23
|
+
final bool disabled;
|
|
24
|
+
|
|
25
|
+
const ShUiTreeNode({
|
|
26
|
+
required this.id,
|
|
27
|
+
required this.label,
|
|
28
|
+
this.children,
|
|
29
|
+
this.icon,
|
|
30
|
+
this.disabled = false,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
bool get hasChildren => children != null && children!.isNotEmpty;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// shUi Tree — 계층형 노드를 들여쓰기로 렌더하는 트리 뷰.
|
|
37
|
+
///
|
|
38
|
+
/// 확장/축소(부모 노드 탭), 단일 선택, disabled 노드 스킵, Semantics(스크린
|
|
39
|
+
/// 리더) 및 데스크탑/웹 키보드 네비(화살표·Enter/Space)를 지원한다.
|
|
40
|
+
///
|
|
41
|
+
/// 확장 상태는 [expandedIds]를 주면 controlled, 없으면 [defaultExpandedIds]로
|
|
42
|
+
/// 시드한 내부 상태로 동작한다. 선택 상태도 [selectedId] / [defaultSelectedId]로
|
|
43
|
+
/// 동일하게 controlled/uncontrolled를 가른다.
|
|
44
|
+
class ShUiTree extends StatefulWidget {
|
|
45
|
+
/// 렌더할 트리 노드 배열.
|
|
46
|
+
final List<ShUiTreeNode> nodes;
|
|
47
|
+
|
|
48
|
+
/// 확장된 노드 id 집합 (controlled). null이면 내부 상태를 사용한다.
|
|
49
|
+
final Set<String>? expandedIds;
|
|
50
|
+
|
|
51
|
+
/// 초기 확장 노드 id 집합 (uncontrolled).
|
|
52
|
+
final Set<String>? defaultExpandedIds;
|
|
53
|
+
|
|
54
|
+
/// 확장 상태 변경 콜백. 변경 후의 확장 id 집합이 전달된다.
|
|
55
|
+
final ValueChanged<Set<String>>? onExpandedChange;
|
|
56
|
+
|
|
57
|
+
/// 선택된 노드 id (controlled). null이면 내부 상태를 사용한다.
|
|
58
|
+
final String? selectedId;
|
|
59
|
+
|
|
60
|
+
/// 초기 선택 노드 id (uncontrolled).
|
|
61
|
+
final String? defaultSelectedId;
|
|
62
|
+
|
|
63
|
+
/// 선택 변경 콜백. 선택된 노드 id(또는 null)가 전달된다.
|
|
64
|
+
final ValueChanged<String?>? onSelect;
|
|
65
|
+
|
|
66
|
+
/// 밀도. [ShUiTreeSize.sm]은 좁은 사이드바용 컴팩트 변형.
|
|
67
|
+
final ShUiTreeSize size;
|
|
68
|
+
|
|
69
|
+
const ShUiTree({
|
|
70
|
+
super.key,
|
|
71
|
+
required this.nodes,
|
|
72
|
+
this.expandedIds,
|
|
73
|
+
this.defaultExpandedIds,
|
|
74
|
+
this.onExpandedChange,
|
|
75
|
+
this.selectedId,
|
|
76
|
+
this.defaultSelectedId,
|
|
77
|
+
this.onSelect,
|
|
78
|
+
this.size = ShUiTreeSize.md,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
@override
|
|
82
|
+
State<ShUiTree> createState() => _ShUiTreeState();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// 평탄화된 한 행의 메타.
|
|
86
|
+
class _FlatRow {
|
|
87
|
+
final ShUiTreeNode node;
|
|
88
|
+
final int level;
|
|
89
|
+
const _FlatRow(this.node, this.level);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
class _ShUiTreeState extends State<ShUiTree> {
|
|
93
|
+
late Set<String> _expanded;
|
|
94
|
+
String? _selected;
|
|
95
|
+
final FocusNode _focusNode = FocusNode();
|
|
96
|
+
String? _focusedId;
|
|
97
|
+
|
|
98
|
+
/// build에서 한 번 계산해 둔, 현재 보이는 행들. 키 이벤트는 build 이후에
|
|
99
|
+
/// 발생하므로 키 핸들러가 이 값을 그대로 재사용해 _flatten() 중복 호출을 피한다.
|
|
100
|
+
List<_FlatRow> _visibleRows = const [];
|
|
101
|
+
|
|
102
|
+
bool get _expandedControlled => widget.expandedIds != null;
|
|
103
|
+
bool get _selectedControlled => widget.selectedId != null;
|
|
104
|
+
|
|
105
|
+
Set<String> get _effectiveExpanded =>
|
|
106
|
+
_expandedControlled ? widget.expandedIds! : _expanded;
|
|
107
|
+
String? get _effectiveSelected =>
|
|
108
|
+
_selectedControlled ? widget.selectedId : _selected;
|
|
109
|
+
|
|
110
|
+
@override
|
|
111
|
+
void initState() {
|
|
112
|
+
super.initState();
|
|
113
|
+
_expanded = {...?widget.defaultExpandedIds};
|
|
114
|
+
_selected = widget.defaultSelectedId;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@override
|
|
118
|
+
void dispose() {
|
|
119
|
+
_focusNode.dispose();
|
|
120
|
+
super.dispose();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// 현재 확장 상태 기준으로 보이는 행만 평탄화한다.
|
|
124
|
+
List<_FlatRow> _flatten() {
|
|
125
|
+
final rows = <_FlatRow>[];
|
|
126
|
+
final expanded = _effectiveExpanded;
|
|
127
|
+
void walk(List<ShUiTreeNode> nodes, int level) {
|
|
128
|
+
for (final node in nodes) {
|
|
129
|
+
rows.add(_FlatRow(node, level));
|
|
130
|
+
if (node.hasChildren && expanded.contains(node.id)) {
|
|
131
|
+
walk(node.children!, level + 1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
walk(widget.nodes, 0);
|
|
137
|
+
return rows;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
void _toggleExpand(String id) {
|
|
141
|
+
final next = {..._effectiveExpanded};
|
|
142
|
+
if (next.contains(id)) {
|
|
143
|
+
next.remove(id);
|
|
144
|
+
} else {
|
|
145
|
+
next.add(id);
|
|
146
|
+
}
|
|
147
|
+
if (!_expandedControlled) {
|
|
148
|
+
setState(() => _expanded = next);
|
|
149
|
+
}
|
|
150
|
+
widget.onExpandedChange?.call(next);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
void _setExpanded(String id, bool expand) {
|
|
154
|
+
final current = _effectiveExpanded;
|
|
155
|
+
if (expand == current.contains(id)) return;
|
|
156
|
+
_toggleExpand(id);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
void _select(String? id) {
|
|
160
|
+
if (!_selectedControlled) {
|
|
161
|
+
setState(() => _selected = id);
|
|
162
|
+
}
|
|
163
|
+
widget.onSelect?.call(id);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// 노드 행 탭: 자식이 있으면 확장 토글 + 선택, 없으면 선택만.
|
|
167
|
+
void _onRowTap(ShUiTreeNode node) {
|
|
168
|
+
if (node.disabled) return;
|
|
169
|
+
setState(() => _focusedId = node.id);
|
|
170
|
+
if (node.hasChildren) {
|
|
171
|
+
_toggleExpand(node.id);
|
|
172
|
+
}
|
|
173
|
+
_select(node.id);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── 키보드 네비게이션 ──────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
List<_FlatRow> _navigableRows(List<_FlatRow> rows) =>
|
|
179
|
+
rows.where((r) => !r.node.disabled).toList();
|
|
180
|
+
|
|
181
|
+
KeyEventResult _handleKey(FocusNode node, KeyEvent event) {
|
|
182
|
+
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
|
183
|
+
return KeyEventResult.ignored;
|
|
184
|
+
}
|
|
185
|
+
final rows = _visibleRows;
|
|
186
|
+
final navigable = _navigableRows(rows);
|
|
187
|
+
if (navigable.isEmpty) return KeyEventResult.ignored;
|
|
188
|
+
|
|
189
|
+
// 현재 포커스 인덱스 (없으면 첫 항목 직전으로 가정).
|
|
190
|
+
int currentIndex =
|
|
191
|
+
navigable.indexWhere((r) => r.node.id == _focusedId);
|
|
192
|
+
final key = event.logicalKey;
|
|
193
|
+
|
|
194
|
+
if (key == LogicalKeyboardKey.arrowDown) {
|
|
195
|
+
final nextIndex =
|
|
196
|
+
currentIndex < 0 ? 0 : (currentIndex + 1).clamp(0, navigable.length - 1);
|
|
197
|
+
setState(() => _focusedId = navigable[nextIndex].node.id);
|
|
198
|
+
return KeyEventResult.handled;
|
|
199
|
+
}
|
|
200
|
+
if (key == LogicalKeyboardKey.arrowUp) {
|
|
201
|
+
final prevIndex =
|
|
202
|
+
currentIndex <= 0 ? 0 : (currentIndex - 1);
|
|
203
|
+
setState(() => _focusedId = navigable[prevIndex].node.id);
|
|
204
|
+
return KeyEventResult.handled;
|
|
205
|
+
}
|
|
206
|
+
if (key == LogicalKeyboardKey.arrowRight) {
|
|
207
|
+
if (currentIndex < 0) return KeyEventResult.ignored;
|
|
208
|
+
final row = navigable[currentIndex];
|
|
209
|
+
if (row.node.hasChildren) {
|
|
210
|
+
if (!_effectiveExpanded.contains(row.node.id)) {
|
|
211
|
+
_setExpanded(row.node.id, true);
|
|
212
|
+
} else {
|
|
213
|
+
// 이미 확장됨 → 첫 자식으로 이동. 확장 상태 변경이 없으므로
|
|
214
|
+
// build에서 계산한 _visibleRows가 그대로 유효하다.
|
|
215
|
+
final after = _navigableRows(_visibleRows);
|
|
216
|
+
final idx = after.indexWhere((r) => r.node.id == row.node.id);
|
|
217
|
+
if (idx >= 0 && idx + 1 < after.length &&
|
|
218
|
+
after[idx + 1].level > row.level) {
|
|
219
|
+
setState(() => _focusedId = after[idx + 1].node.id);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return KeyEventResult.handled;
|
|
224
|
+
}
|
|
225
|
+
if (key == LogicalKeyboardKey.arrowLeft) {
|
|
226
|
+
if (currentIndex < 0) return KeyEventResult.ignored;
|
|
227
|
+
final row = navigable[currentIndex];
|
|
228
|
+
if (row.node.hasChildren && _effectiveExpanded.contains(row.node.id)) {
|
|
229
|
+
_setExpanded(row.node.id, false);
|
|
230
|
+
} else if (row.level > 0) {
|
|
231
|
+
// 부모로 이동.
|
|
232
|
+
final all = rows;
|
|
233
|
+
final pos = all.indexWhere((r) => r.node.id == row.node.id);
|
|
234
|
+
for (var i = pos - 1; i >= 0; i--) {
|
|
235
|
+
if (all[i].level < row.level && !all[i].node.disabled) {
|
|
236
|
+
setState(() => _focusedId = all[i].node.id);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return KeyEventResult.handled;
|
|
242
|
+
}
|
|
243
|
+
if (key == LogicalKeyboardKey.home) {
|
|
244
|
+
setState(() => _focusedId = navigable.first.node.id);
|
|
245
|
+
return KeyEventResult.handled;
|
|
246
|
+
}
|
|
247
|
+
if (key == LogicalKeyboardKey.end) {
|
|
248
|
+
setState(() => _focusedId = navigable.last.node.id);
|
|
249
|
+
return KeyEventResult.handled;
|
|
250
|
+
}
|
|
251
|
+
if (key == LogicalKeyboardKey.enter ||
|
|
252
|
+
key == LogicalKeyboardKey.space) {
|
|
253
|
+
if (currentIndex < 0) return KeyEventResult.ignored;
|
|
254
|
+
_onRowTap(navigable[currentIndex].node);
|
|
255
|
+
return KeyEventResult.handled;
|
|
256
|
+
}
|
|
257
|
+
return KeyEventResult.ignored;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
@override
|
|
261
|
+
Widget build(BuildContext context) {
|
|
262
|
+
final rows = _flatten();
|
|
263
|
+
_visibleRows = rows;
|
|
264
|
+
|
|
265
|
+
return Focus(
|
|
266
|
+
focusNode: _focusNode,
|
|
267
|
+
onKeyEvent: _handleKey,
|
|
268
|
+
child: Semantics(
|
|
269
|
+
container: true,
|
|
270
|
+
explicitChildNodes: true,
|
|
271
|
+
child: Column(
|
|
272
|
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
273
|
+
mainAxisSize: MainAxisSize.min,
|
|
274
|
+
children: [
|
|
275
|
+
for (final row in rows)
|
|
276
|
+
_ShUiTreeRow(
|
|
277
|
+
key: ValueKey(row.node.id),
|
|
278
|
+
node: row.node,
|
|
279
|
+
level: row.level,
|
|
280
|
+
size: widget.size,
|
|
281
|
+
isExpanded:
|
|
282
|
+
row.node.hasChildren && _effectiveExpanded.contains(row.node.id),
|
|
283
|
+
isSelected: _effectiveSelected == row.node.id,
|
|
284
|
+
isFocused: _focusedId == row.node.id,
|
|
285
|
+
onTap: () {
|
|
286
|
+
_focusNode.requestFocus();
|
|
287
|
+
_onRowTap(row.node);
|
|
288
|
+
},
|
|
289
|
+
),
|
|
290
|
+
],
|
|
291
|
+
),
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
class _ShUiTreeRow extends StatefulWidget {
|
|
298
|
+
final ShUiTreeNode node;
|
|
299
|
+
final int level;
|
|
300
|
+
final ShUiTreeSize size;
|
|
301
|
+
final bool isExpanded;
|
|
302
|
+
final bool isSelected;
|
|
303
|
+
final bool isFocused;
|
|
304
|
+
final VoidCallback onTap;
|
|
305
|
+
|
|
306
|
+
const _ShUiTreeRow({
|
|
307
|
+
super.key,
|
|
308
|
+
required this.node,
|
|
309
|
+
required this.level,
|
|
310
|
+
required this.size,
|
|
311
|
+
required this.isExpanded,
|
|
312
|
+
required this.isSelected,
|
|
313
|
+
required this.isFocused,
|
|
314
|
+
required this.onTap,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
@override
|
|
318
|
+
State<_ShUiTreeRow> createState() => _ShUiTreeRowState();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
class _ShUiTreeRowState extends State<_ShUiTreeRow> {
|
|
322
|
+
bool _hover = false;
|
|
323
|
+
|
|
324
|
+
@override
|
|
325
|
+
Widget build(BuildContext context) {
|
|
326
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
327
|
+
final colors = shUi.colors;
|
|
328
|
+
final node = widget.node;
|
|
329
|
+
final disabled = node.disabled;
|
|
330
|
+
final sm = widget.size == ShUiTreeSize.sm;
|
|
331
|
+
|
|
332
|
+
final fontSize = sm ? shUi.text.xs : shUi.text.sm;
|
|
333
|
+
final vPad = sm ? shUi.spacing.s1 : shUi.spacing.s2;
|
|
334
|
+
final indent = shUi.spacing.s4 * widget.level;
|
|
335
|
+
|
|
336
|
+
final highlighted =
|
|
337
|
+
widget.isSelected || (widget.isFocused && !disabled);
|
|
338
|
+
final bg = highlighted
|
|
339
|
+
? colors.backgroundMuted
|
|
340
|
+
: (_hover && !disabled ? colors.backgroundMuted : Colors.transparent);
|
|
341
|
+
|
|
342
|
+
final row = MouseRegion(
|
|
343
|
+
cursor: disabled ? SystemMouseCursors.basic : SystemMouseCursors.click,
|
|
344
|
+
onEnter: (_) => setState(() => _hover = true),
|
|
345
|
+
onExit: (_) => setState(() => _hover = false),
|
|
346
|
+
child: GestureDetector(
|
|
347
|
+
onTap: disabled ? null : widget.onTap,
|
|
348
|
+
behavior: HitTestBehavior.opaque,
|
|
349
|
+
child: AnimatedContainer(
|
|
350
|
+
duration: shUi.duration.fast,
|
|
351
|
+
curve: shUi.ease.standard,
|
|
352
|
+
padding: EdgeInsets.fromLTRB(
|
|
353
|
+
shUi.spacing.s2 + indent,
|
|
354
|
+
vPad,
|
|
355
|
+
shUi.spacing.s2,
|
|
356
|
+
vPad,
|
|
357
|
+
),
|
|
358
|
+
decoration: BoxDecoration(
|
|
359
|
+
color: bg,
|
|
360
|
+
borderRadius:
|
|
361
|
+
BorderRadius.circular(shUi.radius.defaultRadius - 2),
|
|
362
|
+
border: widget.isFocused && !disabled
|
|
363
|
+
? Border.all(
|
|
364
|
+
color: colors.ring,
|
|
365
|
+
width: shUi.borderWidth.strong,
|
|
366
|
+
)
|
|
367
|
+
: Border.all(color: Colors.transparent, width: shUi.borderWidth.strong),
|
|
368
|
+
),
|
|
369
|
+
child: Row(
|
|
370
|
+
children: [
|
|
371
|
+
// chevron — leaf면 자리만 유지(visibility hidden).
|
|
372
|
+
SizedBox(
|
|
373
|
+
width: shUi.spacing.s4,
|
|
374
|
+
child: node.hasChildren
|
|
375
|
+
? AnimatedRotation(
|
|
376
|
+
turns: widget.isExpanded ? 0.25 : 0.0,
|
|
377
|
+
duration: shUi.duration.fast,
|
|
378
|
+
curve: shUi.ease.standard,
|
|
379
|
+
child: Icon(
|
|
380
|
+
Icons.chevron_right,
|
|
381
|
+
size: sm ? 14 : 16,
|
|
382
|
+
color: colors.foregroundMuted,
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
: const SizedBox.shrink(),
|
|
386
|
+
),
|
|
387
|
+
SizedBox(width: shUi.spacing.s2),
|
|
388
|
+
if (node.icon != null) ...[
|
|
389
|
+
Icon(
|
|
390
|
+
node.icon,
|
|
391
|
+
size: sm ? 14 : 16,
|
|
392
|
+
color: colors.foregroundMuted,
|
|
393
|
+
),
|
|
394
|
+
SizedBox(width: shUi.spacing.s2),
|
|
395
|
+
],
|
|
396
|
+
Expanded(
|
|
397
|
+
child: Text(
|
|
398
|
+
node.label,
|
|
399
|
+
overflow: TextOverflow.ellipsis,
|
|
400
|
+
style: TextStyle(
|
|
401
|
+
color: disabled ? colors.foregroundMuted : colors.foreground,
|
|
402
|
+
fontSize: fontSize,
|
|
403
|
+
fontWeight: widget.isSelected
|
|
404
|
+
? shUi.weight.medium
|
|
405
|
+
: shUi.weight.regular,
|
|
406
|
+
height: 1.25,
|
|
407
|
+
),
|
|
408
|
+
),
|
|
409
|
+
),
|
|
410
|
+
],
|
|
411
|
+
),
|
|
412
|
+
),
|
|
413
|
+
),
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
return Semantics(
|
|
417
|
+
selected: widget.isSelected,
|
|
418
|
+
enabled: !disabled,
|
|
419
|
+
expanded: node.hasChildren ? widget.isExpanded : null,
|
|
420
|
+
label: node.label,
|
|
421
|
+
button: true,
|
|
422
|
+
child: Opacity(
|
|
423
|
+
opacity: disabled ? shUi.opacity.disabled : 1,
|
|
424
|
+
child: row,
|
|
425
|
+
),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
5
|
import {
|
|
5
6
|
useEditor,
|
|
6
7
|
useEditorState,
|
|
7
8
|
EditorContent,
|
|
8
9
|
type Editor,
|
|
9
10
|
} from "@tiptap/react";
|
|
11
|
+
import type { AnyExtension } from "@tiptap/core";
|
|
10
12
|
import StarterKit from "@tiptap/starter-kit";
|
|
11
13
|
import Placeholder from "@tiptap/extension-placeholder";
|
|
12
14
|
import Link from "@tiptap/extension-link";
|
|
13
15
|
import { TextStyle, Color } from "@tiptap/extension-text-style";
|
|
16
|
+
import { Markdown } from "tiptap-markdown";
|
|
14
17
|
import { cn } from "@SH_UI_UTILS@";
|
|
15
18
|
import {
|
|
16
19
|
BoldIcon,
|
|
@@ -97,17 +100,42 @@ const DEFAULT_LABELS: RichTextEditorLabels = {
|
|
|
97
100
|
|
|
98
101
|
export interface RichTextEditorProps {
|
|
99
102
|
/**
|
|
100
|
-
*
|
|
103
|
+
* 입출력 포맷. 기본 'html'(하위호환). 'markdown' 이면 value/defaultValue/onChange 가
|
|
104
|
+
* markdown 문자열로 동작한다(tiptap-markdown 직렬화).
|
|
105
|
+
*
|
|
106
|
+
* 주의: 이 값은 **마운트 시점에만** 읽힌다 — 내부 `useEditor` 는 한 번만 생성되므로
|
|
107
|
+
* 런타임에 format 을 바꾸려면 에디터를 리마운트해야 한다(예: key prop 교체).
|
|
108
|
+
* 동일 제약이 `extensions` 에도 적용된다.
|
|
109
|
+
* @default "html"
|
|
110
|
+
*/
|
|
111
|
+
format?: "html" | "markdown";
|
|
112
|
+
/**
|
|
113
|
+
* Controlled — 현재 본문(format 에 따라 HTML 또는 markdown 문자열).
|
|
114
|
+
* 명시 시 외부 상태가 진실원천이 되고 onChange 로 갱신한다.
|
|
101
115
|
* 미지정이면 uncontrolled — Tiptap editor 가 자체 doc 으로 동작.
|
|
102
116
|
*/
|
|
103
117
|
value?: string;
|
|
104
118
|
/**
|
|
105
|
-
* Uncontrolled 초기 HTML. value 미지정 시에만 사용.
|
|
119
|
+
* Uncontrolled 초기 본문(format 에 따라 HTML 또는 markdown 문자열). value 미지정 시에만 사용.
|
|
106
120
|
* @default ""
|
|
107
121
|
*/
|
|
108
122
|
defaultValue?: string;
|
|
109
|
-
/**
|
|
110
|
-
|
|
123
|
+
/**
|
|
124
|
+
* 본문이 바뀔 때마다 호출 (controlled · uncontrolled 모두).
|
|
125
|
+
* format='html' 이면 HTML, format='markdown' 이면 markdown 문자열을 넘긴다.
|
|
126
|
+
*/
|
|
127
|
+
onChange?: (value: string) => void;
|
|
128
|
+
/** Enter 로 제출. true 면 Enter=onSubmit, Shift+Enter=줄바꿈. 기본 false. */
|
|
129
|
+
submitOnEnter?: boolean;
|
|
130
|
+
/** 제출 콜백(submitOnEnter 또는 외부 버튼). */
|
|
131
|
+
onSubmit?: () => void;
|
|
132
|
+
/**
|
|
133
|
+
* StarterKit·기본 확장 뒤에 append 할 추가 TipTap 확장(멘션 등).
|
|
134
|
+
* 주의: `format` 과 마찬가지로 마운트 시점에만 읽힌다 — 런타임 변경은 리마운트 필요.
|
|
135
|
+
*/
|
|
136
|
+
extensions?: AnyExtension[];
|
|
137
|
+
/** 에디터 생성 시 콜백(외부에서 인스턴스 제어). */
|
|
138
|
+
onCreate?: (editor: Editor) => void;
|
|
111
139
|
/** 비어 있을 때 표시할 placeholder. */
|
|
112
140
|
placeholder?: string;
|
|
113
141
|
/** 읽기 전용. 키 입력·툴바 차단. */
|
|
@@ -141,6 +169,16 @@ const COLOR_SWATCHES = [
|
|
|
141
169
|
|
|
142
170
|
const colorValue = (cssVar: string) => `var(${cssVar})`;
|
|
143
171
|
|
|
172
|
+
/** tiptap-markdown storage 로 현재 doc 을 markdown 문자열로 직렬화. */
|
|
173
|
+
function readMarkdown(editor: Editor): string {
|
|
174
|
+
const storage = editor.storage as {
|
|
175
|
+
markdown?: { getMarkdown(): string };
|
|
176
|
+
};
|
|
177
|
+
// markdown storage 가 없으면(직렬화 확장 미등록) HTML 을 흘려보내면 포맷이 어긋난다 —
|
|
178
|
+
// markdown 을 기대한 호출자에게 HTML 을 주지 않도록 빈 문자열로 폴백.
|
|
179
|
+
return storage.markdown?.getMarkdown() ?? "";
|
|
180
|
+
}
|
|
181
|
+
|
|
144
182
|
/** 선택 영역(없으면 URL 텍스트 삽입)에 링크를 적용. */
|
|
145
183
|
function applyLink(editor: Editor, rawUrl: string) {
|
|
146
184
|
const url = rawUrl.trim();
|
|
@@ -178,9 +216,14 @@ type ToolbarPanel = "none" | "link" | "color";
|
|
|
178
216
|
* 구분선 · 실행취소/다시실행. compact 로 핵심만, toolbarMode="focus" 로 인라인 느낌.
|
|
179
217
|
*/
|
|
180
218
|
export function RichTextEditor({
|
|
219
|
+
format = "html",
|
|
181
220
|
value: valueProp,
|
|
182
221
|
defaultValue,
|
|
183
222
|
onChange,
|
|
223
|
+
submitOnEnter = false,
|
|
224
|
+
onSubmit,
|
|
225
|
+
extensions,
|
|
226
|
+
onCreate,
|
|
184
227
|
placeholder,
|
|
185
228
|
readOnly = false,
|
|
186
229
|
hideToolbar = false,
|
|
@@ -197,6 +240,23 @@ export function RichTextEditor({
|
|
|
197
240
|
const [panel, setPanel] = useState<ToolbarPanel>("none");
|
|
198
241
|
const L = labels ? { ...DEFAULT_LABELS, ...labels } : DEFAULT_LABELS;
|
|
199
242
|
|
|
243
|
+
// onSubmit/submitOnEnter 를 ref 로 잡아 handleKeyDown 이 stale closure 가 되지 않게
|
|
244
|
+
// 한다(에디터를 매 렌더마다 재생성하지 않으려고 콜백은 useEditor deps 에서 제외).
|
|
245
|
+
const onSubmitRef = useRef(onSubmit);
|
|
246
|
+
onSubmitRef.current = onSubmit;
|
|
247
|
+
const submitOnEnterRef = useRef(submitOnEnter);
|
|
248
|
+
submitOnEnterRef.current = submitOnEnter;
|
|
249
|
+
|
|
250
|
+
// 마지막으로 onChange 로 흘려보냈거나(우리 echo) controlled-sync 로 주입한 value.
|
|
251
|
+
// controlled-sync 가 자기 자신의 emit 을 다시 setContent 하는 루프(커서 점프)를 막는다.
|
|
252
|
+
// 비교는 정규화된 직렬화 형태가 아니라 "우리가 마지막으로 본 문자열" 기준이라
|
|
253
|
+
// markdown 정규화(`**hi**` vs `**hi**\n`)로도 깨지지 않는다.
|
|
254
|
+
const lastSyncedRef = useRef<string | undefined>(undefined);
|
|
255
|
+
|
|
256
|
+
/** format 에 맞춰 에디터의 현재 본문을 직렬화(html/markdown). */
|
|
257
|
+
const readValue = (ed: Editor): string =>
|
|
258
|
+
format === "markdown" ? readMarkdown(ed) : ed.getHTML();
|
|
259
|
+
|
|
200
260
|
const editor = useEditor({
|
|
201
261
|
extensions: [
|
|
202
262
|
// Link/Underline 은 v3 StarterKit 에 포함 — Link 는 따로 설정하려 끄고 별도 등록(중복 경고 회피).
|
|
@@ -213,26 +273,56 @@ export function RichTextEditor({
|
|
|
213
273
|
}),
|
|
214
274
|
TextStyle,
|
|
215
275
|
Color.configure({ types: ["textStyle"] }),
|
|
276
|
+
// markdown 모드에서만 직렬화 확장 등록 — html 모드(기본)는 기존과 동일.
|
|
277
|
+
...(format === "markdown" ? [Markdown.configure({ html: false })] : []),
|
|
278
|
+
...(extensions ?? []),
|
|
216
279
|
],
|
|
280
|
+
// markdown 모드에서 tiptap-markdown 은 문자열을 markdown 으로 파싱한다.
|
|
217
281
|
content: valueProp ?? defaultValue ?? "",
|
|
218
282
|
editable: !readOnly,
|
|
219
283
|
immediatelyRender: false,
|
|
284
|
+
onCreate: ({ editor }) => {
|
|
285
|
+
onCreate?.(editor);
|
|
286
|
+
},
|
|
220
287
|
onUpdate: ({ editor }) => {
|
|
221
|
-
|
|
288
|
+
const output = readValue(editor);
|
|
289
|
+
// 우리가 방금 emit 한 값을 controlled-sync 가 다시 주입하지 않도록 기록.
|
|
290
|
+
lastSyncedRef.current = output;
|
|
291
|
+
onChange?.(output);
|
|
222
292
|
},
|
|
223
293
|
editorProps: {
|
|
224
294
|
attributes: {
|
|
225
295
|
class: styles.rte__content,
|
|
226
296
|
"aria-label": ariaLabel,
|
|
227
297
|
},
|
|
298
|
+
handleKeyDown: (_view, event) => {
|
|
299
|
+
if (
|
|
300
|
+
submitOnEnterRef.current &&
|
|
301
|
+
event.key === "Enter" &&
|
|
302
|
+
!event.shiftKey &&
|
|
303
|
+
// IME 조합 확정 Enter 는 제출이 아님(한글 등 — 조합 확정을 잘못 제출하면
|
|
304
|
+
// 입력이 날아간다). isComposing + 레거시 keyCode 229 둘 다 가드.
|
|
305
|
+
!event.isComposing &&
|
|
306
|
+
event.keyCode !== 229
|
|
307
|
+
) {
|
|
308
|
+
event.preventDefault();
|
|
309
|
+
onSubmitRef.current?.();
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
},
|
|
228
314
|
},
|
|
229
315
|
});
|
|
230
316
|
|
|
231
|
-
// controlled 모드에서만 외부 value 를 에디터 doc 에
|
|
317
|
+
// controlled 모드에서만 외부 value 를 에디터 doc 에 동기화.
|
|
318
|
+
// 직렬화 결과(readValue) 와 비교하면 markdown 정규화로 영원히 불일치 → 매 렌더 setContent
|
|
319
|
+
// → 커서 점프가 난다. 대신 "마지막으로 우리가 주고받은 문자열"(lastSyncedRef) 과 비교해
|
|
320
|
+
// 자기 echo 만 건너뛰고, 진짜 외부 변경(예: 채널 전환)은 항상 로드한다.
|
|
232
321
|
useEffect(() => {
|
|
233
322
|
if (!isControlled) return;
|
|
234
323
|
if (!editor) return;
|
|
235
|
-
if (
|
|
324
|
+
if (valueProp === lastSyncedRef.current) return;
|
|
325
|
+
lastSyncedRef.current = valueProp;
|
|
236
326
|
editor.commands.setContent(valueProp ?? "", { emitUpdate: false });
|
|
237
327
|
}, [isControlled, valueProp, editor]);
|
|
238
328
|
|