@loword/loword-design-system 0.4.2 → 0.5.1

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,9 +1,12 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useEditorState } from '@tiptap/react';
3
+ import { Fragment, Slice, } from '@tiptap/pm/model';
4
+ import { Plugin, PluginKey } from '@tiptap/pm/state';
5
+ import { Decoration, DecorationSet } from '@tiptap/pm/view';
6
+ import { defaultBlockAt, Extension, useEditorState, } from '@tiptap/react';
4
7
  import { BubbleMenu } from '@tiptap/react/menus';
5
- import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold, ChevronDown, Image as ImageIcon, Italic, Link2, List, ListOrdered, Redo, Strikethrough, Underline, Undo, Unlink, } from 'lucide-react';
6
- import { useEffect, useState } from 'react';
8
+ import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold, ChevronDown, Image as ImageIcon, Italic, Link2, List, ListOrdered, Redo, Strikethrough, Table as TableIcon, Underline, Undo, Unlink, } from 'lucide-react';
9
+ import { useEffect, useRef, useState } from 'react';
7
10
  import EditorBase, { useEditorContext } from '../../components/LDEditor/EditorBase.js';
8
11
  import { createEditorExtensions } from '../../components/LDEditor/extensions.js';
9
12
  import LDToast from '../../components/LDToast/LDToast.js';
@@ -35,11 +38,296 @@ const HEADINGS = [
35
38
  { label: '제목 2', level: 2 },
36
39
  { label: '제목 3', level: 3 },
37
40
  ];
38
- const iconBtn = 'p-1 rounded-lg cursor-pointer transition hover:bg-mono100 disabled:opacity-30 disabled:cursor-not-allowed';
41
+ const TABLE_GRID_MAX = 5;
42
+ const TABLE_ACTIONS = [
43
+ { label: '위에 행 추가', run: (e) => e.chain().focus().addRowBefore().run() },
44
+ { label: '아래 행 추가', run: (e) => e.chain().focus().addRowAfter().run() },
45
+ {
46
+ label: '왼쪽 열 추가',
47
+ run: (e) => e.chain().focus().addColumnBefore().run(),
48
+ },
49
+ {
50
+ label: '오른쪽 열 추가',
51
+ run: (e) => e.chain().focus().addColumnAfter().run(),
52
+ },
53
+ { label: '행 삭제', run: (e) => e.chain().focus().deleteRow().run() },
54
+ { label: '열 삭제', run: (e) => e.chain().focus().deleteColumn().run() },
55
+ {
56
+ label: '헤더 행 토글',
57
+ run: (e) => e.chain().focus().toggleHeaderRow().run(),
58
+ },
59
+ { label: '표 삭제', run: (e) => e.chain().focus().deleteTable().run() },
60
+ ];
61
+ // 삽입할 표 크기를 호버(키보드는 포커스)로 고르는 5x5 그리드.
62
+ // Dropdown의 children은 렌더 콜백이라 훅을 쓸 수 없어 별도 컴포넌트로 뺀다.
63
+ const TableGridPicker = ({ onSelect, }) => {
64
+ const [size, setSize] = useState({ rows: 0, cols: 0 });
65
+ return (_jsxs("div", { className: "p-2", children: [_jsx("div", { className: "grid grid-cols-5 gap-1", children: Array.from({ length: TABLE_GRID_MAX * TABLE_GRID_MAX }, (_, index) => {
66
+ const rows = Math.floor(index / TABLE_GRID_MAX) + 1;
67
+ const cols = (index % TABLE_GRID_MAX) + 1;
68
+ const filled = rows <= size.rows && cols <= size.cols;
69
+ return (_jsx("button", { type: "button", "aria-label": `${rows}행 ${cols}열 표 삽입`, onMouseEnter: () => setSize({ rows, cols }), onFocus: () => setSize({ rows, cols }), onClick: (event) => {
70
+ event.preventDefault();
71
+ onSelect(rows, cols);
72
+ }, className: cn('size-5 cursor-pointer rounded-xs border border-neutral200', filled && 'border-blue-500 bg-blue-100') }, index));
73
+ }) }), _jsx("p", { className: "mt-2 text-center text-sm", children: size.rows ? `${size.rows} x ${size.cols}` : '크기 선택' })] }));
74
+ };
75
+ const iconBtn = 'p-0.5 rounded-lg cursor-pointer transition hover:bg-mono100 disabled:opacity-30 disabled:cursor-not-allowed';
39
76
  const activeBtn = 'bg-mono100';
40
77
  const LDDivider = () => _jsx("div", { className: "mx-1 h-6 w-px bg-neutral200" });
41
- const LDEditor = ({ content, onChange, className, handleImageUpload, }) => {
42
- return (_jsxs(EditorBase.Root, { extensions: createEditorExtensions(), content: content, onChange: onChange, className: className, children: [_jsx(EditorToolbar, { handleImageUpload: handleImageUpload }), _jsx(EditorBase.Content, {})] }));
78
+ // 구분선으로 나뉜 버튼 묶음. 툴바가 flex-wrap이라 묶음 자체가 하나의 flex 아이템이
79
+ // 되어, 폭이 모자라면 묶음 단위로 함께 다음 줄로 내려간다. 구분선은 묶음의 끝에
80
+ // 두어 줄이 바뀌어도 새 줄이 구분선으로 시작하지 않게 한다.
81
+ const ToolbarGroup = ({ children }) => (_jsx("div", { className: "flex items-center gap-2", children: children }));
82
+ // 검증을 통과하면 업로드한 URL을 반환하고, 실패하면 토스트에 띄울 사유를 담아 throw한다.
83
+ const uploadImageFile = async (file, handleImageUpload) => {
84
+ if (!validAllowedExtensions(file, TOOLBAR_ALLOWED_EXTENSIONS)) {
85
+ const allowedFiles = TOOLBAR_ALLOWED_EXTENSIONS.map((item) => FILE_EXTENSIONS[item].toUpperCase()).join(', ');
86
+ throw new Error(`지원하지 않는 파일 형식입니다. ${allowedFiles} 파일만 업로드 가능합니다.`);
87
+ }
88
+ if (!validFileSize(file, TOOLBAR_MAX_SIZE)) {
89
+ throw new Error(`파일 크기가 너무 큽니다. ${getFileSizeUnit(TOOLBAR_MAX_SIZE)} 이하 파일을 선택해주세요.`);
90
+ }
91
+ if (isImage(file.name) && !(await validImageSize(file))) {
92
+ throw new Error('이미지 해상도가 너무 큽니다. 더 작은 이미지를 선택해주세요.');
93
+ }
94
+ let src;
95
+ try {
96
+ const { fileBase64, fileName } = await fileToBase64(file);
97
+ src = await handleImageUpload?.({ filebase64: fileBase64, fileName });
98
+ }
99
+ catch {
100
+ throw new Error('파일 업로드 중 문제가 발생했습니다. 잠시 후 다시 시도해주세요.');
101
+ }
102
+ if (!src)
103
+ throw new Error('이미지 업로드에 실패했습니다.');
104
+ return src;
105
+ };
106
+ // 이미지를 넣을 본문 폭(px). CSS로 100%를 맞추려면 "크기를 정하지 않은 이미지"를
107
+ // 선택자로 가려내야 하는데, 그 조건이 빗나가면 래퍼와 이미지 폭이 어긋나 리사이즈
108
+ // 핸들이 이미지에서 떨어진다. 값으로 넣으면 구조와 무관하게 결과가 같다.
109
+ // 폭을 못 재면(숨겨진 컨테이너 등) undefined를 돌려 원본 크기로 둔다.
110
+ const getContentWidth = (dom) => {
111
+ const { paddingLeft, paddingRight } = getComputedStyle(dom);
112
+ const width = Math.round(dom.clientWidth - parseFloat(paddingLeft) - parseFloat(paddingRight));
113
+ return width > 0 ? width : undefined;
114
+ };
115
+ // ── 이미지 붙여넣기 ──
116
+ const isDataUrl = (src) => typeof src === 'string' && src.startsWith('data:');
117
+ // 상대 경로는 현재 페이지 기준으로 푼다. 주소로 읽을 수 없으면 허용하지 않는다.
118
+ const isAllowedHost = (src, hosts) => {
119
+ try {
120
+ return hosts.includes(new URL(src, window.location.href).hostname);
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ };
126
+ // 다른 호스트의 이미지를 받아 File로 만든다. 화면에 보여주는 것과 달리 내용을 읽는 건
127
+ // CORS를 허용하는 서버만 되므로, 대부분의 외부 사이트에서는 여기서 실패한다.
128
+ const urlToFile = async (url) => {
129
+ const response = await fetch(url).catch(() => null);
130
+ if (!response?.ok) {
131
+ throw new Error('다른 사이트의 이미지를 가져오지 못해 제외했습니다. 이미지를 저장한 뒤 직접 올려주세요.');
132
+ }
133
+ const blob = await response.blob();
134
+ return new File([blob], `image.${blob.type.slice('image/'.length)}`, {
135
+ type: blob.type,
136
+ });
137
+ };
138
+ // 툴바와 같은 검증을 태우려고 data URL을 File로 되돌린다.
139
+ const dataUrlToFile = async (dataUrl) => {
140
+ const [meta = '', base64 = ''] = dataUrl.split(',');
141
+ const type = /^data:(image\/[\w.+-]+);base64$/.exec(meta)?.[1];
142
+ if (!type)
143
+ throw new Error('이미지를 읽을 수 없습니다.');
144
+ return new File([Uint8Array.from(atob(base64), (char) => char.charCodeAt(0))], `image.${type.slice('image/'.length)}`, { type });
145
+ };
146
+ // 여러 장을 한 번에 올린다. 실패한 자리는 null이고, 사유는 첫 번째 것만 토스트로 알린다.
147
+ const uploadAll = async (uploads) => {
148
+ const results = await Promise.allSettled(uploads);
149
+ const failed = results.find((result) => result.status === 'rejected');
150
+ if (failed)
151
+ LDToast.error(failed.reason.message);
152
+ return results.map((result) => result.status === 'fulfilled' ? result.value : null);
153
+ };
154
+ // urls에 있는 이미지의 src를 업로드 URL로 바꾸고, 값이 null(업로드 실패)인 이미지는 뺀다.
155
+ const replaceImages = (fragment, urls) => {
156
+ const nodes = [];
157
+ fragment.forEach((node) => {
158
+ const src = node.attrs.src;
159
+ if (node.type.name === 'image' &&
160
+ typeof src === 'string' &&
161
+ urls.has(src)) {
162
+ const url = urls.get(src);
163
+ if (url)
164
+ nodes.push(node.type.create({ ...node.attrs, src: url }));
165
+ return;
166
+ }
167
+ nodes.push(node.childCount ? node.copy(replaceImages(node.content, urls)) : node);
168
+ });
169
+ return Fragment.from(nodes);
170
+ };
171
+ // 업로드 스켈레톤은 문서가 아닌 decoration이라 getHTML·onChange·실행취소 기록에 남지 않는다.
172
+ // meta에 pos가 있으면 추가, 없으면 id로 제거한다.
173
+ const placeholderKey = new PluginKey('imagePastePlaceholder');
174
+ const createSkeleton = () => {
175
+ const skeleton = document.createElement('span');
176
+ skeleton.setAttribute('role', 'status');
177
+ skeleton.setAttribute('aria-busy', 'true');
178
+ skeleton.setAttribute('aria-label', '이미지 업로드 중');
179
+ skeleton.className =
180
+ 'block h-40 w-60 max-w-full animate-pulse rounded-md bg-mono200';
181
+ return skeleton;
182
+ };
183
+ const findPlaceholder = (state, id) => placeholderKey
184
+ .getState(state)
185
+ ?.find(undefined, undefined, (spec) => spec.id === id)[0]
186
+ ?.from;
187
+ // 업로드하는 동안 붙여넣은 자리에 스켈레톤을 띄우고, 끝나면 그 자리에 slice를 넣는다.
188
+ // 스켈레톤은 이미지 블록이 들어갈 자리라 블록 사이에 두고, 커서는 그 아래 블록으로 보내
189
+ // 업로드 중에도 아래에 이어 쓸 수 있게 한다. 편집으로 자리가 옮겨져도 따라가며,
190
+ // 스켈레톤 자리가 지워지면(아래 줄 맨 앞에서 Backspace 등) 결과를 버린다.
191
+ const pasteAfterUpload = (view, upload) => {
192
+ const id = {};
193
+ const tr = view.state.tr.deleteSelection();
194
+ const { $from } = tr.selection;
195
+ // 줄 중간·끝이면 Enter처럼 블록을 나눈다. 끝에서 나눌 땐 제목이 이어지지 않게 기본 블록(문단)으로 만든다.
196
+ if ($from.parent.isTextblock && $from.parentOffset > 0) {
197
+ const atEnd = $from.parentOffset === $from.parent.content.size;
198
+ const type = atEnd
199
+ ? defaultBlockAt($from.node(-1).contentMatchAt($from.indexAfter(-1)))
200
+ : null;
201
+ tr.split($from.pos, 1, type ? [{ type }] : undefined);
202
+ }
203
+ const $cursor = tr.selection.$from;
204
+ const pos = $cursor.depth ? $cursor.before() : $cursor.pos;
205
+ view.dispatch(tr.setMeta(placeholderKey, { id, pos }).scrollIntoView());
206
+ void upload.then((slice) => {
207
+ if (view.isDestroyed)
208
+ return;
209
+ const pos = findPlaceholder(view.state, id);
210
+ if (pos === undefined)
211
+ return;
212
+ const tr = view.state.tr.setMeta(placeholderKey, { id });
213
+ if (slice.content.size) {
214
+ tr.replaceRange(pos, pos, slice)
215
+ .scrollIntoView()
216
+ .setMeta('paste', true)
217
+ .setMeta('uiEvent', 'paste');
218
+ }
219
+ view.dispatch(tr);
220
+ });
221
+ };
222
+ /**
223
+ * 붙여넣은 이미지 바이너리를 handleImageUpload가 반환한 URL로 바꿔 넣는다.
224
+ * - HTML 속 `data:` 이미지: 업로드 URL로 src를 교체한다.
225
+ * - allowedImageHosts를 지정했다면 HTML 속 그 밖의 호스트 이미지: 받아서 업로드하고 src를
226
+ * 교체한다. 받지 못하거나 업로드에 실패하면 뺀다.
227
+ * - HTML 없이 이미지 파일만 온 경우(스크린샷 등): 업로드 URL로 이미지를 넣는다.
228
+ * 업로드가 끝난 뒤 삽입해 base64가 onChange로 나가지 않게 하고, 그동안은 스켈레톤을 띄운다.
229
+ */
230
+ const ImagePaste = Extension.create({
231
+ name: 'imagePaste',
232
+ addOptions() {
233
+ return { getConfig: () => ({}) };
234
+ },
235
+ addProseMirrorPlugins() {
236
+ const { getConfig } = this.options;
237
+ return [
238
+ new Plugin({
239
+ key: placeholderKey,
240
+ state: {
241
+ init: () => DecorationSet.empty,
242
+ apply: (tr, set) => {
243
+ const mapped = set.map(tr.mapping, tr.doc);
244
+ const action = tr.getMeta(placeholderKey);
245
+ if (!action)
246
+ return mapped;
247
+ if (action.pos !== undefined) {
248
+ return mapped.add(tr.doc, [
249
+ Decoration.widget(action.pos, createSkeleton, {
250
+ id: action.id,
251
+ }),
252
+ ]);
253
+ }
254
+ return mapped.remove(mapped.find(undefined, undefined, (spec) => spec.id === action.id));
255
+ },
256
+ },
257
+ props: {
258
+ decorations: (state) => placeholderKey.getState(state),
259
+ handlePaste: (view, event, slice) => {
260
+ const { handleImageUpload, allowedImageHosts } = getConfig();
261
+ if (!handleImageUpload)
262
+ return false;
263
+ const html = event.clipboardData?.getData('text/html') ?? '';
264
+ const files = Array.from(event.clipboardData?.files ?? []).filter((file) => file.type.startsWith('image/'));
265
+ // data: 이미지는 항상, 허용 호스트를 지정했다면 그 밖의 호스트 이미지도 업로드 대상이다.
266
+ // 에디터에서 복사한 이미지는 이미 허용 호스트에 있으므로 다시 올리지 않는다.
267
+ const sources = new Set();
268
+ slice.content.descendants((node) => {
269
+ const src = node.attrs.src;
270
+ if (node.type.name !== 'image' || typeof src !== 'string')
271
+ return;
272
+ if (isDataUrl(src) ||
273
+ (allowedImageHosts && !isAllowedHost(src, allowedImageHosts))) {
274
+ sources.add(src);
275
+ }
276
+ });
277
+ if (sources.size) {
278
+ const list = [...sources];
279
+ // 웹에서 "이미지 복사"하면 img 하나뿐인 HTML과 그 이미지 파일이 함께 온다.
280
+ // CORS와 무관하게 읽을 수 있는 파일 쪽을 쓴다.
281
+ const copiedImage = slice.content.childCount === 1 &&
282
+ slice.content.firstChild?.type.name === 'image' &&
283
+ files.length === 1
284
+ ? files[0]
285
+ : undefined;
286
+ pasteAfterUpload(view, uploadAll(list.map((src) => isDataUrl(src)
287
+ ? dataUrlToFile(src).then((file) => uploadImageFile(file, handleImageUpload))
288
+ : (copiedImage
289
+ ? Promise.resolve(copiedImage)
290
+ : urlToFile(src)).then((file) => uploadImageFile(file, handleImageUpload)))).then((srcs) => {
291
+ const urls = new Map(list.map((src, index) => [src, srcs[index] ?? null]));
292
+ return new Slice(replaceImages(slice.content, urls), slice.openStart, slice.openEnd);
293
+ }));
294
+ return true;
295
+ }
296
+ // Office는 HTML과 함께 선택 영역을 찍은 PNG를 files에 넣는다. HTML이 있으면 파일은 무시한다.
297
+ if (html || !files.length)
298
+ return false;
299
+ pasteAfterUpload(view, uploadAll(files.map((file) => uploadImageFile(file, handleImageUpload))).then((srcs) => {
300
+ const nodes = files.flatMap((file, index) => {
301
+ const src = srcs[index];
302
+ return src
303
+ ? [
304
+ view.state.schema.node('image', {
305
+ src,
306
+ alt: file.name,
307
+ width: getContentWidth(view.dom),
308
+ }),
309
+ ]
310
+ : [];
311
+ });
312
+ return new Slice(Fragment.from(nodes), 0, 0);
313
+ }));
314
+ return true;
315
+ },
316
+ },
317
+ }),
318
+ ];
319
+ },
320
+ });
321
+ const LDEditor = ({ content, onChange, className, handleImageUpload, allowedImageHosts, }) => {
322
+ // 에디터는 첫 렌더의 extensions로 한 번만 만들어진다. 붙여넣기는 최신 props를 ref로 읽는다.
323
+ const pasteConfigRef = useRef({ handleImageUpload, allowedImageHosts });
324
+ useEffect(() => {
325
+ pasteConfigRef.current = { handleImageUpload, allowedImageHosts };
326
+ });
327
+ return (_jsxs(EditorBase.Root, { extensions: [
328
+ ...createEditorExtensions(),
329
+ ImagePaste.configure({ getConfig: () => pasteConfigRef.current }),
330
+ ], content: content, onChange: onChange, className: className, children: [_jsx(EditorToolbar, { handleImageUpload: handleImageUpload }), _jsx(EditorBase.Content, {})] }));
43
331
  };
44
332
  const EditorToolbar = ({ handleImageUpload, }) => {
45
333
  const [linkUrl, setLinkUrl] = useState('');
@@ -77,6 +365,7 @@ const EditorToolbar = ({ handleImageUpload, }) => {
77
365
  canUndo: editor.can().undo(),
78
366
  canRedo: editor.can().redo(),
79
367
  isLink: editor.isActive('link'),
368
+ isTable: editor.isActive('table'),
80
369
  hasSelection: !editor.state.selection.empty,
81
370
  };
82
371
  },
@@ -85,100 +374,97 @@ const EditorToolbar = ({ handleImageUpload, }) => {
85
374
  if (!files?.[0] || !editor)
86
375
  return;
87
376
  const file = files[0];
88
- if (!validAllowedExtensions(file, TOOLBAR_ALLOWED_EXTENSIONS)) {
89
- const allowedFiles = TOOLBAR_ALLOWED_EXTENSIONS.map((item) => FILE_EXTENSIONS[item].toUpperCase()).join(', ');
90
- LDToast.error(`지원하지 않는 파일 형식입니다. ${allowedFiles} 파일만 업로드 가능합니다.`);
91
- return;
92
- }
93
- if (!validFileSize(file, TOOLBAR_MAX_SIZE)) {
94
- LDToast.error(`파일 크기가 너무 큽니다. ${getFileSizeUnit(TOOLBAR_MAX_SIZE)} 이하 파일을 선택해주세요.`);
95
- return;
96
- }
97
- if (isImage(file.name) && !(await validImageSize(file))) {
98
- LDToast.error('이미지 해상도가 너무 큽니다. 더 작은 이미지를 선택해주세요.');
99
- return;
100
- }
101
377
  try {
102
- const { fileBase64, fileName } = await fileToBase64(file);
103
- const res = await handleImageUpload?.({
104
- filebase64: fileBase64,
105
- fileName,
378
+ const src = await uploadImageFile(file, handleImageUpload);
379
+ editor.commands.setImage({
380
+ src,
381
+ alt: file.name,
382
+ width: getContentWidth(editor.view.dom),
106
383
  });
107
- if (!res) {
108
- LDToast.error('이미지 업로드에 실패했습니다.');
109
- return;
110
- }
111
- editor.commands.setImage({ src: res, alt: fileName });
112
384
  }
113
- catch {
114
- LDToast.error('파일 업로드 중 문제가 발생했습니다. 잠시 후 다시 시도해주세요.');
385
+ catch (error) {
386
+ LDToast.error(error.message);
115
387
  }
116
388
  };
117
389
  if (!editor || !state)
118
390
  return null;
119
- return (_jsxs(_Fragment, { children: [_jsxs(EditorBase.Toolbar, { className: "gap-5 rounded-t-md border border-neutral200 px-4 py-3", children: [_jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-30 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (
120
- // aria-label이 없으면 접근성 이름이 현재 선택값('본문'·'제목 1'…)이 되어,
121
- // 드롭다운이 열렸을 때 트리거와 옵션이 같은 이름을 갖는다.
122
- // 고정 라벨을 줘서 트리거를 항상 같은 이름으로 가리킬 수 있게 한다.
123
- _jsx(EditorBase.Toolbar.LDButton, { "aria-label": "\uC81C\uBAA9 \uC218\uC900", onClick: toggle, className: cn(iconBtn, 'flex items-center gap-5 px-2'), children: _jsx("span", { className: "min-w-10 whitespace-nowrap", children: state.headingLevel ? `제목 ${state.headingLevel}` : '본문' }) })), children: ({ close }) => HEADINGS.map(({ label, level }) => (_jsx("button", { type: "button", onClick: (e) => {
124
- e.preventDefault();
125
- if (level === 0)
126
- editor.chain().focus().setParagraph().run();
127
- else
128
- editor.chain().focus().toggleHeading({ level }).run();
129
- close();
130
- }, className: "w-full cursor-pointer px-3 py-2 text-left text-sm first:rounded-t-lg last:rounded-b-lg hover:bg-mono100", children: label }, level))) }), !isMobile && (_jsxs(_Fragment, { children: [_jsx(LDDivider, {}), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleBold().run(), className: cn(iconBtn, state.isBold && activeBtn), "aria-label": "\uAD75\uAC8C", children: _jsx(Bold, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleItalic().run(), className: cn(iconBtn, state.isItalic && activeBtn), "aria-label": "\uAE30\uC6B8\uC784", children: _jsx(Italic, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleUnderline().run(), className: cn(iconBtn, state.isUnderline && activeBtn), "aria-label": "\uBC11\uC904", children: _jsx(Underline, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleStrike().run(), className: cn(iconBtn, state.isStrike && activeBtn), "aria-label": "\uCDE8\uC18C\uC120", children: _jsx(Strikethrough, { className: "size-5" }) }), _jsx(LDDivider, {}), _jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-25 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (_jsxs(EditorBase.Toolbar.LDButton, { "aria-label": "\uAE00\uC790 \uD06C\uAE30", onClick: toggle, className: cn(iconBtn, 'flex items-center gap-1 px-2'), children: [_jsx("span", { className: "text-sm whitespace-nowrap", children: state.fontSize }), _jsx(ChevronDown, { className: "size-4" })] })), children: ({ close }) => FONT_SIZES.map((size) => (_jsx("button", { type: "button", onClick: (e) => {
131
- e.preventDefault();
132
- editor.chain().focus().setFontSize(size).run();
133
- close();
134
- }, className: "w-full cursor-pointer px-3 py-2 text-left text-sm first:rounded-t-lg last:rounded-b-lg hover:bg-mono100", children: size }, size))) }), _jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-35 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (_jsxs(EditorBase.Toolbar.LDButton, { "aria-label": "\uAE00\uC790 \uC0C9", onClick: toggle, className: cn(iconBtn, 'flex items-center gap-2 px-2'), children: [_jsx("span", { className: "size-4 rounded-full", style: { backgroundColor: state.color } }), state.color, _jsx(ChevronDown, { className: "size-4" })] })), children: ({ close }) => COLORS.map((color) => (_jsxs("button", { type: "button", onClick: (e) => {
391
+ return (_jsxs(_Fragment, { children: [_jsxs(EditorBase.Toolbar, { className: "gap-2 rounded-t-md border border-neutral200 px-4 py-3", children: [_jsxs(ToolbarGroup, { children: [_jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-30 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (
392
+ // aria-label이 없으면 접근성 이름이 현재 선택값('본문'·'제목 1'…)이 되어,
393
+ // 드롭다운이 열렸을 때 트리거와 옵션이 같은 이름을 갖는다.
394
+ // 고정 라벨을 줘서 트리거를 항상 같은 이름으로 가리킬 수 있게 한다.
395
+ _jsx(EditorBase.Toolbar.LDButton, { "aria-label": "\uC81C\uBAA9 \uC218\uC900", onClick: toggle, className: cn(iconBtn, 'flex items-center gap-5 px-2'), children: _jsx("span", { className: "min-w-10 whitespace-nowrap", children: state.headingLevel ? `제목 ${state.headingLevel}` : '본문' }) })), children: ({ close }) => HEADINGS.map(({ label, level }) => (_jsx("button", { type: "button", onClick: (e) => {
135
396
  e.preventDefault();
136
- editor.chain().focus().setColor(color.value).run();
397
+ if (level === 0)
398
+ editor.chain().focus().setParagraph().run();
399
+ else
400
+ editor.chain().focus().toggleHeading({ level }).run();
137
401
  close();
138
- }, className: "flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm first:rounded-t-lg last:rounded-b-lg hover:bg-mono100", children: [_jsx("span", { className: "size-4 rounded-full", style: { backgroundColor: color.value } }), _jsx("span", { children: color.label })] }, color.value))) }), _jsx(LDDivider, {}), ['left', 'center', 'right', 'justify'].map((align) => {
139
- const Icon = {
140
- left: AlignLeft,
141
- center: AlignCenter,
142
- right: AlignRight,
143
- justify: AlignJustify,
144
- }[align];
145
- const alignLabel = {
146
- left: '왼쪽 정렬',
147
- center: '가운데 정렬',
148
- right: '오른쪽 정렬',
149
- justify: '양쪽 정렬',
150
- }[align];
151
- return (_jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().setTextAlign(align).run(), className: cn(iconBtn, state.align[align] && activeBtn), "aria-label": alignLabel, children: _jsx(Icon, { className: "size-5" }) }, align));
152
- })] })), _jsx(LDDivider, {}), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleBulletList().run(), className: cn(iconBtn, state.isBulletList && activeBtn), "aria-label": "\uAE00\uBA38\uB9AC \uAE30\uD638 \uBAA9\uB85D", children: _jsx(List, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleOrderedList().run(), className: cn(iconBtn, state.isOrderedList && activeBtn), "aria-label": "\uBC88\uD638 \uBAA9\uB85D", children: _jsx(ListOrdered, { className: "size-5" }) }), _jsx(LDDivider, {}), _jsx(EditorBase.Toolbar.LDButton, { disabled: !state.canUndo, onClick: () => editor.chain().focus().undo().run(), className: iconBtn, "aria-label": "\uB418\uB3CC\uB9AC\uAE30", children: _jsx(Undo, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { disabled: !state.canRedo, onClick: () => editor.chain().focus().redo().run(), className: iconBtn, "aria-label": "\uB2E4\uC2DC \uC2E4\uD589", children: _jsx(Redo, { className: "size-5" }) }), !isMobile && (_jsxs(_Fragment, { children: [_jsx(LDDivider, {}), _jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-70 rounded-lg border border-neutral200 bg-white p-3 shadow-lg", trigger: ({ toggle }) => (_jsx(EditorBase.Toolbar.LDButton, { disabled: !state.hasSelection, onClick: () => {
153
- setLinkUrl(editor.getAttributes('link').href || '');
154
- toggle();
155
- }, className: cn(iconBtn, state.isLink && activeBtn), "aria-label": "\uB9C1\uD06C", children: _jsx(Link2, { className: "size-5" }) })), children: ({ close }) => {
156
- const applyLink = () => {
157
- const chain = editor.chain().focus().extendMarkRange('link');
158
- if (linkUrl === '') {
159
- chain.unsetLink().run();
160
- }
161
- else {
162
- const url = /^https?:\/\//.test(linkUrl)
163
- ? linkUrl
164
- : `https://${linkUrl}`;
165
- chain.setLink({ href: url }).run();
166
- }
167
- setLinkUrl('');
402
+ }, className: "w-full cursor-pointer px-3 py-2 text-left text-sm first:rounded-t-lg last:rounded-b-lg hover:bg-mono100", children: label }, level))) }), _jsx(LDDivider, {})] }), !isMobile && (_jsxs(_Fragment, { children: [_jsxs(ToolbarGroup, { children: [_jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleBold().run(), className: cn(iconBtn, state.isBold && activeBtn), "aria-label": "\uAD75\uAC8C", children: _jsx(Bold, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleItalic().run(), className: cn(iconBtn, state.isItalic && activeBtn), "aria-label": "\uAE30\uC6B8\uC784", children: _jsx(Italic, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleUnderline().run(), className: cn(iconBtn, state.isUnderline && activeBtn), "aria-label": "\uBC11\uC904", children: _jsx(Underline, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleStrike().run(), className: cn(iconBtn, state.isStrike && activeBtn), "aria-label": "\uCDE8\uC18C\uC120", children: _jsx(Strikethrough, { className: "size-5" }) }), _jsx(LDDivider, {})] }), _jsxs(ToolbarGroup, { children: [_jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-25 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (_jsxs(EditorBase.Toolbar.LDButton, { "aria-label": "\uAE00\uC790 \uD06C\uAE30", onClick: toggle, className: cn(iconBtn, 'flex items-center gap-1 px-2'), children: [_jsx("span", { className: "text-sm whitespace-nowrap", children: state.fontSize }), _jsx(ChevronDown, { className: "size-4" })] })), children: ({ close }) => FONT_SIZES.map((size) => (_jsx("button", { type: "button", onClick: (e) => {
403
+ e.preventDefault();
404
+ editor.chain().focus().setFontSize(size).run();
405
+ close();
406
+ }, className: "w-full cursor-pointer px-3 py-2 text-left text-sm first:rounded-t-lg last:rounded-b-lg hover:bg-mono100", children: size }, size))) }), _jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-35 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (_jsxs(EditorBase.Toolbar.LDButton, { "aria-label": "\uAE00\uC790 \uC0C9", onClick: toggle, className: cn(iconBtn, 'flex items-center gap-2 px-2'), children: [_jsx("span", { className: "size-4 rounded-full", style: { backgroundColor: state.color } }), state.color, _jsx(ChevronDown, { className: "size-4" })] })), children: ({ close }) => COLORS.map((color) => (_jsxs("button", { type: "button", onClick: (e) => {
407
+ e.preventDefault();
408
+ editor.chain().focus().setColor(color.value).run();
409
+ close();
410
+ }, className: "flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm first:rounded-t-lg last:rounded-b-lg hover:bg-mono100", children: [_jsx("span", { className: "size-4 rounded-full", style: { backgroundColor: color.value } }), _jsx("span", { children: color.label })] }, color.value))) }), _jsx(LDDivider, {})] }), _jsxs(ToolbarGroup, { children: [['left', 'center', 'right', 'justify'].map((align) => {
411
+ const Icon = {
412
+ left: AlignLeft,
413
+ center: AlignCenter,
414
+ right: AlignRight,
415
+ justify: AlignJustify,
416
+ }[align];
417
+ const alignLabel = {
418
+ left: '왼쪽 정렬',
419
+ center: '가운데 정렬',
420
+ right: '오른쪽 정렬',
421
+ justify: '양쪽 정렬',
422
+ }[align];
423
+ return (_jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().setTextAlign(align).run(), className: cn(iconBtn, state.align[align] && activeBtn), "aria-label": alignLabel, children: _jsx(Icon, { className: "size-5" }) }, align));
424
+ }), _jsx(LDDivider, {})] })] })), _jsxs(ToolbarGroup, { children: [_jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleBulletList().run(), className: cn(iconBtn, state.isBulletList && activeBtn), "aria-label": "\uAE00\uBA38\uB9AC \uAE30\uD638 \uBAA9\uB85D", children: _jsx(List, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleOrderedList().run(), className: cn(iconBtn, state.isOrderedList && activeBtn), "aria-label": "\uBC88\uD638 \uBAA9\uB85D", children: _jsx(ListOrdered, { className: "size-5" }) }), _jsx(LDDivider, {})] }), _jsxs(ToolbarGroup, { children: [_jsx(EditorBase.Toolbar.LDButton, { disabled: !state.canUndo, onClick: () => editor.chain().focus().undo().run(), className: iconBtn, "aria-label": "\uB418\uB3CC\uB9AC\uAE30", children: _jsx(Undo, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { disabled: !state.canRedo, onClick: () => editor.chain().focus().redo().run(), className: iconBtn, "aria-label": "\uB2E4\uC2DC \uC2E4\uD589", children: _jsx(Redo, { className: "size-5" }) }), _jsx(LDDivider, {})] }), !isMobile && (_jsx(_Fragment, { children: _jsxs(ToolbarGroup, { children: [_jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-70 rounded-lg border border-neutral200 bg-white p-3 shadow-lg", trigger: ({ toggle }) => (_jsx(EditorBase.Toolbar.LDButton, { disabled: !state.hasSelection, onClick: () => {
425
+ setLinkUrl(editor.getAttributes('link').href || '');
426
+ toggle();
427
+ }, className: cn(iconBtn, state.isLink && activeBtn), "aria-label": "\uB9C1\uD06C", children: _jsx(Link2, { className: "size-5" }) })), children: ({ close }) => {
428
+ const applyLink = () => {
429
+ const chain = editor
430
+ .chain()
431
+ .focus()
432
+ .extendMarkRange('link');
433
+ if (linkUrl === '') {
434
+ chain.unsetLink().run();
435
+ }
436
+ else {
437
+ const url = /^https?:\/\//.test(linkUrl)
438
+ ? linkUrl
439
+ : `https://${linkUrl}`;
440
+ chain.setLink({ href: url }).run();
441
+ }
442
+ setLinkUrl('');
443
+ close();
444
+ };
445
+ return (_jsxs(_Fragment, { children: [_jsx("input", { type: "text", autoFocus: true, placeholder: "URL\uC744 \uC785\uB825\uD558\uC138\uC694", value: linkUrl, onChange: (e) => setLinkUrl(e.target.value), onKeyDown: (e) => e.key === 'Enter' && applyLink(), className: "w-full rounded-lg border border-neutral200 px-3 py-2 text-sm transition duration-300 outline-none focus:border-blue-500" }), _jsxs("div", { className: "mt-2 flex gap-2", children: [_jsx("button", { type: "button", onClick: (e) => {
446
+ e.preventDefault();
447
+ applyLink();
448
+ }, className: "flex-1 rounded-lg bg-blue-500 px-3 py-1.5 text-sm text-white transition duration-300 hover:bg-blue-600", children: "\uC801\uC6A9" }), _jsx("button", { type: "button", onClick: (e) => {
449
+ e.preventDefault();
450
+ setLinkUrl('');
451
+ close();
452
+ }, className: "rounded-lg border border-neutral200 px-3 py-1.5 text-sm transition duration-300 hover:bg-mono100", children: "\uCDE8\uC18C" })] })] }));
453
+ } }), _jsx(EditorBase.Toolbar.LDButton, { disabled: !state.isLink, onClick: () => editor.chain().focus().unsetLink().run(), className: iconBtn, "aria-label": "\uB9C1\uD06C \uD574\uC81C", children: _jsx(Unlink, { className: "size-5" }) }), _jsx(LDDivider, {})] }) })), _jsxs(ToolbarGroup, { children: [_jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-40 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (_jsx(EditorBase.Toolbar.LDButton, { "aria-label": "\uD45C", onClick: toggle, className: cn(iconBtn, state.isTable && activeBtn), children: _jsx(TableIcon, { className: "size-5" }) })), children: ({ close }) => state.isTable ? (_jsx("div", { children: TABLE_ACTIONS.map(({ label, run }) => (_jsx("button", { type: "button", onClick: (e) => {
454
+ e.preventDefault();
455
+ run(editor);
456
+ close();
457
+ }, className: "w-full cursor-pointer px-3 py-2 text-left text-sm first:rounded-t-lg last:rounded-b-lg hover:bg-mono100", children: label }, label))) })) : (_jsx(TableGridPicker, { onSelect: (rows, cols) => {
458
+ editor
459
+ .chain()
460
+ .focus()
461
+ .insertTable({ rows, cols, withHeaderRow: true })
462
+ .run();
168
463
  close();
169
- };
170
- return (_jsxs(_Fragment, { children: [_jsx("input", { type: "text", autoFocus: true, placeholder: "URL\uC744 \uC785\uB825\uD558\uC138\uC694", value: linkUrl, onChange: (e) => setLinkUrl(e.target.value), onKeyDown: (e) => e.key === 'Enter' && applyLink(), className: "w-full rounded-lg border border-neutral200 px-3 py-2 text-sm transition duration-300 outline-none focus:border-blue-500" }), _jsxs("div", { className: "mt-2 flex gap-2", children: [_jsx("button", { type: "button", onClick: (e) => {
171
- e.preventDefault();
172
- applyLink();
173
- }, className: "flex-1 rounded-lg bg-blue-500 px-3 py-1.5 text-sm text-white transition duration-300 hover:bg-blue-600", children: "\uC801\uC6A9" }), _jsx("button", { type: "button", onClick: (e) => {
174
- e.preventDefault();
175
- setLinkUrl('');
176
- close();
177
- }, className: "rounded-lg border border-neutral200 px-3 py-1.5 text-sm transition duration-300 hover:bg-mono100", children: "\uCDE8\uC18C" })] })] }));
178
- } }), _jsx(EditorBase.Toolbar.LDButton, { disabled: !state.isLink, onClick: () => editor.chain().focus().unsetLink().run(), className: iconBtn, "aria-label": "\uB9C1\uD06C \uD574\uC81C", children: _jsx(Unlink, { className: "size-5" }) })] })), _jsx(LDDivider, {}), _jsxs("label", { className: cn(iconBtn, 'relative'), children: [_jsx(ImageIcon, { className: "size-5" }), _jsx("input", { type: "file", accept: "image/*", "aria-label": "\uC774\uBBF8\uC9C0 \uC5C5\uB85C\uB4DC", className: "absolute inset-0 size-full cursor-pointer opacity-0", onChange: (e) => {
179
- handleImageUploadAction(e.target.files);
180
- e.target.value = '';
181
- } })] })] }), isMobile && (_jsxs(BubbleMenu, { editor: editor, className: "flex items-center gap-1 rounded-lg border border-neutral200 bg-white p-1 shadow-lg", children: [_jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleBold().run(), className: cn(iconBtn, state.isBold && activeBtn), "aria-label": "\uAD75\uAC8C", children: _jsx(Bold, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleItalic().run(), className: cn(iconBtn, state.isItalic && activeBtn), "aria-label": "\uAE30\uC6B8\uC784", children: _jsx(Italic, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleUnderline().run(), className: cn(iconBtn, state.isUnderline && activeBtn), "aria-label": "\uBC11\uC904", children: _jsx(Underline, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleStrike().run(), className: cn(iconBtn, state.isStrike && activeBtn), "aria-label": "\uCDE8\uC18C\uC120", children: _jsx(Strikethrough, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-35 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (_jsxs(EditorBase.Toolbar.LDButton, { onClick: toggle, className: cn(iconBtn, 'flex items-center gap-1 px-2'), "aria-label": "\uAE00\uC790 \uC0C9", children: [_jsx("span", { className: "size-4 rounded-full", style: { backgroundColor: state.color } }), _jsx(ChevronDown, { className: "size-4" })] })), children: ({ close }) => COLORS.map((color) => (_jsxs("button", { type: "button", onClick: (e) => {
464
+ } })) }), _jsxs("label", { className: cn(iconBtn, 'relative'), children: [_jsx(ImageIcon, { className: "size-5" }), _jsx("input", { type: "file", accept: "image/*", "aria-label": "\uC774\uBBF8\uC9C0 \uC5C5\uB85C\uB4DC", className: "absolute inset-0 size-full cursor-pointer opacity-0", onChange: (e) => {
465
+ handleImageUploadAction(e.target.files);
466
+ e.target.value = '';
467
+ } })] })] })] }), isMobile && (_jsxs(BubbleMenu, { editor: editor, className: "flex items-center gap-1 rounded-lg border border-neutral200 bg-white p-1 shadow-lg", children: [_jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleBold().run(), className: cn(iconBtn, state.isBold && activeBtn), "aria-label": "\uAD75\uAC8C", children: _jsx(Bold, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleItalic().run(), className: cn(iconBtn, state.isItalic && activeBtn), "aria-label": "\uAE30\uC6B8\uC784", children: _jsx(Italic, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleUnderline().run(), className: cn(iconBtn, state.isUnderline && activeBtn), "aria-label": "\uBC11\uC904", children: _jsx(Underline, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleStrike().run(), className: cn(iconBtn, state.isStrike && activeBtn), "aria-label": "\uCDE8\uC18C\uC120", children: _jsx(Strikethrough, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-35 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (_jsxs(EditorBase.Toolbar.LDButton, { onClick: toggle, className: cn(iconBtn, 'flex items-center gap-1 px-2'), "aria-label": "\uAE00\uC790 \uC0C9", children: [_jsx("span", { className: "size-4 rounded-full", style: { backgroundColor: state.color } }), _jsx(ChevronDown, { className: "size-4" })] })), children: ({ close }) => COLORS.map((color) => (_jsxs("button", { type: "button", onClick: (e) => {
182
468
  e.preventDefault();
183
469
  editor.chain().focus().setColor(color.value).run();
184
470
  close();
@@ -1 +1 @@
1
- {"version":3,"file":"LDViewer.d.ts","sourceRoot":"","sources":["../../../src/components/LDEditor/LDViewer.tsx"],"names":[],"mappings":"AAMA,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,QAAA,MAAM,QAAQ,GAAI,wBAAwB,aAAa,4CAQtD,CAAC;AAEF,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"LDViewer.d.ts","sourceRoot":"","sources":["../../../src/components/LDEditor/LDViewer.tsx"],"names":[],"mappings":"AAMA,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,QAAA,MAAM,QAAQ,GAAI,wBAAwB,aAAa,4CAWtD,CAAC;AAEF,eAAe,QAAQ,CAAC"}
@@ -4,6 +4,9 @@ import EditorBase from '../../components/LDEditor/EditorBase.js';
4
4
  import { createEditorExtensions } from '../../components/LDEditor/extensions.js';
5
5
  import cn from '../../utils/cn.js';
6
6
  const LDViewer = ({ content, className }) => {
7
- return (_jsx(EditorBase.LDViewer, { extensions: createEditorExtensions({ resizableImage: false }), content: content, className: cn('prose prose-lg w-full max-w-none', className) }));
7
+ return (_jsx(EditorBase.LDViewer, { extensions: createEditorExtensions({
8
+ resizableImage: false,
9
+ resizableTable: false,
10
+ }), content: content, className: cn('prose prose-lg w-full max-w-none', className) }));
8
11
  };
9
12
  export default LDViewer;
@@ -1,6 +1,7 @@
1
1
  import type { Extensions } from '@tiptap/react';
2
2
  export interface EditorExtensionOptions {
3
3
  resizableImage?: boolean;
4
+ resizableTable?: boolean;
4
5
  }
5
- export declare const createEditorExtensions: ({ resizableImage, }?: EditorExtensionOptions) => Extensions;
6
+ export declare const createEditorExtensions: ({ resizableImage, resizableTable, }?: EditorExtensionOptions) => Extensions;
6
7
  //# sourceMappingURL=extensions.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"extensions.d.ts","sourceRoot":"","sources":["../../../src/components/LDEditor/extensions.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAGhD,MAAM,WAAW,sBAAsB;IACrC,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,eAAO,MAAM,sBAAsB,GAAI,sBAEpC,sBAA2B,KAAG,UA+BhC,CAAC"}
1
+ {"version":3,"file":"extensions.d.ts","sourceRoot":"","sources":["../../../src/components/LDEditor/extensions.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAGhD,MAAM,WAAW,sBAAsB;IACrC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,eAAO,MAAM,sBAAsB,GAAI,sCAGpC,sBAA2B,KAAG,UAwChC,CAAC"}
@@ -1,8 +1,9 @@
1
1
  import Image from '@tiptap/extension-image';
2
+ import { TableKit } from '@tiptap/extension-table';
2
3
  import TextAlign from '@tiptap/extension-text-align';
3
4
  import { Color, FontSize, TextStyle } from '@tiptap/extension-text-style';
4
5
  import StarterKit from '@tiptap/starter-kit';
5
- export const createEditorExtensions = ({ resizableImage = true, } = {}) => [
6
+ export const createEditorExtensions = ({ resizableImage = true, resizableTable = true, } = {}) => [
6
7
  // link·underline은 StarterKit에 포함돼 있다. 따로 등록하면 중복 경고가 난다.
7
8
  StarterKit.configure({
8
9
  heading: {
@@ -33,4 +34,13 @@ export const createEditorExtensions = ({ resizableImage = true, } = {}) => [
33
34
  }
34
35
  : false,
35
36
  }),
37
+ TableKit.configure({
38
+ table: {
39
+ resizable: resizableTable,
40
+ // 리사이즈로 표가 컨테이너보다 넓어질 수 있다. 래퍼가 있어야 가로 스크롤을
41
+ // 표에만 줄 수 있다. 편집 모드에서는 TableView가 항상 래퍼를 만들지만
42
+ // 읽기 전용(LDViewer)에서는 이 옵션이 있어야 생긴다.
43
+ renderWrapper: true,
44
+ },
45
+ }),
36
46
  ];
@@ -24,6 +24,8 @@ export interface LDModalOptions<T = undefined> {
24
24
  scrollBehavior?: 'inside' | 'outside';
25
25
  /** 오버레이(모달 바깥) 클릭으로 닫기 허용 여부 */
26
26
  closeOnOverlayClick?: boolean;
27
+ /** ESC 키로 닫기 허용 여부 */
28
+ closeOnEsc?: boolean;
27
29
  /** 모달 패널에 적용할 추가 CSS 클래스 */
28
30
  className?: string;
29
31
  /** 모달 패널 인라인 스타일 (자유 width/height 등) */
@@ -1 +1 @@
1
- {"version":3,"file":"LDModal.d.ts","sourceRoot":"","sources":["../../../src/components/LDModal/LDModal.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,SAAS,EAAoB,MAAM,OAAO,CAAC;AAG7E,OAAoB,EAElB,KAAK,SAAS,EACd,gBAAgB,EACjB,MAAM,kCAAkC,CAAC;AAC1C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAE7C,MAAM,WAAW,cAAc,CAAC,CAAC,GAAG,SAAS;IAC3C,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB,OAAO,CAAC,EAAE,SAAS,GAAG,CAAC,CAAC,IAAI,EAAE;QAAE,OAAO,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,IAAI,CAAA;KAAE,KAAK,SAAS,CAAC,CAAC;IAC9E,OAAO,CAAC,EAAE;QACR,KAAK,EAAE,CAAC,CAAC;QACT,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,CAAC,EAAE,MAAM,CAAC,OAAO,EAAE,OAAO,GAAG,SAAS,GAAG,OAAO,CAAC,CAAC;QACzD,KAAK,CAAC,EAAE,SAAS,GAAG,OAAO,GAAG,QAAQ,CAAC;QACvC,OAAO,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,GAAG,OAAO,CAAC,IAAI,GAAG,OAAO,CAAC,CAAC;KAC1D,EAAE,CAAC;IACJ,eAAe;IACf,gBAAgB,CAAC,EAAE,KAAK,GAAG,QAAQ,CAAC;IACpC,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,IAAI,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC;IAC1B,+BAA+B;IAC/B,cAAc,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAC;IACtC,gCAAgC;IAChC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wCAAwC;IACxC,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC;IACjD,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC/B;AAED,QAAA,MAAM,OAAO,GAAI,CAAC,GAAG,SAAS,EAC5B,SAAS,cAAc,CAAC,CAAC,CAAC,KACzB,OAAO,CAAC,CAAC,GAAG,SAAS,CA4EvB,CAAC;AAEF,eAAe,OAAO,CAAC"}
1
+ {"version":3,"file":"LDModal.d.ts","sourceRoot":"","sources":["../../../src/components/LDModal/LDModal.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,SAAS,EAAoB,MAAM,OAAO,CAAC;AAG7E,OAAoB,EAElB,KAAK,SAAS,EACd,gBAAgB,EACjB,MAAM,kCAAkC,CAAC;AAC1C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAE7C,MAAM,WAAW,cAAc,CAAC,CAAC,GAAG,SAAS;IAC3C,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB,OAAO,CAAC,EAAE,SAAS,GAAG,CAAC,CAAC,IAAI,EAAE;QAAE,OAAO,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,IAAI,CAAA;KAAE,KAAK,SAAS,CAAC,CAAC;IAC9E,OAAO,CAAC,EAAE;QACR,KAAK,EAAE,CAAC,CAAC;QACT,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,CAAC,EAAE,MAAM,CAAC,OAAO,EAAE,OAAO,GAAG,SAAS,GAAG,OAAO,CAAC,CAAC;QACzD,KAAK,CAAC,EAAE,SAAS,GAAG,OAAO,GAAG,QAAQ,CAAC;QACvC,OAAO,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,GAAG,OAAO,CAAC,IAAI,GAAG,OAAO,CAAC,CAAC;KAC1D,EAAE,CAAC;IACJ,eAAe;IACf,gBAAgB,CAAC,EAAE,KAAK,GAAG,QAAQ,CAAC;IACpC,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,IAAI,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC;IAC1B,+BAA+B;IAC/B,cAAc,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAC;IACtC,gCAAgC;IAChC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,sBAAsB;IACtB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,4BAA4B;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wCAAwC;IACxC,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC;IACjD,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC/B;AAED,QAAA,MAAM,OAAO,GAAI,CAAC,GAAG,SAAS,EAC5B,SAAS,cAAc,CAAC,CAAC,CAAC,KACzB,OAAO,CAAC,CAAC,GAAG,SAAS,CA6EvB,CAAC;AAEF,eAAe,OAAO,CAAC"}