@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.
- package/LICENSE.md +36 -0
- package/README.md +74 -0
- package/dist/index.cjs +701 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +48 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +676 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
- package/src/bubble-menu-preset.ts +809 -0
- package/src/index.ts +3 -0
|
@@ -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
|
+
|