@teriusu/rich-editor 0.11.1 → 0.11.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.
@@ -24,6 +24,7 @@
24
24
  ListChecks,
25
25
  Quote,
26
26
  ChevronDown,
27
+ Check,
27
28
  Sigma,
28
29
  } from "lucide-svelte";
29
30
  import { cn } from "../utils/cn";
@@ -75,6 +76,8 @@
75
76
  let menuEl: HTMLDivElement | undefined = $state();
76
77
  let blockMenuEl: HTMLDivElement | undefined = $state();
77
78
  let colorMenuEl: HTMLDivElement | undefined = $state();
79
+ let blockPopupEl: HTMLDivElement | undefined = $state();
80
+ let colorPopupEl: HTMLDivElement | undefined = $state();
78
81
  const iconSize = 14;
79
82
 
80
83
  const TEXT_COLORS: { labelKey: EditorMessageKey; value: string }[] = [
@@ -100,16 +103,6 @@
100
103
  return t("paragraph");
101
104
  }
102
105
 
103
- function isParagraphActive(): boolean {
104
- return (
105
- !isActive("heading") &&
106
- !isActive("bulletList") &&
107
- !isActive("orderedList") &&
108
- !isActive("taskList") &&
109
- !isActive("blockquote")
110
- );
111
- }
112
-
113
106
  async function addLink() {
114
107
  const selection = editor.state.selection.getBookmark();
115
108
  const documentBeforePrompt = editor.state.doc;
@@ -142,10 +135,10 @@
142
135
  if (mobile && menuEl && !menuEl.contains(target) && !editor.view.dom.contains(target)) {
143
136
  editor.view.dispatch(editor.state.tr.setMeta(bubbleToolbarKey, 'hide'));
144
137
  }
145
- if (showBlockMenu && blockMenuEl && !blockMenuEl.contains(target)) {
138
+ if (showBlockMenu && blockMenuEl && !blockMenuEl.contains(target) && !blockPopupEl?.contains(target)) {
146
139
  showBlockMenu = false;
147
140
  }
148
- if (showColors && colorMenuEl && !colorMenuEl.contains(target)) {
141
+ if (showColors && colorMenuEl && !colorMenuEl.contains(target) && !colorPopupEl?.contains(target)) {
149
142
  showColors = false;
150
143
  }
151
144
  }
@@ -173,7 +166,9 @@
173
166
  const themeTokens = [
174
167
  '--border', '--background', '--foreground', '--popover', '--muted',
175
168
  '--muted-foreground', '--primary', '--primary-foreground', '--accent',
176
- '--accent-foreground', '--ring', '--hce-menu-z', '--hce-menu-surface'
169
+ '--accent-foreground', '--ring', '--hce-menu-z', '--hce-menu-surface',
170
+ '--wh-100', '--wh-500', '--gray-500', '--gray-700', '--gray-900',
171
+ '--primary-500', '--color-line', '--radius-8', '--radius-2xl', '--shadow-popup'
177
172
  ];
178
173
  const syncTheme = () => {
179
174
  const hostStyle = getComputedStyle(editor.view.dom);
@@ -190,7 +185,7 @@
190
185
  const left = viewport?.offsetLeft ?? 0;
191
186
  const top = viewport?.offsetTop ?? 0;
192
187
  anchor.style.left = `${left + width / 2}px`;
193
- anchor.style.top = `${top + height}px`;
188
+ anchor.style.top = `calc(${top + height}px - env(safe-area-inset-bottom, 0px))`;
194
189
  element.style.maxWidth = mobile ? `${Math.max(0, width - 16)}px` : '';
195
190
  element.style.setProperty('--bubble-popup-height', `${Math.max(44, height - element.getBoundingClientRect().height - 24)}px`);
196
191
  };
@@ -327,139 +322,125 @@
327
322
  );
328
323
  </script>
329
324
 
330
- <div bind:this={menuEl} class="bubble-toolbar-container" class:mobile style="visibility: hidden">
331
- <div class="flex items-center gap-0.5 px-1.5 py-1 rounded-full hce-floating-panel">
332
- {#if hasBlockMenu}
333
- <!-- Block type selector -->
334
- <div class="relative bubble-popover-anchor" bind:this={blockMenuEl}>
335
- <button
336
- type="button"
337
- onclick={() => (showBlockMenu = !showBlockMenu)}
338
- aria-haspopup="menu"
339
- aria-expanded={showBlockMenu}
340
- class="flex items-center gap-1 px-2.5 py-1.5 rounded-full text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
341
- >
342
- <Type size={12} />
343
- {getCurrentBlockLabel()}
344
- <ChevronDown size={12} />
345
- </button>
346
- {#if showBlockMenu}
325
+ {#snippet blockItem(labelKey: EditorMessageKey, Icon: typeof Type, select: () => void)}
326
+ {@const selected = getCurrentBlockLabel() === t(labelKey)}
327
+ <button
328
+ type="button"
329
+ class="block-menu-item"
330
+ role="menuitemradio"
331
+ aria-checked={selected}
332
+ onclick={() => { select(); showBlockMenu = false; }}
333
+ >
334
+ <span class="block-menu-icon"><Icon size={16} /></span>
335
+ <span class="block-menu-label">{t(labelKey)}</span>
336
+ {#if selected}<span class="block-menu-check"><Check size={16} /></span>{/if}
337
+ </button>
338
+ {/snippet}
339
+
340
+ {#snippet blockPopover()}
341
+ {#if showBlockMenu}
342
+ <div
343
+ bind:this={blockPopupEl}
344
+ class="bubble-popover bubble-block-menu absolute bottom-full left-0 mb-1"
345
+ onmousedown={(e) => e.preventDefault()}
346
+ role="menu"
347
+ aria-orientation="vertical"
348
+ tabindex="-1"
349
+ >
350
+ {@render blockItem('paragraph', Type, () => { editor.chain().focus().setParagraph().run(); })}
351
+ {#each [1, 2, 3] as level (level)}
352
+ {#if has(level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3')}
353
+ {@const Icon = level === 1 ? Heading1 : level === 2 ? Heading2 : Heading3}
354
+ {@render blockItem(
355
+ level === 1 ? 'heading1' : level === 2 ? 'heading2' : 'heading3', Icon,
356
+ () => { editor.chain().focus().toggleHeading({ level: level as 1 | 2 | 3 }).run(); }
357
+ )}
358
+ {/if}
359
+ {/each}
360
+ {#if has('bullet-list')}
361
+ {@render blockItem('bulletList', List, () => { editor.chain().focus().toggleBulletList().run(); })}
362
+ {/if}
363
+ {#if has('ordered-list')}
364
+ {@render blockItem('orderedList', ListOrdered, () => { editor.chain().focus().toggleOrderedList().run(); })}
365
+ {/if}
366
+ {#if has('checklist')}
367
+ {@render blockItem('taskList', ListChecks, () => { editor.chain().focus().toggleTaskList().run(); })}
368
+ {/if}
369
+ {#if has('blockquote')}
370
+ {@render blockItem('blockquote', Quote, () => { editor.chain().focus().toggleBlockquote().run(); })}
371
+ {/if}
372
+ </div>
373
+ {/if}
374
+ {/snippet}
375
+
376
+ {#snippet colorPopover()}
377
+ {#if showColors}
347
378
  <div
348
- class="bubble-popover absolute bottom-full left-0 mb-1 bg-popover border border-border rounded-lg shadow-xl py-1"
349
- style="min-width: 140px"
379
+ bind:this={colorPopupEl}
380
+ class="bubble-popover absolute bottom-full left-0 mb-1 bg-popover border border-border rounded-lg shadow-xl p-2"
381
+ style="min-width: 160px"
350
382
  onmousedown={(e) => e.preventDefault()}
351
383
  role="menu"
352
384
  tabindex="-1"
353
385
  >
354
- <button
355
- type="button"
356
- class={cn(
357
- "w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
358
- isParagraphActive()
359
- ? "hce-active"
360
- : "text-muted-foreground hover:text-foreground hover:bg-muted",
361
- )}
362
- onclick={() => {
363
- editor.chain().focus().setParagraph().run();
364
- showBlockMenu = false;
365
- }}
366
- >
367
- <Type size={12} /> {t('paragraph')}
368
- </button>
369
- {#each [1, 2, 3] as level (level)}
370
- {#if has(level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3')}
371
- {@const Icon = level === 1 ? Heading1 : level === 2 ? Heading2 : Heading3}
386
+ <div class="grid grid-cols-3 gap-1.5">
387
+ {#each TEXT_COLORS as c (c.value)}
372
388
  <button
373
389
  type="button"
374
- class={cn(
375
- "w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
376
- isActive("heading", { level })
377
- ? "hce-active"
378
- : "text-muted-foreground hover:text-foreground hover:bg-muted",
379
- )}
390
+ title={t(c.labelKey)}
391
+ class="h-7 rounded-md border border-border transition-transform hover:scale-105 flex items-center justify-center text-xs font-bold"
392
+ style="color: {c.value || '#000'}; background: #fff"
380
393
  onclick={() => {
381
- editor
382
- .chain()
383
- .focus()
384
- .toggleHeading({ level: level as 1 | 2 | 3 })
385
- .run();
386
- showBlockMenu = false;
394
+ if (c.value) {
395
+ editor.chain().focus().setColor(c.value).run();
396
+ } else {
397
+ editor.chain().focus().unsetColor().run();
398
+ }
399
+ showColors = false;
387
400
  }}
388
401
  >
389
- <Icon size={12} /> {t(level === 1 ? 'heading1' : level === 2 ? 'heading2' : 'heading3')}
402
+ {c.value ? "A" : "×"}
390
403
  </button>
391
- {/if}
392
- {/each}
393
- {#if has('bullet-list')}
394
- <button
395
- type="button"
396
- class={cn(
397
- "w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
398
- isActive("bulletList")
399
- ? "hce-active"
400
- : "text-muted-foreground hover:text-foreground hover:bg-muted",
401
- )}
402
- onclick={() => {
403
- editor.chain().focus().toggleBulletList().run();
404
- showBlockMenu = false;
405
- }}
406
- >
407
- <List size={12} /> {t('bulletList')}
408
- </button>
409
- {/if}
410
- {#if has('ordered-list')}
411
- <button
412
- type="button"
413
- class={cn(
414
- "w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
415
- isActive("orderedList")
416
- ? "hce-active"
417
- : "text-muted-foreground hover:text-foreground hover:bg-muted",
418
- )}
419
- onclick={() => {
420
- editor.chain().focus().toggleOrderedList().run();
421
- showBlockMenu = false;
422
- }}
423
- >
424
- <ListOrdered size={12} /> {t('orderedList')}
425
- </button>
426
- {/if}
427
- {#if has('checklist')}
428
- <button
429
- type="button"
430
- class={cn(
431
- "w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
432
- isActive("taskList")
433
- ? "hce-active"
434
- : "text-muted-foreground hover:text-foreground hover:bg-muted",
435
- )}
436
- onclick={() => {
437
- editor.chain().focus().toggleTaskList().run();
438
- showBlockMenu = false;
439
- }}
440
- >
441
- <ListChecks size={12} /> {t('taskList')}
442
- </button>
443
- {/if}
444
- {#if has('blockquote')}
445
- <button
446
- type="button"
447
- class={cn(
448
- "w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
449
- isActive("blockquote")
450
- ? "hce-active"
451
- : "text-muted-foreground hover:text-foreground hover:bg-muted",
452
- )}
453
- onclick={() => {
454
- editor.chain().focus().toggleBlockquote().run();
455
- showBlockMenu = false;
404
+ {/each}
405
+ </div>
406
+ <label
407
+ class="hce-color-divider mt-2 pt-2 flex items-center justify-between gap-2 px-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground"
408
+ >
409
+ <span>{t('customColor')}</span>
410
+ <input
411
+ type="color"
412
+ class="h-6 w-10 cursor-pointer rounded border border-border bg-transparent p-0"
413
+ value={(editor.getAttributes("textStyle").color as string) || "#000000"}
414
+ onclick={(e) => e.stopPropagation()}
415
+ oninput={(e) => {
416
+ const v = (e.target as HTMLInputElement).value;
417
+ editor.chain().focus().setColor(v).run();
456
418
  }}
457
- >
458
- <Quote size={12} /> {t('blockquote')}
459
- </button>
460
- {/if}
419
+ />
420
+ </label>
461
421
  </div>
462
422
  {/if}
423
+ {/snippet}
424
+
425
+ <div bind:this={menuEl} class="bubble-toolbar-container" class:mobile style="visibility: hidden">
426
+ <div class="rounded-full hce-floating-panel">
427
+ <div class="bubble-toolbar-rail flex items-center gap-0.5 px-1.5 py-1 rounded-full">
428
+ {#if hasBlockMenu}
429
+ <!-- Block type selector -->
430
+ <div class="relative bubble-popover-anchor" bind:this={blockMenuEl}>
431
+ <button
432
+ type="button"
433
+ onclick={() => (showBlockMenu = !showBlockMenu)}
434
+ aria-haspopup="menu"
435
+ aria-label={getCurrentBlockLabel()}
436
+ aria-expanded={showBlockMenu}
437
+ class="bubble-block-trigger flex items-center gap-1 px-2.5 py-1.5 rounded-full text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
438
+ >
439
+ <Type size={mobile ? 14 : 12} />
440
+ {#if !mobile}{getCurrentBlockLabel()}{/if}
441
+ <ChevronDown size={mobile ? 8 : 12} />
442
+ </button>
443
+ {#if !mobile}{@render blockPopover()}{/if}
463
444
  </div>
464
445
 
465
446
  <div class="bubble-divider w-px h-5 bg-border mx-0.5"></div>
@@ -601,51 +582,7 @@
601
582
  >
602
583
  <Palette size={iconSize} />
603
584
  </button>
604
- {#if showColors}
605
- <div
606
- class="bubble-popover absolute bottom-full left-0 mb-1 bg-popover border border-border rounded-lg shadow-xl p-2"
607
- style="min-width: 160px"
608
- onmousedown={(e) => e.preventDefault()}
609
- role="menu"
610
- tabindex="-1"
611
- >
612
- <div class="grid grid-cols-3 gap-1.5">
613
- {#each TEXT_COLORS as c (c.value)}
614
- <button
615
- type="button"
616
- title={t(c.labelKey)}
617
- class="h-7 rounded-md border border-border transition-transform hover:scale-105 flex items-center justify-center text-xs font-bold"
618
- style="color: {c.value || '#000'}; background: #fff"
619
- onclick={() => {
620
- if (c.value) {
621
- editor.chain().focus().setColor(c.value).run();
622
- } else {
623
- editor.chain().focus().unsetColor().run();
624
- }
625
- showColors = false;
626
- }}
627
- >
628
- {c.value ? "A" : "×"}
629
- </button>
630
- {/each}
631
- </div>
632
- <label
633
- class="hce-color-divider mt-2 pt-2 flex items-center justify-between gap-2 px-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground"
634
- >
635
- <span>{t('customColor')}</span>
636
- <input
637
- type="color"
638
- class="h-6 w-10 cursor-pointer rounded border border-border bg-transparent p-0"
639
- value={(editor.getAttributes("textStyle").color as string) || "#000000"}
640
- onclick={(e) => e.stopPropagation()}
641
- oninput={(e) => {
642
- const v = (e.target as HTMLInputElement).value;
643
- editor.chain().focus().setColor(v).run();
644
- }}
645
- />
646
- </label>
647
- </div>
648
- {/if}
585
+ {#if !mobile}{@render colorPopover()}{/if}
649
586
  </div>
650
587
  {/if}
651
588
 
@@ -665,25 +602,40 @@
665
602
  <LinkIcon size={iconSize} />
666
603
  </button>
667
604
  {/if}
605
+ </div>
606
+ {#if mobile}
607
+ {@render blockPopover()}
608
+ {@render colorPopover()}
609
+ {/if}
668
610
  </div>
669
611
  </div>
670
612
 
671
613
  <style>
672
614
  .mobile .hce-floating-panel {
615
+ border-radius: 8px;
616
+ padding: 1px 3px;
617
+ }
618
+ .mobile .bubble-toolbar-rail {
673
619
  display: flex;
674
- flex-wrap: wrap;
675
- justify-content: center;
676
- border-radius: 12px;
677
- padding: 4px;
678
- padding-bottom: max(4px, env(safe-area-inset-bottom));
620
+ flex-wrap: nowrap;
621
+ max-width: 100%;
622
+ gap: 1px;
623
+ padding: 0;
679
624
  }
680
- .mobile button {
681
- min-width: 44px;
682
- min-height: 44px;
625
+ .mobile .bubble-toolbar-rail > * { flex-shrink: 0; }
626
+ .mobile .bubble-toolbar-rail button {
627
+ width: 28px;
628
+ height: 28px;
629
+ padding: 0;
683
630
  display: inline-flex;
684
631
  align-items: center;
685
632
  justify-content: center;
686
633
  }
634
+ .mobile .bubble-toolbar-rail .bubble-block-trigger {
635
+ width: 32px;
636
+ gap: 1px;
637
+ }
638
+ .mobile .bubble-popover:not(.bubble-block-menu) button { min-width: 44px; min-height: 44px; }
687
639
  .mobile input[type="color"] { min-width: 44px; min-height: 44px; }
688
640
  .mobile .bubble-divider { display: none; }
689
641
  .mobile .bubble-popover-anchor { position: static; }
@@ -693,4 +645,39 @@
693
645
  max-height: min(var(--bubble-popup-height, 50dvh), 300px);
694
646
  overflow-y: auto;
695
647
  }
648
+ .bubble-block-menu {
649
+ width: 200px;
650
+ box-sizing: border-box;
651
+ padding: 4px;
652
+ background: var(--wh-100, var(--popover, var(--background, #fff)));
653
+ border: 1px solid var(--color-line, var(--border));
654
+ border-radius: var(--radius-2xl, 12px);
655
+ box-shadow: var(--shadow-popup, 0 8px 24px rgb(0 0 0 / 12%));
656
+ }
657
+ .mobile .bubble-block-menu { right: auto; max-width: 100%; }
658
+ .block-menu-item {
659
+ display: flex;
660
+ align-items: center;
661
+ width: 100%;
662
+ min-height: 32px;
663
+ gap: 8px;
664
+ padding: 6px 8px;
665
+ border-radius: var(--radius-8, 8px);
666
+ color: var(--gray-700, var(--foreground));
667
+ font: inherit;
668
+ font-size: 13px;
669
+ line-height: 20px;
670
+ text-align: left;
671
+ white-space: nowrap;
672
+ }
673
+ .block-menu-item:hover, .block-menu-item:focus-visible {
674
+ background: var(--wh-500, var(--muted));
675
+ }
676
+ .block-menu-item[aria-checked='true'] {
677
+ color: var(--gray-900, var(--foreground));
678
+ font-weight: 600;
679
+ }
680
+ .block-menu-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
681
+ .block-menu-icon { display: inline-flex; flex: none; color: var(--gray-500, var(--muted-foreground)); }
682
+ .block-menu-check { display: inline-flex; flex: none; color: var(--primary-500, var(--primary)); }
696
683
  </style>
@@ -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;AA0B3C,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAC9D,OAAO,EAA4C,KAAK,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEzF,KAAK,gBAAgB,GAAI;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,8BAA8B;IAC9B,CAAC,CAAC,EAAE,gBAAgB,CAAC;CACtB,CAAC;AA+hBJ,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;AA2B3C,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAC9D,OAAO,EAA4C,KAAK,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEzF,KAAK,gBAAgB,GAAI;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,8BAA8B;IAC9B,CAAC,CAAC,EAAE,gBAAgB,CAAC;CACtB,CAAC;AAmfJ,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teriusu/rich-editor",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "description": "TipTap 기반 Notion-style 리치 텍스트 에디터 (Svelte)",
5
5
  "type": "module",
6
6
  "svelte": "./dist/index.js",