@teriusu/rich-editor 0.4.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.
- package/README.md +178 -0
- package/dist/components/BubbleToolbar.svelte +299 -0
- package/dist/components/BubbleToolbar.svelte.d.ts +9 -0
- package/dist/components/BubbleToolbar.svelte.d.ts.map +1 -0
- package/dist/components/FixedToolbar.svelte +892 -0
- package/dist/components/FixedToolbar.svelte.d.ts +11 -0
- package/dist/components/FixedToolbar.svelte.d.ts.map +1 -0
- package/dist/components/InputModal.svelte +102 -0
- package/dist/components/InputModal.svelte.d.ts +11 -0
- package/dist/components/InputModal.svelte.d.ts.map +1 -0
- package/dist/components/PdfViewer.svelte +152 -0
- package/dist/components/PdfViewer.svelte.d.ts +8 -0
- package/dist/components/PdfViewer.svelte.d.ts.map +1 -0
- package/dist/components/SlashCommandMenu.svelte +315 -0
- package/dist/components/SlashCommandMenu.svelte.d.ts +14 -0
- package/dist/components/SlashCommandMenu.svelte.d.ts.map +1 -0
- package/dist/components/TableBubbleMenu.svelte +472 -0
- package/dist/components/TableBubbleMenu.svelte.d.ts +8 -0
- package/dist/components/TableBubbleMenu.svelte.d.ts.map +1 -0
- package/dist/components/TipTapEditor.svelte +649 -0
- package/dist/components/TipTapEditor.svelte.d.ts +17 -0
- package/dist/components/TipTapEditor.svelte.d.ts.map +1 -0
- package/dist/extensions/Column.d.ts +3 -0
- package/dist/extensions/Column.d.ts.map +1 -0
- package/dist/extensions/Column.js +20 -0
- package/dist/extensions/Columns.d.ts +11 -0
- package/dist/extensions/Columns.d.ts.map +1 -0
- package/dist/extensions/Columns.js +103 -0
- package/dist/extensions/Columns.test.d.ts +2 -0
- package/dist/extensions/Columns.test.d.ts.map +1 -0
- package/dist/extensions/Columns.test.js +62 -0
- package/dist/extensions/FileAttachment.d.ts +24 -0
- package/dist/extensions/FileAttachment.d.ts.map +1 -0
- package/dist/extensions/FileAttachment.js +190 -0
- package/dist/extensions/FileAttachment.test.d.ts +2 -0
- package/dist/extensions/FileAttachment.test.d.ts.map +1 -0
- package/dist/extensions/FileAttachment.test.js +63 -0
- package/dist/extensions/FixedDetails.d.ts +2 -0
- package/dist/extensions/FixedDetails.d.ts.map +1 -0
- package/dist/extensions/FixedDetails.js +100 -0
- package/dist/extensions/FixedDetails.test.d.ts +2 -0
- package/dist/extensions/FixedDetails.test.d.ts.map +1 -0
- package/dist/extensions/FixedDetails.test.js +40 -0
- package/dist/extensions/Indent.d.ts +11 -0
- package/dist/extensions/Indent.d.ts.map +1 -0
- package/dist/extensions/Indent.js +87 -0
- package/dist/extensions/Indent.test.d.ts +2 -0
- package/dist/extensions/Indent.test.d.ts.map +1 -0
- package/dist/extensions/Indent.test.js +78 -0
- package/dist/extensions/PdfBlock.d.ts +3 -0
- package/dist/extensions/PdfBlock.d.ts.map +1 -0
- package/dist/extensions/PdfBlock.js +301 -0
- package/dist/extensions/PdfBlock.test.d.ts +2 -0
- package/dist/extensions/PdfBlock.test.d.ts.map +1 -0
- package/dist/extensions/PdfBlock.test.js +51 -0
- package/dist/extensions/VideoBlock.d.ts +16 -0
- package/dist/extensions/VideoBlock.d.ts.map +1 -0
- package/dist/extensions/VideoBlock.js +87 -0
- package/dist/extensions/VideoBlock.test.d.ts +2 -0
- package/dist/extensions/VideoBlock.test.d.ts.map +1 -0
- package/dist/extensions/VideoBlock.test.js +54 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/styles/editor.css +698 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/utils/cn.d.ts +3 -0
- package/dist/utils/cn.d.ts.map +1 -0
- package/dist/utils/cn.js +5 -0
- package/dist/utils/cn.test.d.ts +2 -0
- package/dist/utils/cn.test.d.ts.map +1 -0
- package/dist/utils/cn.test.js +28 -0
- package/dist/utils/escape-html.d.ts +2 -0
- package/dist/utils/escape-html.d.ts.map +1 -0
- package/dist/utils/escape-html.js +7 -0
- package/dist/utils/escape-html.test.d.ts +2 -0
- package/dist/utils/escape-html.test.d.ts.map +1 -0
- package/dist/utils/escape-html.test.js +25 -0
- package/dist/utils/pdf.d.ts +9 -0
- package/dist/utils/pdf.d.ts.map +1 -0
- package/dist/utils/pdf.js +33 -0
- package/dist/utils/sanitize.d.ts +9 -0
- package/dist/utils/sanitize.d.ts.map +1 -0
- package/dist/utils/sanitize.js +115 -0
- package/dist/utils/sanitize.test.d.ts +2 -0
- package/dist/utils/sanitize.test.d.ts.map +1 -0
- package/dist/utils/sanitize.test.js +206 -0
- package/package.json +76 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from "svelte";
|
|
3
|
+
import { Editor } from "@tiptap/core";
|
|
4
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
5
|
+
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
|
6
|
+
import { common, createLowlight } from "lowlight";
|
|
7
|
+
import cpp from "highlight.js/lib/languages/cpp";
|
|
8
|
+
import python from "highlight.js/lib/languages/python";
|
|
9
|
+
import Placeholder from "@tiptap/extension-placeholder";
|
|
10
|
+
import Image from "@tiptap/extension-image";
|
|
11
|
+
import Link from "@tiptap/extension-link";
|
|
12
|
+
import Underline from "@tiptap/extension-underline";
|
|
13
|
+
import TextAlign from "@tiptap/extension-text-align";
|
|
14
|
+
import Color from "@tiptap/extension-color";
|
|
15
|
+
import { TextStyle } from "@tiptap/extension-text-style";
|
|
16
|
+
import HighlightExt from "@tiptap/extension-highlight";
|
|
17
|
+
import TaskList from "@tiptap/extension-task-list";
|
|
18
|
+
import TaskItem from "@tiptap/extension-task-item";
|
|
19
|
+
import SubscriptExt from "@tiptap/extension-subscript";
|
|
20
|
+
import SuperscriptExt from "@tiptap/extension-superscript";
|
|
21
|
+
import Typography from "@tiptap/extension-typography";
|
|
22
|
+
import CharacterCount from "@tiptap/extension-character-count";
|
|
23
|
+
import { Table } from "@tiptap/extension-table";
|
|
24
|
+
import { TableRow } from "@tiptap/extension-table-row";
|
|
25
|
+
import { TableHeader } from "@tiptap/extension-table-header";
|
|
26
|
+
import { TableCell } from "@tiptap/extension-table-cell";
|
|
27
|
+
import { DetailsContent, DetailsSummary } from "@tiptap/extension-details";
|
|
28
|
+
import { FixedDetails } from "../extensions/FixedDetails";
|
|
29
|
+
import Youtube from "@tiptap/extension-youtube";
|
|
30
|
+
import FileHandler from "@tiptap/extension-file-handler";
|
|
31
|
+
import { Extension, type AnyExtension } from "@tiptap/core";
|
|
32
|
+
import { TextSelection } from "@tiptap/pm/state";
|
|
33
|
+
import { PdfBlock } from "../extensions/PdfBlock";
|
|
34
|
+
import { Columns } from "../extensions/Columns";
|
|
35
|
+
import { Column } from "../extensions/Column";
|
|
36
|
+
import { transformLegacyHtml } from "../utils/sanitize";
|
|
37
|
+
import { Indent } from "../extensions/Indent";
|
|
38
|
+
import { FileAttachment } from "../extensions/FileAttachment";
|
|
39
|
+
import { VideoBlock } from "../extensions/VideoBlock";
|
|
40
|
+
import FixedToolbar from "./FixedToolbar.svelte";
|
|
41
|
+
import BubbleToolbar from "./BubbleToolbar.svelte";
|
|
42
|
+
import SlashCommandMenu from "./SlashCommandMenu.svelte";
|
|
43
|
+
import TableBubbleMenu from "./TableBubbleMenu.svelte";
|
|
44
|
+
import type { UploadHandler, ToolbarMode } from "../types";
|
|
45
|
+
import type { FileResolver } from "../extensions/FileAttachment";
|
|
46
|
+
|
|
47
|
+
const cellAttrs = {
|
|
48
|
+
backgroundColor: {
|
|
49
|
+
default: null,
|
|
50
|
+
parseHTML: (element: HTMLElement) =>
|
|
51
|
+
element.style.backgroundColor || null,
|
|
52
|
+
renderHTML: (attributes: Record<string, unknown>) => {
|
|
53
|
+
const styles: string[] = [];
|
|
54
|
+
if (attributes.backgroundColor)
|
|
55
|
+
styles.push(`background-color: ${attributes.backgroundColor}`);
|
|
56
|
+
if (attributes.lineHeight)
|
|
57
|
+
styles.push(`line-height: ${attributes.lineHeight}`);
|
|
58
|
+
return styles.length ? { style: styles.join("; ") } : {};
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
lineHeight: {
|
|
62
|
+
default: null,
|
|
63
|
+
parseHTML: (element: HTMLElement) => element.style.lineHeight || null,
|
|
64
|
+
renderHTML: () => ({}),
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const CustomTableCell = TableCell.extend({
|
|
69
|
+
addAttributes() {
|
|
70
|
+
return { ...this.parent?.(), ...cellAttrs };
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const CustomTableHeader = TableHeader.extend({
|
|
75
|
+
addAttributes() {
|
|
76
|
+
return { ...this.parent?.(), ...cellAttrs };
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 코드블록이 문서 맨 첫 노드일 때, 코드블록 맨 앞에서
|
|
82
|
+
* ArrowUp을 누르면 위에 빈 paragraph를 삽입하고 커서 이동.
|
|
83
|
+
*/
|
|
84
|
+
const CodeBlockTopEscape = Extension.create({
|
|
85
|
+
name: "codeBlockTopEscape",
|
|
86
|
+
addKeyboardShortcuts() {
|
|
87
|
+
return {
|
|
88
|
+
ArrowUp: () => {
|
|
89
|
+
const { state, view } = this.editor;
|
|
90
|
+
const { from } = state.selection;
|
|
91
|
+
const firstNode = state.doc.firstChild;
|
|
92
|
+
if (!firstNode) return false;
|
|
93
|
+
if (firstNode.type.name !== "codeBlock") return false;
|
|
94
|
+
const codeStart = 1;
|
|
95
|
+
if (from !== codeStart) return false;
|
|
96
|
+
const tr = state.tr.insert(0, state.schema.nodes.paragraph.create());
|
|
97
|
+
tr.setSelection(TextSelection.create(tr.doc, 1));
|
|
98
|
+
view.dispatch(tr);
|
|
99
|
+
return true;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const lowlight = createLowlight(common);
|
|
106
|
+
lowlight.register("cpp", cpp);
|
|
107
|
+
lowlight.register("python", python);
|
|
108
|
+
|
|
109
|
+
let {
|
|
110
|
+
content = "",
|
|
111
|
+
onChange,
|
|
112
|
+
placeholder = "'/'를 눌러 명령어를 입력하세요...",
|
|
113
|
+
onUploadFile,
|
|
114
|
+
onResolveFile,
|
|
115
|
+
extensions: extraExtensions = [],
|
|
116
|
+
editable = true,
|
|
117
|
+
toolbar = 'full',
|
|
118
|
+
}: {
|
|
119
|
+
content: string;
|
|
120
|
+
onChange: (html: string) => void;
|
|
121
|
+
placeholder?: string;
|
|
122
|
+
onUploadFile?: UploadHandler;
|
|
123
|
+
onResolveFile?: FileResolver;
|
|
124
|
+
extensions?: AnyExtension[];
|
|
125
|
+
editable?: boolean;
|
|
126
|
+
toolbar?: ToolbarMode;
|
|
127
|
+
} = $props();
|
|
128
|
+
|
|
129
|
+
let editorElement: HTMLDivElement | undefined = $state();
|
|
130
|
+
let editor: Editor | undefined = $state();
|
|
131
|
+
let uploading = $state(false);
|
|
132
|
+
let pdfInputEl: HTMLInputElement | undefined = $state();
|
|
133
|
+
let fileInputEl: HTMLInputElement | undefined = $state();
|
|
134
|
+
let videoInputEl: HTMLInputElement | undefined = $state();
|
|
135
|
+
let lastEmittedHtml = content; // onChange로 내보낸 마지막 HTML (외부→내부 변경만 감지용)
|
|
136
|
+
let tableObserver: MutationObserver | undefined;
|
|
137
|
+
|
|
138
|
+
// Slash command state
|
|
139
|
+
let slashMenuOpen = $state(false);
|
|
140
|
+
let slashMenuPos = $state({ top: 0, left: 0 });
|
|
141
|
+
let slashQuery = $state("");
|
|
142
|
+
let slashStartPos: number | null = null;
|
|
143
|
+
const MENU_HEIGHT = 200;
|
|
144
|
+
|
|
145
|
+
function updateSlashMenuPosition() {
|
|
146
|
+
if (!editor || slashStartPos === null) return;
|
|
147
|
+
const { from } = editor.state.selection;
|
|
148
|
+
const coords = editor.view.coordsAtPos(from);
|
|
149
|
+
|
|
150
|
+
const headerHeight = parseInt(
|
|
151
|
+
getComputedStyle(document.documentElement).getPropertyValue(
|
|
152
|
+
"--header-height",
|
|
153
|
+
) || "74",
|
|
154
|
+
);
|
|
155
|
+
const toolbarEl = editor.view.dom
|
|
156
|
+
.closest(".hce-editor-wrapper")
|
|
157
|
+
?.querySelector(".sticky");
|
|
158
|
+
const toolbarBottom = toolbarEl
|
|
159
|
+
? toolbarEl.getBoundingClientRect().bottom
|
|
160
|
+
: headerHeight;
|
|
161
|
+
const topSafe = Math.max(toolbarBottom, headerHeight) + 4;
|
|
162
|
+
|
|
163
|
+
const bottomBar = document.querySelector(".sticky.bottom-0");
|
|
164
|
+
const bottomBarHeight = bottomBar
|
|
165
|
+
? bottomBar.getBoundingClientRect().height
|
|
166
|
+
: 0;
|
|
167
|
+
const bottomSafe = window.innerHeight - bottomBarHeight - 4;
|
|
168
|
+
|
|
169
|
+
const spaceBelow = bottomSafe - coords.bottom;
|
|
170
|
+
const spaceAbove = coords.top - topSafe;
|
|
171
|
+
const goUp = spaceBelow < MENU_HEIGHT && spaceAbove > spaceBelow;
|
|
172
|
+
|
|
173
|
+
let top: number;
|
|
174
|
+
if (goUp) {
|
|
175
|
+
top = Math.max(topSafe, coords.top - MENU_HEIGHT);
|
|
176
|
+
} else {
|
|
177
|
+
top = Math.min(coords.bottom + 4, bottomSafe - MENU_HEIGHT);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
slashMenuPos = { top, left: coords.left };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function deleteSlashText() {
|
|
184
|
+
if (!editor || slashStartPos === null) return;
|
|
185
|
+
const { from } = editor.state.selection;
|
|
186
|
+
editor
|
|
187
|
+
.chain()
|
|
188
|
+
.focus()
|
|
189
|
+
.deleteRange({ from: slashStartPos, to: from })
|
|
190
|
+
.run();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function closeSlashMenu() {
|
|
194
|
+
deleteSlashText();
|
|
195
|
+
slashMenuOpen = false;
|
|
196
|
+
slashStartPos = null;
|
|
197
|
+
slashQuery = "";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function uploadPdf(file: File) {
|
|
201
|
+
if (!editor || !onUploadFile) return;
|
|
202
|
+
uploading = true;
|
|
203
|
+
onUploadFile(file)
|
|
204
|
+
.then((url) => {
|
|
205
|
+
editor!
|
|
206
|
+
.chain()
|
|
207
|
+
.focus()
|
|
208
|
+
.insertContent({
|
|
209
|
+
type: "pdfBlock",
|
|
210
|
+
attrs: { src: url, name: file.name },
|
|
211
|
+
})
|
|
212
|
+
.run();
|
|
213
|
+
})
|
|
214
|
+
.catch(() => {
|
|
215
|
+
alert("PDF 업로드에 실패했습니다.");
|
|
216
|
+
})
|
|
217
|
+
.finally(() => {
|
|
218
|
+
uploading = false;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function uploadFile(file: File) {
|
|
223
|
+
if (!editor || !onUploadFile) return;
|
|
224
|
+
uploading = true;
|
|
225
|
+
const size = file.size;
|
|
226
|
+
onUploadFile(file)
|
|
227
|
+
.then((url) => {
|
|
228
|
+
editor!
|
|
229
|
+
.chain()
|
|
230
|
+
.focus()
|
|
231
|
+
.insertContent({
|
|
232
|
+
type: "fileAttachment",
|
|
233
|
+
attrs: { src: url, name: file.name, size },
|
|
234
|
+
})
|
|
235
|
+
.run();
|
|
236
|
+
})
|
|
237
|
+
.catch(() => {
|
|
238
|
+
alert("파일 업로드에 실패했습니다.");
|
|
239
|
+
})
|
|
240
|
+
.finally(() => {
|
|
241
|
+
uploading = false;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function uploadVideo(file: File) {
|
|
246
|
+
if (!editor || !onUploadFile) return;
|
|
247
|
+
uploading = true;
|
|
248
|
+
onUploadFile(file)
|
|
249
|
+
.then((url) => {
|
|
250
|
+
editor!
|
|
251
|
+
.chain()
|
|
252
|
+
.focus()
|
|
253
|
+
.insertContent({
|
|
254
|
+
type: "videoBlock",
|
|
255
|
+
attrs: { src: url, name: file.name },
|
|
256
|
+
})
|
|
257
|
+
.run();
|
|
258
|
+
})
|
|
259
|
+
.catch(() => {
|
|
260
|
+
alert("영상 업로드에 실패했습니다.");
|
|
261
|
+
})
|
|
262
|
+
.finally(() => {
|
|
263
|
+
uploading = false;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Slash command handlers (component-level for cleanup access)
|
|
268
|
+
function handleUpdate() {
|
|
269
|
+
if (!editor) return;
|
|
270
|
+
const { state } = editor;
|
|
271
|
+
const { from } = state.selection;
|
|
272
|
+
const resolvedPos = state.doc.resolve(from);
|
|
273
|
+
const lineStart = resolvedPos.start();
|
|
274
|
+
const lineText = state.doc.textBetween(lineStart, from, "\n");
|
|
275
|
+
|
|
276
|
+
if (lineText.startsWith("/")) {
|
|
277
|
+
if (slashStartPos === null) {
|
|
278
|
+
slashStartPos = lineStart;
|
|
279
|
+
}
|
|
280
|
+
slashQuery = lineText.slice(1);
|
|
281
|
+
slashMenuOpen = true;
|
|
282
|
+
updateSlashMenuPosition();
|
|
283
|
+
} else {
|
|
284
|
+
if (slashMenuOpen) {
|
|
285
|
+
slashMenuOpen = false;
|
|
286
|
+
slashStartPos = null;
|
|
287
|
+
slashQuery = "";
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function handleSelectionUpdate() {
|
|
293
|
+
if (!editor || !slashMenuOpen) return;
|
|
294
|
+
const { state } = editor;
|
|
295
|
+
const { from } = state.selection;
|
|
296
|
+
const resolvedPos = state.doc.resolve(from);
|
|
297
|
+
const lineStart = resolvedPos.start();
|
|
298
|
+
const lineText = state.doc.textBetween(lineStart, from, "\n");
|
|
299
|
+
if (!lineText.startsWith("/")) {
|
|
300
|
+
slashMenuOpen = false;
|
|
301
|
+
slashStartPos = null;
|
|
302
|
+
slashQuery = "";
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
onMount(() => {
|
|
307
|
+
if (!editorElement) return;
|
|
308
|
+
|
|
309
|
+
editor = new Editor({
|
|
310
|
+
element: editorElement,
|
|
311
|
+
extensions: [
|
|
312
|
+
StarterKit.configure({
|
|
313
|
+
heading: { levels: [1, 2, 3] },
|
|
314
|
+
codeBlock: false,
|
|
315
|
+
}),
|
|
316
|
+
...(extraExtensions.some((ext) => (ext as any).name === 'codeBlock')
|
|
317
|
+
? []
|
|
318
|
+
: [CodeBlockLowlight.configure({
|
|
319
|
+
lowlight,
|
|
320
|
+
defaultLanguage: "cpp",
|
|
321
|
+
})]),
|
|
322
|
+
Placeholder.configure({
|
|
323
|
+
placeholder: ({ node }) => {
|
|
324
|
+
if (node.type.name === "heading") {
|
|
325
|
+
const level = node.attrs.level;
|
|
326
|
+
if (level === 1) return "제목 1";
|
|
327
|
+
if (level === 2) return "제목 2";
|
|
328
|
+
if (level === 3) return "제목 3";
|
|
329
|
+
}
|
|
330
|
+
return placeholder;
|
|
331
|
+
},
|
|
332
|
+
showOnlyWhenEditable: true,
|
|
333
|
+
showOnlyCurrent: true,
|
|
334
|
+
}),
|
|
335
|
+
Image.configure({ inline: false }),
|
|
336
|
+
Link.configure({
|
|
337
|
+
openOnClick: false,
|
|
338
|
+
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
|
|
339
|
+
}),
|
|
340
|
+
Underline,
|
|
341
|
+
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
|
342
|
+
TextStyle,
|
|
343
|
+
Color,
|
|
344
|
+
HighlightExt.configure({ multicolor: true }),
|
|
345
|
+
TaskList,
|
|
346
|
+
TaskItem.configure({ nested: true }),
|
|
347
|
+
SubscriptExt,
|
|
348
|
+
SuperscriptExt,
|
|
349
|
+
Typography,
|
|
350
|
+
CharacterCount,
|
|
351
|
+
Table.configure({ resizable: true, allowTableNodeSelection: true }),
|
|
352
|
+
TableRow,
|
|
353
|
+
CustomTableHeader,
|
|
354
|
+
CustomTableCell,
|
|
355
|
+
PdfBlock,
|
|
356
|
+
FileAttachment,
|
|
357
|
+
VideoBlock,
|
|
358
|
+
Columns,
|
|
359
|
+
Column,
|
|
360
|
+
CodeBlockTopEscape,
|
|
361
|
+
Indent,
|
|
362
|
+
FixedDetails,
|
|
363
|
+
DetailsContent,
|
|
364
|
+
DetailsSummary,
|
|
365
|
+
Youtube.configure({ inline: false, allowFullscreen: true }),
|
|
366
|
+
...extraExtensions,
|
|
367
|
+
...(onUploadFile
|
|
368
|
+
? [
|
|
369
|
+
FileHandler.configure({
|
|
370
|
+
allowedMimeTypes: [
|
|
371
|
+
"image/jpeg",
|
|
372
|
+
"image/png",
|
|
373
|
+
"image/gif",
|
|
374
|
+
"image/webp",
|
|
375
|
+
"application/pdf",
|
|
376
|
+
"video/mp4",
|
|
377
|
+
"video/webm",
|
|
378
|
+
"video/quicktime",
|
|
379
|
+
],
|
|
380
|
+
onDrop: (_currentEditor, files, pos) => {
|
|
381
|
+
for (const file of files) {
|
|
382
|
+
if (file.type.startsWith("image/")) {
|
|
383
|
+
uploading = true;
|
|
384
|
+
onUploadFile!(file)
|
|
385
|
+
.then((url) => {
|
|
386
|
+
_currentEditor
|
|
387
|
+
.chain()
|
|
388
|
+
.focus()
|
|
389
|
+
.insertContentAt(pos, {
|
|
390
|
+
type: "image",
|
|
391
|
+
attrs: { src: url },
|
|
392
|
+
})
|
|
393
|
+
.run();
|
|
394
|
+
})
|
|
395
|
+
.catch(() =>
|
|
396
|
+
alert("이미지 업로드에 실패했습니다."),
|
|
397
|
+
)
|
|
398
|
+
.finally(() => (uploading = false));
|
|
399
|
+
} else if (file.type === "application/pdf") {
|
|
400
|
+
uploading = true;
|
|
401
|
+
onUploadFile!(file)
|
|
402
|
+
.then((url) => {
|
|
403
|
+
_currentEditor
|
|
404
|
+
.chain()
|
|
405
|
+
.focus()
|
|
406
|
+
.insertContentAt(pos, {
|
|
407
|
+
type: "pdfBlock",
|
|
408
|
+
attrs: { src: url, name: file.name },
|
|
409
|
+
})
|
|
410
|
+
.run();
|
|
411
|
+
})
|
|
412
|
+
.catch(() =>
|
|
413
|
+
alert("PDF 업로드에 실패했습니다."),
|
|
414
|
+
)
|
|
415
|
+
.finally(() => (uploading = false));
|
|
416
|
+
} else if (file.type.startsWith("video/")) {
|
|
417
|
+
uploading = true;
|
|
418
|
+
onUploadFile!(file)
|
|
419
|
+
.then((url) => {
|
|
420
|
+
_currentEditor
|
|
421
|
+
.chain()
|
|
422
|
+
.focus()
|
|
423
|
+
.insertContentAt(pos, {
|
|
424
|
+
type: "videoBlock",
|
|
425
|
+
attrs: { src: url, name: file.name },
|
|
426
|
+
})
|
|
427
|
+
.run();
|
|
428
|
+
})
|
|
429
|
+
.catch(() =>
|
|
430
|
+
alert("영상 업로드에 실패했습니다."),
|
|
431
|
+
)
|
|
432
|
+
.finally(() => (uploading = false));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
onPaste: (_currentEditor, files) => {
|
|
437
|
+
for (const file of files) {
|
|
438
|
+
if (file.type.startsWith("image/")) {
|
|
439
|
+
uploading = true;
|
|
440
|
+
onUploadFile!(file)
|
|
441
|
+
.then((url) => {
|
|
442
|
+
_currentEditor
|
|
443
|
+
.chain()
|
|
444
|
+
.focus()
|
|
445
|
+
.setImage({ src: url })
|
|
446
|
+
.run();
|
|
447
|
+
})
|
|
448
|
+
.catch(() =>
|
|
449
|
+
alert("이미지 업로드에 실패했습니다."),
|
|
450
|
+
)
|
|
451
|
+
.finally(() => (uploading = false));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
}),
|
|
456
|
+
]
|
|
457
|
+
: []),
|
|
458
|
+
],
|
|
459
|
+
content: transformLegacyHtml(content),
|
|
460
|
+
editable,
|
|
461
|
+
onUpdate: ({ editor: e }) => {
|
|
462
|
+
const html = e
|
|
463
|
+
.getHTML()
|
|
464
|
+
.replace(/(<p><br\s*\/?><\/p>\s*)+$/, "")
|
|
465
|
+
.replace(/<p><\/p>/g, "<p><br></p>");
|
|
466
|
+
lastEmittedHtml = html;
|
|
467
|
+
onChange(html);
|
|
468
|
+
},
|
|
469
|
+
editorProps: {
|
|
470
|
+
attributes: {
|
|
471
|
+
class: "tiptap outline-none p-4",
|
|
472
|
+
},
|
|
473
|
+
scrollThreshold: 100,
|
|
474
|
+
scrollMargin: 100,
|
|
475
|
+
},
|
|
476
|
+
onTransaction: () => {
|
|
477
|
+
// Force Svelte to re-render so toolbar active states update
|
|
478
|
+
editor = editor;
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// File resolver를 storage에 등록
|
|
483
|
+
if (onResolveFile) {
|
|
484
|
+
editor.storage.fileAttachment = { resolver: onResolveFile };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
editor.on("update", handleUpdate);
|
|
488
|
+
editor.on("selectionUpdate", handleSelectionUpdate);
|
|
489
|
+
|
|
490
|
+
// Table overflow fix
|
|
491
|
+
const editorDom = editor.view.dom;
|
|
492
|
+
tableObserver = new MutationObserver(() => {
|
|
493
|
+
const wrapper = editorDom.closest(".hce-editor-wrapper");
|
|
494
|
+
if (!wrapper) return;
|
|
495
|
+
const maxW = wrapper.clientWidth - 32;
|
|
496
|
+
wrapper.querySelectorAll("table").forEach((table) => {
|
|
497
|
+
const cols = table.querySelectorAll("colgroup col");
|
|
498
|
+
if (cols.length < 2) return;
|
|
499
|
+
let total = 0;
|
|
500
|
+
cols.forEach((col) => {
|
|
501
|
+
total += parseInt((col as HTMLElement).style.width || "0", 10);
|
|
502
|
+
});
|
|
503
|
+
if (total > maxW) {
|
|
504
|
+
const lastCol = cols[cols.length - 1] as HTMLElement;
|
|
505
|
+
const lastW = parseInt(lastCol.style.width || "0", 10);
|
|
506
|
+
const newW = lastW - (total - maxW);
|
|
507
|
+
if (newW >= 40) lastCol.style.width = `${newW}px`;
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
tableObserver.observe(editorDom, {
|
|
512
|
+
subtree: true,
|
|
513
|
+
attributes: true,
|
|
514
|
+
attributeFilter: ["style"],
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// Scroll handler for slash menu position
|
|
519
|
+
$effect(() => {
|
|
520
|
+
if (!slashMenuOpen) return;
|
|
521
|
+
const onScroll = () => updateSlashMenuPosition();
|
|
522
|
+
window.addEventListener("scroll", onScroll, true);
|
|
523
|
+
return () => window.removeEventListener("scroll", onScroll, true);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// Sync content from parent (외부에서 content prop이 변경된 경우만)
|
|
527
|
+
$effect(() => {
|
|
528
|
+
if (!editor) return;
|
|
529
|
+
// 에디터 자체 onChange에서 나온 값이면 무시 (무한 루프 방지)
|
|
530
|
+
if (content === lastEmittedHtml) return;
|
|
531
|
+
const transformed = transformLegacyHtml(content);
|
|
532
|
+
editor.commands.setContent(transformed, { emitUpdate: false });
|
|
533
|
+
lastEmittedHtml = content;
|
|
534
|
+
editor.commands.fixTables();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
onDestroy(() => {
|
|
538
|
+
tableObserver?.disconnect();
|
|
539
|
+
if (editor) {
|
|
540
|
+
editor.off("update", handleUpdate);
|
|
541
|
+
editor.off("selectionUpdate", handleSelectionUpdate);
|
|
542
|
+
editor.destroy();
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
</script>
|
|
546
|
+
|
|
547
|
+
<div
|
|
548
|
+
class="hce-editor-wrapper relative{editable && toolbar === 'full' ? ' border border-border rounded-xl bg-background' : ''}"
|
|
549
|
+
ondragover={(e) => e.preventDefault()}
|
|
550
|
+
ondrop={(e) => { if (!onUploadFile) e.preventDefault(); }}
|
|
551
|
+
>
|
|
552
|
+
{#if editor && editable && toolbar === 'full'}
|
|
553
|
+
<FixedToolbar
|
|
554
|
+
{editor}
|
|
555
|
+
onPdfClick={() => pdfInputEl?.click()}
|
|
556
|
+
onFileClick={onUploadFile ? () => fileInputEl?.click() : undefined}
|
|
557
|
+
onVideoClick={onUploadFile ? () => videoInputEl?.click() : undefined}
|
|
558
|
+
/>
|
|
559
|
+
{/if}
|
|
560
|
+
|
|
561
|
+
<div bind:this={editorElement}></div>
|
|
562
|
+
|
|
563
|
+
{#if editor && editable}
|
|
564
|
+
<BubbleToolbar {editor} minimal={toolbar === 'minimal'} />
|
|
565
|
+
|
|
566
|
+
{#if toolbar !== 'minimal'}
|
|
567
|
+
<TableBubbleMenu {editor} />
|
|
568
|
+
{/if}
|
|
569
|
+
|
|
570
|
+
{#if toolbar === 'full'}
|
|
571
|
+
{#if uploading}
|
|
572
|
+
<div
|
|
573
|
+
class="absolute inset-0 flex items-center justify-center bg-background/60 rounded-xl"
|
|
574
|
+
>
|
|
575
|
+
<p class="text-sm text-muted-foreground animate-pulse">
|
|
576
|
+
업로드 중...
|
|
577
|
+
</p>
|
|
578
|
+
</div>
|
|
579
|
+
{/if}
|
|
580
|
+
{/if}
|
|
581
|
+
|
|
582
|
+
{#if toolbar !== 'minimal' && slashMenuOpen}
|
|
583
|
+
<div
|
|
584
|
+
style="top: {slashMenuPos.top}px; left: {slashMenuPos.left}px"
|
|
585
|
+
class="fixed z-50"
|
|
586
|
+
>
|
|
587
|
+
<SlashCommandMenu
|
|
588
|
+
{editor}
|
|
589
|
+
query={slashQuery}
|
|
590
|
+
onClose={closeSlashMenu}
|
|
591
|
+
onPdfUpload={onUploadFile
|
|
592
|
+
? () => pdfInputEl?.click()
|
|
593
|
+
: undefined}
|
|
594
|
+
onFileUpload={onUploadFile
|
|
595
|
+
? () => fileInputEl?.click()
|
|
596
|
+
: undefined}
|
|
597
|
+
onVideoUpload={onUploadFile
|
|
598
|
+
? () => videoInputEl?.click()
|
|
599
|
+
: undefined}
|
|
600
|
+
/>
|
|
601
|
+
</div>
|
|
602
|
+
{/if}
|
|
603
|
+
|
|
604
|
+
{#if toolbar === 'full'}
|
|
605
|
+
<div
|
|
606
|
+
class="flex justify-end px-4 py-2 text-xs text-muted-foreground border-t border-border"
|
|
607
|
+
>
|
|
608
|
+
{editor.storage.characterCount.characters()} 자 ·
|
|
609
|
+
{editor.storage.characterCount.words()} 단어
|
|
610
|
+
</div>
|
|
611
|
+
|
|
612
|
+
<input
|
|
613
|
+
bind:this={pdfInputEl}
|
|
614
|
+
type="file"
|
|
615
|
+
accept="application/pdf"
|
|
616
|
+
class="hidden"
|
|
617
|
+
onchange={(e) => {
|
|
618
|
+
const target = e.target as HTMLInputElement;
|
|
619
|
+
const file = target.files?.[0];
|
|
620
|
+
if (file) uploadPdf(file);
|
|
621
|
+
target.value = "";
|
|
622
|
+
}}
|
|
623
|
+
/>
|
|
624
|
+
<input
|
|
625
|
+
bind:this={fileInputEl}
|
|
626
|
+
type="file"
|
|
627
|
+
class="hidden"
|
|
628
|
+
onchange={(e) => {
|
|
629
|
+
const target = e.target as HTMLInputElement;
|
|
630
|
+
const file = target.files?.[0];
|
|
631
|
+
if (file) uploadFile(file);
|
|
632
|
+
target.value = "";
|
|
633
|
+
}}
|
|
634
|
+
/>
|
|
635
|
+
<input
|
|
636
|
+
bind:this={videoInputEl}
|
|
637
|
+
type="file"
|
|
638
|
+
accept="video/mp4,video/webm,video/quicktime"
|
|
639
|
+
class="hidden"
|
|
640
|
+
onchange={(e) => {
|
|
641
|
+
const target = e.target as HTMLInputElement;
|
|
642
|
+
const file = target.files?.[0];
|
|
643
|
+
if (file) uploadVideo(file);
|
|
644
|
+
target.value = "";
|
|
645
|
+
}}
|
|
646
|
+
/>
|
|
647
|
+
{/if}
|
|
648
|
+
{/if}
|
|
649
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type AnyExtension } from "@tiptap/core";
|
|
2
|
+
import type { UploadHandler, ToolbarMode } from "../types";
|
|
3
|
+
import type { FileResolver } from "../extensions/FileAttachment";
|
|
4
|
+
type $$ComponentProps = {
|
|
5
|
+
content: string;
|
|
6
|
+
onChange: (html: string) => void;
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
onUploadFile?: UploadHandler;
|
|
9
|
+
onResolveFile?: FileResolver;
|
|
10
|
+
extensions?: AnyExtension[];
|
|
11
|
+
editable?: boolean;
|
|
12
|
+
toolbar?: ToolbarMode;
|
|
13
|
+
};
|
|
14
|
+
declare const TipTapEditor: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
15
|
+
type TipTapEditor = ReturnType<typeof TipTapEditor>;
|
|
16
|
+
export default TipTapEditor;
|
|
17
|
+
//# sourceMappingURL=TipTapEditor.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TipTapEditor.svelte.d.ts","sourceRoot":"","sources":["../../src/components/TipTapEditor.svelte.ts"],"names":[],"mappings":"AAgCA,OAAO,EAAa,KAAK,YAAY,EAAE,MAAM,cAAc,CAAC;AAa5D,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAEhE,KAAK,gBAAgB,GAAI;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B,UAAU,CAAC,EAAE,YAAY,EAAE,CAAC;IAC5B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,WAAW,CAAC;CACvB,CAAC;AA4lBJ,QAAA,MAAM,YAAY,sDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Column.d.ts","sourceRoot":"","sources":["../../src/extensions/Column.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,IAAI,UAAU,EAAmB,MAAM,cAAc,CAAC;AAEnE,eAAO,MAAM,MAAM,sBAoBjB,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
|
|
2
|
+
export const Column = TiptapNode.create({
|
|
3
|
+
name: "column",
|
|
4
|
+
group: "",
|
|
5
|
+
content: "block+",
|
|
6
|
+
isolating: true,
|
|
7
|
+
parseHTML() {
|
|
8
|
+
return [
|
|
9
|
+
{ tag: 'div[data-type="column"]' },
|
|
10
|
+
{ tag: "div.tiptap-column" },
|
|
11
|
+
];
|
|
12
|
+
},
|
|
13
|
+
renderHTML({ HTMLAttributes }) {
|
|
14
|
+
return [
|
|
15
|
+
"div",
|
|
16
|
+
mergeAttributes(HTMLAttributes, { "data-type": "column" }),
|
|
17
|
+
0,
|
|
18
|
+
];
|
|
19
|
+
},
|
|
20
|
+
});
|