@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.
- package/LICENSE +21 -0
- package/README.md +10 -0
- package/dist/ts-components.d.mts +1662 -0
- package/dist/ts-components.d.mts.map +1 -0
- package/dist/ts-components.mjs +9200 -0
- package/dist/ts-components.mjs.map +1 -0
- package/package.json +130 -0
- package/src/defineComponents.ts +87 -0
- package/src/index.ts +270 -0
- package/src/registerStyles.ts +63 -0
- package/src/standard/alertBox/alertBox.ts +64 -0
- package/src/standard/autoId.ts +25 -0
- package/src/standard/badge/badge.ts +41 -0
- package/src/standard/badge/badgeStyle.ts +21 -0
- package/src/standard/barChart/barChart.ts +338 -0
- package/src/standard/barChart/barChartStyle.ts +81 -0
- package/src/standard/btn/btn.ts +191 -0
- package/src/standard/btn/btnStyle.ts +59 -0
- package/src/standard/btnGroup/btnGroup.ts +208 -0
- package/src/standard/btnGroup/btnGroupStyle.ts +85 -0
- package/src/standard/chart/cartesianChart.ts +99 -0
- package/src/standard/chart/chartDefaults.ts +25 -0
- package/src/standard/chart/chartUtils.ts +84 -0
- package/src/standard/classicLogo/classicLogo.ts +429 -0
- package/src/standard/classicLogo/classicLogoStyle.ts +292 -0
- package/src/standard/componentVariant.ts +153 -0
- package/src/standard/composer/composer.ts +1035 -0
- package/src/standard/composer/composerCanvasInitialStyle.ts +225 -0
- package/src/standard/composer/composerCanvasRevertStyle.ts +14 -0
- package/src/standard/composer/composerCanvasSharedStyle.ts +60 -0
- package/src/standard/composer/composerCanvasStyleTypes.ts +8 -0
- package/src/standard/composer/composerCanvasThemeStyle.ts +53 -0
- package/src/standard/composer/composerStyle.ts +156 -0
- package/src/standard/composer/emailHtmlPolicy.ts +474 -0
- package/src/standard/consent/consent.ts +173 -0
- package/src/standard/consent/consentStyle.ts +44 -0
- package/src/standard/contactForm/contactForm.ts +135 -0
- package/src/standard/contactForm/contactFormStyle.ts +116 -0
- package/src/standard/doughnutChart/doughnutChart.ts +527 -0
- package/src/standard/doughnutChart/doughnutChartStyle.ts +64 -0
- package/src/standard/dropFiles/dropFiles.ts +480 -0
- package/src/standard/dropFiles/dropFilesStyle.ts +171 -0
- package/src/standard/expandablePanel/expandablePanel.ts +106 -0
- package/src/standard/expandablePanel/expandablePanelStyle.ts +56 -0
- package/src/standard/flex/flex.ts +269 -0
- package/src/standard/flex/flexStyle.ts +556 -0
- package/src/standard/footer/footer.ts +93 -0
- package/src/standard/footer/footerStyle.ts +39 -0
- package/src/standard/form/autoCompleteInput.ts +567 -0
- package/src/standard/form/form.ts +205 -0
- package/src/standard/form/formCheck.ts +96 -0
- package/src/standard/form/formInputField.ts +184 -0
- package/src/standard/form/formSelectField.ts +152 -0
- package/src/standard/form/formStyle.ts +585 -0
- package/src/standard/form/multiAutoCompleteInput.ts +880 -0
- package/src/standard/grid/grid.ts +177 -0
- package/src/standard/grid/gridStyle.ts +56 -0
- package/src/standard/icon/icon.ts +96 -0
- package/src/standard/icon/iconStyle.ts +50 -0
- package/src/standard/landing/codeShowcase.ts +54 -0
- package/src/standard/landing/comparisonColumn.ts +30 -0
- package/src/standard/landing/comparisonFeature.ts +22 -0
- package/src/standard/landing/comparisonTable.ts +38 -0
- package/src/standard/landing/ctaSection.ts +59 -0
- package/src/standard/landing/featureCard.ts +41 -0
- package/src/standard/landing/landing.ts +52 -0
- package/src/standard/landing/landingBand.ts +217 -0
- package/src/standard/landing/landingSection.ts +43 -0
- package/src/standard/landing/landingTypes.ts +141 -0
- package/src/standard/landing/metricItem.ts +25 -0
- package/src/standard/landing/metricStrip.ts +20 -0
- package/src/standard/lineChart/lineChart.ts +462 -0
- package/src/standard/lineChart/lineChartStyle.ts +91 -0
- package/src/standard/logo/logo.ts +192 -0
- package/src/standard/logo/logoStyle.ts +164 -0
- package/src/standard/modal/modal.ts +171 -0
- package/src/standard/modal/modalStore.ts +154 -0
- package/src/standard/modal/modalStyle.ts +190 -0
- package/src/standard/navMenu/navMenu.ts +278 -0
- package/src/standard/navMenu/navMenuStyle.ts +323 -0
- package/src/standard/pageLinks/pageLinks.ts +90 -0
- package/src/standard/pageScript/pageScript.ts +155 -0
- package/src/standard/pageToc/pageToc.ts +154 -0
- package/src/standard/pageToc/pageTocStyle.ts +372 -0
- package/src/standard/panel/panel.ts +55 -0
- package/src/standard/panel/panelStyle.ts +15 -0
- package/src/standard/pricing/pricing.ts +146 -0
- package/src/standard/searchBox/searchBox.ts +54 -0
- package/src/standard/searchBox/searchBoxStyle.ts +180 -0
- package/src/standard/sectionHeader/sectionHeader.ts +99 -0
- package/src/standard/signIn/signIn.ts +167 -0
- package/src/standard/signIn/signInStyle.ts +70 -0
- package/src/standard/tabs/tabs.ts +193 -0
- package/src/standard/tabs/tabsStyle.ts +231 -0
- package/src/standard/themeToggle/themeToggle.ts +33 -0
- package/src/standard/themeToggle/themeToggleStyle.ts +88 -0
- package/src/standard/toast/toastHost.ts +42 -0
- package/src/standard/toast/toastStore.ts +50 -0
- package/src/standard/topBar/topBar.ts +101 -0
- package/src/standard/topBar/topBarStyle.ts +169 -0
- package/src/standard/virtualList/variableVirtualTable.ts +371 -0
- package/src/standard/virtualList/virtualList.ts +423 -0
- package/src/standard/virtualList/virtualListStyle.ts +34 -0
- package/src/standard/virtualList/virtualTable.ts +179 -0
- package/src/standard/virtualList/virtualTableStyle.ts +24 -0
- 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('&', '&')
|
|
1027
|
+
.replaceAll('<', '<')
|
|
1028
|
+
.replaceAll('>', '>')
|
|
1029
|
+
.replaceAll('"', '"')
|
|
1030
|
+
.replaceAll("'", ''')
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function escapeHtmlAttribute(value: string) {
|
|
1034
|
+
return escapeHtml(value).replaceAll('`', '`')
|
|
1035
|
+
}
|