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.
@@ -1,14 +1,21 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect } from "react";
4
- import { useEditor, EditorContent, type Editor } from "@tiptap/react";
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 from "./styles.module.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.module.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
- * 기본 toolbar StarterKit 표준 마크업(헤딩·리스트·인용·코드·링크 등) 다룬다.
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: styles["rte__is-empty"],
83
207
  }),
84
208
  Link.configure({
85
- openOnClick: false,
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(styles.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
- {!hideToolbar && <Toolbar editor={editor} disabled={readOnly} />}
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={styles.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
- function Toolbar({ editor, disabled }: ToolbarProps) {
139
- const promptLink = useCallback(() => {
140
- if (!editor) return;
141
- const previous = editor.getAttributes("link").href as string | undefined;
142
- const url = window.prompt("URL", previous ?? "https://");
143
- if (url === null) return;
144
- if (url === "") {
145
- editor.chain().focus().extendMarkRange("link").unsetLink().run();
146
- return;
147
- }
148
- const { empty } = editor.state.selection;
149
- const inLink = editor.isActive("link");
150
- if (empty && !inLink) {
151
- // 선택 없이 호출되면 URL 자체를 anchor 텍스트로 삽입 — 빈 링크 마크가 남는 걸 방지
152
- editor
153
- .chain()
154
- .focus()
155
- .insertContent({
156
- type: "text",
157
- text: url,
158
- marks: [{ type: "link", attrs: { href: url } }],
159
- })
160
- .run();
161
- return;
162
- }
163
- editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
164
- }, [editor]);
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 className={styles.rte__toolbar} role="toolbar" aria-label="Formatting" aria-disabled={disabled || undefined}>
168
- <ToolbarButton
169
- editor={editor}
170
- label="Bold"
171
- icon={<BoldIcon size={16} />}
172
- isActive={editor?.isActive("bold")}
173
- canRun={() => !!editor?.can().toggleBold()}
174
- run={() => editor?.chain().focus().toggleBold().run()}
175
- disabled={disabled}
176
- />
177
- <ToolbarButton
178
- editor={editor}
179
- label="Italic"
180
- icon={<ItalicIcon size={16} />}
181
- isActive={editor?.isActive("italic")}
182
- canRun={() => !!editor?.can().toggleItalic()}
183
- run={() => editor?.chain().focus().toggleItalic().run()}
184
- disabled={disabled}
185
- />
186
- <ToolbarButton
187
- editor={editor}
188
- label="Strikethrough"
189
- icon={<StrikethroughIcon size={16} />}
190
- isActive={editor?.isActive("strike")}
191
- canRun={() => !!editor?.can().toggleStrike()}
192
- run={() => editor?.chain().focus().toggleStrike().run()}
193
- disabled={disabled}
194
- />
195
- <ToolbarButton
196
- editor={editor}
197
- label="Inline code"
198
- icon={<CodeIcon size={16} />}
199
- isActive={editor?.isActive("code")}
200
- canRun={() => !!editor?.can().toggleCode()}
201
- run={() => editor?.chain().focus().toggleCode().run()}
202
- disabled={disabled}
203
- />
338
+ <div
339
+ className={styles.rte__toolbar}
340
+ role="toolbar"
341
+ aria-label="Formatting"
342
+ aria-disabled={disabled || undefined}
343
+ >
344
+ <div className={styles["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
- <ToolbarSeparator />
383
+ <ToolbarSeparator />
206
384
 
207
- <ToolbarButton
208
- editor={editor}
209
- label="Heading 1"
210
- icon={<Heading1Icon size={16} />}
211
- isActive={editor?.isActive("heading", { level: 1 })}
212
- run={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}
213
- disabled={disabled}
214
- />
215
- <ToolbarButton
216
- editor={editor}
217
- label="Heading 2"
218
- icon={<Heading2Icon size={16} />}
219
- isActive={editor?.isActive("heading", { level: 2 })}
220
- run={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
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
- <ToolbarSeparator />
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
- <ToolbarButton
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
- <ToolbarSeparator />
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
- <ToolbarButton
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
- <ToolbarSeparator />
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
- <ToolbarButton
288
- editor={editor}
289
- label="Undo"
290
- icon={<Undo2Icon size={16} />}
291
- canRun={() => !!editor?.can().undo()}
292
- run={() => editor?.chain().focus().undo().run()}
293
- disabled={disabled}
294
- />
295
- <ToolbarButton
296
- editor={editor}
297
- label="Redo"
298
- icon={<Redo2Icon size={16} />}
299
- canRun={() => !!editor?.can().redo()}
300
- run={() => editor?.chain().focus().redo().run()}
301
- disabled={disabled}
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={cn(styles.rte__panel, styles["rte__panel--swatches"])}>
532
+ <Swatch
533
+ label={L.colorDefault}
534
+ onChoose={() => choose(null)}
535
+ isActive={!current}
536
+ >
537
+ <span className={styles["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(styles.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={styles.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={styles["rte__link-input"]}
302
631
  />
632
+ <button
633
+ type="button"
634
+ className={styles.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={styles.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={styles.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
- /** false 를 반환하면 비활성. */
313
- canRun?: () => boolean;
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={isDisabled}
687
+ disabled={disabled}
336
688
  onMouseDown={(e) => {
337
689
  e.preventDefault();
338
690
  }}
339
- onClick={run}
691
+ onClick={onClick}
340
692
  >
341
693
  {icon}
342
694
  </button>