@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.
Files changed (90) hide show
  1. package/README.md +178 -0
  2. package/dist/components/BubbleToolbar.svelte +299 -0
  3. package/dist/components/BubbleToolbar.svelte.d.ts +9 -0
  4. package/dist/components/BubbleToolbar.svelte.d.ts.map +1 -0
  5. package/dist/components/FixedToolbar.svelte +892 -0
  6. package/dist/components/FixedToolbar.svelte.d.ts +11 -0
  7. package/dist/components/FixedToolbar.svelte.d.ts.map +1 -0
  8. package/dist/components/InputModal.svelte +102 -0
  9. package/dist/components/InputModal.svelte.d.ts +11 -0
  10. package/dist/components/InputModal.svelte.d.ts.map +1 -0
  11. package/dist/components/PdfViewer.svelte +152 -0
  12. package/dist/components/PdfViewer.svelte.d.ts +8 -0
  13. package/dist/components/PdfViewer.svelte.d.ts.map +1 -0
  14. package/dist/components/SlashCommandMenu.svelte +315 -0
  15. package/dist/components/SlashCommandMenu.svelte.d.ts +14 -0
  16. package/dist/components/SlashCommandMenu.svelte.d.ts.map +1 -0
  17. package/dist/components/TableBubbleMenu.svelte +472 -0
  18. package/dist/components/TableBubbleMenu.svelte.d.ts +8 -0
  19. package/dist/components/TableBubbleMenu.svelte.d.ts.map +1 -0
  20. package/dist/components/TipTapEditor.svelte +649 -0
  21. package/dist/components/TipTapEditor.svelte.d.ts +17 -0
  22. package/dist/components/TipTapEditor.svelte.d.ts.map +1 -0
  23. package/dist/extensions/Column.d.ts +3 -0
  24. package/dist/extensions/Column.d.ts.map +1 -0
  25. package/dist/extensions/Column.js +20 -0
  26. package/dist/extensions/Columns.d.ts +11 -0
  27. package/dist/extensions/Columns.d.ts.map +1 -0
  28. package/dist/extensions/Columns.js +103 -0
  29. package/dist/extensions/Columns.test.d.ts +2 -0
  30. package/dist/extensions/Columns.test.d.ts.map +1 -0
  31. package/dist/extensions/Columns.test.js +62 -0
  32. package/dist/extensions/FileAttachment.d.ts +24 -0
  33. package/dist/extensions/FileAttachment.d.ts.map +1 -0
  34. package/dist/extensions/FileAttachment.js +190 -0
  35. package/dist/extensions/FileAttachment.test.d.ts +2 -0
  36. package/dist/extensions/FileAttachment.test.d.ts.map +1 -0
  37. package/dist/extensions/FileAttachment.test.js +63 -0
  38. package/dist/extensions/FixedDetails.d.ts +2 -0
  39. package/dist/extensions/FixedDetails.d.ts.map +1 -0
  40. package/dist/extensions/FixedDetails.js +100 -0
  41. package/dist/extensions/FixedDetails.test.d.ts +2 -0
  42. package/dist/extensions/FixedDetails.test.d.ts.map +1 -0
  43. package/dist/extensions/FixedDetails.test.js +40 -0
  44. package/dist/extensions/Indent.d.ts +11 -0
  45. package/dist/extensions/Indent.d.ts.map +1 -0
  46. package/dist/extensions/Indent.js +87 -0
  47. package/dist/extensions/Indent.test.d.ts +2 -0
  48. package/dist/extensions/Indent.test.d.ts.map +1 -0
  49. package/dist/extensions/Indent.test.js +78 -0
  50. package/dist/extensions/PdfBlock.d.ts +3 -0
  51. package/dist/extensions/PdfBlock.d.ts.map +1 -0
  52. package/dist/extensions/PdfBlock.js +301 -0
  53. package/dist/extensions/PdfBlock.test.d.ts +2 -0
  54. package/dist/extensions/PdfBlock.test.d.ts.map +1 -0
  55. package/dist/extensions/PdfBlock.test.js +51 -0
  56. package/dist/extensions/VideoBlock.d.ts +16 -0
  57. package/dist/extensions/VideoBlock.d.ts.map +1 -0
  58. package/dist/extensions/VideoBlock.js +87 -0
  59. package/dist/extensions/VideoBlock.test.d.ts +2 -0
  60. package/dist/extensions/VideoBlock.test.d.ts.map +1 -0
  61. package/dist/extensions/VideoBlock.test.js +54 -0
  62. package/dist/index.d.ts +20 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +20 -0
  65. package/dist/styles/editor.css +698 -0
  66. package/dist/types.d.ts +44 -0
  67. package/dist/types.d.ts.map +1 -0
  68. package/dist/types.js +1 -0
  69. package/dist/utils/cn.d.ts +3 -0
  70. package/dist/utils/cn.d.ts.map +1 -0
  71. package/dist/utils/cn.js +5 -0
  72. package/dist/utils/cn.test.d.ts +2 -0
  73. package/dist/utils/cn.test.d.ts.map +1 -0
  74. package/dist/utils/cn.test.js +28 -0
  75. package/dist/utils/escape-html.d.ts +2 -0
  76. package/dist/utils/escape-html.d.ts.map +1 -0
  77. package/dist/utils/escape-html.js +7 -0
  78. package/dist/utils/escape-html.test.d.ts +2 -0
  79. package/dist/utils/escape-html.test.d.ts.map +1 -0
  80. package/dist/utils/escape-html.test.js +25 -0
  81. package/dist/utils/pdf.d.ts +9 -0
  82. package/dist/utils/pdf.d.ts.map +1 -0
  83. package/dist/utils/pdf.js +33 -0
  84. package/dist/utils/sanitize.d.ts +9 -0
  85. package/dist/utils/sanitize.d.ts.map +1 -0
  86. package/dist/utils/sanitize.js +115 -0
  87. package/dist/utils/sanitize.test.d.ts +2 -0
  88. package/dist/utils/sanitize.test.d.ts.map +1 -0
  89. package/dist/utils/sanitize.test.js +206 -0
  90. 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,3 @@
1
+ import { Node as TiptapNode } from "@tiptap/core";
2
+ export declare const Column: TiptapNode<any, any>;
3
+ //# sourceMappingURL=Column.d.ts.map
@@ -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
+ });