@blockslides/extension-bubble-menu-preset 0.1.0

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.
@@ -0,0 +1,809 @@
1
+ import { Extension } from '@blockslides/core'
2
+ import type { Editor } from '@blockslides/core'
3
+ import {
4
+ BubbleMenuPlugin,
5
+ type BubbleMenuPluginProps,
6
+ type BubbleMenuOptions,
7
+ } from '@blockslides/extension-bubble-menu'
8
+
9
+ type BubbleMenuPresetItem =
10
+ | 'undo'
11
+ | 'redo'
12
+ | 'fontFamily'
13
+ | 'fontSize'
14
+ | 'bold'
15
+ | 'italic'
16
+ | 'underline'
17
+ | 'textColor'
18
+ | 'highlightColor'
19
+ | 'link'
20
+ | 'align'
21
+
22
+ type TextAlignValue = 'left' | 'center' | 'right' | 'justify'
23
+
24
+ export interface BubbleMenuPresetOptions
25
+ extends Omit<BubbleMenuOptions, 'element'> {
26
+ /**
27
+ * Optional custom element to use for the menu. If omitted, a default
28
+ * element with built-in buttons is rendered.
29
+ */
30
+ element?: HTMLElement | null
31
+
32
+ /**
33
+ * Order of built-in controls to render.
34
+ */
35
+ items?: BubbleMenuPresetItem[]
36
+
37
+ /**
38
+ * Additional class names to attach to the menu element to allow easy
39
+ * style overrides.
40
+ */
41
+ className?: string
42
+
43
+ /**
44
+ * Inject default CSS. Set to false to opt out if you provide your own styles.
45
+ */
46
+ injectStyles?: boolean
47
+
48
+ /**
49
+ * Palette for text color swatches.
50
+ */
51
+ textColors?: string[]
52
+
53
+ /**
54
+ * Palette for highlight swatches.
55
+ */
56
+ highlightColors?: string[]
57
+
58
+ /**
59
+ * Fonts exposed in the font picker.
60
+ */
61
+ fonts?: string[]
62
+
63
+ /**
64
+ * Font sizes (any CSS length, e.g. "16px", "1rem").
65
+ */
66
+ fontSizes?: string[]
67
+
68
+ /**
69
+ * Alignments to expose in the align control.
70
+ */
71
+ alignments?: TextAlignValue[]
72
+ }
73
+
74
+ type Cleanup = () => void
75
+
76
+ const STYLE_ID = 'blockslides-bubble-menu-preset-styles'
77
+
78
+ const DEFAULT_ITEMS: BubbleMenuPresetItem[] = [
79
+ 'undo',
80
+ 'redo',
81
+ 'fontFamily',
82
+ 'fontSize',
83
+ 'bold',
84
+ 'italic',
85
+ 'underline',
86
+ 'textColor',
87
+ 'highlightColor',
88
+ 'link',
89
+ 'align',
90
+ ]
91
+
92
+ const DEFAULT_FONTS = [
93
+ 'Inter',
94
+ 'Arial',
95
+ 'Helvetica',
96
+ 'Times New Roman',
97
+ 'Georgia',
98
+ 'Courier New',
99
+ 'Monaco',
100
+ ]
101
+
102
+ const DEFAULT_FONT_SIZES = ['12px', '14px', '16px', '18px', '20px', '24px', '32px', '40px']
103
+
104
+ const DEFAULT_ALIGNMENTS: TextAlignValue[] = ['left', 'center', 'right', 'justify']
105
+
106
+ // A rich palette approximating the attached reference.
107
+ const DEFAULT_COLOR_PALETTE: string[] = [
108
+ '#000000',
109
+ '#434343',
110
+ '#666666',
111
+ '#999999',
112
+ '#b7b7b7',
113
+ '#cccccc',
114
+ '#d9d9d9',
115
+ '#efefef',
116
+ '#f3f3f3',
117
+ '#ffffff',
118
+ '#e60000',
119
+ '#ff0000',
120
+ '#ff9900',
121
+ '#ffff00',
122
+ '#00ff00',
123
+ '#00ffff',
124
+ '#4a86e8',
125
+ '#0000ff',
126
+ '#9900ff',
127
+ '#ff00ff',
128
+ '#f4cccc',
129
+ '#fce5cd',
130
+ '#fff2cc',
131
+ '#d9ead3',
132
+ '#d0e0e3',
133
+ '#cfe2f3',
134
+ '#d9d2e9',
135
+ '#ead1dc',
136
+ '#f9cb9c',
137
+ '#ffe599',
138
+ '#b6d7a8',
139
+ '#a2c4c9',
140
+ '#9fc5e8',
141
+ '#b4a7d6',
142
+ '#d5a6bd',
143
+ '#e06666',
144
+ '#f6b26b',
145
+ '#ffd966',
146
+ '#93c47d',
147
+ '#76a5af',
148
+ '#6fa8dc',
149
+ '#8e7cc3',
150
+ '#c27ba0',
151
+ '#cc0000',
152
+ '#e69138',
153
+ '#f1c232',
154
+ '#6aa84f',
155
+ '#45818e',
156
+ '#3d85c6',
157
+ '#674ea7',
158
+ '#a64d79',
159
+ '#990000',
160
+ '#b45f06',
161
+ '#bf9000',
162
+ '#38761d',
163
+ '#134f5c',
164
+ '#0b5394',
165
+ '#351c75',
166
+ '#741b47',
167
+ '#660000',
168
+ '#783f04',
169
+ '#7f6000',
170
+ '#274e13',
171
+ '#0c343d',
172
+ '#073763',
173
+ '#20124d',
174
+ '#4c1130',
175
+ ]
176
+
177
+ const DEFAULT_HIGHLIGHT_PALETTE = DEFAULT_COLOR_PALETTE
178
+
179
+ const DEFAULT_LABELS: Record<BubbleMenuPresetItem, string> = {
180
+ undo: 'Undo',
181
+ redo: 'Redo',
182
+ fontFamily: 'Font',
183
+ fontSize: 'Size',
184
+ bold: 'B',
185
+ italic: 'I',
186
+ underline: 'U',
187
+ textColor: 'A',
188
+ highlightColor: 'Hi',
189
+ link: 'Link',
190
+ align: 'Align',
191
+ }
192
+
193
+ interface MenuBuildResult {
194
+ element: HTMLElement
195
+ cleanup: Cleanup
196
+ }
197
+
198
+ export const BubbleMenuPreset = Extension.create<BubbleMenuPresetOptions>({
199
+ name: 'bubbleMenuPreset',
200
+
201
+ addOptions() {
202
+ return {
203
+ element: null,
204
+ items: DEFAULT_ITEMS,
205
+ className: '',
206
+ injectStyles: true,
207
+ textColors: DEFAULT_COLOR_PALETTE,
208
+ highlightColors: DEFAULT_HIGHLIGHT_PALETTE,
209
+ fonts: DEFAULT_FONTS,
210
+ fontSizes: DEFAULT_FONT_SIZES,
211
+ alignments: DEFAULT_ALIGNMENTS,
212
+ pluginKey: 'bubbleMenuPreset',
213
+ updateDelay: 250,
214
+ resizeDelay: 60,
215
+ appendTo: undefined,
216
+ shouldShow: null,
217
+ options: {
218
+ placement: 'top',
219
+ strategy: 'absolute',
220
+ offset: 8,
221
+ flip: {},
222
+ shift: {},
223
+ },
224
+ }
225
+ },
226
+
227
+ addProseMirrorPlugins() {
228
+ const options = this.options
229
+ const editor = this.editor
230
+
231
+ const usingCustomElement = !!options.element
232
+ const { element, cleanup } =
233
+ options.element && typeof document !== 'undefined'
234
+ ? { element: options.element, cleanup: () => {} }
235
+ : buildMenuElement(editor, {
236
+ items: options.items ?? DEFAULT_ITEMS,
237
+ className: options.className ?? '',
238
+ injectStyles: options.injectStyles !== false,
239
+ textColors: options.textColors ?? DEFAULT_COLOR_PALETTE,
240
+ highlightColors: options.highlightColors ?? DEFAULT_HIGHLIGHT_PALETTE,
241
+ fonts: options.fonts ?? DEFAULT_FONTS,
242
+ fontSizes: options.fontSizes ?? DEFAULT_FONT_SIZES,
243
+ alignments: options.alignments ?? DEFAULT_ALIGNMENTS,
244
+ })
245
+
246
+ this.storage.element = element
247
+ this.storage.cleanup = cleanup
248
+ this.storage.usingCustomElement = usingCustomElement
249
+
250
+ const pluginOptions: BubbleMenuPluginProps = {
251
+ pluginKey: options.pluginKey ?? 'bubbleMenuPreset',
252
+ editor,
253
+ element: element,
254
+ updateDelay: options.updateDelay,
255
+ resizeDelay: options.resizeDelay,
256
+ appendTo: options.appendTo,
257
+ options: options.options,
258
+ getReferencedVirtualElement: options.getReferencedVirtualElement,
259
+ shouldShow: options.shouldShow ?? undefined,
260
+ }
261
+
262
+ return [BubbleMenuPlugin(pluginOptions)]
263
+ },
264
+
265
+ onDestroy() {
266
+ const el = this.storage.element as HTMLElement | undefined
267
+ const cleanup = this.storage.cleanup as Cleanup | undefined
268
+ const usingCustomElement = this.storage.usingCustomElement as boolean | undefined
269
+ if (cleanup) {
270
+ cleanup()
271
+ }
272
+ if (el && !usingCustomElement) {
273
+ el.remove()
274
+ }
275
+ },
276
+ })
277
+
278
+ function buildMenuElement(
279
+ editor: Editor,
280
+ opts: {
281
+ items: BubbleMenuPresetItem[]
282
+ className: string
283
+ injectStyles: boolean
284
+ textColors: string[]
285
+ highlightColors: string[]
286
+ fonts: string[]
287
+ fontSizes: string[]
288
+ alignments: TextAlignValue[]
289
+ },
290
+ ): MenuBuildResult {
291
+ if (opts.injectStyles) {
292
+ injectStyles()
293
+ }
294
+
295
+ const element = document.createElement('div')
296
+ element.className = `bs-bubble-menu-preset ${opts.className ?? ''}`.trim()
297
+ element.setAttribute('data-bubble-menu-preset', 'true')
298
+ element.tabIndex = 0
299
+
300
+ const toolbar = document.createElement('div')
301
+ toolbar.className = 'bs-bmp-toolbar'
302
+
303
+ const popovers: HTMLElement[] = []
304
+ const cleanupFns: Cleanup[] = []
305
+
306
+ const getCommand = (name: string): any => (editor.commands as any)?.[name]
307
+ const runChainCommand = (name: string, ...args: any[]) => {
308
+ const chain = (editor.chain as any)?.()
309
+ if (!chain || typeof chain[name] !== 'function') return false
310
+ const runner = typeof chain.focus === 'function' ? chain.focus() : chain
311
+ if (typeof runner[name] !== 'function') return false
312
+ return runner[name](...args).run?.() ?? false
313
+ }
314
+
315
+ const closePopovers = () => {
316
+ popovers.forEach((p) => p.classList.add('bs-bmp-hidden'))
317
+ }
318
+
319
+ const runWithFocus = (fn?: () => boolean) => {
320
+ if (!fn) return false
321
+ return !!fn()
322
+ }
323
+
324
+ const addButton = (
325
+ item: BubbleMenuPresetItem,
326
+ label: string,
327
+ onClick: () => void,
328
+ opts: { disabled?: boolean; title?: string } = {},
329
+ ) => {
330
+ const btn = document.createElement('button')
331
+ btn.type = 'button'
332
+ btn.className = `bs-bmp-btn bs-bmp-btn-${item}`
333
+ btn.textContent = label
334
+ if (opts.title) {
335
+ btn.title = opts.title
336
+ btn.setAttribute('aria-label', opts.title)
337
+ }
338
+ if (opts.disabled) {
339
+ btn.disabled = true
340
+ btn.classList.add('bs-bmp-disabled')
341
+ } else {
342
+ btn.addEventListener('click', () => {
343
+ closePopovers()
344
+ onClick()
345
+ })
346
+ }
347
+ toolbar.appendChild(btn)
348
+ }
349
+
350
+ const addUndoRedo = () => {
351
+ const hasUndo = typeof getCommand('undo') === 'function'
352
+ const hasRedo = typeof getCommand('redo') === 'function'
353
+ addButton('undo', DEFAULT_LABELS.undo, () => runWithFocus(() => runChainCommand('undo')), {
354
+ disabled: !hasUndo,
355
+ title: 'Undo',
356
+ })
357
+ addButton('redo', DEFAULT_LABELS.redo, () => runWithFocus(() => runChainCommand('redo')), {
358
+ disabled: !hasRedo,
359
+ title: 'Redo',
360
+ })
361
+ }
362
+
363
+ const addFontFamily = () => {
364
+ const hasCommand = typeof getCommand('setFontFamily') === 'function'
365
+ const wrapper = document.createElement('div')
366
+ wrapper.className = 'bs-bmp-select'
367
+ const select = document.createElement('select')
368
+ select.className = 'bs-bmp-select-input'
369
+ select.title = 'Font family'
370
+ opts.fonts.forEach((font) => {
371
+ const option = document.createElement('option')
372
+ option.value = font
373
+ option.textContent = font
374
+ option.style.fontFamily = font
375
+ select.appendChild(option)
376
+ })
377
+ select.disabled = !hasCommand
378
+ select.addEventListener('change', () => {
379
+ runWithFocus(() => runChainCommand('setFontFamily', select.value))
380
+ editor.commands.setMeta?.('bubbleMenu', 'updatePosition')
381
+ })
382
+ wrapper.appendChild(select)
383
+ toolbar.appendChild(wrapper)
384
+ }
385
+
386
+ const addFontSize = () => {
387
+ const hasCommand = typeof getCommand('setFontSize') === 'function'
388
+ const wrapper = document.createElement('div')
389
+ wrapper.className = 'bs-bmp-select'
390
+ const select = document.createElement('select')
391
+ select.className = 'bs-bmp-select-input'
392
+ select.title = 'Font size'
393
+ opts.fontSizes.forEach((size) => {
394
+ const option = document.createElement('option')
395
+ option.value = size
396
+ option.textContent = size
397
+ select.appendChild(option)
398
+ })
399
+ select.disabled = !hasCommand
400
+ select.addEventListener('change', () => {
401
+ runWithFocus(() => runChainCommand('setFontSize', select.value))
402
+ editor.commands.setMeta?.('bubbleMenu', 'updatePosition')
403
+ })
404
+ wrapper.appendChild(select)
405
+ toolbar.appendChild(wrapper)
406
+ }
407
+
408
+ const addToggleMark = (item: BubbleMenuPresetItem, commandName: string, title: string) => {
409
+ const fn = getCommand(commandName) as (() => boolean) | undefined
410
+ const disabled = typeof fn !== 'function'
411
+ addButton(
412
+ item,
413
+ DEFAULT_LABELS[item],
414
+ () => {
415
+ runWithFocus(() => runChainCommand(commandName))
416
+ },
417
+ { disabled, title },
418
+ )
419
+ }
420
+
421
+ const addTextColor = () => {
422
+ const hasSet = typeof getCommand('setColor') === 'function'
423
+ const hasUnset = typeof getCommand('unsetColor') === 'function'
424
+ const { popover, toggle, destroy } = createColorPopover({
425
+ label: DEFAULT_LABELS.textColor,
426
+ title: 'Text color',
427
+ colors: opts.textColors,
428
+ onSelect: (color) => {
429
+ if (!hasSet) return
430
+ runWithFocus(() => runChainCommand('setColor', color))
431
+ editor.commands.setMeta?.('bubbleMenu', 'updatePosition')
432
+ },
433
+ onClear: () => {
434
+ if (!hasUnset) return
435
+ runWithFocus(() => runChainCommand('unsetColor'))
436
+ editor.commands.setMeta?.('bubbleMenu', 'updatePosition')
437
+ },
438
+ onToggle: () => editor.commands.setMeta?.('bubbleMenu', 'updatePosition'),
439
+ })
440
+ popovers.push(popover)
441
+ cleanupFns.push(destroy)
442
+ toolbar.appendChild(toggle)
443
+ toolbar.appendChild(popover)
444
+ }
445
+
446
+ const addHighlightColor = () => {
447
+ const hasToggle = typeof getCommand('toggleHighlight') === 'function'
448
+ const hasUnset = typeof getCommand('unsetHighlight') === 'function'
449
+ const { popover, toggle, destroy } = createColorPopover({
450
+ label: DEFAULT_LABELS.highlightColor,
451
+ title: 'Highlight color',
452
+ colors: opts.highlightColors,
453
+ onSelect: (color) => {
454
+ if (!hasToggle) return
455
+ runWithFocus(() => runChainCommand('toggleHighlight', { color }))
456
+ editor.commands.setMeta?.('bubbleMenu', 'updatePosition')
457
+ },
458
+ onClear: () => {
459
+ if (!hasUnset) return
460
+ runWithFocus(() => runChainCommand('unsetHighlight'))
461
+ editor.commands.setMeta?.('bubbleMenu', 'updatePosition')
462
+ },
463
+ onToggle: () => editor.commands.setMeta?.('bubbleMenu', 'updatePosition'),
464
+ })
465
+ popovers.push(popover)
466
+ cleanupFns.push(destroy)
467
+ toolbar.appendChild(toggle)
468
+ toolbar.appendChild(popover)
469
+ }
470
+
471
+ const addLink = () => {
472
+ const hasToggle = typeof getCommand('toggleLink') === 'function'
473
+ const hasUnset = typeof getCommand('unsetLink') === 'function'
474
+ addButton(
475
+ 'link',
476
+ DEFAULT_LABELS.link,
477
+ () => {
478
+ const currentHref = editor.getAttributes('link')?.href ?? ''
479
+ const href = window.prompt('Link URL', currentHref)
480
+ if (href === null) return
481
+ if (!href) {
482
+ if (hasUnset) {
483
+ runWithFocus(() => runChainCommand('unsetLink'))
484
+ }
485
+ return
486
+ }
487
+ if (hasToggle) {
488
+ runWithFocus(() => runChainCommand('toggleLink', { href }))
489
+ }
490
+ },
491
+ { disabled: !hasToggle && !hasUnset, title: 'Insert link' },
492
+ )
493
+ }
494
+
495
+ const addAlign = () => {
496
+ const hasSet = typeof getCommand('setTextAlign') === 'function'
497
+ const hasToggle = typeof getCommand('toggleTextAlign') === 'function'
498
+ const wrapper = document.createElement('div')
499
+ wrapper.className = 'bs-bmp-select'
500
+ const select = document.createElement('select')
501
+ select.className = 'bs-bmp-select-input'
502
+ select.title = 'Align'
503
+ opts.alignments.forEach((alignment) => {
504
+ const option = document.createElement('option')
505
+ option.value = alignment
506
+ option.textContent = alignment.charAt(0).toUpperCase() + alignment.slice(1)
507
+ select.appendChild(option)
508
+ })
509
+ select.disabled = !hasSet && !hasToggle
510
+ select.addEventListener('change', () => {
511
+ if (hasToggle) {
512
+ runWithFocus(() => runChainCommand('toggleTextAlign', select.value))
513
+ } else if (hasSet) {
514
+ runWithFocus(() => runChainCommand('setTextAlign', select.value))
515
+ }
516
+ editor.commands.setMeta?.('bubbleMenu', 'updatePosition')
517
+ })
518
+ wrapper.appendChild(select)
519
+ toolbar.appendChild(wrapper)
520
+ }
521
+
522
+ // Build in the order requested.
523
+ opts.items.forEach((item) => {
524
+ switch (item) {
525
+ case 'undo':
526
+ addUndoRedo()
527
+ break
528
+ case 'redo':
529
+ // handled in undo block to keep buttons together; skip.
530
+ break
531
+ case 'fontFamily':
532
+ addFontFamily()
533
+ break
534
+ case 'fontSize':
535
+ addFontSize()
536
+ break
537
+ case 'bold':
538
+ addToggleMark('bold', 'toggleBold', 'Bold')
539
+ break
540
+ case 'italic':
541
+ addToggleMark('italic', 'toggleItalic', 'Italic')
542
+ break
543
+ case 'underline':
544
+ addToggleMark('underline', 'toggleUnderline', 'Underline')
545
+ break
546
+ case 'textColor':
547
+ addTextColor()
548
+ break
549
+ case 'highlightColor':
550
+ addHighlightColor()
551
+ break
552
+ case 'link':
553
+ addLink()
554
+ break
555
+ case 'align':
556
+ addAlign()
557
+ break
558
+ default:
559
+ break
560
+ }
561
+ })
562
+
563
+ element.appendChild(toolbar)
564
+
565
+ return {
566
+ element,
567
+ cleanup: () => {
568
+ popovers.forEach((p) => p.remove())
569
+ cleanupFns.forEach((fn) => fn())
570
+ element.replaceChildren()
571
+ },
572
+ }
573
+ }
574
+
575
+ function createColorPopover(args: {
576
+ label: string
577
+ title: string
578
+ colors: string[]
579
+ onSelect: (color: string) => void
580
+ onClear: () => void
581
+ onToggle?: () => void
582
+ }): { toggle: HTMLElement; popover: HTMLElement; destroy: () => void } {
583
+ const toggle = document.createElement('button')
584
+ toggle.type = 'button'
585
+ toggle.className = 'bs-bmp-btn bs-bmp-btn-color'
586
+ toggle.textContent = args.label
587
+ toggle.title = args.title
588
+ toggle.setAttribute('aria-expanded', 'false')
589
+
590
+ const popover = document.createElement('div')
591
+ popover.className = 'bs-bmp-popover bs-bmp-hidden'
592
+ popover.setAttribute('role', 'menu')
593
+
594
+ const header = document.createElement('div')
595
+ header.className = 'bs-bmp-popover-header'
596
+ const noneBtn = document.createElement('button')
597
+ noneBtn.type = 'button'
598
+ noneBtn.className = 'bs-bmp-btn bs-bmp-btn-ghost'
599
+ noneBtn.textContent = 'None'
600
+ noneBtn.addEventListener('click', () => {
601
+ args.onClear()
602
+ hide()
603
+ })
604
+ header.appendChild(noneBtn)
605
+ popover.appendChild(header)
606
+
607
+ const grid = document.createElement('div')
608
+ grid.className = 'bs-bmp-color-grid'
609
+ const columns = 10
610
+ grid.style.setProperty('--bs-bmp-grid-columns', String(columns))
611
+
612
+ args.colors.forEach((color) => {
613
+ const swatch = document.createElement('button')
614
+ swatch.type = 'button'
615
+ swatch.className = 'bs-bmp-color-swatch'
616
+ swatch.style.backgroundColor = color
617
+ swatch.setAttribute('aria-label', color)
618
+ swatch.addEventListener('click', (event) => {
619
+ event.stopPropagation()
620
+ args.onSelect(color)
621
+ hide()
622
+ })
623
+ grid.appendChild(swatch)
624
+ })
625
+
626
+ popover.appendChild(grid)
627
+
628
+ const customRow = document.createElement('div')
629
+ customRow.className = 'bs-bmp-popover-footer'
630
+ const customLabel = document.createElement('span')
631
+ customLabel.textContent = 'Custom'
632
+ const customInput = document.createElement('input')
633
+ customInput.type = 'color'
634
+ customInput.className = 'bs-bmp-color-input'
635
+ customInput.addEventListener('input', () => {
636
+ args.onSelect(customInput.value)
637
+ })
638
+ customRow.appendChild(customLabel)
639
+ customRow.appendChild(customInput)
640
+ popover.appendChild(customRow)
641
+
642
+ const hide = () => {
643
+ popover.classList.add('bs-bmp-hidden')
644
+ toggle.setAttribute('aria-expanded', 'false')
645
+ args.onToggle?.()
646
+ }
647
+
648
+ const show = () => {
649
+ popover.classList.remove('bs-bmp-hidden')
650
+ toggle.setAttribute('aria-expanded', 'true')
651
+ args.onToggle?.()
652
+ }
653
+
654
+ const toggleHandler = (event: MouseEvent) => {
655
+ event.stopPropagation()
656
+ if (popover.classList.contains('bs-bmp-hidden')) {
657
+ show()
658
+ } else {
659
+ hide()
660
+ }
661
+ }
662
+ toggle.addEventListener('click', toggleHandler)
663
+
664
+ const outsideHandler = (event: MouseEvent) => {
665
+ if (popover.contains(event.target as Node) || toggle.contains(event.target as Node)) {
666
+ return
667
+ }
668
+ hide()
669
+ }
670
+
671
+ document.addEventListener(
672
+ 'click',
673
+ outsideHandler,
674
+ { capture: true },
675
+ )
676
+
677
+ const destroy = () => {
678
+ document.removeEventListener('click', outsideHandler, { capture: true } as any)
679
+ toggle.removeEventListener('click', toggleHandler)
680
+ }
681
+
682
+ return { toggle, popover, destroy }
683
+ }
684
+
685
+ function injectStyles() {
686
+ if (typeof document === 'undefined') return
687
+ if (document.getElementById(STYLE_ID)) return
688
+
689
+ const style = document.createElement('style')
690
+ style.id = STYLE_ID
691
+ style.textContent = `
692
+ .bs-bubble-menu-preset {
693
+ display: inline-flex;
694
+ align-items: center;
695
+ gap: 8px;
696
+ background: #ffffff;
697
+ border: 1px solid #e5e7eb;
698
+ padding: 8px 10px;
699
+ border-radius: 20%;
700
+ box-shadow: 0 10px 30px rgba(0,0,0,0.12);
701
+ color: #111827;
702
+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
703
+ }
704
+ .bs-bmp-toolbar {
705
+ display: inline-flex;
706
+ align-items: center;
707
+ gap: 6px;
708
+ }
709
+ .bs-bmp-btn {
710
+ border: none;
711
+ background: transparent;
712
+ color: inherit;
713
+ padding: 6px 8px;
714
+ border-radius: 12px;
715
+ cursor: pointer;
716
+ font-size: 13px;
717
+ line-height: 1;
718
+ transition: background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
719
+ }
720
+ .bs-bmp-btn:hover {
721
+ background: #f3f4f6;
722
+ }
723
+ .bs-bmp-btn:disabled {
724
+ opacity: 0.45;
725
+ cursor: not-allowed;
726
+ }
727
+ .bs-bmp-select {
728
+ display: inline-flex;
729
+ align-items: center;
730
+ }
731
+ .bs-bmp-select-input {
732
+ border: 1px solid #e5e7eb;
733
+ background: #ffffff;
734
+ color: inherit;
735
+ padding: 6px 8px;
736
+ border-radius: 12px;
737
+ font-size: 13px;
738
+ outline: none;
739
+ }
740
+ .bs-bmp-select-input:focus {
741
+ border-color: #4f46e5;
742
+ box-shadow: 0 0 0 2px rgba(79,70,229,0.15);
743
+ }
744
+ .bs-bmp-popover {
745
+ position: absolute;
746
+ margin-top: 6px;
747
+ padding: 10px;
748
+ background: #ffffff;
749
+ border: 1px solid #e5e7eb;
750
+ border-radius: 12px;
751
+ box-shadow: 0 12px 40px rgba(15, 23, 42, 0.18);
752
+ z-index: 999;
753
+ }
754
+ .bs-bmp-hidden {
755
+ display: none;
756
+ }
757
+ .bs-bmp-popover-header,
758
+ .bs-bmp-popover-footer {
759
+ display: flex;
760
+ align-items: center;
761
+ justify-content: space-between;
762
+ margin-bottom: 6px;
763
+ }
764
+ .bs-bmp-btn-ghost {
765
+ background: transparent;
766
+ border: none;
767
+ color: inherit;
768
+ padding: 4px 6px;
769
+ border-radius: 8px;
770
+ cursor: pointer;
771
+ }
772
+ .bs-bmp-btn-ghost:hover {
773
+ background: #f3f4f6;
774
+ }
775
+ .bs-bmp-color-grid {
776
+ display: grid;
777
+ grid-template-columns: repeat(var(--bs-bmp-grid-columns, 10), 22px);
778
+ gap: 4px;
779
+ margin-bottom: 8px;
780
+ }
781
+ .bs-bmp-color-swatch {
782
+ width: 22px;
783
+ height: 22px;
784
+ border-radius: 50%;
785
+ border: 1px solid #e5e7eb;
786
+ cursor: pointer;
787
+ padding: 0;
788
+ }
789
+ .bs-bmp-color-swatch:hover {
790
+ outline: 2px solid #4f46e5;
791
+ outline-offset: 2px;
792
+ }
793
+ .bs-bmp-color-input {
794
+ width: 32px;
795
+ height: 28px;
796
+ padding: 0;
797
+ border: 1px solid #e5e7eb;
798
+ border-radius: 8px;
799
+ background: #ffffff;
800
+ }
801
+ `
802
+
803
+ document.head.appendChild(style)
804
+ }
805
+
806
+
807
+
808
+
809
+