sh-ui-cli 0.114.0 → 0.116.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/bin/sh-ui.mjs +70 -3
- package/data/changelog/versions.json +26 -0
- package/data/registry/react/components/rich-text-editor/index.module.tsx +523 -171
- package/data/registry/react/components/rich-text-editor/index.tailwind.tsx +596 -70
- package/data/registry/react/components/rich-text-editor/index.tsx +523 -171
- package/data/registry/react/components/rich-text-editor/styles.css +103 -5
- package/data/registry/react/components/rich-text-editor/styles.module.css +103 -5
- package/data/registry/react/registry.json +319 -963
- package/data/registry/react/tokens-used.json +4 -1
- package/package.json +1 -1
- package/src/add.mjs +31 -1
- package/src/commands.mjs +6 -0
- package/src/create/generator.js +4 -0
- package/src/doctor.mjs +14 -0
- package/src/init.mjs +19 -0
- package/src/levenshtein.mjs +36 -0
- package/src/list.mjs +13 -0
- package/src/mcp.mjs +14 -0
- package/src/migrate-bundled.mjs +14 -0
- package/src/migrate-v065.mjs +14 -0
- package/src/remove.mjs +15 -0
- package/src/rename-app.mjs +15 -0
- package/src/theme-extract.mjs +13 -0
- package/src/tokens-cmd.mjs +12 -0
- package/src/upgrade-cli.mjs +13 -0
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
useEditor,
|
|
6
|
+
useEditorState,
|
|
7
|
+
EditorContent,
|
|
8
|
+
type Editor,
|
|
9
|
+
} from "@tiptap/react";
|
|
5
10
|
import StarterKit from "@tiptap/starter-kit";
|
|
6
11
|
import Placeholder from "@tiptap/extension-placeholder";
|
|
7
12
|
import Link from "@tiptap/extension-link";
|
|
13
|
+
import { TextStyle, Color } from "@tiptap/extension-text-style";
|
|
8
14
|
import { cn } from "@SH_UI_UTILS@";
|
|
9
15
|
import {
|
|
10
16
|
BoldIcon,
|
|
11
17
|
ItalicIcon,
|
|
18
|
+
UnderlineIcon,
|
|
12
19
|
StrikethroughIcon,
|
|
13
20
|
Heading1Icon,
|
|
14
21
|
Heading2Icon,
|
|
@@ -18,13 +25,76 @@ import {
|
|
|
18
25
|
QuoteIcon,
|
|
19
26
|
CodeIcon,
|
|
20
27
|
Code2Icon,
|
|
28
|
+
BaselineIcon,
|
|
21
29
|
LinkIcon,
|
|
30
|
+
UnlinkIcon,
|
|
22
31
|
MinusIcon,
|
|
23
32
|
Undo2Icon,
|
|
24
33
|
Redo2Icon,
|
|
34
|
+
CheckIcon,
|
|
35
|
+
XIcon,
|
|
25
36
|
} from "lucide-react";
|
|
26
37
|
import "./styles.css";
|
|
27
38
|
|
|
39
|
+
/** 툴바 a11y 라벨/툴팁 — 기본 영어. `labels` prop 으로 부분 덮어쓰기(i18n). */
|
|
40
|
+
export interface RichTextEditorLabels {
|
|
41
|
+
bold: string;
|
|
42
|
+
italic: string;
|
|
43
|
+
underline: string;
|
|
44
|
+
strike: string;
|
|
45
|
+
code: string;
|
|
46
|
+
h1: string;
|
|
47
|
+
h2: string;
|
|
48
|
+
h3: string;
|
|
49
|
+
bulletList: string;
|
|
50
|
+
orderedList: string;
|
|
51
|
+
blockquote: string;
|
|
52
|
+
codeBlock: string;
|
|
53
|
+
textColor: string;
|
|
54
|
+
link: string;
|
|
55
|
+
horizontalRule: string;
|
|
56
|
+
undo: string;
|
|
57
|
+
redo: string;
|
|
58
|
+
colorDefault: string;
|
|
59
|
+
colorMoss: string;
|
|
60
|
+
colorRed: string;
|
|
61
|
+
colorOrange: string;
|
|
62
|
+
colorBlue: string;
|
|
63
|
+
linkUrl: string;
|
|
64
|
+
apply: string;
|
|
65
|
+
removeLink: string;
|
|
66
|
+
cancel: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DEFAULT_LABELS: RichTextEditorLabels = {
|
|
70
|
+
bold: "Bold",
|
|
71
|
+
italic: "Italic",
|
|
72
|
+
underline: "Underline",
|
|
73
|
+
strike: "Strikethrough",
|
|
74
|
+
code: "Inline code",
|
|
75
|
+
h1: "Heading 1",
|
|
76
|
+
h2: "Heading 2",
|
|
77
|
+
h3: "Heading 3",
|
|
78
|
+
bulletList: "Bulleted list",
|
|
79
|
+
orderedList: "Ordered list",
|
|
80
|
+
blockquote: "Blockquote",
|
|
81
|
+
codeBlock: "Code block",
|
|
82
|
+
textColor: "Text color",
|
|
83
|
+
link: "Link",
|
|
84
|
+
horizontalRule: "Horizontal rule",
|
|
85
|
+
undo: "Undo",
|
|
86
|
+
redo: "Redo",
|
|
87
|
+
colorDefault: "Default",
|
|
88
|
+
colorMoss: "Moss",
|
|
89
|
+
colorRed: "Red",
|
|
90
|
+
colorOrange: "Orange",
|
|
91
|
+
colorBlue: "Blue",
|
|
92
|
+
linkUrl: "Link URL",
|
|
93
|
+
apply: "Apply",
|
|
94
|
+
removeLink: "Remove link",
|
|
95
|
+
cancel: "Cancel",
|
|
96
|
+
};
|
|
97
|
+
|
|
28
98
|
export interface RichTextEditorProps {
|
|
29
99
|
/**
|
|
30
100
|
* Controlled — 현재 HTML. 명시 시 외부 상태가 진실원천이 되고 onChange 로 갱신한다.
|
|
@@ -44,6 +114,12 @@ export interface RichTextEditorProps {
|
|
|
44
114
|
readOnly?: boolean;
|
|
45
115
|
/** 상단 툴바 숨기기. 본문 영역만 렌더. */
|
|
46
116
|
hideToolbar?: boolean;
|
|
117
|
+
/** 핵심 버튼만 노출(좁은 패널용). 제목/코드블록/목록 일부를 생략한다. */
|
|
118
|
+
compact?: boolean;
|
|
119
|
+
/** "always"(기본) 항상 노출 · "focus" 포커스/편집 중에만 노출(인라인 느낌). */
|
|
120
|
+
toolbarMode?: "always" | "focus";
|
|
121
|
+
/** 툴바 라벨/툴팁 i18n. 누락 키는 영어 기본값. */
|
|
122
|
+
labels?: Partial<RichTextEditorLabels>;
|
|
47
123
|
/** 본문 영역의 최소 높이. */
|
|
48
124
|
minHeight?: string;
|
|
49
125
|
/** 본문 영역의 최대 높이. 초과 시 내부 스크롤. */
|
|
@@ -52,6 +128,45 @@ export interface RichTextEditorProps {
|
|
|
52
128
|
"aria-label"?: string;
|
|
53
129
|
}
|
|
54
130
|
|
|
131
|
+
/**
|
|
132
|
+
* 본문 텍스트 색 — CSS 변수로 저장(`var(--sh-ui-rte-c-*)`)해 라이트/다크 테마를
|
|
133
|
+
* 추종한다(하드 hex 가 아님). 변수 정의부는 styles.css. moss 는 accent.
|
|
134
|
+
*/
|
|
135
|
+
const COLOR_SWATCHES = [
|
|
136
|
+
{ key: "moss", cssVar: "--sh-ui-rte-c-moss", labelKey: "colorMoss" },
|
|
137
|
+
{ key: "red", cssVar: "--sh-ui-rte-c-red", labelKey: "colorRed" },
|
|
138
|
+
{ key: "orange", cssVar: "--sh-ui-rte-c-orange", labelKey: "colorOrange" },
|
|
139
|
+
{ key: "blue", cssVar: "--sh-ui-rte-c-blue", labelKey: "colorBlue" },
|
|
140
|
+
] as const;
|
|
141
|
+
|
|
142
|
+
const colorValue = (cssVar: string) => `var(${cssVar})`;
|
|
143
|
+
|
|
144
|
+
/** 선택 영역(없으면 URL 텍스트 삽입)에 링크를 적용. */
|
|
145
|
+
function applyLink(editor: Editor, rawUrl: string) {
|
|
146
|
+
const url = rawUrl.trim();
|
|
147
|
+
if (url === "") {
|
|
148
|
+
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const { empty } = editor.state.selection;
|
|
152
|
+
const inLink = editor.isActive("link");
|
|
153
|
+
if (empty && !inLink) {
|
|
154
|
+
// 선택 없이 호출되면 URL 자체를 anchor 텍스트로 삽입 — 빈 링크 마크가 남는 걸 방지
|
|
155
|
+
editor
|
|
156
|
+
.chain()
|
|
157
|
+
.focus()
|
|
158
|
+
.insertContent({
|
|
159
|
+
type: "text",
|
|
160
|
+
text: url,
|
|
161
|
+
marks: [{ type: "link", attrs: { href: url } }],
|
|
162
|
+
})
|
|
163
|
+
.run();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
type ToolbarPanel = "none" | "link" | "color";
|
|
55
170
|
|
|
56
171
|
/**
|
|
57
172
|
* Tiptap 기반 리치 텍스트 에디터.
|
|
@@ -59,7 +174,8 @@ export interface RichTextEditorProps {
|
|
|
59
174
|
* Controlled (value/onChange) · Uncontrolled (defaultValue) 모두 지원. 라우터·외부 상태와
|
|
60
175
|
* 동기화할 게 없는 단일 입력 폼이라면 defaultValue 한 줄로 끝 — useState 불필요.
|
|
61
176
|
*
|
|
62
|
-
*
|
|
177
|
+
* 툴바: 굵게/기울임/밑줄/취소선/코드 · 글자색 · 링크 · 헤딩 · 리스트 · 인용 · 코드블록 ·
|
|
178
|
+
* 구분선 · 실행취소/다시실행. compact 로 핵심만, toolbarMode="focus" 로 인라인 느낌.
|
|
63
179
|
*/
|
|
64
180
|
export function RichTextEditor({
|
|
65
181
|
value: valueProp,
|
|
@@ -68,24 +184,35 @@ export function RichTextEditor({
|
|
|
68
184
|
placeholder,
|
|
69
185
|
readOnly = false,
|
|
70
186
|
hideToolbar = false,
|
|
187
|
+
compact = false,
|
|
188
|
+
toolbarMode = "always",
|
|
189
|
+
labels,
|
|
71
190
|
minHeight,
|
|
72
191
|
maxHeight,
|
|
73
192
|
className,
|
|
74
193
|
"aria-label": ariaLabel = "Rich text editor",
|
|
75
194
|
}: RichTextEditorProps) {
|
|
76
195
|
const isControlled = valueProp !== undefined;
|
|
196
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
197
|
+
const [panel, setPanel] = useState<ToolbarPanel>("none");
|
|
198
|
+
const L = labels ? { ...DEFAULT_LABELS, ...labels } : DEFAULT_LABELS;
|
|
199
|
+
|
|
77
200
|
const editor = useEditor({
|
|
78
201
|
extensions: [
|
|
79
|
-
StarterKit
|
|
202
|
+
// Link/Underline 은 v3 StarterKit 에 포함 — Link 는 따로 설정하려 끄고 별도 등록(중복 경고 회피).
|
|
203
|
+
StarterKit.configure({ link: false }),
|
|
80
204
|
Placeholder.configure({
|
|
81
205
|
placeholder: placeholder ?? "",
|
|
82
206
|
emptyEditorClass: "sh-ui-rte__is-empty",
|
|
83
207
|
}),
|
|
84
208
|
Link.configure({
|
|
85
|
-
|
|
209
|
+
// 편집 중엔 클릭으로 이탈 방지, 읽기 전용일 때만 클릭 시 열림.
|
|
210
|
+
openOnClick: readOnly,
|
|
86
211
|
autolink: true,
|
|
87
212
|
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
|
|
88
213
|
}),
|
|
214
|
+
TextStyle,
|
|
215
|
+
Color.configure({ types: ["textStyle"] }),
|
|
89
216
|
],
|
|
90
217
|
content: valueProp ?? defaultValue ?? "",
|
|
91
218
|
editable: !readOnly,
|
|
@@ -113,10 +240,23 @@ export function RichTextEditor({
|
|
|
113
240
|
editor?.setEditable(!readOnly);
|
|
114
241
|
}, [readOnly, editor]);
|
|
115
242
|
|
|
243
|
+
const showToolbar =
|
|
244
|
+
!hideToolbar && (toolbarMode === "always" || isFocused || panel !== "none");
|
|
245
|
+
|
|
246
|
+
// 포커스 추적(toolbarMode="focus" 노출 판정용) — 래퍼의 focusin/focusout(버블).
|
|
247
|
+
// 에디터·툴바 버튼·링크 입력·컬러 스와치 어디로 포커스가 옮겨가도 컨테이너 안이면
|
|
248
|
+
// "포커스 중"을 유지(focusout 의 relatedTarget 가 컨테이너 내부면 무시).
|
|
249
|
+
const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
|
|
250
|
+
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
|
251
|
+
setIsFocused(false);
|
|
252
|
+
};
|
|
253
|
+
|
|
116
254
|
return (
|
|
117
255
|
<div
|
|
118
256
|
className={cn("sh-ui-rte", className)}
|
|
119
257
|
data-readonly={readOnly || undefined}
|
|
258
|
+
onFocus={() => setIsFocused(true)}
|
|
259
|
+
onBlur={handleBlur}
|
|
120
260
|
style={
|
|
121
261
|
{
|
|
122
262
|
"--sh-ui-rte-min-height": minHeight,
|
|
@@ -124,7 +264,16 @@ export function RichTextEditor({
|
|
|
124
264
|
} as React.CSSProperties
|
|
125
265
|
}
|
|
126
266
|
>
|
|
127
|
-
{
|
|
267
|
+
{showToolbar && (
|
|
268
|
+
<Toolbar
|
|
269
|
+
editor={editor}
|
|
270
|
+
disabled={readOnly}
|
|
271
|
+
compact={compact}
|
|
272
|
+
panel={panel}
|
|
273
|
+
setPanel={setPanel}
|
|
274
|
+
labels={L}
|
|
275
|
+
/>
|
|
276
|
+
)}
|
|
128
277
|
<EditorContent editor={editor} className="sh-ui-rte__viewport" />
|
|
129
278
|
</div>
|
|
130
279
|
);
|
|
@@ -133,198 +282,401 @@ export function RichTextEditor({
|
|
|
133
282
|
interface ToolbarProps {
|
|
134
283
|
editor: Editor | null;
|
|
135
284
|
disabled: boolean;
|
|
285
|
+
compact: boolean;
|
|
286
|
+
panel: ToolbarPanel;
|
|
287
|
+
setPanel: (next: ToolbarPanel) => void;
|
|
288
|
+
labels: RichTextEditorLabels;
|
|
136
289
|
}
|
|
137
290
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
291
|
+
/** 활성/실행가능 상태 스냅샷 — useEditorState 로 트랜잭션마다 구독(버튼 강조 즉시 반영). */
|
|
292
|
+
function Toolbar({
|
|
293
|
+
editor,
|
|
294
|
+
disabled,
|
|
295
|
+
compact,
|
|
296
|
+
panel,
|
|
297
|
+
setPanel,
|
|
298
|
+
labels: L,
|
|
299
|
+
}: ToolbarProps) {
|
|
300
|
+
const flags = useEditorState({
|
|
301
|
+
editor,
|
|
302
|
+
selector: ({ editor: e }) =>
|
|
303
|
+
e
|
|
304
|
+
? {
|
|
305
|
+
bold: e.isActive("bold"),
|
|
306
|
+
canBold: e.can().toggleBold(),
|
|
307
|
+
italic: e.isActive("italic"),
|
|
308
|
+
canItalic: e.can().toggleItalic(),
|
|
309
|
+
underline: e.isActive("underline"),
|
|
310
|
+
canUnderline: e.can().toggleUnderline(),
|
|
311
|
+
strike: e.isActive("strike"),
|
|
312
|
+
canStrike: e.can().toggleStrike(),
|
|
313
|
+
code: e.isActive("code"),
|
|
314
|
+
canCode: e.can().toggleCode(),
|
|
315
|
+
h1: e.isActive("heading", { level: 1 }),
|
|
316
|
+
h2: e.isActive("heading", { level: 2 }),
|
|
317
|
+
h3: e.isActive("heading", { level: 3 }),
|
|
318
|
+
bulletList: e.isActive("bulletList"),
|
|
319
|
+
orderedList: e.isActive("orderedList"),
|
|
320
|
+
blockquote: e.isActive("blockquote"),
|
|
321
|
+
codeBlock: e.isActive("codeBlock"),
|
|
322
|
+
link: e.isActive("link"),
|
|
323
|
+
color: e.getAttributes("textStyle").color as string | undefined,
|
|
324
|
+
canUndo: e.can().undo(),
|
|
325
|
+
canRedo: e.can().redo(),
|
|
326
|
+
}
|
|
327
|
+
: null,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const togglePanel = (next: Exclude<ToolbarPanel, "none">) =>
|
|
331
|
+
setPanel(panel === next ? "none" : next);
|
|
332
|
+
|
|
333
|
+
const f = flags;
|
|
334
|
+
const editorReady = !!editor && !!f;
|
|
335
|
+
const colorActive = Boolean(f?.color) || panel === "color";
|
|
165
336
|
|
|
166
337
|
return (
|
|
167
|
-
<div
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
338
|
+
<div
|
|
339
|
+
className="sh-ui-rte__toolbar"
|
|
340
|
+
role="toolbar"
|
|
341
|
+
aria-label="Formatting"
|
|
342
|
+
aria-disabled={disabled || undefined}
|
|
343
|
+
>
|
|
344
|
+
<div className="sh-ui-rte__toolbar-row">
|
|
345
|
+
<ToolbarButton
|
|
346
|
+
label={L.bold}
|
|
347
|
+
icon={<BoldIcon size={16} />}
|
|
348
|
+
isActive={f?.bold}
|
|
349
|
+
disabled={disabled || !editorReady || !f?.canBold}
|
|
350
|
+
onClick={() => editor?.chain().focus().toggleBold().run()}
|
|
351
|
+
/>
|
|
352
|
+
<ToolbarButton
|
|
353
|
+
label={L.italic}
|
|
354
|
+
icon={<ItalicIcon size={16} />}
|
|
355
|
+
isActive={f?.italic}
|
|
356
|
+
disabled={disabled || !editorReady || !f?.canItalic}
|
|
357
|
+
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
|
358
|
+
/>
|
|
359
|
+
<ToolbarButton
|
|
360
|
+
label={L.underline}
|
|
361
|
+
icon={<UnderlineIcon size={16} />}
|
|
362
|
+
isActive={f?.underline}
|
|
363
|
+
disabled={disabled || !editorReady || !f?.canUnderline}
|
|
364
|
+
onClick={() => editor?.chain().focus().toggleUnderline().run()}
|
|
365
|
+
/>
|
|
366
|
+
<ToolbarButton
|
|
367
|
+
label={L.strike}
|
|
368
|
+
icon={<StrikethroughIcon size={16} />}
|
|
369
|
+
isActive={f?.strike}
|
|
370
|
+
disabled={disabled || !editorReady || !f?.canStrike}
|
|
371
|
+
onClick={() => editor?.chain().focus().toggleStrike().run()}
|
|
372
|
+
/>
|
|
373
|
+
{!compact && (
|
|
374
|
+
<ToolbarButton
|
|
375
|
+
label={L.code}
|
|
376
|
+
icon={<CodeIcon size={16} />}
|
|
377
|
+
isActive={f?.code}
|
|
378
|
+
disabled={disabled || !editorReady || !f?.canCode}
|
|
379
|
+
onClick={() => editor?.chain().focus().toggleCode().run()}
|
|
380
|
+
/>
|
|
381
|
+
)}
|
|
204
382
|
|
|
205
|
-
|
|
383
|
+
<ToolbarSeparator />
|
|
206
384
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
disabled={disabled}
|
|
222
|
-
/>
|
|
223
|
-
<ToolbarButton
|
|
224
|
-
editor={editor}
|
|
225
|
-
label="Heading 3"
|
|
226
|
-
icon={<Heading3Icon size={16} />}
|
|
227
|
-
isActive={editor?.isActive("heading", { level: 3 })}
|
|
228
|
-
run={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
|
|
229
|
-
disabled={disabled}
|
|
230
|
-
/>
|
|
385
|
+
<ToolbarButton
|
|
386
|
+
label={L.textColor}
|
|
387
|
+
icon={<BaselineIcon size={16} />}
|
|
388
|
+
isActive={colorActive}
|
|
389
|
+
disabled={disabled || !editorReady}
|
|
390
|
+
onClick={() => togglePanel("color")}
|
|
391
|
+
/>
|
|
392
|
+
<ToolbarButton
|
|
393
|
+
label={L.link}
|
|
394
|
+
icon={<LinkIcon size={16} />}
|
|
395
|
+
isActive={f?.link || panel === "link"}
|
|
396
|
+
disabled={disabled || !editorReady}
|
|
397
|
+
onClick={() => togglePanel("link")}
|
|
398
|
+
/>
|
|
231
399
|
|
|
232
|
-
|
|
400
|
+
{!compact && (
|
|
401
|
+
<>
|
|
402
|
+
<ToolbarSeparator />
|
|
403
|
+
<ToolbarButton
|
|
404
|
+
label={L.h1}
|
|
405
|
+
icon={<Heading1Icon size={16} />}
|
|
406
|
+
isActive={f?.h1}
|
|
407
|
+
disabled={disabled || !editorReady}
|
|
408
|
+
onClick={() =>
|
|
409
|
+
editor?.chain().focus().toggleHeading({ level: 1 }).run()
|
|
410
|
+
}
|
|
411
|
+
/>
|
|
412
|
+
<ToolbarButton
|
|
413
|
+
label={L.h2}
|
|
414
|
+
icon={<Heading2Icon size={16} />}
|
|
415
|
+
isActive={f?.h2}
|
|
416
|
+
disabled={disabled || !editorReady}
|
|
417
|
+
onClick={() =>
|
|
418
|
+
editor?.chain().focus().toggleHeading({ level: 2 }).run()
|
|
419
|
+
}
|
|
420
|
+
/>
|
|
421
|
+
<ToolbarButton
|
|
422
|
+
label={L.h3}
|
|
423
|
+
icon={<Heading3Icon size={16} />}
|
|
424
|
+
isActive={f?.h3}
|
|
425
|
+
disabled={disabled || !editorReady}
|
|
426
|
+
onClick={() =>
|
|
427
|
+
editor?.chain().focus().toggleHeading({ level: 3 }).run()
|
|
428
|
+
}
|
|
429
|
+
/>
|
|
430
|
+
</>
|
|
431
|
+
)}
|
|
233
432
|
|
|
234
|
-
|
|
235
|
-
editor={editor}
|
|
236
|
-
label="Bulleted list"
|
|
237
|
-
icon={<ListIcon size={16} />}
|
|
238
|
-
isActive={editor?.isActive("bulletList")}
|
|
239
|
-
run={() => editor?.chain().focus().toggleBulletList().run()}
|
|
240
|
-
disabled={disabled}
|
|
241
|
-
/>
|
|
242
|
-
<ToolbarButton
|
|
243
|
-
editor={editor}
|
|
244
|
-
label="Ordered list"
|
|
245
|
-
icon={<ListOrderedIcon size={16} />}
|
|
246
|
-
isActive={editor?.isActive("orderedList")}
|
|
247
|
-
run={() => editor?.chain().focus().toggleOrderedList().run()}
|
|
248
|
-
disabled={disabled}
|
|
249
|
-
/>
|
|
250
|
-
<ToolbarButton
|
|
251
|
-
editor={editor}
|
|
252
|
-
label="Blockquote"
|
|
253
|
-
icon={<QuoteIcon size={16} />}
|
|
254
|
-
isActive={editor?.isActive("blockquote")}
|
|
255
|
-
run={() => editor?.chain().focus().toggleBlockquote().run()}
|
|
256
|
-
disabled={disabled}
|
|
257
|
-
/>
|
|
258
|
-
<ToolbarButton
|
|
259
|
-
editor={editor}
|
|
260
|
-
label="Code block"
|
|
261
|
-
icon={<Code2Icon size={16} />}
|
|
262
|
-
isActive={editor?.isActive("codeBlock")}
|
|
263
|
-
run={() => editor?.chain().focus().toggleCodeBlock().run()}
|
|
264
|
-
disabled={disabled}
|
|
265
|
-
/>
|
|
433
|
+
<ToolbarSeparator />
|
|
266
434
|
|
|
267
|
-
|
|
435
|
+
<ToolbarButton
|
|
436
|
+
label={L.bulletList}
|
|
437
|
+
icon={<ListIcon size={16} />}
|
|
438
|
+
isActive={f?.bulletList}
|
|
439
|
+
disabled={disabled || !editorReady}
|
|
440
|
+
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
|
441
|
+
/>
|
|
442
|
+
{!compact && (
|
|
443
|
+
<>
|
|
444
|
+
<ToolbarButton
|
|
445
|
+
label={L.orderedList}
|
|
446
|
+
icon={<ListOrderedIcon size={16} />}
|
|
447
|
+
isActive={f?.orderedList}
|
|
448
|
+
disabled={disabled || !editorReady}
|
|
449
|
+
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
|
450
|
+
/>
|
|
451
|
+
<ToolbarButton
|
|
452
|
+
label={L.blockquote}
|
|
453
|
+
icon={<QuoteIcon size={16} />}
|
|
454
|
+
isActive={f?.blockquote}
|
|
455
|
+
disabled={disabled || !editorReady}
|
|
456
|
+
onClick={() => editor?.chain().focus().toggleBlockquote().run()}
|
|
457
|
+
/>
|
|
458
|
+
<ToolbarButton
|
|
459
|
+
label={L.codeBlock}
|
|
460
|
+
icon={<Code2Icon size={16} />}
|
|
461
|
+
isActive={f?.codeBlock}
|
|
462
|
+
disabled={disabled || !editorReady}
|
|
463
|
+
onClick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
|
464
|
+
/>
|
|
268
465
|
|
|
269
|
-
|
|
270
|
-
editor={editor}
|
|
271
|
-
label="Link"
|
|
272
|
-
icon={<LinkIcon size={16} />}
|
|
273
|
-
isActive={editor?.isActive("link")}
|
|
274
|
-
run={promptLink}
|
|
275
|
-
disabled={disabled}
|
|
276
|
-
/>
|
|
277
|
-
<ToolbarButton
|
|
278
|
-
editor={editor}
|
|
279
|
-
label="Horizontal rule"
|
|
280
|
-
icon={<MinusIcon size={16} />}
|
|
281
|
-
run={() => editor?.chain().focus().setHorizontalRule().run()}
|
|
282
|
-
disabled={disabled}
|
|
283
|
-
/>
|
|
466
|
+
<ToolbarSeparator />
|
|
284
467
|
|
|
285
|
-
|
|
468
|
+
<ToolbarButton
|
|
469
|
+
label={L.horizontalRule}
|
|
470
|
+
icon={<MinusIcon size={16} />}
|
|
471
|
+
disabled={disabled || !editorReady}
|
|
472
|
+
onClick={() => editor?.chain().focus().setHorizontalRule().run()}
|
|
473
|
+
/>
|
|
474
|
+
</>
|
|
475
|
+
)}
|
|
286
476
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
477
|
+
<ToolbarSeparator />
|
|
478
|
+
|
|
479
|
+
<ToolbarButton
|
|
480
|
+
label={L.undo}
|
|
481
|
+
icon={<Undo2Icon size={16} />}
|
|
482
|
+
disabled={disabled || !editorReady || !f?.canUndo}
|
|
483
|
+
onClick={() => editor?.chain().focus().undo().run()}
|
|
484
|
+
/>
|
|
485
|
+
<ToolbarButton
|
|
486
|
+
label={L.redo}
|
|
487
|
+
icon={<Redo2Icon size={16} />}
|
|
488
|
+
disabled={disabled || !editorReady || !f?.canRedo}
|
|
489
|
+
onClick={() => editor?.chain().focus().redo().run()}
|
|
490
|
+
/>
|
|
491
|
+
</div>
|
|
492
|
+
|
|
493
|
+
{panel === "color" && editor && (
|
|
494
|
+
<ColorRow
|
|
495
|
+
editor={editor}
|
|
496
|
+
current={f?.color}
|
|
497
|
+
onClose={() => setPanel("none")}
|
|
498
|
+
labels={L}
|
|
499
|
+
/>
|
|
500
|
+
)}
|
|
501
|
+
{panel === "link" && editor && (
|
|
502
|
+
<LinkRow
|
|
503
|
+
editor={editor}
|
|
504
|
+
hasLink={!!f?.link}
|
|
505
|
+
onClose={() => setPanel("none")}
|
|
506
|
+
labels={L}
|
|
507
|
+
/>
|
|
508
|
+
)}
|
|
509
|
+
</div>
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** 텍스트 색 선택 행 — 스와치 클릭 시 적용 후 닫힘. mousedown preventDefault 로 에디터 포커스 유지. */
|
|
514
|
+
function ColorRow({
|
|
515
|
+
editor,
|
|
516
|
+
current,
|
|
517
|
+
onClose,
|
|
518
|
+
labels: L,
|
|
519
|
+
}: {
|
|
520
|
+
editor: Editor;
|
|
521
|
+
current: string | undefined;
|
|
522
|
+
onClose: () => void;
|
|
523
|
+
labels: RichTextEditorLabels;
|
|
524
|
+
}) {
|
|
525
|
+
const choose = (value: string | null) => {
|
|
526
|
+
if (value === null) editor.chain().focus().unsetColor().run();
|
|
527
|
+
else editor.chain().focus().setColor(value).run();
|
|
528
|
+
onClose();
|
|
529
|
+
};
|
|
530
|
+
return (
|
|
531
|
+
<div className="sh-ui-rte__panel sh-ui-rte__panel--swatches">
|
|
532
|
+
<Swatch
|
|
533
|
+
label={L.colorDefault}
|
|
534
|
+
onChoose={() => choose(null)}
|
|
535
|
+
isActive={!current}
|
|
536
|
+
>
|
|
537
|
+
<span className="sh-ui-rte__swatch-a">A</span>
|
|
538
|
+
</Swatch>
|
|
539
|
+
{COLOR_SWATCHES.map((c) => {
|
|
540
|
+
const value = colorValue(c.cssVar);
|
|
541
|
+
return (
|
|
542
|
+
<Swatch
|
|
543
|
+
key={c.key}
|
|
544
|
+
label={L[c.labelKey]}
|
|
545
|
+
color={value}
|
|
546
|
+
isActive={current === value}
|
|
547
|
+
onChoose={() => choose(value)}
|
|
548
|
+
/>
|
|
549
|
+
);
|
|
550
|
+
})}
|
|
551
|
+
</div>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function Swatch({
|
|
556
|
+
label,
|
|
557
|
+
color,
|
|
558
|
+
isActive,
|
|
559
|
+
onChoose,
|
|
560
|
+
children,
|
|
561
|
+
}: {
|
|
562
|
+
label: string;
|
|
563
|
+
color?: string;
|
|
564
|
+
isActive?: boolean;
|
|
565
|
+
onChoose: () => void;
|
|
566
|
+
children?: React.ReactNode;
|
|
567
|
+
}) {
|
|
568
|
+
return (
|
|
569
|
+
<button
|
|
570
|
+
type="button"
|
|
571
|
+
className={cn("sh-ui-rte__swatch", isActive && "is-active")}
|
|
572
|
+
style={color ? { backgroundColor: color } : undefined}
|
|
573
|
+
aria-label={label}
|
|
574
|
+
aria-pressed={isActive || undefined}
|
|
575
|
+
title={label}
|
|
576
|
+
onMouseDown={(e) => {
|
|
577
|
+
e.preventDefault();
|
|
578
|
+
}}
|
|
579
|
+
onClick={onChoose}
|
|
580
|
+
>
|
|
581
|
+
{children}
|
|
582
|
+
</button>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** 인라인 링크 편집 행 — window.prompt 대체. 적용/제거/취소(아이콘). Enter 적용, Esc 취소. */
|
|
587
|
+
function LinkRow({
|
|
588
|
+
editor,
|
|
589
|
+
hasLink,
|
|
590
|
+
onClose,
|
|
591
|
+
labels: L,
|
|
592
|
+
}: {
|
|
593
|
+
editor: Editor;
|
|
594
|
+
hasLink: boolean;
|
|
595
|
+
onClose: () => void;
|
|
596
|
+
labels: RichTextEditorLabels;
|
|
597
|
+
}) {
|
|
598
|
+
const [url, setUrl] = useState(
|
|
599
|
+
() =>
|
|
600
|
+
(editor.getAttributes("link").href as string | undefined) ?? "https://",
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
const apply = () => {
|
|
604
|
+
applyLink(editor, url);
|
|
605
|
+
onClose();
|
|
606
|
+
};
|
|
607
|
+
const remove = () => {
|
|
608
|
+
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
609
|
+
onClose();
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
return (
|
|
613
|
+
<div className="sh-ui-rte__panel">
|
|
614
|
+
<input
|
|
615
|
+
type="url"
|
|
616
|
+
value={url}
|
|
617
|
+
autoFocus
|
|
618
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
619
|
+
onKeyDown={(e) => {
|
|
620
|
+
if (e.key === "Enter") {
|
|
621
|
+
e.preventDefault();
|
|
622
|
+
apply();
|
|
623
|
+
} else if (e.key === "Escape") {
|
|
624
|
+
e.preventDefault();
|
|
625
|
+
onClose();
|
|
626
|
+
}
|
|
627
|
+
}}
|
|
628
|
+
placeholder="https://"
|
|
629
|
+
aria-label={L.linkUrl}
|
|
630
|
+
className="sh-ui-rte__link-input"
|
|
302
631
|
/>
|
|
632
|
+
<button
|
|
633
|
+
type="button"
|
|
634
|
+
className="sh-ui-rte__btn"
|
|
635
|
+
aria-label={L.apply}
|
|
636
|
+
title={L.apply}
|
|
637
|
+
onClick={apply}
|
|
638
|
+
>
|
|
639
|
+
<CheckIcon size={16} />
|
|
640
|
+
</button>
|
|
641
|
+
{hasLink && (
|
|
642
|
+
<button
|
|
643
|
+
type="button"
|
|
644
|
+
className="sh-ui-rte__btn"
|
|
645
|
+
aria-label={L.removeLink}
|
|
646
|
+
title={L.removeLink}
|
|
647
|
+
onClick={remove}
|
|
648
|
+
>
|
|
649
|
+
<UnlinkIcon size={16} />
|
|
650
|
+
</button>
|
|
651
|
+
)}
|
|
652
|
+
<button
|
|
653
|
+
type="button"
|
|
654
|
+
className="sh-ui-rte__btn"
|
|
655
|
+
aria-label={L.cancel}
|
|
656
|
+
title={L.cancel}
|
|
657
|
+
onClick={onClose}
|
|
658
|
+
>
|
|
659
|
+
<XIcon size={16} />
|
|
660
|
+
</button>
|
|
303
661
|
</div>
|
|
304
662
|
);
|
|
305
663
|
}
|
|
306
664
|
|
|
307
665
|
interface ToolbarButtonProps {
|
|
308
|
-
editor: Editor | null;
|
|
309
666
|
label: string;
|
|
310
667
|
icon: React.ReactNode;
|
|
311
668
|
isActive?: boolean;
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
run: () => void;
|
|
315
|
-
disabled: boolean;
|
|
669
|
+
disabled?: boolean;
|
|
670
|
+
onClick: () => void;
|
|
316
671
|
}
|
|
317
672
|
|
|
318
673
|
function ToolbarButton({
|
|
319
|
-
editor,
|
|
320
674
|
label,
|
|
321
675
|
icon,
|
|
322
676
|
isActive,
|
|
323
|
-
canRun,
|
|
324
|
-
run,
|
|
325
677
|
disabled,
|
|
678
|
+
onClick,
|
|
326
679
|
}: ToolbarButtonProps) {
|
|
327
|
-
const isDisabled = disabled || !editor || (canRun ? !canRun() : false);
|
|
328
680
|
return (
|
|
329
681
|
<button
|
|
330
682
|
type="button"
|
|
@@ -332,11 +684,11 @@ function ToolbarButton({
|
|
|
332
684
|
aria-label={label}
|
|
333
685
|
aria-pressed={isActive || undefined}
|
|
334
686
|
title={label}
|
|
335
|
-
disabled={
|
|
687
|
+
disabled={disabled}
|
|
336
688
|
onMouseDown={(e) => {
|
|
337
689
|
e.preventDefault();
|
|
338
690
|
}}
|
|
339
|
-
onClick={
|
|
691
|
+
onClick={onClick}
|
|
340
692
|
>
|
|
341
693
|
{icon}
|
|
342
694
|
</button>
|