@purestack/ts-components 1.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.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -0
  3. package/dist/ts-components.d.mts +1662 -0
  4. package/dist/ts-components.d.mts.map +1 -0
  5. package/dist/ts-components.mjs +9200 -0
  6. package/dist/ts-components.mjs.map +1 -0
  7. package/package.json +130 -0
  8. package/src/defineComponents.ts +87 -0
  9. package/src/index.ts +270 -0
  10. package/src/registerStyles.ts +63 -0
  11. package/src/standard/alertBox/alertBox.ts +64 -0
  12. package/src/standard/autoId.ts +25 -0
  13. package/src/standard/badge/badge.ts +41 -0
  14. package/src/standard/badge/badgeStyle.ts +21 -0
  15. package/src/standard/barChart/barChart.ts +338 -0
  16. package/src/standard/barChart/barChartStyle.ts +81 -0
  17. package/src/standard/btn/btn.ts +191 -0
  18. package/src/standard/btn/btnStyle.ts +59 -0
  19. package/src/standard/btnGroup/btnGroup.ts +208 -0
  20. package/src/standard/btnGroup/btnGroupStyle.ts +85 -0
  21. package/src/standard/chart/cartesianChart.ts +99 -0
  22. package/src/standard/chart/chartDefaults.ts +25 -0
  23. package/src/standard/chart/chartUtils.ts +84 -0
  24. package/src/standard/classicLogo/classicLogo.ts +429 -0
  25. package/src/standard/classicLogo/classicLogoStyle.ts +292 -0
  26. package/src/standard/componentVariant.ts +153 -0
  27. package/src/standard/composer/composer.ts +1035 -0
  28. package/src/standard/composer/composerCanvasInitialStyle.ts +225 -0
  29. package/src/standard/composer/composerCanvasRevertStyle.ts +14 -0
  30. package/src/standard/composer/composerCanvasSharedStyle.ts +60 -0
  31. package/src/standard/composer/composerCanvasStyleTypes.ts +8 -0
  32. package/src/standard/composer/composerCanvasThemeStyle.ts +53 -0
  33. package/src/standard/composer/composerStyle.ts +156 -0
  34. package/src/standard/composer/emailHtmlPolicy.ts +474 -0
  35. package/src/standard/consent/consent.ts +173 -0
  36. package/src/standard/consent/consentStyle.ts +44 -0
  37. package/src/standard/contactForm/contactForm.ts +135 -0
  38. package/src/standard/contactForm/contactFormStyle.ts +116 -0
  39. package/src/standard/doughnutChart/doughnutChart.ts +527 -0
  40. package/src/standard/doughnutChart/doughnutChartStyle.ts +64 -0
  41. package/src/standard/dropFiles/dropFiles.ts +480 -0
  42. package/src/standard/dropFiles/dropFilesStyle.ts +171 -0
  43. package/src/standard/expandablePanel/expandablePanel.ts +106 -0
  44. package/src/standard/expandablePanel/expandablePanelStyle.ts +56 -0
  45. package/src/standard/flex/flex.ts +269 -0
  46. package/src/standard/flex/flexStyle.ts +556 -0
  47. package/src/standard/footer/footer.ts +93 -0
  48. package/src/standard/footer/footerStyle.ts +39 -0
  49. package/src/standard/form/autoCompleteInput.ts +567 -0
  50. package/src/standard/form/form.ts +205 -0
  51. package/src/standard/form/formCheck.ts +96 -0
  52. package/src/standard/form/formInputField.ts +184 -0
  53. package/src/standard/form/formSelectField.ts +152 -0
  54. package/src/standard/form/formStyle.ts +585 -0
  55. package/src/standard/form/multiAutoCompleteInput.ts +880 -0
  56. package/src/standard/grid/grid.ts +177 -0
  57. package/src/standard/grid/gridStyle.ts +56 -0
  58. package/src/standard/icon/icon.ts +96 -0
  59. package/src/standard/icon/iconStyle.ts +50 -0
  60. package/src/standard/landing/codeShowcase.ts +54 -0
  61. package/src/standard/landing/comparisonColumn.ts +30 -0
  62. package/src/standard/landing/comparisonFeature.ts +22 -0
  63. package/src/standard/landing/comparisonTable.ts +38 -0
  64. package/src/standard/landing/ctaSection.ts +59 -0
  65. package/src/standard/landing/featureCard.ts +41 -0
  66. package/src/standard/landing/landing.ts +52 -0
  67. package/src/standard/landing/landingBand.ts +217 -0
  68. package/src/standard/landing/landingSection.ts +43 -0
  69. package/src/standard/landing/landingTypes.ts +141 -0
  70. package/src/standard/landing/metricItem.ts +25 -0
  71. package/src/standard/landing/metricStrip.ts +20 -0
  72. package/src/standard/lineChart/lineChart.ts +462 -0
  73. package/src/standard/lineChart/lineChartStyle.ts +91 -0
  74. package/src/standard/logo/logo.ts +192 -0
  75. package/src/standard/logo/logoStyle.ts +164 -0
  76. package/src/standard/modal/modal.ts +171 -0
  77. package/src/standard/modal/modalStore.ts +154 -0
  78. package/src/standard/modal/modalStyle.ts +190 -0
  79. package/src/standard/navMenu/navMenu.ts +278 -0
  80. package/src/standard/navMenu/navMenuStyle.ts +323 -0
  81. package/src/standard/pageLinks/pageLinks.ts +90 -0
  82. package/src/standard/pageScript/pageScript.ts +155 -0
  83. package/src/standard/pageToc/pageToc.ts +154 -0
  84. package/src/standard/pageToc/pageTocStyle.ts +372 -0
  85. package/src/standard/panel/panel.ts +55 -0
  86. package/src/standard/panel/panelStyle.ts +15 -0
  87. package/src/standard/pricing/pricing.ts +146 -0
  88. package/src/standard/searchBox/searchBox.ts +54 -0
  89. package/src/standard/searchBox/searchBoxStyle.ts +180 -0
  90. package/src/standard/sectionHeader/sectionHeader.ts +99 -0
  91. package/src/standard/signIn/signIn.ts +167 -0
  92. package/src/standard/signIn/signInStyle.ts +70 -0
  93. package/src/standard/tabs/tabs.ts +193 -0
  94. package/src/standard/tabs/tabsStyle.ts +231 -0
  95. package/src/standard/themeToggle/themeToggle.ts +33 -0
  96. package/src/standard/themeToggle/themeToggleStyle.ts +88 -0
  97. package/src/standard/toast/toastHost.ts +42 -0
  98. package/src/standard/toast/toastStore.ts +50 -0
  99. package/src/standard/topBar/topBar.ts +101 -0
  100. package/src/standard/topBar/topBarStyle.ts +169 -0
  101. package/src/standard/virtualList/variableVirtualTable.ts +371 -0
  102. package/src/standard/virtualList/virtualList.ts +423 -0
  103. package/src/standard/virtualList/virtualListStyle.ts +34 -0
  104. package/src/standard/virtualList/virtualTable.ts +179 -0
  105. package/src/standard/virtualList/virtualTableStyle.ts +24 -0
  106. package/tsconfig.json +23 -0
@@ -0,0 +1,1035 @@
1
+ import type { SemanticTone } from '@purestack/ts-style'
2
+ import {
3
+ type ComponentHead,
4
+ type ComputedRef,
5
+ computed,
6
+ defineComponent,
7
+ html,
8
+ observe,
9
+ type Ref,
10
+ type RefOrValue,
11
+ ref,
12
+ type SRef,
13
+ sref,
14
+ unref,
15
+ } from 'regor'
16
+ import { createAutoId } from '../autoId'
17
+ import {
18
+ type ComponentVariant,
19
+ resolveComponentClasses,
20
+ } from '../componentVariant'
21
+ import {
22
+ copySafeEmailStyles,
23
+ GMAIL_COMPATIBLE_EMAIL_TAGS,
24
+ normalizeEmailCidUrl,
25
+ sanitizePlainAttribute,
26
+ toSafeEmailImageUrl,
27
+ } from './emailHtmlPolicy'
28
+
29
+ export type ComposerCommand =
30
+ | 'bold'
31
+ | 'italic'
32
+ | 'underline'
33
+ | 'justifyLeft'
34
+ | 'justifyCenter'
35
+ | 'justifyRight'
36
+ | 'insertUnorderedList'
37
+ | 'insertOrderedList'
38
+ | 'removeFormat'
39
+
40
+ export interface Composer {
41
+ html?: Ref<string> | SRef<string>
42
+ text?: Ref<string> | SRef<string>
43
+ label?: RefOrValue<string>
44
+ placeholder?: RefOrValue<string>
45
+ disabled?: RefOrValue<boolean>
46
+ focusOnMount?: RefOrValue<boolean>
47
+ minHeight?: RefOrValue<number | string>
48
+ tone?: RefOrValue<SemanticTone>
49
+ variant?: RefOrValue<ComponentVariant>
50
+ imagePreviewUrls?: RefOrValue<Record<string, string>>
51
+ classes?: ComputedRef<string>
52
+ editorStyle?: ComputedRef<Record<string, string>>
53
+ sourceId?: string
54
+ editorElement?: ReturnType<typeof sref<HTMLElement | null>>
55
+ sourceHtml?: Ref<string>
56
+ sourceMode?: Ref<boolean>
57
+ format?: (command: ComposerCommand) => void
58
+ createLink?: () => void
59
+ toggleSourceMode?: () => void
60
+ handleInput?: (event: Event) => void
61
+ handlePaste?: (event: ClipboardEvent) => void
62
+ handleDragOver?: (event: DragEvent) => void
63
+ handleDrop?: (event: DragEvent) => void
64
+ handleSourceInput?: (event: Event) => void
65
+ }
66
+
67
+ const composerTemplate = html`<div class="composer-field">
68
+ <span class="composer-field__label" r-if="label">{{ label }}</span>
69
+ <div class="composer" :class="classes">
70
+ <div class="composer__toolbar" role="toolbar" aria-label="Composer tools">
71
+ <button
72
+ class="composer__tool"
73
+ type="button"
74
+ title="Bold"
75
+ aria-label="Bold"
76
+ :disabled="disabled"
77
+ @click="format('bold')">
78
+ <Icon name="lucide:bold"/>
79
+ </button>
80
+ <button
81
+ class="composer__tool"
82
+ type="button"
83
+ title="Italic"
84
+ aria-label="Italic"
85
+ :disabled="disabled"
86
+ @click="format('italic')">
87
+ <Icon name="lucide:italic"/>
88
+ </button>
89
+ <button
90
+ class="composer__tool"
91
+ type="button"
92
+ title="Underline"
93
+ aria-label="Underline"
94
+ :disabled="disabled"
95
+ @click="format('underline')">
96
+ <Icon name="lucide:underline"/>
97
+ </button>
98
+ <span class="composer__divider" aria-hidden="true"></span>
99
+ <button
100
+ class="composer__tool"
101
+ type="button"
102
+ title="Align left"
103
+ aria-label="Align left"
104
+ :disabled="disabled"
105
+ @click="format('justifyLeft')">
106
+ <Icon name="tabler:align-left"/>
107
+ </button>
108
+ <button
109
+ class="composer__tool"
110
+ type="button"
111
+ title="Align center"
112
+ aria-label="Align center"
113
+ :disabled="disabled"
114
+ @click="format('justifyCenter')">
115
+ <Icon name="tabler:align-center"/>
116
+ </button>
117
+ <button
118
+ class="composer__tool"
119
+ type="button"
120
+ title="Align right"
121
+ aria-label="Align right"
122
+ :disabled="disabled"
123
+ @click="format('justifyRight')">
124
+ <Icon name="tabler:align-right"/>
125
+ </button>
126
+ <span class="composer__divider" aria-hidden="true"></span>
127
+ <button
128
+ class="composer__tool"
129
+ type="button"
130
+ title="Bulleted list"
131
+ aria-label="Bulleted list"
132
+ :disabled="disabled"
133
+ @click="format('insertUnorderedList')">
134
+ <Icon name="lucide:list"/>
135
+ </button>
136
+ <button
137
+ class="composer__tool"
138
+ type="button"
139
+ title="Numbered list"
140
+ aria-label="Numbered list"
141
+ :disabled="disabled"
142
+ @click="format('insertOrderedList')">
143
+ <Icon name="lucide:list-ordered"/>
144
+ </button>
145
+ <button
146
+ class="composer__tool"
147
+ type="button"
148
+ title="Link"
149
+ aria-label="Link"
150
+ :disabled="disabled"
151
+ @click="createLink">
152
+ <Icon name="lucide:link"/>
153
+ </button>
154
+ <span class="composer__divider" aria-hidden="true"></span>
155
+ <button
156
+ class="composer__tool"
157
+ type="button"
158
+ title="Clear formatting"
159
+ aria-label="Clear formatting"
160
+ :disabled="disabled"
161
+ @click="format('removeFormat')">
162
+ <Icon name="lucide:eraser"/>
163
+ </button>
164
+ <button
165
+ class="composer__tool composer__tool--source"
166
+ type="button"
167
+ title="HTML source"
168
+ aria-label="HTML source"
169
+ :aria-pressed="sourceMode ? 'true' : 'false'"
170
+ :disabled="disabled"
171
+ @click="toggleSourceMode">
172
+ <Icon name="lucide:code"/>
173
+ </button>
174
+ </div>
175
+ <div class="composer__surface">
176
+ <div
177
+ r-if="!sourceMode"
178
+ class="composer__editor"
179
+ :ref="editorElement"
180
+ :style="editorStyle"
181
+ :contenteditable="disabled ? 'false' : 'true'"
182
+ :data-placeholder="placeholder"
183
+ role="textbox"
184
+ :aria-label="label || placeholder || 'Message body'"
185
+ aria-multiline="true"
186
+ spellcheck="true"
187
+ @input="handleInput"
188
+ @paste="handlePaste"
189
+ @dragover="handleDragOver"
190
+ @drop="handleDrop"></div>
191
+ <textarea
192
+ r-if="sourceMode"
193
+ :id="sourceId"
194
+ class="composer__source"
195
+ :style="editorStyle"
196
+ :disabled="disabled"
197
+ :placeholder="placeholder"
198
+ :aria-label="label || placeholder || 'Message body source'"
199
+ spellcheck="false"
200
+ :value="sourceHtml"
201
+ @input="handleSourceInput"></textarea>
202
+ </div>
203
+ </div>
204
+ </div>`
205
+
206
+ function defineComposerComponent() {
207
+ return defineComponent<Composer>(composerTemplate, {
208
+ props: [
209
+ 'html',
210
+ 'text',
211
+ 'label',
212
+ 'placeholder',
213
+ 'disabled',
214
+ 'focusOnMount',
215
+ 'minHeight',
216
+ 'tone',
217
+ 'variant',
218
+ 'imagePreviewUrls',
219
+ ],
220
+ context: (head) => resolveComposer(head),
221
+ })
222
+ }
223
+
224
+ export function defineComposerComponents() {
225
+ return {
226
+ composer: defineComposerComponent(),
227
+ }
228
+ }
229
+
230
+ class ComposerContext implements Composer {
231
+ readonly html: Ref<string> | SRef<string>
232
+ readonly text: Ref<string> | SRef<string>
233
+ readonly label?: RefOrValue<string>
234
+ readonly placeholder?: RefOrValue<string>
235
+ readonly disabled?: RefOrValue<boolean>
236
+ readonly focusOnMount?: RefOrValue<boolean>
237
+ readonly minHeight?: RefOrValue<number | string>
238
+ readonly tone?: RefOrValue<SemanticTone>
239
+ readonly variant?: RefOrValue<ComponentVariant>
240
+ readonly imagePreviewUrls?: RefOrValue<Record<string, string>>
241
+ readonly editorElement = sref<HTMLElement | null>(null)
242
+ readonly sourceHtml = ref('')
243
+ readonly sourceMode = ref(false)
244
+ readonly classes: ComputedRef<string>
245
+ readonly editorStyle: ComputedRef<Record<string, string>>
246
+ readonly sourceId: string
247
+ private writingModel = false
248
+
249
+ constructor(props: Composer) {
250
+ Object.assign(this, props)
251
+ this.html = props.html ?? ref('')
252
+ this.text = props.text ?? ref('')
253
+ this.classes = computed(() =>
254
+ resolveComponentClasses(props, {
255
+ defaultVariant: 'surfaceAlt',
256
+ }),
257
+ )
258
+ this.editorStyle = computed<Record<string, string>>(() => ({
259
+ minHeight: resolveCssSize(unref(props.minHeight), '13.75rem'),
260
+ }))
261
+ this.sourceId = resolveComposerSourceId()
262
+ this.sourceHtml(this.sanitizeModelHtml(this.html()))
263
+ this.text(htmlToText(this.sourceHtml()))
264
+ observe(this.editorElement, () => {
265
+ this.syncEditorFromModel()
266
+ })
267
+ observe(this.html, (value) => {
268
+ if (this.writingModel) return
269
+ this.syncFromExternalHtml(value)
270
+ })
271
+ if (typeof this.imagePreviewUrls === 'function') {
272
+ observe(this.imagePreviewUrls, () => {
273
+ this.syncEditorFromModel()
274
+ })
275
+ }
276
+ }
277
+
278
+ mounted = () => {
279
+ if (unref(this.focusOnMount)) this.scheduleEditorFocus()
280
+ }
281
+
282
+ format = (command: ComposerCommand) => {
283
+ if (unref(this.disabled) || this.sourceMode()) return
284
+ this.focusEditor()
285
+ document.execCommand(command)
286
+ this.syncFromEditor()
287
+ }
288
+
289
+ createLink = () => {
290
+ if (unref(this.disabled) || this.sourceMode()) return
291
+ const href = sanitizeHref(prompt('Link URL') ?? '')
292
+ if (!href) return
293
+
294
+ this.focusEditor()
295
+ document.execCommand('createLink', false, href)
296
+ this.syncFromEditor()
297
+ }
298
+
299
+ toggleSourceMode = () => {
300
+ if (this.sourceMode()) {
301
+ this.writeModel(this.sourceHtml())
302
+ this.sourceMode(false)
303
+ this.syncEditorFromModel()
304
+ return
305
+ }
306
+
307
+ this.sourceHtml(this.html())
308
+ this.sourceMode(true)
309
+ }
310
+
311
+ handleInput = (_event: Event) => {
312
+ if (unref(this.disabled) || this.sourceMode()) return
313
+ this.syncFromEditor()
314
+ }
315
+
316
+ handlePaste = (event: ClipboardEvent) => {
317
+ if (unref(this.disabled) || this.sourceMode()) return
318
+
319
+ const data = event.clipboardData
320
+ if (!data) return
321
+
322
+ const files = Array.from(data.files ?? [])
323
+ const htmlData = data.getData('text/html')
324
+ const textData = data.getData('text/plain')
325
+ if (files.length > 0) this.emitFiles(event, files)
326
+
327
+ event.preventDefault()
328
+ const nextHtml = htmlData
329
+ ? this.sanitizeModelHtml(htmlData)
330
+ : plainTextToHtml(textData)
331
+ if (!nextHtml) return
332
+
333
+ this.focusEditor()
334
+ document.execCommand('insertHTML', false, nextHtml)
335
+ this.syncFromEditor()
336
+ }
337
+
338
+ handleDragOver = (event: DragEvent) => {
339
+ if (
340
+ unref(this.disabled) ||
341
+ this.sourceMode() ||
342
+ !hasFileTransfer(event.dataTransfer)
343
+ ) {
344
+ return
345
+ }
346
+
347
+ event.preventDefault()
348
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
349
+ }
350
+
351
+ handleDrop = (event: DragEvent) => {
352
+ if (
353
+ unref(this.disabled) ||
354
+ this.sourceMode() ||
355
+ !hasFileTransfer(event.dataTransfer)
356
+ ) {
357
+ return
358
+ }
359
+
360
+ event.preventDefault()
361
+ const files = Array.from(event.dataTransfer?.files ?? [])
362
+ if (files.length > 0) this.emitFiles(event, files)
363
+ }
364
+
365
+ handleSourceInput = (event: Event) => {
366
+ if (unref(this.disabled)) return
367
+
368
+ const target = event.currentTarget
369
+ if (!(target instanceof HTMLTextAreaElement)) return
370
+
371
+ this.sourceHtml(target.value)
372
+ this.writeSourceModel(target.value)
373
+ }
374
+
375
+ private syncFromEditor() {
376
+ const element = this.editorElement()
377
+ if (!element) return
378
+
379
+ const value = this.readModelHtmlFromEditor(element)
380
+ this.writeModel(value)
381
+ if (!value) this.syncEditorFromModel()
382
+ }
383
+
384
+ private syncFromExternalHtml(value: string) {
385
+ const sanitized = this.sanitizeModelHtml(value)
386
+ if (sanitized !== value) {
387
+ this.writeModel(sanitized)
388
+ this.syncEditorFromModel()
389
+ return
390
+ }
391
+
392
+ this.sourceHtml(sanitized)
393
+ this.text(htmlToText(sanitized))
394
+ this.syncEditorFromModel()
395
+ }
396
+
397
+ private syncEditorFromModel() {
398
+ const element = this.editorElement()
399
+ if (!element || this.sourceMode()) return
400
+
401
+ const sanitized = this.toEditorHtml(this.html())
402
+ if (element.innerHTML !== sanitized) element.innerHTML = sanitized
403
+ }
404
+
405
+ private writeModel(value: string) {
406
+ const sanitized = this.sanitizeModelHtml(value)
407
+ this.writingModel = true
408
+ this.html(sanitized)
409
+ this.text(htmlToText(sanitized))
410
+ this.sourceHtml(sanitized)
411
+ this.writingModel = false
412
+ }
413
+
414
+ private writeSourceModel(value: string) {
415
+ const sanitized = this.sanitizeModelHtml(value)
416
+ this.writingModel = true
417
+ this.html(sanitized)
418
+ this.text(htmlToText(sanitized))
419
+ this.writingModel = false
420
+ }
421
+
422
+ private focusEditor() {
423
+ const element = this.editorElement()
424
+ if (element) element.focus()
425
+ }
426
+
427
+ private scheduleEditorFocus(attempt = 0) {
428
+ requestAnimationFrame(() => {
429
+ this.syncEditorFromModel()
430
+ if (this.focusEditorForWriting()) return
431
+ if (attempt >= FOCUS_RETRY_COUNT) return
432
+
433
+ setTimeout(
434
+ () => this.scheduleEditorFocus(attempt + 1),
435
+ FOCUS_RETRY_DELAY_MS,
436
+ )
437
+ })
438
+ }
439
+
440
+ private focusEditorForWriting() {
441
+ const element = this.editorElement()
442
+ if (!element || this.sourceMode() || unref(this.disabled)) return false
443
+ if (!isVisibleForFocus(element)) return false
444
+
445
+ element.focus({ preventScroll: true })
446
+ const target = findWritingStartNode(element)
447
+ const range = document.createRange()
448
+ if (target instanceof Text) {
449
+ range.setStart(target, 0)
450
+ } else {
451
+ range.setStart(target, 0)
452
+ }
453
+ range.collapse(true)
454
+
455
+ const selection = window.getSelection()
456
+ selection?.removeAllRanges()
457
+ selection?.addRange(range)
458
+ return document.activeElement === element
459
+ }
460
+
461
+ private emitFiles(event: Event, files: File[]) {
462
+ const target = event.currentTarget
463
+ if (!(target instanceof EventTarget)) return
464
+
465
+ target.dispatchEvent(
466
+ new CustomEvent('files', {
467
+ bubbles: true,
468
+ detail: { files },
469
+ }),
470
+ )
471
+ }
472
+
473
+ private sanitizeModelHtml(value: string | undefined) {
474
+ return sanitizeHtml(value, {
475
+ imageUrlResolver: (url) => this.toModelImageUrl(url),
476
+ deferCidImageSrc: true,
477
+ })
478
+ }
479
+
480
+ private toEditorHtml(value: string | undefined) {
481
+ const sanitized = sanitizeHtml(value, {
482
+ imageUrlResolver: (url) => this.toEditorImageUrl(url),
483
+ })
484
+ return sanitized || createEmptyEditorHtml(unref(this.placeholder))
485
+ }
486
+
487
+ private readModelHtmlFromEditor(element: HTMLElement) {
488
+ if (!hasMeaningfulEditorContent(element)) return ''
489
+
490
+ return this.sanitizeModelHtml(element.innerHTML)
491
+ }
492
+
493
+ private toEditorImageUrl(value: string) {
494
+ const contentId = normalizeEmailCidUrl(value)
495
+ if (contentId) {
496
+ const previewUrls = this.readImagePreviewUrls()
497
+ const previewUrl = previewUrls[contentId]
498
+ if (typeof previewUrl === 'string' && previewUrl) return previewUrl
499
+ return TRANSPARENT_IMAGE_DATA_URL
500
+ }
501
+
502
+ return toSafeEmailImageUrl(value)
503
+ }
504
+
505
+ private toModelImageUrl(value: string) {
506
+ const contentId = this.findPreviewContentId(value)
507
+ if (contentId) return `cid:${contentId}`
508
+ return toSafeEmailImageUrl(value)
509
+ }
510
+
511
+ private findPreviewContentId(value: string) {
512
+ const urls = this.readImagePreviewUrls()
513
+ for (const [contentId, url] of Object.entries(urls))
514
+ if (url === value) return contentId
515
+
516
+ return ''
517
+ }
518
+
519
+ private readImagePreviewUrls() {
520
+ return (unref(this.imagePreviewUrls) ?? {}) as Record<string, string>
521
+ }
522
+ }
523
+
524
+ const resolveComposerSourceId = createAutoId('composer-source')
525
+ export const COMPOSER_BODY_STYLE = 'padding:1rem'
526
+ const FOCUS_RETRY_COUNT = 12
527
+ const FOCUS_RETRY_DELAY_MS = 25
528
+
529
+ function resolveComposer(head: ComponentHead<Composer>) {
530
+ return new ComposerContext(head.props) as Composer
531
+ }
532
+
533
+ export function createComposerBodyHtml(contentHtml = '', placeholder?: string) {
534
+ const placeholderAttribute = placeholder
535
+ ? ` data-placeholder="${escapeHtmlAttribute(placeholder)}"`
536
+ : ''
537
+ return `<div data-composer-body="true"${placeholderAttribute} style="${COMPOSER_BODY_STYLE}">${contentHtml}</div>`
538
+ }
539
+
540
+ function createEmptyEditorHtml(placeholder: string | undefined) {
541
+ return createComposerBodyHtml('', placeholder)
542
+ }
543
+
544
+ function resolveCssSize(value: number | string | undefined, fallback: string) {
545
+ if (typeof value === 'number') return `${Math.max(0, value)}px`
546
+ if (typeof value === 'string' && value.trim()) return value
547
+ return fallback
548
+ }
549
+
550
+ const COMPOSER_BLOCKED_CONTAINER_TAGS = new Set(['BODY', 'HTML', 'STYLE'])
551
+ const DROPPED_TAGS = new Set([
552
+ 'AREA',
553
+ 'AUDIO',
554
+ 'BASE',
555
+ 'BUTTON',
556
+ 'CANVAS',
557
+ 'EMBED',
558
+ 'FORM',
559
+ 'HEAD',
560
+ 'IFRAME',
561
+ 'INPUT',
562
+ 'LINK',
563
+ 'MAP',
564
+ 'META',
565
+ 'NOSCRIPT',
566
+ 'OBJECT',
567
+ 'OPTION',
568
+ 'SCRIPT',
569
+ 'SELECT',
570
+ 'SOURCE',
571
+ 'STYLE',
572
+ 'TEMPLATE',
573
+ 'TEXTAREA',
574
+ 'TITLE',
575
+ 'VIDEO',
576
+ ])
577
+ const ALLOWED_TAGS = new Set(
578
+ Array.from(GMAIL_COMPATIBLE_EMAIL_TAGS).filter(
579
+ (tag) => !COMPOSER_BLOCKED_CONTAINER_TAGS.has(tag),
580
+ ),
581
+ )
582
+
583
+ const TRANSPARENT_IMAGE_DATA_URL =
584
+ 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='
585
+
586
+ interface SanitizeHtmlOptions {
587
+ imageUrlResolver?: (url: string) => string | undefined
588
+ deferCidImageSrc?: boolean
589
+ }
590
+
591
+ function sanitizeHtml(
592
+ value: string | undefined,
593
+ options: SanitizeHtmlOptions = {},
594
+ ) {
595
+ const raw = value ?? ''
596
+ if (!raw.trim()) return ''
597
+ if (typeof document === 'undefined') return escapeHtml(raw.trim())
598
+
599
+ const template = document.createElement('template')
600
+ template.innerHTML = raw
601
+ const output = document.createDocumentFragment()
602
+ for (const child of Array.from(template.content.childNodes)) {
603
+ appendSanitizedNode(output, child, options)
604
+ }
605
+ const wrapper = document.createElement('div')
606
+ wrapper.appendChild(output)
607
+ const html = wrapper.innerHTML.trim()
608
+ return options.deferCidImageSrc ? restoreDeferredCidImageSources(html) : html
609
+ }
610
+
611
+ function appendSanitizedNode(
612
+ parent: Node,
613
+ node: Node,
614
+ options: SanitizeHtmlOptions,
615
+ ) {
616
+ if (node.nodeType === Node.TEXT_NODE) {
617
+ parent.appendChild(document.createTextNode(node.textContent ?? ''))
618
+ return
619
+ }
620
+ if (!(node instanceof HTMLElement)) return
621
+
622
+ const tag = node.tagName.toUpperCase()
623
+ if (!ALLOWED_TAGS.has(tag)) {
624
+ if (DROPPED_TAGS.has(tag)) return
625
+
626
+ for (const child of Array.from(node.childNodes))
627
+ appendSanitizedNode(parent, child, options)
628
+ return
629
+ }
630
+
631
+ if (tag === 'A') {
632
+ const href = sanitizeHref(node.getAttribute('href') ?? '')
633
+ if (!href) {
634
+ for (const child of Array.from(node.childNodes))
635
+ appendSanitizedNode(parent, child, options)
636
+ return
637
+ }
638
+ }
639
+ const imageSrc =
640
+ tag === 'IMG'
641
+ ? sanitizeImageSrc(node.getAttribute('src') ?? '', options)
642
+ : undefined
643
+ if (tag === 'IMG' && !imageSrc) return
644
+
645
+ const element = document.createElement(tag.toLowerCase())
646
+ if (tag === 'A') {
647
+ element.setAttribute(
648
+ 'href',
649
+ sanitizeHref(node.getAttribute('href') ?? '') ?? '',
650
+ )
651
+ element.setAttribute('target', '_blank')
652
+ element.setAttribute('rel', 'noopener noreferrer')
653
+ }
654
+ if (tag === 'IMG') {
655
+ if (
656
+ options.deferCidImageSrc &&
657
+ imageSrc &&
658
+ normalizeEmailCidUrl(imageSrc)
659
+ ) {
660
+ element.setAttribute('data-composer-cid-src', imageSrc)
661
+ } else {
662
+ element.setAttribute('src', imageSrc ?? '')
663
+ }
664
+ const alt = sanitizePlainAttribute(node.getAttribute('alt') ?? '')
665
+ const title = sanitizePlainAttribute(node.getAttribute('title') ?? '')
666
+ if (alt) element.setAttribute('alt', alt)
667
+ if (title) element.setAttribute('title', title)
668
+ }
669
+
670
+ copyAllowedAttributes(node, element, tag)
671
+ copySafeEmailStyles(node, element, {
672
+ imageUrlResolver: options.imageUrlResolver,
673
+ })
674
+
675
+ for (const child of Array.from(node.childNodes))
676
+ appendSanitizedNode(element, child, options)
677
+ parent.appendChild(element)
678
+ }
679
+
680
+ function sanitizeImageSrc(value: string, options: SanitizeHtmlOptions) {
681
+ return options.imageUrlResolver
682
+ ? options.imageUrlResolver(value)
683
+ : toSafeEmailImageUrl(value)
684
+ }
685
+
686
+ function restoreDeferredCidImageSources(html: string) {
687
+ return html.replace(/\sdata-composer-cid-src="([^"]*)"/g, ' src="$1"')
688
+ }
689
+
690
+ function sanitizeHref(value: string) {
691
+ const parsed = parseUrl(value)
692
+ if (!parsed) return undefined
693
+
694
+ if (
695
+ parsed.protocol !== 'http:' &&
696
+ parsed.protocol !== 'https:' &&
697
+ parsed.protocol !== 'mailto:'
698
+ )
699
+ return undefined
700
+
701
+ if (parsed.protocol === 'mailto:') return parsed.href
702
+ return parsed.toString()
703
+ }
704
+
705
+ function parseUrl(value: string) {
706
+ const trimmed = value.trim()
707
+ if (!trimmed) return undefined
708
+ if (typeof URL === 'undefined') return undefined
709
+
710
+ try {
711
+ return new URL(trimmed, globalThis.location?.href ?? 'https://localhost/')
712
+ } catch {
713
+ return undefined
714
+ }
715
+ }
716
+
717
+ function hasFileTransfer(dataTransfer: DataTransfer | null) {
718
+ if (!dataTransfer) return false
719
+ return Array.from(dataTransfer.types).includes('Files')
720
+ }
721
+
722
+ function copyAllowedAttributes(
723
+ source: HTMLElement,
724
+ target: HTMLElement,
725
+ tag: string,
726
+ ) {
727
+ if (tag === 'TABLE') {
728
+ copyDimensionAttribute(source, target, 'width')
729
+ copyNumberAttribute(source, target, 'border', 0, 20)
730
+ copyNumberAttribute(source, target, 'cellpadding', 0, 80)
731
+ copyNumberAttribute(source, target, 'cellspacing', 0, 80)
732
+ copyKeywordAttribute(source, target, 'align', ['center', 'left', 'right'])
733
+ copyKeywordAttribute(source, target, 'role', ['none', 'presentation'])
734
+ return
735
+ }
736
+
737
+ if (tag === 'COL' || tag === 'COLGROUP') {
738
+ copyDimensionAttribute(source, target, 'width')
739
+ copyNumberAttribute(source, target, 'span', 1, 100)
740
+ copyKeywordAttribute(source, target, 'align', ['center', 'left', 'right'])
741
+ return
742
+ }
743
+
744
+ if (tag === 'TD' || tag === 'TH') {
745
+ copyDimensionAttribute(source, target, 'width')
746
+ copyDimensionAttribute(source, target, 'height')
747
+ copyNumberAttribute(source, target, 'colspan', 1, 100)
748
+ copyNumberAttribute(source, target, 'rowspan', 1, 100)
749
+ copyKeywordAttribute(source, target, 'align', [
750
+ 'center',
751
+ 'justify',
752
+ 'left',
753
+ 'right',
754
+ ])
755
+ copyKeywordAttribute(source, target, 'valign', [
756
+ 'baseline',
757
+ 'bottom',
758
+ 'middle',
759
+ 'top',
760
+ ])
761
+ copyKeywordAttribute(source, target, 'aria-hidden', ['false', 'true'])
762
+ return
763
+ }
764
+
765
+ if (tag === 'TR') {
766
+ copyKeywordAttribute(source, target, 'align', [
767
+ 'center',
768
+ 'justify',
769
+ 'left',
770
+ 'right',
771
+ ])
772
+ copyKeywordAttribute(source, target, 'valign', [
773
+ 'baseline',
774
+ 'bottom',
775
+ 'middle',
776
+ 'top',
777
+ ])
778
+ }
779
+
780
+ if (tag === 'FONT') {
781
+ copyPlainAttribute(source, target, 'face')
782
+ copyColorAttribute(source, target, 'color')
783
+ copyFontSizeAttribute(source, target, 'size')
784
+ return
785
+ }
786
+
787
+ if (tag === 'OL') {
788
+ copyNumberAttribute(source, target, 'start', 1, 9999)
789
+ copyKeywordAttribute(source, target, 'type', ['1', 'a', 'A', 'i', 'I'])
790
+ }
791
+ }
792
+
793
+ function copyPlainAttribute(
794
+ source: HTMLElement,
795
+ target: HTMLElement,
796
+ name: string,
797
+ ) {
798
+ const value = sanitizePlainAttribute(source.getAttribute(name) ?? '')
799
+ if (value) target.setAttribute(name, value)
800
+ }
801
+
802
+ function copyColorAttribute(
803
+ source: HTMLElement,
804
+ target: HTMLElement,
805
+ name: string,
806
+ ) {
807
+ const value = sanitizeColorAttribute(source.getAttribute(name) ?? '')
808
+ if (value) target.setAttribute(name, value)
809
+ }
810
+
811
+ function copyFontSizeAttribute(
812
+ source: HTMLElement,
813
+ target: HTMLElement,
814
+ name: string,
815
+ ) {
816
+ const value = sanitizeFontSizeAttribute(source.getAttribute(name) ?? '')
817
+ if (value) target.setAttribute(name, value)
818
+ }
819
+
820
+ function copyDimensionAttribute(
821
+ source: HTMLElement,
822
+ target: HTMLElement,
823
+ name: string,
824
+ ) {
825
+ const value = sanitizeHtmlDimension(source.getAttribute(name) ?? '')
826
+ if (value) target.setAttribute(name, value)
827
+ }
828
+
829
+ function copyNumberAttribute(
830
+ source: HTMLElement,
831
+ target: HTMLElement,
832
+ name: string,
833
+ min: number,
834
+ max: number,
835
+ ) {
836
+ const value = sanitizeIntegerAttribute(
837
+ source.getAttribute(name) ?? '',
838
+ min,
839
+ max,
840
+ )
841
+ if (value) target.setAttribute(name, value)
842
+ }
843
+
844
+ function copyKeywordAttribute(
845
+ source: HTMLElement,
846
+ target: HTMLElement,
847
+ name: string,
848
+ allowed: string[],
849
+ ) {
850
+ const value = sanitizeKeyword(source.getAttribute(name) ?? '', allowed)
851
+ if (value) target.setAttribute(name, value)
852
+ }
853
+
854
+ function sanitizeHtmlDimension(value: string) {
855
+ const trimmed = value.trim()
856
+ const match = /^(\d{1,4})(%)?$/.exec(trimmed)
857
+ if (!match) return undefined
858
+
859
+ const amount = Number(match[1])
860
+ if (!Number.isFinite(amount)) return undefined
861
+ if (match[2]) return `${Math.min(100, Math.max(1, amount))}%`
862
+ return String(Math.min(2400, Math.max(0, amount)))
863
+ }
864
+
865
+ function sanitizeIntegerAttribute(value: string, min: number, max: number) {
866
+ const match = /^\d{1,4}$/.exec(value.trim())
867
+ if (!match) return undefined
868
+
869
+ const amount = Number(match[0])
870
+ if (!Number.isFinite(amount)) return undefined
871
+ return String(Math.min(max, Math.max(min, amount)))
872
+ }
873
+
874
+ function sanitizeKeyword(value: string, allowed: string[]) {
875
+ const normalized = value.trim()
876
+ const exactMatch = allowed.find((item) => item === normalized)
877
+ if (exactMatch) return exactMatch
878
+
879
+ const match = allowed.find(
880
+ (item) => item.toLowerCase() === normalized.toLowerCase(),
881
+ )
882
+ return match
883
+ }
884
+
885
+ function sanitizeColorAttribute(value: string) {
886
+ const normalized = value.trim()
887
+ if (
888
+ /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(normalized) ||
889
+ /^[a-z]{3,24}$/i.test(normalized)
890
+ ) {
891
+ return normalized
892
+ }
893
+
894
+ return undefined
895
+ }
896
+
897
+ function sanitizeFontSizeAttribute(value: string) {
898
+ const match = /^[+-]?\d$/.exec(value.trim())
899
+ if (!match) return undefined
900
+
901
+ const amount = Number(match[0])
902
+ if (!Number.isFinite(amount)) return undefined
903
+ return String(Math.min(7, Math.max(-7, amount)))
904
+ }
905
+
906
+ function htmlToText(value: string) {
907
+ if (!value.trim()) return ''
908
+
909
+ const template = document.createElement('template')
910
+ template.innerHTML = sanitizeHtml(value, {
911
+ deferCidImageSrc: true,
912
+ })
913
+ const lines: string[] = []
914
+ collectText(template.content, lines)
915
+ return lines
916
+ .join('\n')
917
+ .replace(/[ \t]+\n/g, '\n')
918
+ .replace(/\n{3,}/g, '\n\n')
919
+ .trim()
920
+ }
921
+
922
+ function hasMeaningfulEditorContent(node: Node) {
923
+ if (node.nodeType === Node.TEXT_NODE) return !!node.textContent?.trim()
924
+ if (!(node instanceof HTMLElement || node instanceof DocumentFragment))
925
+ return false
926
+
927
+ if (node instanceof HTMLElement) {
928
+ const tag = node.tagName.toUpperCase()
929
+ if (tag === 'IMG' || tag === 'HR') return true
930
+ if (tag === 'BR') return false
931
+ }
932
+
933
+ for (const child of Array.from(node.childNodes))
934
+ if (hasMeaningfulEditorContent(child)) return true
935
+
936
+ return false
937
+ }
938
+
939
+ function findWritingStartNode(root: HTMLElement): Node {
940
+ for (const child of Array.from(root.childNodes)) {
941
+ if (child instanceof HTMLElement && isEmptyWritingBlock(child)) return child
942
+ if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim())
943
+ return child
944
+ }
945
+
946
+ return root
947
+ }
948
+
949
+ function isVisibleForFocus(element: HTMLElement) {
950
+ return element.isConnected && element.getClientRects().length > 0
951
+ }
952
+
953
+ function isEmptyWritingBlock(element: HTMLElement) {
954
+ const tag = element.tagName.toUpperCase()
955
+ if (tag !== 'P' && tag !== 'DIV') return false
956
+ if (element.querySelector('img, table, hr')) return false
957
+ return !element.textContent?.trim()
958
+ }
959
+
960
+ function collectText(node: Node, lines: string[]) {
961
+ if (node.nodeType === Node.TEXT_NODE) {
962
+ const text = node.textContent?.replace(/\s+/g, ' ') ?? ''
963
+ if (!text) return
964
+ if (lines.length === 0) lines.push(text)
965
+ else lines[lines.length - 1] += text
966
+ return
967
+ }
968
+ if (!(node instanceof HTMLElement || node instanceof DocumentFragment)) return
969
+
970
+ const isBlock =
971
+ node instanceof HTMLElement &&
972
+ [
973
+ 'ADDRESS',
974
+ 'BLOCKQUOTE',
975
+ 'CAPTION',
976
+ 'CENTER',
977
+ 'DD',
978
+ 'DIV',
979
+ 'DL',
980
+ 'DT',
981
+ 'H1',
982
+ 'H2',
983
+ 'H3',
984
+ 'H4',
985
+ 'H5',
986
+ 'H6',
987
+ 'HR',
988
+ 'LI',
989
+ 'OL',
990
+ 'P',
991
+ 'PRE',
992
+ 'TABLE',
993
+ 'TBODY',
994
+ 'TD',
995
+ 'TFOOT',
996
+ 'TH',
997
+ 'THEAD',
998
+ 'TR',
999
+ 'UL',
1000
+ ].includes(node.tagName.toUpperCase())
1001
+ if (isBlock && lines[lines.length - 1]?.trim()) lines.push('')
1002
+ if (node instanceof HTMLElement && node.tagName.toUpperCase() === 'BR') {
1003
+ lines.push('')
1004
+ return
1005
+ }
1006
+
1007
+ for (const child of Array.from(node.childNodes)) collectText(child, lines)
1008
+ if (isBlock && lines[lines.length - 1]?.trim()) lines.push('')
1009
+ }
1010
+
1011
+ function plainTextToHtml(value: string) {
1012
+ const text = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim()
1013
+ if (!text) return ''
1014
+
1015
+ return text
1016
+ .split(/\n{2,}/)
1017
+ .map(
1018
+ (paragraph) =>
1019
+ `<p>${paragraph.split('\n').map(escapeHtml).join('<br>')}</p>`,
1020
+ )
1021
+ .join('')
1022
+ }
1023
+
1024
+ function escapeHtml(value: string) {
1025
+ return value
1026
+ .replaceAll('&', '&amp;')
1027
+ .replaceAll('<', '&lt;')
1028
+ .replaceAll('>', '&gt;')
1029
+ .replaceAll('"', '&quot;')
1030
+ .replaceAll("'", '&#39;')
1031
+ }
1032
+
1033
+ function escapeHtmlAttribute(value: string) {
1034
+ return escapeHtml(value).replaceAll('`', '&#96;')
1035
+ }