sh-ui-cli 0.116.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.
- package/data/changelog/versions.json +12 -0
- package/data/registry/flutter/registry.json +9 -0
- package/data/registry/flutter/widgets/sh_ui_tree.dart +428 -0
- package/data/registry/react/components/rich-text-editor/index.module.tsx +98 -8
- package/data/registry/react/components/rich-text-editor/index.tailwind.tsx +87 -4
- package/data/registry/react/components/rich-text-editor/index.tsx +98 -8
- package/data/registry/react/components/rich-text-editor/rich-text-editor.test.tsx +283 -0
- package/data/registry/react/components/tree/flatten.test.ts +72 -0
- package/data/registry/react/components/tree/flatten.ts +69 -0
- package/data/registry/react/components/tree/index.module.tsx +215 -0
- package/data/registry/react/components/tree/index.tailwind.tsx +224 -0
- package/data/registry/react/components/tree/index.tsx +215 -0
- package/data/registry/react/components/tree/styles.css +69 -0
- package/data/registry/react/components/tree/styles.module.css +69 -0
- package/data/registry/react/components/tree/tree.test.tsx +110 -0
- package/data/registry/react/components/tree/types.ts +36 -0
- package/data/registry/react/peer-versions.json +9 -1
- package/data/registry/react/registry.json +45 -0
- package/data/registry/react/tokens-used.json +40 -1
- package/data/summaries/react.json +2 -1
- package/package.json +3 -3
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
110
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { Extension } from "@tiptap/core";
|
|
6
|
+
import { RichTextEditor } from "./index";
|
|
7
|
+
|
|
8
|
+
void React;
|
|
9
|
+
|
|
10
|
+
describe('RichTextEditor format="markdown"', () => {
|
|
11
|
+
it("markdown defaultValue 를 받아 굵게 마크로 렌더한다", async () => {
|
|
12
|
+
render(<RichTextEditor format="markdown" defaultValue={"**굵게**"} />);
|
|
13
|
+
// 에디터가 굵게 마크로 렌더(strong 존재)
|
|
14
|
+
await screen.findByText("굵게");
|
|
15
|
+
expect(screen.getByText("굵게").closest("strong")).not.toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("편집 시 onChange 가 markdown 문자열을 돌려준다", async () => {
|
|
19
|
+
const onChange = vi.fn();
|
|
20
|
+
let editor: import("@tiptap/react").Editor | undefined;
|
|
21
|
+
render(
|
|
22
|
+
<RichTextEditor
|
|
23
|
+
format="markdown"
|
|
24
|
+
defaultValue={"*기울임*"}
|
|
25
|
+
onChange={onChange}
|
|
26
|
+
onCreate={(e) => {
|
|
27
|
+
editor = e;
|
|
28
|
+
}}
|
|
29
|
+
/>,
|
|
30
|
+
);
|
|
31
|
+
await screen.findByText("기울임");
|
|
32
|
+
await vi.waitFor(() => expect(editor).toBeDefined());
|
|
33
|
+
// 본문 끝에 굵은 텍스트를 추가하는 편집을 트랜잭션으로 일으켜 onUpdate 를 트리거.
|
|
34
|
+
editor!.chain().focus("end").insertContent(" **굵게**").run();
|
|
35
|
+
await vi.waitFor(() => expect(onChange).toHaveBeenCalled());
|
|
36
|
+
const out = onChange.mock.calls.at(-1)![0] as string;
|
|
37
|
+
// markdown(HTML 아님) 으로 직렬화됐는지 검증: 굵게 마크 + 태그 부재.
|
|
38
|
+
expect(out).toContain("**굵게**");
|
|
39
|
+
expect(out).not.toContain("<strong>");
|
|
40
|
+
// _italic_ 은 CommonMark 정규화로 *italic* 형태(별표 기반)로 나온다.
|
|
41
|
+
expect(out).toContain("*기울임*");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("html 모드(기본)에서는 markdown storage 가 없다(직렬화 미등록)", async () => {
|
|
45
|
+
let editor: import("@tiptap/react").Editor | undefined;
|
|
46
|
+
render(
|
|
47
|
+
<RichTextEditor
|
|
48
|
+
defaultValue={"<p><em>기울임</em></p>"}
|
|
49
|
+
onCreate={(e) => {
|
|
50
|
+
editor = e;
|
|
51
|
+
}}
|
|
52
|
+
/>,
|
|
53
|
+
);
|
|
54
|
+
await screen.findByText("기울임");
|
|
55
|
+
await vi.waitFor(() => expect(editor).toBeDefined());
|
|
56
|
+
const storage = editor!.storage as { markdown?: unknown };
|
|
57
|
+
expect(storage.markdown).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("RichTextEditor submitOnEnter", () => {
|
|
62
|
+
it("Enter 는 onSubmit, Shift+Enter 는 줄바꿈 (ProseMirror handleKeyDown 경유)", async () => {
|
|
63
|
+
const user = userEvent.setup();
|
|
64
|
+
const onSubmit = vi.fn();
|
|
65
|
+
let editor: import("@tiptap/react").Editor | undefined;
|
|
66
|
+
render(
|
|
67
|
+
<RichTextEditor
|
|
68
|
+
format="markdown"
|
|
69
|
+
submitOnEnter
|
|
70
|
+
onSubmit={onSubmit}
|
|
71
|
+
onCreate={(e) => {
|
|
72
|
+
editor = e;
|
|
73
|
+
}}
|
|
74
|
+
aria-label="c"
|
|
75
|
+
/>,
|
|
76
|
+
);
|
|
77
|
+
const el = await screen.findByLabelText("c");
|
|
78
|
+
await vi.waitFor(() => expect(editor).toBeDefined());
|
|
79
|
+
// user-event 로 실제 키 이벤트를 발생시켜 ProseMirror 의 handleKeyDown 플러그인
|
|
80
|
+
// 체인을 진짜로 통과시킨다(raw dispatchEvent 는 PM view 를 거치지 않을 수 있음).
|
|
81
|
+
editor!.commands.focus();
|
|
82
|
+
el.focus();
|
|
83
|
+
await user.keyboard("{Enter}");
|
|
84
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
85
|
+
|
|
86
|
+
await user.keyboard("{Shift>}{Enter}{/Shift}");
|
|
87
|
+
expect(onSubmit).toHaveBeenCalledTimes(1); // 늘지 않음(줄바꿈)
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("IME 조합 중(isComposing) Enter 는 제출하지 않고, 일반 Enter 는 제출한다", async () => {
|
|
91
|
+
const onSubmit = vi.fn();
|
|
92
|
+
let editor: import("@tiptap/react").Editor | undefined;
|
|
93
|
+
render(
|
|
94
|
+
<RichTextEditor
|
|
95
|
+
format="markdown"
|
|
96
|
+
submitOnEnter
|
|
97
|
+
onSubmit={onSubmit}
|
|
98
|
+
onCreate={(e) => {
|
|
99
|
+
editor = e;
|
|
100
|
+
}}
|
|
101
|
+
aria-label="ime"
|
|
102
|
+
/>,
|
|
103
|
+
);
|
|
104
|
+
const el = await screen.findByLabelText("ime");
|
|
105
|
+
await vi.waitFor(() => expect(editor).toBeDefined());
|
|
106
|
+
editor!.commands.focus();
|
|
107
|
+
el.focus();
|
|
108
|
+
|
|
109
|
+
// 한글 조합 확정 Enter 는 isComposing: true 로 들어온다 — 제출되면 안 됨.
|
|
110
|
+
el.dispatchEvent(
|
|
111
|
+
new KeyboardEvent("keydown", {
|
|
112
|
+
key: "Enter",
|
|
113
|
+
bubbles: true,
|
|
114
|
+
cancelable: true,
|
|
115
|
+
// @ts-expect-error jsdom KeyboardEvent 는 isComposing 을 옵션으로 받음
|
|
116
|
+
isComposing: true,
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
120
|
+
|
|
121
|
+
// 레거시 브라우저가 보내는 keyCode 229(조합 중) Enter 도 제출 금지.
|
|
122
|
+
el.dispatchEvent(
|
|
123
|
+
new KeyboardEvent("keydown", {
|
|
124
|
+
key: "Enter",
|
|
125
|
+
bubbles: true,
|
|
126
|
+
cancelable: true,
|
|
127
|
+
keyCode: 229,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
131
|
+
|
|
132
|
+
// 조합이 끝난 일반 Enter 는 제출된다.
|
|
133
|
+
el.dispatchEvent(
|
|
134
|
+
new KeyboardEvent("keydown", {
|
|
135
|
+
key: "Enter",
|
|
136
|
+
bubbles: true,
|
|
137
|
+
cancelable: true,
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("RichTextEditor controlled-sync (C-2 루프/커서 점프 방지)", () => {
|
|
145
|
+
it("동일 value 로 부모가 리렌더해도 setContent·셀렉션을 건드리지 않는다(커서 점프 방지)", async () => {
|
|
146
|
+
let editor: import("@tiptap/react").Editor | undefined;
|
|
147
|
+
let forceRerender: (() => void) | undefined;
|
|
148
|
+
|
|
149
|
+
// controlled value 가 그대로인데 부모가 리렌더하는 흔한 상황. lastSyncedRef 비교 덕에
|
|
150
|
+
// sync effect 가 우리 doc 을 다시 setContent 하지 않아 커서/셀렉션이 보존된다.
|
|
151
|
+
// (직렬화 비교만 했다면 markdown 정규화 차이로 매 렌더 setContent → 점프 위험.)
|
|
152
|
+
function Harness() {
|
|
153
|
+
const [, tick] = React.useState(0);
|
|
154
|
+
forceRerender = () => tick((n) => n + 1);
|
|
155
|
+
return (
|
|
156
|
+
<RichTextEditor
|
|
157
|
+
format="markdown"
|
|
158
|
+
value={"**hi**"}
|
|
159
|
+
onChange={() => {}}
|
|
160
|
+
onCreate={(e) => {
|
|
161
|
+
editor = e;
|
|
162
|
+
}}
|
|
163
|
+
aria-label="sync"
|
|
164
|
+
/>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
render(<Harness />);
|
|
169
|
+
await vi.waitFor(() => expect(editor).toBeDefined());
|
|
170
|
+
await screen.findByText("hi");
|
|
171
|
+
|
|
172
|
+
// 커서를 문서 끝으로 두고 위치 기록.
|
|
173
|
+
editor!.commands.focus("end");
|
|
174
|
+
const before = editor!.state.selection.from;
|
|
175
|
+
expect(before).toBeGreaterThan(1);
|
|
176
|
+
|
|
177
|
+
// 같은 value 로 부모 리렌더 — sync effect 가 echo 를 다시 주입하면 안 된다.
|
|
178
|
+
const setContentSpy = vi.spyOn(editor!.commands, "setContent");
|
|
179
|
+
await React.act(async () => {
|
|
180
|
+
forceRerender!();
|
|
181
|
+
// effect flush 까지 대기(setContent 가 일어난다면 여기서 일어난다).
|
|
182
|
+
await Promise.resolve();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// setContent 미호출 + 셀렉션 그대로(점프 없음).
|
|
186
|
+
expect(setContentSpy).not.toHaveBeenCalled();
|
|
187
|
+
expect(editor!.state.selection.from).toBe(before);
|
|
188
|
+
setContentSpy.mockRestore();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("진짜 다른 외부 value 는 에디터에 로드된다 (채널 전환)", async () => {
|
|
192
|
+
function Harness({ value }: { value: string }) {
|
|
193
|
+
return <RichTextEditor format="markdown" value={value} aria-label="ext" />;
|
|
194
|
+
}
|
|
195
|
+
const { rerender } = render(<Harness value={"**first**"} />);
|
|
196
|
+
await screen.findByText("first");
|
|
197
|
+
|
|
198
|
+
// 외부에서 완전히 다른 value 주입 → 에디터에 반영돼야 한다.
|
|
199
|
+
rerender(<Harness value={"**second**"} />);
|
|
200
|
+
await screen.findByText("second");
|
|
201
|
+
expect(screen.queryByText("first")).toBeNull();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("html 모드에서도 controlled echo 루프가 발생하지 않는다", async () => {
|
|
205
|
+
let editor: import("@tiptap/react").Editor | undefined;
|
|
206
|
+
let lastEmitted = "";
|
|
207
|
+
function Harness() {
|
|
208
|
+
const [value, setValue] = React.useState("<p>hi</p>");
|
|
209
|
+
return (
|
|
210
|
+
<RichTextEditor
|
|
211
|
+
value={value}
|
|
212
|
+
onChange={(v) => {
|
|
213
|
+
lastEmitted = v;
|
|
214
|
+
setValue(v);
|
|
215
|
+
}}
|
|
216
|
+
onCreate={(e) => {
|
|
217
|
+
editor = e;
|
|
218
|
+
}}
|
|
219
|
+
aria-label="html-sync"
|
|
220
|
+
/>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
render(<Harness />);
|
|
224
|
+
await vi.waitFor(() => expect(editor).toBeDefined());
|
|
225
|
+
await screen.findByText("hi");
|
|
226
|
+
|
|
227
|
+
editor!.chain().focus("end").insertContent(" x").run();
|
|
228
|
+
await vi.waitFor(() => expect(lastEmitted).toContain("x"));
|
|
229
|
+
|
|
230
|
+
const setContentSpy = vi.spyOn(editor!.commands, "setContent");
|
|
231
|
+
editor!.chain().focus("end").insertContent("y").run();
|
|
232
|
+
await vi.waitFor(() => expect(lastEmitted).toContain("y"));
|
|
233
|
+
expect(setContentSpy).not.toHaveBeenCalled();
|
|
234
|
+
setContentSpy.mockRestore();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("RichTextEditor extensions/onCreate", () => {
|
|
239
|
+
it("추가 확장이 등록되고 onCreate 로 editor 에 접근한다", async () => {
|
|
240
|
+
const onCreate = vi.fn();
|
|
241
|
+
const marker = Extension.create({ name: "spikeMarker" });
|
|
242
|
+
render(
|
|
243
|
+
<RichTextEditor
|
|
244
|
+
format="markdown"
|
|
245
|
+
extensions={[marker]}
|
|
246
|
+
onCreate={onCreate}
|
|
247
|
+
/>,
|
|
248
|
+
);
|
|
249
|
+
await vi.waitFor(() => expect(onCreate).toHaveBeenCalled());
|
|
250
|
+
const editor = onCreate.mock.calls[0][0];
|
|
251
|
+
expect(
|
|
252
|
+
editor.extensionManager.extensions.some(
|
|
253
|
+
(e: { name: string }) => e.name === "spikeMarker",
|
|
254
|
+
),
|
|
255
|
+
).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("RichTextEditor 하위호환 (default html)", () => {
|
|
260
|
+
it("기본 format(html)에서 편집 시 onChange 가 HTML 을 돌려준다", async () => {
|
|
261
|
+
const onChange = vi.fn();
|
|
262
|
+
let editor: import("@tiptap/react").Editor | undefined;
|
|
263
|
+
render(
|
|
264
|
+
<RichTextEditor
|
|
265
|
+
defaultValue={"<p><strong>굵게</strong></p>"}
|
|
266
|
+
onChange={onChange}
|
|
267
|
+
onCreate={(e) => {
|
|
268
|
+
editor = e;
|
|
269
|
+
}}
|
|
270
|
+
/>,
|
|
271
|
+
);
|
|
272
|
+
await screen.findByText("굵게");
|
|
273
|
+
expect(screen.getByText("굵게").closest("strong")).not.toBeNull();
|
|
274
|
+
await vi.waitFor(() => expect(editor).toBeDefined());
|
|
275
|
+
editor!.chain().focus("end").insertContent(" 추가").run();
|
|
276
|
+
await vi.waitFor(() => expect(onChange).toHaveBeenCalled());
|
|
277
|
+
const out = onChange.mock.calls.at(-1)![0] as string;
|
|
278
|
+
// html 모드(기본)이므로 태그 문자열이 그대로 나와야 한다(markdown 별표 아님).
|
|
279
|
+
expect(out).toContain("<strong>");
|
|
280
|
+
expect(out).toContain("<p>");
|
|
281
|
+
expect(out).not.toContain("**");
|
|
282
|
+
});
|
|
283
|
+
});
|