@teriusu/rich-editor 0.11.0 → 0.11.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.
@@ -69,6 +69,7 @@
69
69
 
70
70
  const has = (f: ToolbarFeature) => features.has(f);
71
71
 
72
+ let mobile = $state(false);
72
73
  let showBlockMenu = $state(false);
73
74
  let showColors = $state(false);
74
75
  let menuEl: HTMLDivElement | undefined = $state();
@@ -110,11 +111,18 @@
110
111
  }
111
112
 
112
113
  async function addLink() {
114
+ const selection = editor.state.selection.getBookmark();
115
+ const documentBeforePrompt = editor.state.doc;
113
116
  const previousUrl = editor.getAttributes("link").href || "";
114
117
  const url = onPromptLink
115
118
  ? await onPromptLink(previousUrl)
116
119
  : window.prompt(t("promptLinkUrl"), previousUrl);
117
- if (url === null) return;
120
+ if (url === null || editor.isDestroyed || editor.state.doc !== documentBeforePrompt) return;
121
+ try {
122
+ editor.view.dispatch(editor.state.tr.setSelection(selection.resolve(editor.state.doc)));
123
+ } catch {
124
+ return;
125
+ }
118
126
  if (url === "") {
119
127
  editor.chain().focus().extendMarkRange("link").unsetLink().run();
120
128
  } else {
@@ -127,8 +135,13 @@
127
135
  }
128
136
  }
129
137
 
130
- function handleDocClick(e: MouseEvent) {
138
+ function handleDocClick(e: Event) {
131
139
  const target = e.target as Node;
140
+ // The upstream plugin treats any focus inside the menu's parent as internal.
141
+ // A body portal needs an explicit boundary so another input/editor dismisses it.
142
+ if (mobile && menuEl && !menuEl.contains(target) && !editor.view.dom.contains(target)) {
143
+ editor.view.dispatch(editor.state.tr.setMeta(bubbleToolbarKey, 'hide'));
144
+ }
132
145
  if (showBlockMenu && blockMenuEl && !blockMenuEl.contains(target)) {
133
146
  showBlockMenu = false;
134
147
  }
@@ -147,12 +160,74 @@
147
160
  onMount(() => {
148
161
  if (!menuEl) return;
149
162
 
163
+ const element = menuEl;
164
+ const media = window.matchMedia('(max-width: 767px), (pointer: coarse)');
165
+ const viewport = window.visualViewport;
166
+ const anchor = document.createElement('span');
167
+ anchor.style.cssText = 'position:fixed;width:0;height:0;pointer-events:none;visibility:hidden';
168
+ anchor.setAttribute('aria-hidden', 'true');
169
+ document.body.appendChild(anchor);
170
+ mobile = media.matches;
171
+
172
+ // A portal leaves the host wrapper, where apps map their own theme to editor tokens.
173
+ const themeTokens = [
174
+ '--border', '--background', '--foreground', '--popover', '--muted',
175
+ '--muted-foreground', '--primary', '--primary-foreground', '--accent',
176
+ '--accent-foreground', '--ring', '--hce-menu-z', '--hce-menu-surface'
177
+ ];
178
+ const syncTheme = () => {
179
+ const hostStyle = getComputedStyle(editor.view.dom);
180
+ for (const token of themeTokens) {
181
+ const value = mobile ? hostStyle.getPropertyValue(token) : '';
182
+ if (value) element.style.setProperty(token, value);
183
+ else element.style.removeProperty(token);
184
+ }
185
+ };
186
+ const syncViewport = () => {
187
+ syncTheme();
188
+ const width = viewport?.width ?? window.innerWidth;
189
+ const height = viewport?.height ?? window.innerHeight;
190
+ const left = viewport?.offsetLeft ?? 0;
191
+ const top = viewport?.offsetTop ?? 0;
192
+ anchor.style.left = `${left + width / 2}px`;
193
+ anchor.style.top = `${top + height}px`;
194
+ element.style.maxWidth = mobile ? `${Math.max(0, width - 16)}px` : '';
195
+ element.style.setProperty('--bubble-popup-height', `${Math.max(44, height - element.getBoundingClientRect().height - 24)}px`);
196
+ };
197
+ syncViewport();
198
+
199
+ const positionOptions = () => mobile
200
+ ? { strategy: 'fixed' as const, placement: 'top' as const, flip: false, shift: false, offset: 8 }
201
+ : { strategy: 'absolute' as const, placement: 'top' as const, flip: true, shift: true, offset: 8 };
202
+ const updatePosition = () => {
203
+ if (editor.isDestroyed) return;
204
+ syncViewport();
205
+ editor.view.dispatch(editor.state.tr.setMeta(bubbleToolbarKey, 'updatePosition'));
206
+ };
207
+ const updateMode = () => {
208
+ mobile = media.matches;
209
+ syncViewport();
210
+ editor.view.dispatch(editor.state.tr.setMeta(bubbleToolbarKey, {
211
+ type: 'updateOptions', options: { options: positionOptions() }
212
+ }));
213
+ if (element.isConnected) {
214
+ (mobile ? document.body : editor.view.dom.parentElement)?.appendChild(element);
215
+ }
216
+ updatePosition();
217
+ };
218
+ // Prevent button focus from collapsing the text selection; native OS menus remain enabled.
219
+ const preserveSelection = (event: MouseEvent) => {
220
+ if (mobile && (event.target as HTMLElement).closest('button')) event.preventDefault();
221
+ };
150
222
  const plugin = BubbleMenuPlugin({
151
223
  pluginKey: bubbleToolbarKey,
152
224
  editor,
153
- element: menuEl,
225
+ element,
226
+ appendTo: () => mobile ? document.body : editor.view.dom.parentElement!,
227
+ getReferencedVirtualElement: () => mobile ? anchor : null,
154
228
  shouldShow: ({ editor: e, state }) => {
155
229
  const { from, to } = state.selection;
230
+ if (!e.isEditable || (!e.view.hasFocus() && !element.contains(document.activeElement))) return false;
156
231
  if (from === to) return false;
157
232
  /*
158
233
  * ⚠️ **덩어리를 고른 것(NodeSelection)에는 뜨지 않는다.**
@@ -197,18 +272,36 @@
197
272
  * 마침 v3 기본값이 `placement: "top"` + `flip`·`shift` 활성이라 화면상 차이는 없었지만,
198
273
  * 죽은 설정을 남겨 두면 "위치를 지정해 뒀다"고 착각하게 된다.
199
274
  */
200
- options: {
201
- placement: "top",
202
- },
275
+ options: { ...positionOptions(), onShow: syncTheme },
203
276
  });
204
277
 
205
278
  editor.registerPlugin(plugin);
206
- document.addEventListener("mousedown", handleDocClick);
279
+ const themeObserver = new MutationObserver(syncTheme);
280
+ themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
281
+ themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
282
+ const observer = new ResizeObserver(updatePosition);
283
+ observer.observe(element);
284
+ media.addEventListener('change', updateMode);
285
+ viewport?.addEventListener('resize', updatePosition);
286
+ viewport?.addEventListener('scroll', updatePosition);
287
+ window.addEventListener('resize', updatePosition);
288
+ element.addEventListener('mousedown', preserveSelection);
289
+ document.addEventListener("pointerdown", handleDocClick);
290
+ document.addEventListener("focusin", handleDocClick);
207
291
  document.addEventListener("keydown", handleKeydown);
208
292
 
209
293
  return () => {
210
294
  editor.unregisterPlugin(bubbleToolbarKey);
211
- document.removeEventListener("mousedown", handleDocClick);
295
+ observer.disconnect();
296
+ themeObserver.disconnect();
297
+ anchor.remove();
298
+ media.removeEventListener('change', updateMode);
299
+ viewport?.removeEventListener('resize', updatePosition);
300
+ viewport?.removeEventListener('scroll', updatePosition);
301
+ window.removeEventListener('resize', updatePosition);
302
+ element.removeEventListener('mousedown', preserveSelection);
303
+ document.removeEventListener("pointerdown", handleDocClick);
304
+ document.removeEventListener("focusin", handleDocClick);
212
305
  document.removeEventListener("keydown", handleKeydown);
213
306
  };
214
307
  });
@@ -234,14 +327,16 @@
234
327
  );
235
328
  </script>
236
329
 
237
- <div bind:this={menuEl} class="bubble-toolbar-container" style="visibility: hidden">
330
+ <div bind:this={menuEl} class="bubble-toolbar-container" class:mobile style="visibility: hidden">
238
331
  <div class="flex items-center gap-0.5 px-1.5 py-1 rounded-full hce-floating-panel">
239
332
  {#if hasBlockMenu}
240
333
  <!-- Block type selector -->
241
- <div class="relative" bind:this={blockMenuEl}>
334
+ <div class="relative bubble-popover-anchor" bind:this={blockMenuEl}>
242
335
  <button
243
336
  type="button"
244
337
  onclick={() => (showBlockMenu = !showBlockMenu)}
338
+ aria-haspopup="menu"
339
+ aria-expanded={showBlockMenu}
245
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"
246
341
  >
247
342
  <Type size={12} />
@@ -250,7 +345,7 @@
250
345
  </button>
251
346
  {#if showBlockMenu}
252
347
  <div
253
- class="absolute bottom-full left-0 mb-1 bg-popover border border-border rounded-lg shadow-xl py-1"
348
+ class="bubble-popover absolute bottom-full left-0 mb-1 bg-popover border border-border rounded-lg shadow-xl py-1"
254
349
  style="min-width: 140px"
255
350
  onmousedown={(e) => e.preventDefault()}
256
351
  role="menu"
@@ -271,7 +366,7 @@
271
366
  >
272
367
  <Type size={12} /> {t('paragraph')}
273
368
  </button>
274
- {#each [1, 2, 3] as level}
369
+ {#each [1, 2, 3] as level (level)}
275
370
  {#if has(level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3')}
276
371
  {@const Icon = level === 1 ? Heading1 : level === 2 ? Heading2 : Heading3}
277
372
  <button
@@ -367,7 +462,7 @@
367
462
  {/if}
368
463
  </div>
369
464
 
370
- <div class="w-px h-5 bg-border mx-0.5"></div>
465
+ <div class="bubble-divider w-px h-5 bg-border mx-0.5"></div>
371
466
  {/if}
372
467
 
373
468
  <!-- Format buttons -->
@@ -471,7 +566,7 @@
471
566
  {/if}
472
567
 
473
568
  {#if has('highlight') || has('text-color')}
474
- <div class="w-px h-5 bg-border mx-0.5"></div>
569
+ <div class="bubble-divider w-px h-5 bg-border mx-0.5"></div>
475
570
  {/if}
476
571
 
477
572
  {#if has('highlight')}
@@ -490,10 +585,12 @@
490
585
  </button>
491
586
  {/if}
492
587
  {#if has('text-color')}
493
- <div class="relative" bind:this={colorMenuEl}>
588
+ <div class="relative bubble-popover-anchor" bind:this={colorMenuEl}>
494
589
  <button
495
590
  type="button"
496
591
  onclick={() => (showColors = !showColors)}
592
+ aria-haspopup="menu"
593
+ aria-expanded={showColors}
497
594
  aria-label={t('textColor')}
498
595
  class={cn(
499
596
  "p-1.5 rounded-full transition-colors",
@@ -506,14 +603,14 @@
506
603
  </button>
507
604
  {#if showColors}
508
605
  <div
509
- class="absolute bottom-full left-0 mb-1 bg-popover border border-border rounded-lg shadow-xl p-2"
606
+ class="bubble-popover absolute bottom-full left-0 mb-1 bg-popover border border-border rounded-lg shadow-xl p-2"
510
607
  style="min-width: 160px"
511
608
  onmousedown={(e) => e.preventDefault()}
512
609
  role="menu"
513
610
  tabindex="-1"
514
611
  >
515
612
  <div class="grid grid-cols-3 gap-1.5">
516
- {#each TEXT_COLORS as c}
613
+ {#each TEXT_COLORS as c (c.value)}
517
614
  <button
518
615
  type="button"
519
616
  title={t(c.labelKey)}
@@ -553,7 +650,7 @@
553
650
  {/if}
554
651
 
555
652
  {#if has('link')}
556
- <div class="w-px h-5 bg-border mx-0.5"></div>
653
+ <div class="bubble-divider w-px h-5 bg-border mx-0.5"></div>
557
654
  <button
558
655
  type="button"
559
656
  onclick={addLink}
@@ -570,3 +667,30 @@
570
667
  {/if}
571
668
  </div>
572
669
  </div>
670
+
671
+ <style>
672
+ .mobile .hce-floating-panel {
673
+ 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));
679
+ }
680
+ .mobile button {
681
+ min-width: 44px;
682
+ min-height: 44px;
683
+ display: inline-flex;
684
+ align-items: center;
685
+ justify-content: center;
686
+ }
687
+ .mobile input[type="color"] { min-width: 44px; min-height: 44px; }
688
+ .mobile .bubble-divider { display: none; }
689
+ .mobile .bubble-popover-anchor { position: static; }
690
+ .mobile .bubble-popover {
691
+ left: 0;
692
+ right: 0;
693
+ max-height: min(var(--bubble-popup-height, 50dvh), 300px);
694
+ overflow-y: auto;
695
+ }
696
+ </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;AAgcJ,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;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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teriusu/rich-editor",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "TipTap 기반 Notion-style 리치 텍스트 에디터 (Svelte)",
5
5
  "type": "module",
6
6
  "svelte": "./dist/index.js",