@teriusu/rich-editor 0.7.1 → 0.7.3

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.
@@ -12,15 +12,16 @@
12
12
  Strikethrough,
13
13
  Highlighter,
14
14
  LinkIcon,
15
- AlignLeft,
16
- AlignCenter,
17
- AlignRight,
18
15
  Heading1,
19
16
  Heading2,
20
17
  Heading3,
21
18
  Type,
22
- Code,
23
19
  Palette,
20
+ List,
21
+ ListOrdered,
22
+ ListChecks,
23
+ Quote,
24
+ ChevronDown,
24
25
  } from "lucide-svelte";
25
26
  import { cn } from "../utils/cn";
26
27
  import type { ToolbarFeature, PromptHandler } from "../types";
@@ -37,9 +38,11 @@
37
38
 
38
39
  const has = (f: ToolbarFeature) => features.has(f);
39
40
 
40
- let showHeadings = $state(false);
41
+ let showBlockMenu = $state(false);
41
42
  let showColors = $state(false);
42
43
  let menuEl: HTMLDivElement | undefined = $state();
44
+ let blockMenuEl: HTMLDivElement | undefined = $state();
45
+ let colorMenuEl: HTMLDivElement | undefined = $state();
43
46
  const iconSize = 14;
44
47
 
45
48
  const TEXT_COLORS = [
@@ -58,9 +61,23 @@
58
61
  if (editor.isActive("heading", { level: 1 })) return "제목 1";
59
62
  if (editor.isActive("heading", { level: 2 })) return "제목 2";
60
63
  if (editor.isActive("heading", { level: 3 })) return "제목 3";
64
+ if (editor.isActive("bulletList")) return "글머리 목록";
65
+ if (editor.isActive("orderedList")) return "번호 목록";
66
+ if (editor.isActive("taskList")) return "체크리스트";
67
+ if (editor.isActive("blockquote")) return "인용문";
61
68
  return "본문";
62
69
  }
63
70
 
71
+ function isParagraphActive(): boolean {
72
+ return (
73
+ !editor.isActive("heading") &&
74
+ !editor.isActive("bulletList") &&
75
+ !editor.isActive("orderedList") &&
76
+ !editor.isActive("taskList") &&
77
+ !editor.isActive("blockquote")
78
+ );
79
+ }
80
+
64
81
  async function addLink() {
65
82
  const previousUrl = editor.getAttributes("link").href || "";
66
83
  const url = onPromptLink
@@ -79,6 +96,23 @@
79
96
  }
80
97
  }
81
98
 
99
+ function handleDocClick(e: MouseEvent) {
100
+ const target = e.target as Node;
101
+ if (showBlockMenu && blockMenuEl && !blockMenuEl.contains(target)) {
102
+ showBlockMenu = false;
103
+ }
104
+ if (showColors && colorMenuEl && !colorMenuEl.contains(target)) {
105
+ showColors = false;
106
+ }
107
+ }
108
+
109
+ function handleKeydown(e: KeyboardEvent) {
110
+ if (e.key === "Escape") {
111
+ showBlockMenu = false;
112
+ showColors = false;
113
+ }
114
+ }
115
+
82
116
  onMount(() => {
83
117
  if (!menuEl) return;
84
118
 
@@ -99,70 +133,156 @@
99
133
  });
100
134
 
101
135
  editor.registerPlugin(plugin);
136
+ document.addEventListener("mousedown", handleDocClick);
137
+ document.addEventListener("keydown", handleKeydown);
102
138
 
103
139
  return () => {
104
140
  editor.unregisterPlugin(bubbleToolbarKey);
141
+ document.removeEventListener("mousedown", handleDocClick);
142
+ document.removeEventListener("keydown", handleKeydown);
105
143
  };
106
144
  });
145
+
146
+ const hasBlockMenu = $derived(
147
+ has('h1') ||
148
+ has('h2') ||
149
+ has('h3') ||
150
+ has('bullet-list') ||
151
+ has('ordered-list') ||
152
+ has('checklist') ||
153
+ has('blockquote'),
154
+ );
107
155
  </script>
108
156
 
109
157
  <div bind:this={menuEl} class="bubble-toolbar-container" style="visibility: hidden">
110
158
  <div class="flex items-center gap-0.5 px-1.5 py-1 bg-foreground rounded-full shadow-xl">
111
- {#if has('h1') || has('h2') || has('h3')}
159
+ {#if hasBlockMenu}
112
160
  <!-- Block type selector -->
113
- <div class="relative">
161
+ <div class="relative" bind:this={blockMenuEl}>
114
162
  <button
115
163
  type="button"
116
- onclick={() => (showHeadings = !showHeadings)}
164
+ onclick={() => (showBlockMenu = !showBlockMenu)}
117
165
  class="flex items-center gap-1 px-2.5 py-1.5 rounded-full text-xs font-medium text-white/80 hover:text-white hover:bg-white/10 transition-colors"
118
166
  >
119
167
  <Type size={12} />
120
168
  {getCurrentBlockLabel()}
169
+ <ChevronDown size={12} />
121
170
  </button>
122
- {#if showHeadings}
171
+ {#if showBlockMenu}
123
172
  <div
124
173
  class="absolute bottom-full left-0 mb-1 bg-foreground rounded-lg shadow-xl border border-white/10 py-1"
125
- style="min-width: 120px"
174
+ style="min-width: 140px"
175
+ onmousedown={(e) => e.preventDefault()}
176
+ role="menu"
177
+ tabindex="-1"
126
178
  >
127
179
  <button
128
180
  type="button"
129
181
  class={cn(
130
182
  "w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
131
- !editor.isActive("heading")
132
- ? "text-white bg-white/10"
183
+ isParagraphActive()
184
+ ? "bg-primary/10 text-primary"
133
185
  : "text-white/70 hover:text-white hover:bg-white/10",
134
186
  )}
135
187
  onclick={() => {
136
188
  editor.chain().focus().setParagraph().run();
137
- showHeadings = false;
189
+ showBlockMenu = false;
138
190
  }}
139
191
  >
140
192
  <Type size={12} /> 본문
141
193
  </button>
142
194
  {#each [1, 2, 3] as level}
143
195
  {#if has(level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3')}
144
- {@const Icon = level === 1 ? Heading1 : level === 2 ? Heading2 : Heading3}
196
+ {@const Icon = level === 1 ? Heading1 : level === 2 ? Heading2 : Heading3}
197
+ <button
198
+ type="button"
199
+ class={cn(
200
+ "w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
201
+ editor.isActive("heading", { level })
202
+ ? "bg-primary/10 text-primary"
203
+ : "text-white/70 hover:text-white hover:bg-white/10",
204
+ )}
205
+ onclick={() => {
206
+ editor
207
+ .chain()
208
+ .focus()
209
+ .toggleHeading({ level: level as 1 | 2 | 3 })
210
+ .run();
211
+ showBlockMenu = false;
212
+ }}
213
+ >
214
+ <Icon size={12} /> 제목 {level}
215
+ </button>
216
+ {/if}
217
+ {/each}
218
+ {#if has('bullet-list')}
145
219
  <button
146
220
  type="button"
147
221
  class={cn(
148
222
  "w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
149
- editor.isActive("heading", { level })
150
- ? "text-white bg-white/10"
223
+ editor.isActive("bulletList")
224
+ ? "bg-primary/10 text-primary"
151
225
  : "text-white/70 hover:text-white hover:bg-white/10",
152
226
  )}
153
227
  onclick={() => {
154
- editor
155
- .chain()
156
- .focus()
157
- .toggleHeading({ level: level as 1 | 2 | 3 })
158
- .run();
159
- showHeadings = false;
228
+ editor.chain().focus().toggleBulletList().run();
229
+ showBlockMenu = false;
160
230
  }}
161
231
  >
162
- <Icon size={12} /> 제목 {level}
232
+ <List size={12} /> 글머리 목록
163
233
  </button>
164
- {/if}
165
- {/each}
234
+ {/if}
235
+ {#if has('ordered-list')}
236
+ <button
237
+ type="button"
238
+ class={cn(
239
+ "w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
240
+ editor.isActive("orderedList")
241
+ ? "bg-primary/10 text-primary"
242
+ : "text-white/70 hover:text-white hover:bg-white/10",
243
+ )}
244
+ onclick={() => {
245
+ editor.chain().focus().toggleOrderedList().run();
246
+ showBlockMenu = false;
247
+ }}
248
+ >
249
+ <ListOrdered size={12} /> 번호 목록
250
+ </button>
251
+ {/if}
252
+ {#if has('checklist')}
253
+ <button
254
+ type="button"
255
+ class={cn(
256
+ "w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
257
+ editor.isActive("taskList")
258
+ ? "bg-primary/10 text-primary"
259
+ : "text-white/70 hover:text-white hover:bg-white/10",
260
+ )}
261
+ onclick={() => {
262
+ editor.chain().focus().toggleTaskList().run();
263
+ showBlockMenu = false;
264
+ }}
265
+ >
266
+ <ListChecks size={12} /> 체크리스트
267
+ </button>
268
+ {/if}
269
+ {#if has('blockquote')}
270
+ <button
271
+ type="button"
272
+ class={cn(
273
+ "w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
274
+ editor.isActive("blockquote")
275
+ ? "bg-primary/10 text-primary"
276
+ : "text-white/70 hover:text-white hover:bg-white/10",
277
+ )}
278
+ onclick={() => {
279
+ editor.chain().focus().toggleBlockquote().run();
280
+ showBlockMenu = false;
281
+ }}
282
+ >
283
+ <Quote size={12} /> 인용문
284
+ </button>
285
+ {/if}
166
286
  </div>
167
287
  {/if}
168
288
  </div>
@@ -235,8 +355,29 @@
235
355
  <Strikethrough size={iconSize} />
236
356
  </button>
237
357
  {/if}
358
+
359
+ {#if has('highlight') || has('text-color')}
360
+ <div class="w-px h-5 bg-white/20 mx-0.5"></div>
361
+ {/if}
362
+
363
+ {#if has('highlight')}
364
+ <button
365
+ type="button"
366
+ onclick={() => editor.chain().focus().toggleHighlight().run()}
367
+ title="하이라이트"
368
+ aria-label="하이라이트"
369
+ class={cn(
370
+ "p-1.5 rounded-full transition-colors",
371
+ editor.isActive("highlight")
372
+ ? "bg-white/20 text-white"
373
+ : "text-white/70 hover:text-white hover:bg-white/10",
374
+ )}
375
+ >
376
+ <Highlighter size={iconSize} />
377
+ </button>
378
+ {/if}
238
379
  {#if has('text-color')}
239
- <div class="relative">
380
+ <div class="relative" bind:this={colorMenuEl}>
240
381
  <button
241
382
  type="button"
242
383
  onclick={() => (showColors = !showColors)}
@@ -255,6 +396,9 @@
255
396
  <div
256
397
  class="absolute bottom-full left-0 mb-1 bg-foreground rounded-lg shadow-xl border border-white/10 p-2"
257
398
  style="min-width: 160px"
399
+ onmousedown={(e) => e.preventDefault()}
400
+ role="menu"
401
+ tabindex="-1"
258
402
  >
259
403
  <div class="grid grid-cols-3 gap-1.5">
260
404
  {#each TEXT_COLORS as c}
@@ -295,110 +439,23 @@
295
439
  {/if}
296
440
  </div>
297
441
  {/if}
298
- {#if has('highlight')}
299
- <button
300
- type="button"
301
- onclick={() => editor.chain().focus().toggleHighlight().run()}
302
- title="하이라이트"
303
- aria-label="하이라이트"
304
- class={cn(
305
- "p-1.5 rounded-full transition-colors",
306
- editor.isActive("highlight")
307
- ? "bg-white/20 text-white"
308
- : "text-white/70 hover:text-white hover:bg-white/10",
309
- )}
310
- >
311
- <Highlighter size={iconSize} />
312
- </button>
313
- {/if}
314
- {#if has('code')}
315
- <button
316
- type="button"
317
- onclick={() => editor.chain().focus().toggleCode().run()}
318
- title="코드"
319
- aria-label="코드"
320
- class={cn(
321
- "p-1.5 rounded-full transition-colors",
322
- editor.isActive("code")
323
- ? "bg-white/20 text-white"
324
- : "text-white/70 hover:text-white hover:bg-white/10",
325
- )}
326
- >
327
- <Code size={iconSize} />
328
- </button>
329
- {/if}
330
442
 
331
443
  {#if has('link')}
332
- <div class="w-px h-5 bg-white/20 mx-0.5"></div>
333
-
334
- <button
335
- type="button"
336
- onclick={addLink}
337
- title="링크"
338
- aria-label="링크"
339
- class={cn(
340
- "p-1.5 rounded-full transition-colors",
341
- editor.isActive("link")
342
- ? "bg-white/20 text-white"
343
- : "text-white/70 hover:text-white hover:bg-white/10",
344
- )}
345
- >
346
- <LinkIcon size={iconSize} />
347
- </button>
348
- {/if}
349
-
350
- {#if has('align-left') || has('align-center') || has('align-right')}
351
444
  <div class="w-px h-5 bg-white/20 mx-0.5"></div>
352
-
353
- <!-- Alignment -->
354
- {#if has('align-left')}
355
- <button
356
- type="button"
357
- onclick={() => editor.chain().focus().setTextAlign("left").run()}
358
- title="왼쪽 정렬"
359
- aria-label="왼쪽 정렬"
360
- class={cn(
361
- "p-1.5 rounded-full transition-colors",
362
- editor.isActive({ textAlign: "left" })
363
- ? "bg-white/20 text-white"
364
- : "text-white/70 hover:text-white hover:bg-white/10",
365
- )}
366
- >
367
- <AlignLeft size={iconSize} />
368
- </button>
369
- {/if}
370
- {#if has('align-center')}
371
- <button
372
- type="button"
373
- onclick={() => editor.chain().focus().setTextAlign("center").run()}
374
- title="가운데 정렬"
375
- aria-label="가운데 정렬"
376
- class={cn(
377
- "p-1.5 rounded-full transition-colors",
378
- editor.isActive({ textAlign: "center" })
379
- ? "bg-white/20 text-white"
380
- : "text-white/70 hover:text-white hover:bg-white/10",
381
- )}
382
- >
383
- <AlignCenter size={iconSize} />
384
- </button>
385
- {/if}
386
- {#if has('align-right')}
387
445
  <button
388
446
  type="button"
389
- onclick={() => editor.chain().focus().setTextAlign("right").run()}
390
- title="오른쪽 정렬"
391
- aria-label="오른쪽 정렬"
447
+ onclick={addLink}
448
+ title="링크"
449
+ aria-label="링크"
392
450
  class={cn(
393
451
  "p-1.5 rounded-full transition-colors",
394
- editor.isActive({ textAlign: "right" })
452
+ editor.isActive("link")
395
453
  ? "bg-white/20 text-white"
396
454
  : "text-white/70 hover:text-white hover:bg-white/10",
397
455
  )}
398
456
  >
399
- <AlignRight size={iconSize} />
457
+ <LinkIcon size={iconSize} />
400
458
  </button>
401
- {/if}
402
459
  {/if}
403
460
  </div>
404
461
  </div>
@@ -1 +1 @@
1
- {"version":3,"file":"BubbleToolbar.svelte.d.ts","sourceRoot":"","sources":["../../src/components/BubbleToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAsB3C,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAE7D,KAAK,gBAAgB,GAAI;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;CAC9B,CAAC;AAkSJ,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"BubbleToolbar.svelte.d.ts","sourceRoot":"","sources":["../../src/components/BubbleToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAuB3C,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAE7D,KAAK,gBAAgB,GAAI;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;CAC9B,CAAC;AA4VJ,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}