sh-ui-cli 0.114.0 → 0.115.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,19 +1,99 @@
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
- BoldIcon, ItalicIcon, StrikethroughIcon,
11
- Heading1Icon, Heading2Icon, Heading3Icon,
12
- ListIcon, ListOrderedIcon, QuoteIcon,
13
- CodeIcon, Code2Icon, LinkIcon, MinusIcon,
14
- Undo2Icon, Redo2Icon,
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, defaultValue, onChange, placeholder, readOnly = false, hideToolbar = false,
33
- minHeight, maxHeight, className,
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
- openOnClick: false, autolink: true,
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 }) => { onChange?.(editor.getHTML()); },
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(() => { editor?.setEditable(!readOnly); }, [readOnly, editor]);
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 border border-border rounded-[var(--radius)] bg-background overflow-hidden transition-[border-color] duration-[var(--duration-fast)] focus-within:border-primary focus-within:outline-[length:var(--border-width-strong)] focus-within:outline-ring focus-within:outline-offset-2 data-[readonly]:bg-background-subtle motion-reduce:transition-none",
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
- style={{ "--sh-ui-rte-min-height": minHeight, "--sh-ui-rte-max-height": maxHeight } as React.CSSProperties}
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
- {!hideToolbar && <Toolbar editor={editor} disabled={readOnly} />}
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 { editor: Editor | null; disabled: boolean; }
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
- function Toolbar({ editor, disabled }: ToolbarProps) {
85
- const promptLink = useCallback(() => {
86
- if (!editor) return;
87
- const previous = editor.getAttributes("link").href as string | undefined;
88
- const url = window.prompt("URL", previous ?? "https://");
89
- if (url === null) return;
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="flex flex-wrap items-center gap-0.5 py-[var(--space-1)] px-[var(--space-2)] bg-background-muted border-b border-border"
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
- <ToolbarButton editor={editor} label="Bold" icon={<BoldIcon size={16} />} isActive={editor?.isActive("bold")} canRun={() => !!editor?.can().toggleBold()} run={() => editor?.chain().focus().toggleBold().run()} disabled={disabled} />
114
- <ToolbarButton editor={editor} label="Italic" icon={<ItalicIcon size={16} />} isActive={editor?.isActive("italic")} canRun={() => !!editor?.can().toggleItalic()} run={() => editor?.chain().focus().toggleItalic().run()} disabled={disabled} />
115
- <ToolbarButton editor={editor} label="Strikethrough" icon={<StrikethroughIcon size={16} />} isActive={editor?.isActive("strike")} canRun={() => !!editor?.can().toggleStrike()} run={() => editor?.chain().focus().toggleStrike().run()} disabled={disabled} />
116
- <ToolbarButton editor={editor} label="Inline code" icon={<CodeIcon size={16} />} isActive={editor?.isActive("code")} canRun={() => !!editor?.can().toggleCode()} run={() => editor?.chain().focus().toggleCode().run()} disabled={disabled} />
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
- <ToolbarSeparator />
358
+ <ToolbarSeparator />
119
359
 
120
- <ToolbarButton editor={editor} label="Heading 1" icon={<Heading1Icon size={16} />} isActive={editor?.isActive("heading", { level: 1 })} run={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()} disabled={disabled} />
121
- <ToolbarButton editor={editor} label="Heading 2" icon={<Heading2Icon size={16} />} isActive={editor?.isActive("heading", { level: 2 })} run={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} disabled={disabled} />
122
- <ToolbarButton editor={editor} label="Heading 3" icon={<Heading3Icon size={16} />} isActive={editor?.isActive("heading", { level: 3 })} run={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()} disabled={disabled} />
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
- <ToolbarSeparator />
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
- <ToolbarButton editor={editor} label="Bulleted list" icon={<ListIcon size={16} />} isActive={editor?.isActive("bulletList")} run={() => editor?.chain().focus().toggleBulletList().run()} disabled={disabled} />
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
- <ToolbarSeparator />
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
- <ToolbarButton editor={editor} label="Link" icon={<LinkIcon size={16} />} isActive={editor?.isActive("link")} run={promptLink} disabled={disabled} />
134
- <ToolbarButton editor={editor} label="Horizontal rule" icon={<MinusIcon size={16} />} run={() => editor?.chain().focus().setHorizontalRule().run()} disabled={disabled} />
441
+ <ToolbarSeparator />
135
442
 
136
- <ToolbarSeparator />
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
- <ToolbarButton editor={editor} label="Undo" icon={<Undo2Icon size={16} />} canRun={() => !!editor?.can().undo()} run={() => editor?.chain().focus().undo().run()} disabled={disabled} />
139
- <ToolbarButton editor={editor} label="Redo" icon={<Redo2Icon size={16} />} canRun={() => !!editor?.can().redo()} run={() => editor?.chain().focus().redo().run()} disabled={disabled} />
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
- canRun?: () => boolean;
150
- run: () => void;
151
- disabled: boolean;
655
+ disabled?: boolean;
656
+ onClick: () => void;
152
657
  }
153
658
 
154
- function ToolbarButton({ editor, label, icon, isActive, canRun, run, disabled }: ToolbarButtonProps) {
155
- const isDisabled = disabled || !editor || (canRun ? !canRun() : false);
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
- "inline-flex items-center justify-center w-7 h-7 p-0 bg-transparent text-foreground-muted border border-transparent rounded-[calc(var(--radius)-2px)] cursor-pointer transition-[color,background-color,border-color] duration-[var(--duration-fast)] hover:not-disabled:text-foreground hover:not-disabled:bg-background hover:not-disabled:border-border focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-ring focus-visible:outline-offset-1 disabled:opacity-[var(--opacity-disabled)] disabled:cursor-not-allowed motion-reduce:transition-none",
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={isDisabled}
167
- onMouseDown={(e) => { e.preventDefault(); }}
168
- onClick={run}
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 <span aria-hidden className="inline-block w-px h-5 mx-[var(--space-1)] bg-border" />;
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 (typeof document !== "undefined" && !document.querySelector("style[data-sh-ui-rte]")) {
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); }