sh-ui-cli 0.115.0 → 0.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/bin/sh-ui.mjs +70 -3
  2. package/data/changelog/versions.json +24 -0
  3. package/data/registry/flutter/registry.json +9 -0
  4. package/data/registry/flutter/widgets/sh_ui_tree.dart +428 -0
  5. package/data/registry/react/components/rich-text-editor/index.module.tsx +98 -8
  6. package/data/registry/react/components/rich-text-editor/index.tailwind.tsx +87 -4
  7. package/data/registry/react/components/rich-text-editor/index.tsx +98 -8
  8. package/data/registry/react/components/rich-text-editor/rich-text-editor.test.tsx +283 -0
  9. package/data/registry/react/components/tree/flatten.test.ts +72 -0
  10. package/data/registry/react/components/tree/flatten.ts +69 -0
  11. package/data/registry/react/components/tree/index.module.tsx +215 -0
  12. package/data/registry/react/components/tree/index.tailwind.tsx +224 -0
  13. package/data/registry/react/components/tree/index.tsx +215 -0
  14. package/data/registry/react/components/tree/styles.css +69 -0
  15. package/data/registry/react/components/tree/styles.module.css +69 -0
  16. package/data/registry/react/components/tree/tree.test.tsx +110 -0
  17. package/data/registry/react/components/tree/types.ts +36 -0
  18. package/data/registry/react/peer-versions.json +9 -1
  19. package/data/registry/react/registry.json +45 -0
  20. package/data/registry/react/tokens-used.json +40 -1
  21. package/data/summaries/react.json +2 -1
  22. package/package.json +3 -3
  23. package/src/add.mjs +31 -1
  24. package/src/commands.mjs +6 -0
  25. package/src/create/generator.js +4 -0
  26. package/src/doctor.mjs +14 -0
  27. package/src/init.mjs +19 -0
  28. package/src/levenshtein.mjs +36 -0
  29. package/src/list.mjs +13 -0
  30. package/src/mcp.mjs +14 -0
  31. package/src/migrate-bundled.mjs +14 -0
  32. package/src/migrate-v065.mjs +14 -0
  33. package/src/remove.mjs +15 -0
  34. package/src/rename-app.mjs +15 -0
  35. package/src/theme-extract.mjs +13 -0
  36. package/src/tokens-cmd.mjs +12 -0
  37. package/src/upgrade-cli.mjs +13 -0
@@ -1,16 +1,19 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState } from "react";
3
+ import * as React from "react";
4
+ import { useEffect, useRef, useState } from "react";
4
5
  import {
5
6
  useEditor,
6
7
  useEditorState,
7
8
  EditorContent,
8
9
  type Editor,
9
10
  } from "@tiptap/react";
11
+ import type { AnyExtension } from "@tiptap/core";
10
12
  import StarterKit from "@tiptap/starter-kit";
11
13
  import Placeholder from "@tiptap/extension-placeholder";
12
14
  import Link from "@tiptap/extension-link";
13
15
  import { TextStyle, Color } from "@tiptap/extension-text-style";
16
+ import { Markdown } from "tiptap-markdown";
14
17
  import { cn } from "@SH_UI_UTILS@";
15
18
  import {
16
19
  BoldIcon,
@@ -97,17 +100,42 @@ const DEFAULT_LABELS: RichTextEditorLabels = {
97
100
 
98
101
  export interface RichTextEditorProps {
99
102
  /**
100
- * Controlled 현재 HTML. 명시 외부 상태가 진실원천이 되고 onChange 로 갱신한다.
103
+ * 입출력 포맷. 기본 'html'(하위호환). 'markdown' 이면 value/defaultValue/onChange
104
+ * markdown 문자열로 동작한다(tiptap-markdown 직렬화).
105
+ *
106
+ * 주의: 이 값은 **마운트 시점에만** 읽힌다 — 내부 `useEditor` 는 한 번만 생성되므로
107
+ * 런타임에 format 을 바꾸려면 에디터를 리마운트해야 한다(예: key prop 교체).
108
+ * 동일 제약이 `extensions` 에도 적용된다.
109
+ * @default "html"
110
+ */
111
+ format?: "html" | "markdown";
112
+ /**
113
+ * Controlled — 현재 본문(format 에 따라 HTML 또는 markdown 문자열).
114
+ * 명시 시 외부 상태가 진실원천이 되고 onChange 로 갱신한다.
101
115
  * 미지정이면 uncontrolled — Tiptap editor 가 자체 doc 으로 동작.
102
116
  */
103
117
  value?: string;
104
118
  /**
105
- * Uncontrolled 초기 HTML. value 미지정 시에만 사용.
119
+ * Uncontrolled 초기 본문(format 에 따라 HTML 또는 markdown 문자열). value 미지정 시에만 사용.
106
120
  * @default ""
107
121
  */
108
122
  defaultValue?: string;
109
- /** 본문이 바뀔 때마다 호출 (controlled · uncontrolled 모두). HTML 문자열을 그대로 넘긴다. */
110
- onChange?: (html: string) => void;
123
+ /**
124
+ * 본문이 바뀔 때마다 호출 (controlled · uncontrolled 모두).
125
+ * format='html' 이면 HTML, format='markdown' 이면 markdown 문자열을 넘긴다.
126
+ */
127
+ onChange?: (value: string) => void;
128
+ /** Enter 로 제출. true 면 Enter=onSubmit, Shift+Enter=줄바꿈. 기본 false. */
129
+ submitOnEnter?: boolean;
130
+ /** 제출 콜백(submitOnEnter 또는 외부 버튼). */
131
+ onSubmit?: () => void;
132
+ /**
133
+ * StarterKit·기본 확장 뒤에 append 할 추가 TipTap 확장(멘션 등).
134
+ * 주의: `format` 과 마찬가지로 마운트 시점에만 읽힌다 — 런타임 변경은 리마운트 필요.
135
+ */
136
+ extensions?: AnyExtension[];
137
+ /** 에디터 생성 시 콜백(외부에서 인스턴스 제어). */
138
+ onCreate?: (editor: Editor) => void;
111
139
  /** 비어 있을 때 표시할 placeholder. */
112
140
  placeholder?: string;
113
141
  /** 읽기 전용. 키 입력·툴바 차단. */
@@ -141,6 +169,16 @@ const COLOR_SWATCHES = [
141
169
 
142
170
  const colorValue = (cssVar: string) => `var(${cssVar})`;
143
171
 
172
+ /** tiptap-markdown storage 로 현재 doc 을 markdown 문자열로 직렬화. */
173
+ function readMarkdown(editor: Editor): string {
174
+ const storage = editor.storage as {
175
+ markdown?: { getMarkdown(): string };
176
+ };
177
+ // markdown storage 가 없으면(직렬화 확장 미등록) HTML 을 흘려보내면 포맷이 어긋난다 —
178
+ // markdown 을 기대한 호출자에게 HTML 을 주지 않도록 빈 문자열로 폴백.
179
+ return storage.markdown?.getMarkdown() ?? "";
180
+ }
181
+
144
182
  /** 선택 영역(없으면 URL 텍스트 삽입)에 링크를 적용. */
145
183
  function applyLink(editor: Editor, rawUrl: string) {
146
184
  const url = rawUrl.trim();
@@ -178,9 +216,14 @@ type ToolbarPanel = "none" | "link" | "color";
178
216
  * 구분선 · 실행취소/다시실행. compact 로 핵심만, toolbarMode="focus" 로 인라인 느낌.
179
217
  */
180
218
  export function RichTextEditor({
219
+ format = "html",
181
220
  value: valueProp,
182
221
  defaultValue,
183
222
  onChange,
223
+ submitOnEnter = false,
224
+ onSubmit,
225
+ extensions,
226
+ onCreate,
184
227
  placeholder,
185
228
  readOnly = false,
186
229
  hideToolbar = false,
@@ -197,6 +240,23 @@ export function RichTextEditor({
197
240
  const [panel, setPanel] = useState<ToolbarPanel>("none");
198
241
  const L = labels ? { ...DEFAULT_LABELS, ...labels } : DEFAULT_LABELS;
199
242
 
243
+ // onSubmit/submitOnEnter 를 ref 로 잡아 handleKeyDown 이 stale closure 가 되지 않게
244
+ // 한다(에디터를 매 렌더마다 재생성하지 않으려고 콜백은 useEditor deps 에서 제외).
245
+ const onSubmitRef = useRef(onSubmit);
246
+ onSubmitRef.current = onSubmit;
247
+ const submitOnEnterRef = useRef(submitOnEnter);
248
+ submitOnEnterRef.current = submitOnEnter;
249
+
250
+ // 마지막으로 onChange 로 흘려보냈거나(우리 echo) controlled-sync 로 주입한 value.
251
+ // controlled-sync 가 자기 자신의 emit 을 다시 setContent 하는 루프(커서 점프)를 막는다.
252
+ // 비교는 정규화된 직렬화 형태가 아니라 "우리가 마지막으로 본 문자열" 기준이라
253
+ // markdown 정규화(`**hi**` vs `**hi**\n`)로도 깨지지 않는다.
254
+ const lastSyncedRef = useRef<string | undefined>(undefined);
255
+
256
+ /** format 에 맞춰 에디터의 현재 본문을 직렬화(html/markdown). */
257
+ const readValue = (ed: Editor): string =>
258
+ format === "markdown" ? readMarkdown(ed) : ed.getHTML();
259
+
200
260
  const editor = useEditor({
201
261
  extensions: [
202
262
  // Link/Underline 은 v3 StarterKit 에 포함 — Link 는 따로 설정하려 끄고 별도 등록(중복 경고 회피).
@@ -213,26 +273,56 @@ export function RichTextEditor({
213
273
  }),
214
274
  TextStyle,
215
275
  Color.configure({ types: ["textStyle"] }),
276
+ // markdown 모드에서만 직렬화 확장 등록 — html 모드(기본)는 기존과 동일.
277
+ ...(format === "markdown" ? [Markdown.configure({ html: false })] : []),
278
+ ...(extensions ?? []),
216
279
  ],
280
+ // markdown 모드에서 tiptap-markdown 은 문자열을 markdown 으로 파싱한다.
217
281
  content: valueProp ?? defaultValue ?? "",
218
282
  editable: !readOnly,
219
283
  immediatelyRender: false,
284
+ onCreate: ({ editor }) => {
285
+ onCreate?.(editor);
286
+ },
220
287
  onUpdate: ({ editor }) => {
221
- onChange?.(editor.getHTML());
288
+ const output = readValue(editor);
289
+ // 우리가 방금 emit 한 값을 controlled-sync 가 다시 주입하지 않도록 기록.
290
+ lastSyncedRef.current = output;
291
+ onChange?.(output);
222
292
  },
223
293
  editorProps: {
224
294
  attributes: {
225
295
  class: styles.rte__content,
226
296
  "aria-label": ariaLabel,
227
297
  },
298
+ handleKeyDown: (_view, event) => {
299
+ if (
300
+ submitOnEnterRef.current &&
301
+ event.key === "Enter" &&
302
+ !event.shiftKey &&
303
+ // IME 조합 확정 Enter 는 제출이 아님(한글 등 — 조합 확정을 잘못 제출하면
304
+ // 입력이 날아간다). isComposing + 레거시 keyCode 229 둘 다 가드.
305
+ !event.isComposing &&
306
+ event.keyCode !== 229
307
+ ) {
308
+ event.preventDefault();
309
+ onSubmitRef.current?.();
310
+ return true;
311
+ }
312
+ return false;
313
+ },
228
314
  },
229
315
  });
230
316
 
231
- // controlled 모드에서만 외부 value 를 에디터 doc 에 동기화
317
+ // controlled 모드에서만 외부 value 를 에디터 doc 에 동기화.
318
+ // 직렬화 결과(readValue) 와 비교하면 markdown 정규화로 영원히 불일치 → 매 렌더 setContent
319
+ // → 커서 점프가 난다. 대신 "마지막으로 우리가 주고받은 문자열"(lastSyncedRef) 과 비교해
320
+ // 자기 echo 만 건너뛰고, 진짜 외부 변경(예: 채널 전환)은 항상 로드한다.
232
321
  useEffect(() => {
233
322
  if (!isControlled) return;
234
323
  if (!editor) return;
235
- if (editor.getHTML() === valueProp) return;
324
+ if (valueProp === lastSyncedRef.current) return;
325
+ lastSyncedRef.current = valueProp;
236
326
  editor.commands.setContent(valueProp ?? "", { emitUpdate: false });
237
327
  }, [isControlled, valueProp, editor]);
238
328
 
@@ -1,16 +1,19 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState } from "react";
3
+ import * as React from "react";
4
+ import { useEffect, useRef, useState } from "react";
4
5
  import {
5
6
  useEditor,
6
7
  useEditorState,
7
8
  EditorContent,
8
9
  type Editor,
9
10
  } from "@tiptap/react";
11
+ import type { AnyExtension } from "@tiptap/core";
10
12
  import StarterKit from "@tiptap/starter-kit";
11
13
  import Placeholder from "@tiptap/extension-placeholder";
12
14
  import Link from "@tiptap/extension-link";
13
15
  import { TextStyle, Color } from "@tiptap/extension-text-style";
16
+ import { Markdown } from "tiptap-markdown";
14
17
  import { cn } from "@SH_UI_UTILS@";
15
18
  import {
16
19
  BoldIcon,
@@ -95,9 +98,26 @@ const DEFAULT_LABELS: RichTextEditorLabels = {
95
98
  };
96
99
 
97
100
  export interface RichTextEditorProps {
101
+ /**
102
+ * 입출력 포맷. 기본 'html'(하위호환). 'markdown' 이면 value/defaultValue/onChange 가 markdown 문자열.
103
+ * 주의: 마운트 시점에만 읽힘 — 런타임 변경은 에디터 리마운트 필요(extensions 도 동일).
104
+ */
105
+ format?: "html" | "markdown";
98
106
  value?: string;
99
107
  defaultValue?: string;
100
- onChange?: (html: string) => void;
108
+ /** format='html' 이면 HTML, format='markdown' 이면 markdown 문자열을 넘긴다. */
109
+ onChange?: (value: string) => void;
110
+ /** Enter 로 제출. true 면 Enter=onSubmit, Shift+Enter=줄바꿈. 기본 false. */
111
+ submitOnEnter?: boolean;
112
+ /** 제출 콜백(submitOnEnter 또는 외부 버튼). */
113
+ onSubmit?: () => void;
114
+ /**
115
+ * StarterKit·기본 확장 뒤에 append 할 추가 TipTap 확장(멘션 등).
116
+ * 주의: format 과 마찬가지로 마운트 시점에만 읽힘 — 런타임 변경은 리마운트 필요.
117
+ */
118
+ extensions?: AnyExtension[];
119
+ /** 에디터 생성 시 콜백(외부에서 인스턴스 제어). */
120
+ onCreate?: (editor: Editor) => void;
101
121
  placeholder?: string;
102
122
  readOnly?: boolean;
103
123
  hideToolbar?: boolean;
@@ -127,6 +147,16 @@ const COLOR_SWATCHES = [
127
147
 
128
148
  const colorValue = (cssVar: string) => `var(${cssVar})`;
129
149
 
150
+ /** tiptap-markdown storage 로 현재 doc 을 markdown 문자열로 직렬화. */
151
+ function readMarkdown(editor: Editor): string {
152
+ const storage = editor.storage as {
153
+ markdown?: { getMarkdown(): string };
154
+ };
155
+ // markdown storage 가 없으면(직렬화 확장 미등록) HTML 을 흘려보내면 포맷이 어긋난다 —
156
+ // markdown 을 기대한 호출자에게 HTML 을 주지 않도록 빈 문자열로 폴백.
157
+ return storage.markdown?.getMarkdown() ?? "";
158
+ }
159
+
130
160
  /** 선택 영역(없으면 URL 텍스트 삽입)에 링크를 적용. */
131
161
  function applyLink(editor: Editor, rawUrl: string) {
132
162
  const url = rawUrl.trim();
@@ -154,9 +184,14 @@ function applyLink(editor: Editor, rawUrl: string) {
154
184
  type ToolbarPanel = "none" | "link" | "color";
155
185
 
156
186
  export function RichTextEditor({
187
+ format = "html",
157
188
  value: valueProp,
158
189
  defaultValue,
159
190
  onChange,
191
+ submitOnEnter = false,
192
+ onSubmit,
193
+ extensions,
194
+ onCreate,
160
195
  placeholder,
161
196
  readOnly = false,
162
197
  hideToolbar = false,
@@ -173,6 +208,23 @@ export function RichTextEditor({
173
208
  const [panel, setPanel] = useState<ToolbarPanel>("none");
174
209
  const L = labels ? { ...DEFAULT_LABELS, ...labels } : DEFAULT_LABELS;
175
210
 
211
+ // onSubmit/submitOnEnter 를 ref 로 잡아 handleKeyDown 이 stale closure 가 되지 않게
212
+ // 한다(에디터를 매 렌더마다 재생성하지 않으려고 콜백은 useEditor deps 에서 제외).
213
+ const onSubmitRef = useRef(onSubmit);
214
+ onSubmitRef.current = onSubmit;
215
+ const submitOnEnterRef = useRef(submitOnEnter);
216
+ submitOnEnterRef.current = submitOnEnter;
217
+
218
+ // 마지막으로 onChange 로 흘려보냈거나(우리 echo) controlled-sync 로 주입한 value.
219
+ // controlled-sync 가 자기 자신의 emit 을 다시 setContent 하는 루프(커서 점프)를 막는다.
220
+ // 비교는 정규화된 직렬화 형태가 아니라 "우리가 마지막으로 본 문자열" 기준이라
221
+ // markdown 정규화(`**hi**` vs `**hi**\n`)로도 깨지지 않는다.
222
+ const lastSyncedRef = useRef<string | undefined>(undefined);
223
+
224
+ /** format 에 맞춰 에디터의 현재 본문을 직렬화(html/markdown). */
225
+ const readValue = (ed: Editor): string =>
226
+ format === "markdown" ? readMarkdown(ed) : ed.getHTML();
227
+
176
228
  const editor = useEditor({
177
229
  extensions: [
178
230
  // Link/Underline 은 v3 StarterKit 에 포함 — Link 는 따로 설정하려 끄고 별도 등록(중복 경고 회피).
@@ -189,22 +241,53 @@ export function RichTextEditor({
189
241
  }),
190
242
  TextStyle,
191
243
  Color.configure({ types: ["textStyle"] }),
244
+ // markdown 모드에서만 직렬화 확장 등록 — html 모드(기본)는 기존과 동일.
245
+ ...(format === "markdown" ? [Markdown.configure({ html: false })] : []),
246
+ ...(extensions ?? []),
192
247
  ],
248
+ // markdown 모드에서 tiptap-markdown 은 문자열을 markdown 으로 파싱한다.
193
249
  content: valueProp ?? defaultValue ?? "",
194
250
  editable: !readOnly,
195
251
  immediatelyRender: false,
252
+ onCreate: ({ editor }) => {
253
+ onCreate?.(editor);
254
+ },
196
255
  onUpdate: ({ editor }) => {
197
- onChange?.(editor.getHTML());
256
+ const output = readValue(editor);
257
+ // 우리가 방금 emit 한 값을 controlled-sync 가 다시 주입하지 않도록 기록.
258
+ lastSyncedRef.current = output;
259
+ onChange?.(output);
198
260
  },
199
261
  editorProps: {
200
262
  attributes: { class: "sh-ui-rte__content", "aria-label": ariaLabel },
263
+ handleKeyDown: (_view, event) => {
264
+ if (
265
+ submitOnEnterRef.current &&
266
+ event.key === "Enter" &&
267
+ !event.shiftKey &&
268
+ // IME 조합 확정 Enter 는 제출이 아님(한글 등 — 조합 확정을 잘못 제출하면
269
+ // 입력이 날아간다). isComposing + 레거시 keyCode 229 둘 다 가드.
270
+ !event.isComposing &&
271
+ event.keyCode !== 229
272
+ ) {
273
+ event.preventDefault();
274
+ onSubmitRef.current?.();
275
+ return true;
276
+ }
277
+ return false;
278
+ },
201
279
  },
202
280
  });
203
281
 
282
+ // controlled 모드에서만 외부 value 를 에디터 doc 에 동기화.
283
+ // 직렬화 결과(readValue) 와 비교하면 markdown 정규화로 영원히 불일치 → 매 렌더 setContent
284
+ // → 커서 점프가 난다. 대신 "마지막으로 우리가 주고받은 문자열"(lastSyncedRef) 과 비교해
285
+ // 자기 echo 만 건너뛰고, 진짜 외부 변경(예: 채널 전환)은 항상 로드한다.
204
286
  useEffect(() => {
205
287
  if (!isControlled) return;
206
288
  if (!editor) return;
207
- if (editor.getHTML() === valueProp) return;
289
+ if (valueProp === lastSyncedRef.current) return;
290
+ lastSyncedRef.current = valueProp;
208
291
  editor.commands.setContent(valueProp ?? "", { emitUpdate: false });
209
292
  }, [isControlled, valueProp, editor]);
210
293
 
@@ -1,16 +1,19 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState } from "react";
3
+ import * as React from "react";
4
+ import { useEffect, useRef, useState } from "react";
4
5
  import {
5
6
  useEditor,
6
7
  useEditorState,
7
8
  EditorContent,
8
9
  type Editor,
9
10
  } from "@tiptap/react";
11
+ import type { AnyExtension } from "@tiptap/core";
10
12
  import StarterKit from "@tiptap/starter-kit";
11
13
  import Placeholder from "@tiptap/extension-placeholder";
12
14
  import Link from "@tiptap/extension-link";
13
15
  import { TextStyle, Color } from "@tiptap/extension-text-style";
16
+ import { Markdown } from "tiptap-markdown";
14
17
  import { cn } from "@SH_UI_UTILS@";
15
18
  import {
16
19
  BoldIcon,
@@ -97,17 +100,42 @@ const DEFAULT_LABELS: RichTextEditorLabels = {
97
100
 
98
101
  export interface RichTextEditorProps {
99
102
  /**
100
- * Controlled 현재 HTML. 명시 외부 상태가 진실원천이 되고 onChange 로 갱신한다.
103
+ * 입출력 포맷. 기본 'html'(하위호환). 'markdown' 이면 value/defaultValue/onChange
104
+ * markdown 문자열로 동작한다(tiptap-markdown 직렬화).
105
+ *
106
+ * 주의: 이 값은 **마운트 시점에만** 읽힌다 — 내부 `useEditor` 는 한 번만 생성되므로
107
+ * 런타임에 format 을 바꾸려면 에디터를 리마운트해야 한다(예: key prop 교체).
108
+ * 동일 제약이 `extensions` 에도 적용된다.
109
+ * @default "html"
110
+ */
111
+ format?: "html" | "markdown";
112
+ /**
113
+ * Controlled — 현재 본문(format 에 따라 HTML 또는 markdown 문자열).
114
+ * 명시 시 외부 상태가 진실원천이 되고 onChange 로 갱신한다.
101
115
  * 미지정이면 uncontrolled — Tiptap editor 가 자체 doc 으로 동작.
102
116
  */
103
117
  value?: string;
104
118
  /**
105
- * Uncontrolled 초기 HTML. value 미지정 시에만 사용.
119
+ * Uncontrolled 초기 본문(format 에 따라 HTML 또는 markdown 문자열). value 미지정 시에만 사용.
106
120
  * @default ""
107
121
  */
108
122
  defaultValue?: string;
109
- /** 본문이 바뀔 때마다 호출 (controlled · uncontrolled 모두). HTML 문자열을 그대로 넘긴다. */
110
- onChange?: (html: string) => void;
123
+ /**
124
+ * 본문이 바뀔 때마다 호출 (controlled · uncontrolled 모두).
125
+ * format='html' 이면 HTML, format='markdown' 이면 markdown 문자열을 넘긴다.
126
+ */
127
+ onChange?: (value: string) => void;
128
+ /** Enter 로 제출. true 면 Enter=onSubmit, Shift+Enter=줄바꿈. 기본 false. */
129
+ submitOnEnter?: boolean;
130
+ /** 제출 콜백(submitOnEnter 또는 외부 버튼). */
131
+ onSubmit?: () => void;
132
+ /**
133
+ * StarterKit·기본 확장 뒤에 append 할 추가 TipTap 확장(멘션 등).
134
+ * 주의: `format` 과 마찬가지로 마운트 시점에만 읽힌다 — 런타임 변경은 리마운트 필요.
135
+ */
136
+ extensions?: AnyExtension[];
137
+ /** 에디터 생성 시 콜백(외부에서 인스턴스 제어). */
138
+ onCreate?: (editor: Editor) => void;
111
139
  /** 비어 있을 때 표시할 placeholder. */
112
140
  placeholder?: string;
113
141
  /** 읽기 전용. 키 입력·툴바 차단. */
@@ -141,6 +169,16 @@ const COLOR_SWATCHES = [
141
169
 
142
170
  const colorValue = (cssVar: string) => `var(${cssVar})`;
143
171
 
172
+ /** tiptap-markdown storage 로 현재 doc 을 markdown 문자열로 직렬화. */
173
+ function readMarkdown(editor: Editor): string {
174
+ const storage = editor.storage as {
175
+ markdown?: { getMarkdown(): string };
176
+ };
177
+ // markdown storage 가 없으면(직렬화 확장 미등록) HTML 을 흘려보내면 포맷이 어긋난다 —
178
+ // markdown 을 기대한 호출자에게 HTML 을 주지 않도록 빈 문자열로 폴백.
179
+ return storage.markdown?.getMarkdown() ?? "";
180
+ }
181
+
144
182
  /** 선택 영역(없으면 URL 텍스트 삽입)에 링크를 적용. */
145
183
  function applyLink(editor: Editor, rawUrl: string) {
146
184
  const url = rawUrl.trim();
@@ -178,9 +216,14 @@ type ToolbarPanel = "none" | "link" | "color";
178
216
  * 구분선 · 실행취소/다시실행. compact 로 핵심만, toolbarMode="focus" 로 인라인 느낌.
179
217
  */
180
218
  export function RichTextEditor({
219
+ format = "html",
181
220
  value: valueProp,
182
221
  defaultValue,
183
222
  onChange,
223
+ submitOnEnter = false,
224
+ onSubmit,
225
+ extensions,
226
+ onCreate,
184
227
  placeholder,
185
228
  readOnly = false,
186
229
  hideToolbar = false,
@@ -197,6 +240,23 @@ export function RichTextEditor({
197
240
  const [panel, setPanel] = useState<ToolbarPanel>("none");
198
241
  const L = labels ? { ...DEFAULT_LABELS, ...labels } : DEFAULT_LABELS;
199
242
 
243
+ // onSubmit/submitOnEnter 를 ref 로 잡아 handleKeyDown 이 stale closure 가 되지 않게
244
+ // 한다(에디터를 매 렌더마다 재생성하지 않으려고 콜백은 useEditor deps 에서 제외).
245
+ const onSubmitRef = useRef(onSubmit);
246
+ onSubmitRef.current = onSubmit;
247
+ const submitOnEnterRef = useRef(submitOnEnter);
248
+ submitOnEnterRef.current = submitOnEnter;
249
+
250
+ // 마지막으로 onChange 로 흘려보냈거나(우리 echo) controlled-sync 로 주입한 value.
251
+ // controlled-sync 가 자기 자신의 emit 을 다시 setContent 하는 루프(커서 점프)를 막는다.
252
+ // 비교는 정규화된 직렬화 형태가 아니라 "우리가 마지막으로 본 문자열" 기준이라
253
+ // markdown 정규화(`**hi**` vs `**hi**\n`)로도 깨지지 않는다.
254
+ const lastSyncedRef = useRef<string | undefined>(undefined);
255
+
256
+ /** format 에 맞춰 에디터의 현재 본문을 직렬화(html/markdown). */
257
+ const readValue = (ed: Editor): string =>
258
+ format === "markdown" ? readMarkdown(ed) : ed.getHTML();
259
+
200
260
  const editor = useEditor({
201
261
  extensions: [
202
262
  // Link/Underline 은 v3 StarterKit 에 포함 — Link 는 따로 설정하려 끄고 별도 등록(중복 경고 회피).
@@ -213,26 +273,56 @@ export function RichTextEditor({
213
273
  }),
214
274
  TextStyle,
215
275
  Color.configure({ types: ["textStyle"] }),
276
+ // markdown 모드에서만 직렬화 확장 등록 — html 모드(기본)는 기존과 동일.
277
+ ...(format === "markdown" ? [Markdown.configure({ html: false })] : []),
278
+ ...(extensions ?? []),
216
279
  ],
280
+ // markdown 모드에서 tiptap-markdown 은 문자열을 markdown 으로 파싱한다.
217
281
  content: valueProp ?? defaultValue ?? "",
218
282
  editable: !readOnly,
219
283
  immediatelyRender: false,
284
+ onCreate: ({ editor }) => {
285
+ onCreate?.(editor);
286
+ },
220
287
  onUpdate: ({ editor }) => {
221
- onChange?.(editor.getHTML());
288
+ const output = readValue(editor);
289
+ // 우리가 방금 emit 한 값을 controlled-sync 가 다시 주입하지 않도록 기록.
290
+ lastSyncedRef.current = output;
291
+ onChange?.(output);
222
292
  },
223
293
  editorProps: {
224
294
  attributes: {
225
295
  class: "sh-ui-rte__content",
226
296
  "aria-label": ariaLabel,
227
297
  },
298
+ handleKeyDown: (_view, event) => {
299
+ if (
300
+ submitOnEnterRef.current &&
301
+ event.key === "Enter" &&
302
+ !event.shiftKey &&
303
+ // IME 조합 확정 Enter 는 제출이 아님(한글 등 — 조합 확정을 잘못 제출하면
304
+ // 입력이 날아간다). isComposing + 레거시 keyCode 229 둘 다 가드.
305
+ !event.isComposing &&
306
+ event.keyCode !== 229
307
+ ) {
308
+ event.preventDefault();
309
+ onSubmitRef.current?.();
310
+ return true;
311
+ }
312
+ return false;
313
+ },
228
314
  },
229
315
  });
230
316
 
231
- // controlled 모드에서만 외부 value 를 에디터 doc 에 동기화
317
+ // controlled 모드에서만 외부 value 를 에디터 doc 에 동기화.
318
+ // 직렬화 결과(readValue) 와 비교하면 markdown 정규화로 영원히 불일치 → 매 렌더 setContent
319
+ // → 커서 점프가 난다. 대신 "마지막으로 우리가 주고받은 문자열"(lastSyncedRef) 과 비교해
320
+ // 자기 echo 만 건너뛰고, 진짜 외부 변경(예: 채널 전환)은 항상 로드한다.
232
321
  useEffect(() => {
233
322
  if (!isControlled) return;
234
323
  if (!editor) return;
235
- if (editor.getHTML() === valueProp) return;
324
+ if (valueProp === lastSyncedRef.current) return;
325
+ lastSyncedRef.current = valueProp;
236
326
  editor.commands.setContent(valueProp ?? "", { emitUpdate: false });
237
327
  }, [isControlled, valueProp, editor]);
238
328