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