@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,11 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ type $$ComponentProps = {
3
+ editor: Editor;
4
+ onPdfClick: () => void;
5
+ onFileClick?: () => void;
6
+ onVideoClick?: () => void;
7
+ };
8
+ declare const FixedToolbar: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type FixedToolbar = ReturnType<typeof FixedToolbar>;
10
+ export default FixedToolbar;
11
+ //# sourceMappingURL=FixedToolbar.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FixedToolbar.svelte.d.ts","sourceRoot":"","sources":["../../src/components/FixedToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AA6C1C,KAAK,gBAAgB,GAAI;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;CAC3B,CAAC;AAiiBJ,QAAA,MAAM,YAAY,sDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -0,0 +1,102 @@
1
+ <script lang="ts">
2
+ import { X } from "lucide-svelte";
3
+
4
+ let {
5
+ title,
6
+ placeholder = "",
7
+ defaultValue = "",
8
+ onConfirm,
9
+ onCancel,
10
+ }: {
11
+ title: string;
12
+ placeholder?: string;
13
+ defaultValue?: string;
14
+ onConfirm: (value: string) => void;
15
+ onCancel: () => void;
16
+ } = $props();
17
+
18
+ let value = $state(defaultValue);
19
+ let inputEl: HTMLInputElement | undefined = $state();
20
+
21
+ $effect(() => {
22
+ if (inputEl) {
23
+ inputEl.focus();
24
+ inputEl.select();
25
+ }
26
+ });
27
+
28
+ // Window-level Escape key handler
29
+ $effect(() => {
30
+ function onKeydown(e: KeyboardEvent) {
31
+ if (e.key === "Escape") {
32
+ e.preventDefault();
33
+ onCancel();
34
+ }
35
+ }
36
+ window.addEventListener("keydown", onKeydown);
37
+ return () => window.removeEventListener("keydown", onKeydown);
38
+ });
39
+
40
+ function handleSubmit() {
41
+ const trimmed = value.trim();
42
+ if (trimmed) onConfirm(trimmed);
43
+ else onCancel();
44
+ }
45
+
46
+ function handleBackdropClick() {
47
+ onCancel();
48
+ }
49
+
50
+ function handleKeydown(e: KeyboardEvent) {
51
+ if (e.key === "Enter") handleSubmit();
52
+ }
53
+ </script>
54
+
55
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
56
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
57
+ <div
58
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
59
+ onclick={handleBackdropClick}
60
+ >
61
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
62
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
63
+ <div
64
+ class="bg-background border border-border rounded-lg shadow-lg p-4 w-80"
65
+ onclick={(e) => e.stopPropagation()}
66
+ >
67
+ <div class="flex items-center justify-between mb-3">
68
+ <span class="text-sm font-medium">{title}</span>
69
+ <button
70
+ type="button"
71
+ class="p-0.5 rounded hover:bg-muted text-muted-foreground"
72
+ onclick={onCancel}
73
+ >
74
+ <X size={14} />
75
+ </button>
76
+ </div>
77
+ <input
78
+ bind:this={inputEl}
79
+ type="text"
80
+ class="w-full border border-border rounded px-3 py-1.5 text-sm bg-background focus:outline-none focus:ring-1 focus:ring-primary"
81
+ {placeholder}
82
+ bind:value
83
+ onkeydown={handleKeydown}
84
+ />
85
+ <div class="flex justify-end gap-2 mt-3">
86
+ <button
87
+ type="button"
88
+ class="px-3 py-1 text-sm rounded border border-border hover:bg-muted"
89
+ onclick={onCancel}
90
+ >
91
+ 취소
92
+ </button>
93
+ <button
94
+ type="button"
95
+ class="px-3 py-1 text-sm rounded bg-primary text-primary-foreground hover:opacity-90"
96
+ onclick={handleSubmit}
97
+ >
98
+ 확인
99
+ </button>
100
+ </div>
101
+ </div>
102
+ </div>
@@ -0,0 +1,11 @@
1
+ type $$ComponentProps = {
2
+ title: string;
3
+ placeholder?: string;
4
+ defaultValue?: string;
5
+ onConfirm: (value: string) => void;
6
+ onCancel: () => void;
7
+ };
8
+ declare const InputModal: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type InputModal = ReturnType<typeof InputModal>;
10
+ export default InputModal;
11
+ //# sourceMappingURL=InputModal.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"InputModal.svelte.d.ts","sourceRoot":"","sources":["../../src/components/InputModal.svelte.ts"],"names":[],"mappings":"AAKC,KAAK,gBAAgB,GAAI;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB,CAAC;AA2EJ,QAAA,MAAM,UAAU,sDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -0,0 +1,152 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte";
3
+ import { ChevronLeft, ChevronRight } from "lucide-svelte";
4
+ import { getPdfJs } from "../utils/pdf";
5
+
6
+ let { src, fileName }: { src: string; fileName?: string } = $props();
7
+
8
+ let canvasEl: HTMLCanvasElement | undefined = $state();
9
+ let containerEl: HTMLDivElement | undefined = $state();
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ let pdf: any = $state(null);
12
+ let page = $state(1);
13
+ let total = $state(0);
14
+ let loading = $state(true);
15
+ let error = $state(false);
16
+ let rendering = false;
17
+
18
+ async function renderPage() {
19
+ if (!pdf || !canvasEl || !containerEl || rendering) return;
20
+ rendering = true;
21
+ try {
22
+ const pageObj = await pdf.getPage(page);
23
+ const unscaledViewport = pageObj.getViewport({ scale: 1 });
24
+ const availableWidth = containerEl.clientWidth - 32;
25
+ if (availableWidth <= 0) return;
26
+ const scale = availableWidth / unscaledViewport.width;
27
+ const viewport = pageObj.getViewport({ scale });
28
+ const dpr = window.devicePixelRatio || 1;
29
+ canvasEl.width = viewport.width * dpr;
30
+ canvasEl.height = viewport.height * dpr;
31
+ canvasEl.style.width = `${viewport.width}px`;
32
+ canvasEl.style.height = `${viewport.height}px`;
33
+ const ctx = canvasEl.getContext("2d");
34
+ if (!ctx) return;
35
+ ctx.resetTransform();
36
+ ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
37
+ ctx.scale(dpr, dpr);
38
+ await pageObj.render({ canvasContext: ctx, viewport }).promise;
39
+ } finally {
40
+ rendering = false;
41
+ }
42
+ }
43
+
44
+ $effect(() => {
45
+ if (pdf && page) renderPage();
46
+ });
47
+
48
+ onMount(() => {
49
+ let cancelled = false;
50
+
51
+ (async () => {
52
+ try {
53
+ loading = true;
54
+ error = false;
55
+ const pdfjsLib = await getPdfJs();
56
+ const loadingTask = pdfjsLib.getDocument(src);
57
+ const pdfDoc = await loadingTask.promise;
58
+ if (!cancelled) {
59
+ pdf = pdfDoc;
60
+ total = pdfDoc.numPages;
61
+ loading = false;
62
+ }
63
+ } catch (e) {
64
+ console.error("[PdfViewer] PDF 로드 실패:", src, e);
65
+ if (!cancelled) {
66
+ error = true;
67
+ loading = false;
68
+ }
69
+ }
70
+ })();
71
+
72
+ // ResizeObserver
73
+ let timeout: ReturnType<typeof setTimeout>;
74
+ const observer = new ResizeObserver(() => {
75
+ clearTimeout(timeout);
76
+ timeout = setTimeout(renderPage, 100);
77
+ });
78
+ if (containerEl) observer.observe(containerEl);
79
+
80
+ return () => {
81
+ cancelled = true;
82
+ observer.disconnect();
83
+ clearTimeout(timeout);
84
+ };
85
+ });
86
+ </script>
87
+
88
+ {#if error}
89
+ <div class="border border-border rounded-lg p-4 text-center">
90
+ <p class="text-sm text-muted-foreground">PDF를 불러올 수 없습니다.</p>
91
+ <a
92
+ href={src}
93
+ target="_blank"
94
+ rel="noopener noreferrer"
95
+ class="text-sm text-primary hover:underline"
96
+ >
97
+ 직접 다운로드
98
+ </a>
99
+ </div>
100
+ {:else if loading}
101
+ <div class="border border-border rounded-lg p-8 text-center">
102
+ <p class="text-sm text-muted-foreground animate-pulse">PDF 로딩 중...</p>
103
+ </div>
104
+ {:else}
105
+ <div class="border border-border rounded-lg overflow-hidden bg-muted/30 my-4">
106
+ <div
107
+ class="flex items-center justify-between px-4 py-2 border-b border-border bg-background"
108
+ >
109
+ <span class="text-xs text-muted-foreground truncate">
110
+ {fileName || "PDF 문서"}
111
+ </span>
112
+ <span class="text-xs text-muted-foreground tabular-nums">
113
+ {page} / {total}
114
+ </span>
115
+ </div>
116
+
117
+ <div
118
+ bind:this={containerEl}
119
+ class="flex items-center justify-center p-4 bg-neutral-100 dark:bg-neutral-900"
120
+ >
121
+ <canvas bind:this={canvasEl} class="shadow-sm"></canvas>
122
+ </div>
123
+
124
+ {#if total > 1}
125
+ <div
126
+ class="flex items-center justify-center gap-4 px-4 py-2 border-t border-border bg-background"
127
+ >
128
+ <button
129
+ type="button"
130
+ onclick={() => (page = Math.max(1, page - 1))}
131
+ disabled={page <= 1}
132
+ class="p-1.5 rounded hover:bg-muted transition-colors disabled:opacity-30"
133
+ title="이전 페이지"
134
+ >
135
+ <ChevronLeft class="w-4 h-4" />
136
+ </button>
137
+ <span class="text-sm tabular-nums">
138
+ {page} / {total}
139
+ </span>
140
+ <button
141
+ type="button"
142
+ onclick={() => (page = Math.min(total, page + 1))}
143
+ disabled={page >= total}
144
+ class="p-1.5 rounded hover:bg-muted transition-colors disabled:opacity-30"
145
+ title="다음 페이지"
146
+ >
147
+ <ChevronRight class="w-4 h-4" />
148
+ </button>
149
+ </div>
150
+ {/if}
151
+ </div>
152
+ {/if}
@@ -0,0 +1,8 @@
1
+ type $$ComponentProps = {
2
+ src: string;
3
+ fileName?: string;
4
+ };
5
+ declare const PdfViewer: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type PdfViewer = ReturnType<typeof PdfViewer>;
7
+ export default PdfViewer;
8
+ //# sourceMappingURL=PdfViewer.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PdfViewer.svelte.d.ts","sourceRoot":"","sources":["../../src/components/PdfViewer.svelte.ts"],"names":[],"mappings":"AAOC,KAAK,gBAAgB,GAAI;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAoI7D,QAAA,MAAM,SAAS,sDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -0,0 +1,315 @@
1
+ <script lang="ts">
2
+ import type { Editor } from "@tiptap/core";
3
+ import {
4
+ Heading1,
5
+ Heading2,
6
+ Heading3,
7
+ List,
8
+ ListOrdered,
9
+ CheckSquare,
10
+ Quote,
11
+ Minus,
12
+ Code2,
13
+ ImageIcon,
14
+ LinkIcon,
15
+ Table as TableIcon,
16
+ FileText,
17
+ Video,
18
+ ChevronRight,
19
+ Paperclip,
20
+ Film,
21
+ Columns2,
22
+ Columns3,
23
+ } from "lucide-svelte";
24
+ import type { SlashMenuItem } from "../types";
25
+ import type { Component } from "svelte";
26
+
27
+ const SI = 14;
28
+
29
+ const SLASH_MENU_ITEMS_DATA: {
30
+ label: string;
31
+ keywords: string;
32
+ icon: Component<{ size?: number }>;
33
+ command: (editor: Editor) => void;
34
+ }[] = [
35
+ {
36
+ label: "제목 1",
37
+ keywords: "heading h1 제목",
38
+ icon: Heading1,
39
+ command: (editor) =>
40
+ editor.chain().focus().toggleHeading({ level: 1 }).run(),
41
+ },
42
+ {
43
+ label: "제목 2",
44
+ keywords: "heading h2 제목",
45
+ icon: Heading2,
46
+ command: (editor) =>
47
+ editor.chain().focus().toggleHeading({ level: 2 }).run(),
48
+ },
49
+ {
50
+ label: "제목 3",
51
+ keywords: "heading h3 제목",
52
+ icon: Heading3,
53
+ command: (editor) =>
54
+ editor.chain().focus().toggleHeading({ level: 3 }).run(),
55
+ },
56
+ {
57
+ label: "글머리 목록",
58
+ keywords: "bullet list 목록 리스트",
59
+ icon: List,
60
+ command: (editor) => editor.chain().focus().toggleBulletList().run(),
61
+ },
62
+ {
63
+ label: "번호 목록",
64
+ keywords: "ordered number list 번호 리스트",
65
+ icon: ListOrdered,
66
+ command: (editor) => editor.chain().focus().toggleOrderedList().run(),
67
+ },
68
+ {
69
+ label: "체크리스트",
70
+ keywords: "checklist task todo 체크 할일",
71
+ icon: CheckSquare,
72
+ command: (editor) => editor.chain().focus().toggleTaskList().run(),
73
+ },
74
+ {
75
+ label: "인용문",
76
+ keywords: "quote blockquote 인용",
77
+ icon: Quote,
78
+ command: (editor) => editor.chain().focus().toggleBlockquote().run(),
79
+ },
80
+ {
81
+ label: "구분선",
82
+ keywords: "divider hr horizontal rule 구분",
83
+ icon: Minus,
84
+ command: (editor) => editor.chain().focus().setHorizontalRule().run(),
85
+ },
86
+ {
87
+ label: "C++ 코드",
88
+ keywords: "code cpp c++ 코드 블록",
89
+ icon: Code2,
90
+ command: (editor) =>
91
+ editor.chain().focus().setCodeBlock({ language: "cpp" }).run(),
92
+ },
93
+ {
94
+ label: "Python 코드",
95
+ keywords: "code python 파이썬 코드 블록",
96
+ icon: Code2,
97
+ command: (editor) =>
98
+ editor.chain().focus().setCodeBlock({ language: "python" }).run(),
99
+ },
100
+ {
101
+ label: "토글",
102
+ keywords: "toggle details 접기 펼치기 토글",
103
+ icon: ChevronRight,
104
+ command: (editor) => editor.chain().focus().setDetails().run(),
105
+ },
106
+ {
107
+ label: "표",
108
+ keywords: "table 표 테이블",
109
+ icon: TableIcon,
110
+ command: (editor) =>
111
+ editor
112
+ .chain()
113
+ .focus()
114
+ .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
115
+ .run(),
116
+ },
117
+ {
118
+ label: "2단 컬럼",
119
+ keywords: "column 컬럼 2단 분할",
120
+ icon: Columns2,
121
+ command: (editor) => editor.chain().focus().setColumns(2).run(),
122
+ },
123
+ {
124
+ label: "3단 컬럼",
125
+ keywords: "column 컬럼 3단 분할",
126
+ icon: Columns3,
127
+ command: (editor) => editor.chain().focus().setColumns(3).run(),
128
+ },
129
+ {
130
+ label: "YouTube 영상",
131
+ keywords: "youtube video 영상 동영상 유튜브",
132
+ icon: Video,
133
+ command: (editor) => {
134
+ const url = window.prompt("YouTube URL을 입력하세요");
135
+ if (url) editor.chain().focus().setYoutubeVideo({ src: url }).run();
136
+ },
137
+ },
138
+ {
139
+ label: "이미지",
140
+ keywords: "image 이미지 사진 img",
141
+ icon: ImageIcon,
142
+ command: (editor) => {
143
+ const url = window.prompt("이미지 URL을 입력하세요");
144
+ if (url) editor.chain().focus().setImage({ src: url }).run();
145
+ },
146
+ },
147
+ {
148
+ label: "링크",
149
+ keywords: "link url 링크 하이퍼",
150
+ icon: LinkIcon,
151
+ command: (editor) => {
152
+ const url = window.prompt("링크 URL을 입력하세요");
153
+ if (url)
154
+ editor
155
+ .chain()
156
+ .focus()
157
+ .extendMarkRange("link")
158
+ .setLink({ href: url })
159
+ .run();
160
+ },
161
+ },
162
+ ];
163
+
164
+ let {
165
+ editor,
166
+ query,
167
+ onClose,
168
+ onPdfUpload,
169
+ onFileUpload,
170
+ onVideoUpload,
171
+ }: {
172
+ editor: Editor;
173
+ query: string;
174
+ onClose: () => void;
175
+ onPdfUpload?: () => void;
176
+ onFileUpload?: () => void;
177
+ onVideoUpload?: () => void;
178
+ } = $props();
179
+
180
+ let selectedIndex = $state(0);
181
+ let menuEl: HTMLDivElement | undefined = $state();
182
+ let mouseMovedSinceKeyboard = $state(true);
183
+
184
+ const allItems = $derived.by(() => {
185
+ const items = [...SLASH_MENU_ITEMS_DATA];
186
+ if (onPdfUpload) {
187
+ items.push({
188
+ label: "PDF 파일",
189
+ keywords: "pdf 파일 문서",
190
+ icon: FileText,
191
+ command: () => onPdfUpload!(),
192
+ });
193
+ }
194
+ if (onVideoUpload) {
195
+ items.push({
196
+ label: "영상 파일",
197
+ keywords: "video 영상 동영상 비디오 메타버스",
198
+ icon: Film,
199
+ command: () => onVideoUpload!(),
200
+ });
201
+ }
202
+ if (onFileUpload) {
203
+ items.push({
204
+ label: "파일 첨부",
205
+ keywords: "file attach 파일 첨부",
206
+ icon: Paperclip,
207
+ command: () => onFileUpload!(),
208
+ });
209
+ }
210
+ return items;
211
+ });
212
+
213
+ const filtered = $derived.by(() => {
214
+ const q = query.toLowerCase();
215
+ return q
216
+ ? allItems.filter(
217
+ (item) =>
218
+ item.label.toLowerCase().includes(q) ||
219
+ item.keywords.toLowerCase().includes(q),
220
+ )
221
+ : allItems;
222
+ });
223
+
224
+ $effect(() => {
225
+ // Reset index when query changes
226
+ query;
227
+ selectedIndex = 0;
228
+ });
229
+
230
+ $effect(() => {
231
+ // Scroll selected into view
232
+ if (menuEl) {
233
+ const el = menuEl.querySelector(`[data-index="${selectedIndex}"]`);
234
+ el?.scrollIntoView({ block: "nearest" });
235
+ }
236
+ });
237
+
238
+ $effect(() => {
239
+ function handleKeyDown(e: KeyboardEvent) {
240
+ if (e.key === "ArrowDown") {
241
+ e.preventDefault();
242
+ if (filtered.length === 0) return;
243
+ mouseMovedSinceKeyboard = false;
244
+ selectedIndex = (selectedIndex + 1) % filtered.length;
245
+ } else if (e.key === "ArrowUp") {
246
+ e.preventDefault();
247
+ if (filtered.length === 0) return;
248
+ mouseMovedSinceKeyboard = false;
249
+ selectedIndex =
250
+ selectedIndex <= 0 ? filtered.length - 1 : selectedIndex - 1;
251
+ } else if (e.key === "Enter") {
252
+ e.preventDefault();
253
+ if (filtered[selectedIndex]) {
254
+ onClose();
255
+ filtered[selectedIndex].command(editor);
256
+ }
257
+ } else if (e.key === "Escape") {
258
+ e.preventDefault();
259
+ onClose();
260
+ }
261
+ }
262
+ document.addEventListener("keydown", handleKeyDown, true);
263
+ return () => document.removeEventListener("keydown", handleKeyDown, true);
264
+ });
265
+ </script>
266
+
267
+ {#if filtered.length === 0}
268
+ <div
269
+ bind:this={menuEl}
270
+ class="z-50 bg-popover border border-border rounded-lg shadow-lg p-2"
271
+ style="width: 180px"
272
+ >
273
+ <p class="text-xs text-muted-foreground px-2 py-1">결과 없음</p>
274
+ </div>
275
+ {:else}
276
+ <div
277
+ bind:this={menuEl}
278
+ class="z-50 bg-popover border border-border rounded-lg shadow-lg overflow-y-auto py-1"
279
+ style="width: 180px; max-height: 200px"
280
+ >
281
+ <p
282
+ class="text-[10px] font-medium text-muted-foreground uppercase tracking-wider px-3 py-1.5"
283
+ >
284
+ 블록
285
+ </p>
286
+ {#each filtered as item, i}
287
+ <button
288
+ type="button"
289
+ data-index={i}
290
+ class="w-full flex items-center gap-2 px-2 py-1.5 text-left transition-colors rounded-md mx-0 {i ===
291
+ selectedIndex
292
+ ? 'bg-accent text-accent-foreground'
293
+ : 'hover:bg-muted text-foreground'}"
294
+ onmousemove={() => {
295
+ if (!mouseMovedSinceKeyboard) {
296
+ mouseMovedSinceKeyboard = true;
297
+ return;
298
+ }
299
+ selectedIndex = i;
300
+ }}
301
+ onclick={() => {
302
+ onClose();
303
+ item.command(editor);
304
+ }}
305
+ >
306
+ <span
307
+ class="flex items-center justify-center w-6 h-6 rounded border border-border bg-background text-muted-foreground shrink-0"
308
+ >
309
+ <item.icon size={SI} />
310
+ </span>
311
+ <span class="text-sm font-medium">{item.label}</span>
312
+ </button>
313
+ {/each}
314
+ </div>
315
+ {/if}
@@ -0,0 +1,14 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import type { Component } from "svelte";
3
+ type $$ComponentProps = {
4
+ editor: Editor;
5
+ query: string;
6
+ onClose: () => void;
7
+ onPdfUpload?: () => void;
8
+ onFileUpload?: () => void;
9
+ onVideoUpload?: () => void;
10
+ };
11
+ declare const SlashCommandMenu: Component<$$ComponentProps, {}, "">;
12
+ type SlashCommandMenu = ReturnType<typeof SlashCommandMenu>;
13
+ export default SlashCommandMenu;
14
+ //# sourceMappingURL=SlashCommandMenu.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SlashCommandMenu.svelte.d.ts","sourceRoot":"","sources":["../../src/components/SlashCommandMenu.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAuB3C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAEvC,KAAK,gBAAgB,GAAI;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,IAAI,CAAC;CAC5B,CAAC;AAkRJ,QAAA,MAAM,gBAAgB,qCAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}