@devchitchat/chat 4.5.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/index.js +0 -9
  2. package/package.json +2 -3
  3. package/pages/_layout.html +0 -7
  4. package/pages/admin/_layout.html +0 -7
  5. package/pages/channels/[channelId].phtml +17 -42
  6. package/pages/design/_layout.html +349 -0
  7. package/pages/design/_layout.js +13 -0
  8. package/pages/design/components/index.js +3 -0
  9. package/pages/design/components/index.phtml +380 -0
  10. package/pages/design/index.js +3 -0
  11. package/pages/design/index.phtml +78 -0
  12. package/pages/design/principles/index.js +3 -0
  13. package/pages/design/principles/index.phtml +147 -0
  14. package/pages/design/tokens/index.js +3 -0
  15. package/pages/design/tokens/index.phtml +236 -0
  16. package/pages/public/client/app.js +171 -13
  17. package/pages/public/client/controllers/ChatController.js +204 -0
  18. package/pages/public/client/controllers/WebSocketController.js +191 -0
  19. package/pages/public/client/model/AppModel.js +351 -0
  20. package/pages/public/client/model/events.js +41 -0
  21. package/pages/public/client/resizable.js +74 -0
  22. package/pages/public/client/rtc-peer-manager.js +5 -2
  23. package/pages/public/client/settings-sync.js +45 -7
  24. package/pages/public/client/shared/messages.js +21 -9
  25. package/pages/public/client/theme.js +6 -4
  26. package/pages/public/client/views/CallView.js +754 -0
  27. package/pages/public/client/views/ChatHeaderView.js +67 -0
  28. package/pages/public/client/views/ComposerView.js +491 -0
  29. package/pages/public/client/views/MessageListView.js +461 -0
  30. package/pages/public/client/views/SidebarView.js +977 -0
  31. package/pages/public/client/views/ThreadPanelView.js +260 -0
  32. package/pages/public/client/views/shared/EmojiPickerSingleton.js +201 -0
  33. package/pages/public/client/views/shared/MentionPicker.js +139 -0
  34. package/pages/public/client/views/shared/MessageInteractions.js +353 -0
  35. package/pages/public/themes/base.css +29 -31
  36. package/src/ws/ChatServer.js +2 -1
  37. package/src/ws/handlers/rtcHandlers.js +7 -0
  38. package/pages/public/client/islands/call.js +0 -2282
  39. package/pages/public/client/islands/sidebar.js +0 -1198
@@ -0,0 +1,67 @@
1
+ /**
2
+ * ChatHeaderView.js — owns the <header class="chat-header"> element.
3
+ *
4
+ * Responsibilities:
5
+ * - Update channel title and topic on CHANNEL_SELECTED
6
+ * - Dispatch 'call:start-requested' when the Start Call button is clicked
7
+ * - Show/hide the Start Call button in response to 'call:state-changed'
8
+ * - Handle the mobile back button (show sidebar)
9
+ */
10
+
11
+ import * as Ev from '../model/events.js'
12
+
13
+ export class ChatHeaderView {
14
+ #model
15
+ #headerEl
16
+ #titleEl
17
+ #topicEl
18
+ #startCallBtn
19
+
20
+ /**
21
+ * @param {AppModel} model
22
+ * @param {HTMLElement} headerEl — <header class="chat-header">
23
+ */
24
+ constructor(model, headerEl) {
25
+ this.#model = model
26
+ this.#headerEl = headerEl
27
+ this.#titleEl = headerEl.querySelector('.chat-title')
28
+ this.#topicEl = headerEl.querySelector('.chat-topic')
29
+ this.#startCallBtn = headerEl.querySelector('#btn-start-call')
30
+
31
+ this.#bindModelEvents()
32
+ this.#bindInteractions()
33
+ }
34
+
35
+ // ─────────────────────────────────────────────────────────────────────────
36
+ // Model events
37
+ // ─────────────────────────────────────────────────────────────────────────
38
+
39
+ #bindModelEvents() {
40
+ this.#model.addEventListener(Ev.CHANNEL_SELECTED, e => {
41
+ const { name = '', topic = '' } = e.detail.meta ?? {}
42
+ if (this.#titleEl) this.#titleEl.textContent = name
43
+ if (this.#topicEl) this.#topicEl.textContent = topic
44
+ })
45
+ }
46
+
47
+ // ─────────────────────────────────────────────────────────────────────────
48
+ // User interactions
49
+ // ─────────────────────────────────────────────────────────────────────────
50
+
51
+ #bindInteractions() {
52
+ // Mobile: back to sidebar
53
+ this.#headerEl.querySelector('.btn-back-mobile')?.addEventListener('click', () => {
54
+ document.body.classList.add('sidebar-open')
55
+ })
56
+
57
+ // Start Call button → let CallView handle the WebRTC side
58
+ this.#startCallBtn?.addEventListener('click', () => {
59
+ document.dispatchEvent(new CustomEvent('call:start-requested'))
60
+ })
61
+
62
+ // CallView signals when a call is entered or left
63
+ document.addEventListener('call:state-changed', e => {
64
+ if (this.#startCallBtn) this.#startCallBtn.hidden = e.detail?.inCall ?? false
65
+ })
66
+ }
67
+ }
@@ -0,0 +1,491 @@
1
+ /**
2
+ * ComposerView.js — the message composer (textarea, attachments, @mention picker).
3
+ *
4
+ * Owned DOM (all within the .composer element or #compose-overlay):
5
+ * #message-input — main textarea
6
+ * .attachment-chips — injected chip strip
7
+ * .btn-attach — injected paperclip button
8
+ * #compose-overlay — full-screen compose mode
9
+ *
10
+ * When the user submits, dispatches 'send-message' on document so ChatController
11
+ * picks it up — the view never calls ws.send() directly.
12
+ *
13
+ * Urgent mode and compose-overlay are view-local (no model state needed).
14
+ */
15
+
16
+ import { escHtml } from '../shared/messages.js'
17
+ import { dispatch } from '../controllers/ChatController.js'
18
+ import { MentionPicker } from './shared/MentionPicker.js'
19
+ import * as Ev from '../model/events.js'
20
+
21
+ export class ComposerView {
22
+ #model
23
+ #composerEl // .composer wrapper
24
+ #textareaEl // #message-input
25
+ #mentionPicker // MentionPicker for main textarea
26
+ #chipsEl
27
+ #fileInputEl
28
+ #btnAttachEl
29
+
30
+ // Compose overlay
31
+ #overlayEl
32
+ #overlayTaEl
33
+ #overlayPreviewEl
34
+ #overlayMentionPicker // MentionPicker for overlay textarea
35
+ #composeOpen = false
36
+ #composeChipsEl
37
+
38
+ // member list — shared closure passed to both MentionPicker instances
39
+ #members = []
40
+
41
+ // Attachments
42
+ #pendingAttachments = []
43
+
44
+ // Urgent mode
45
+ #urgentMode = false
46
+
47
+ /**
48
+ * @param {AppModel} model
49
+ * @param {HTMLElement} composerEl — the .composer wrapper element
50
+ */
51
+ constructor(model, composerEl) {
52
+ this.#model = model
53
+ this.#composerEl = composerEl
54
+ this.#textareaEl = composerEl.querySelector('#message-input')
55
+
56
+ this.#buildAttachmentChips()
57
+ this.#buildFileInput()
58
+ this.#buildAttachButton()
59
+ this.#buildDropOverlay()
60
+ this.#bindComposerEvents()
61
+ this.#bindOverlay()
62
+ this.#bindGlobalKeys()
63
+ this.#bindModelEvents()
64
+
65
+ // Mention picker for the main textarea — injected into the composer element
66
+ if (this.#textareaEl) {
67
+ this.#mentionPicker = new MentionPicker(
68
+ this.#textareaEl,
69
+ this.#composerEl,
70
+ () => this.#members,
71
+ )
72
+ }
73
+ }
74
+
75
+ // ─────────────────────────────────────────────────────────────────────────
76
+ // Model events
77
+ // ─────────────────────────────────────────────────────────────────────────
78
+
79
+ #bindModelEvents() {
80
+ this.#model.addEventListener(Ev.MEMBERS_UPDATED, e => {
81
+ this.#members = [...(e.detail.members ?? []), ...(e.detail.bots ?? [])]
82
+ .filter(m => m.handle)
83
+ })
84
+
85
+ this.#model.addEventListener(Ev.CHANNEL_SELECTED, e => {
86
+ // Clear pending state on navigation
87
+ this.#pendingAttachments = []
88
+ this.#renderChips()
89
+ if (this.#textareaEl) {
90
+ this.#textareaEl.value = ''
91
+ this.#textareaEl.placeholder = `Message in ${e.detail?.meta?.name ?? ''}`
92
+ }
93
+ })
94
+ }
95
+
96
+ // ─────────────────────────────────────────────────────────────────────────
97
+ // Build injected elements
98
+ // ─────────────────────────────────────────────────────────────────────────
99
+
100
+ #buildAttachmentChips() {
101
+ const el = document.createElement('div')
102
+ el.className = 'attachment-chips'
103
+ el.hidden = true
104
+ this.#composerEl.insertBefore(el, this.#textareaEl)
105
+ this.#chipsEl = el
106
+
107
+ el.addEventListener('click', e => {
108
+ const btn = e.target.closest('.attachment-chip-remove')
109
+ if (!btn) return
110
+ this.#removeChipAt(parseInt(btn.dataset.index, 10))
111
+ })
112
+ }
113
+
114
+ #buildFileInput() {
115
+ const el = document.createElement('input')
116
+ el.type = 'file'
117
+ el.multiple = true
118
+ el.style.display = 'none'
119
+ el.setAttribute('aria-hidden', 'true')
120
+ this.#composerEl.appendChild(el)
121
+ this.#fileInputEl = el
122
+ el.addEventListener('change', () => {
123
+ this.#uploadFiles([...el.files])
124
+ el.value = ''
125
+ })
126
+ }
127
+
128
+ #buildAttachButton() {
129
+ const btn = document.createElement('button')
130
+ btn.type = 'button'
131
+ btn.className = 'btn-attach btn-icon'
132
+ btn.title = 'Attach file'
133
+ btn.setAttribute('aria-label', 'Attach file')
134
+ btn.innerHTML = '📎'
135
+ const sendBtn = this.#composerEl.querySelector('.btn-send')
136
+ if (sendBtn) this.#composerEl.insertBefore(btn, sendBtn)
137
+ else this.#composerEl.appendChild(btn)
138
+ this.#btnAttachEl = btn
139
+ btn.addEventListener('click', () => this.#fileInputEl.click())
140
+ }
141
+
142
+ #buildDropOverlay() {
143
+ let dropOverlayEl = null
144
+
145
+ const ensure = () => {
146
+ if (dropOverlayEl) return dropOverlayEl
147
+ dropOverlayEl = document.createElement('div')
148
+ dropOverlayEl.className = 'drop-overlay'
149
+ dropOverlayEl.textContent = 'Drop to attach'
150
+ this.#composerEl.appendChild(dropOverlayEl)
151
+ return dropOverlayEl
152
+ }
153
+
154
+ this.#composerEl.addEventListener('dragover', e => {
155
+ if (!e.dataTransfer.types.includes('Files')) return
156
+ e.preventDefault()
157
+ ensure().hidden = false
158
+ })
159
+ this.#composerEl.addEventListener('dragleave', e => {
160
+ if (this.#composerEl.contains(e.relatedTarget)) return
161
+ if (dropOverlayEl) dropOverlayEl.hidden = true
162
+ })
163
+ this.#composerEl.addEventListener('drop', e => {
164
+ e.preventDefault()
165
+ if (dropOverlayEl) dropOverlayEl.hidden = true
166
+ const files = [...(e.dataTransfer.files ?? [])]
167
+ if (files.length) this.#uploadFiles(files)
168
+ })
169
+ }
170
+
171
+ // ─────────────────────────────────────────────────────────────────────────
172
+ // Bind composer textarea events
173
+ // ─────────────────────────────────────────────────────────────────────────
174
+
175
+ #bindComposerEvents() {
176
+ const ta = this.#textareaEl
177
+ if (!ta) return
178
+
179
+ ta.addEventListener('keydown', e => {
180
+ // Let MentionPicker handle its keys first; only handle Enter for send
181
+ if (this.#mentionPicker?.isOpen) return
182
+ if (e.key === 'Enter' && !e.shiftKey) {
183
+ e.preventDefault()
184
+ const priority = (e.ctrlKey || e.metaKey) ? 'now' : undefined
185
+ this.#submitMain({ priority })
186
+ }
187
+ })
188
+ ta.addEventListener('paste', e => this.#handlePaste(e))
189
+
190
+ // Send button
191
+ this.#composerEl.querySelector('.btn-send')?.addEventListener('click', () => {
192
+ this.#submitMain()
193
+ })
194
+
195
+ // Urgent mode toggle button
196
+ this.#composerEl.querySelector('.btn-urgent-toggle')?.addEventListener('click', () => {
197
+ this.#toggleUrgent()
198
+ })
199
+
200
+ // Compose-expand button
201
+ this.#composerEl.querySelector('.btn-compose-expand')?.addEventListener('click', () => {
202
+ this.#composeOpen ? this.#closeOverlay() : this.#openOverlay()
203
+ })
204
+ }
205
+
206
+ // ─────────────────────────────────────────────────────────────────────────
207
+ // Compose overlay (Ctrl+E full-screen editor)
208
+ // ─────────────────────────────────────────────────────────────────────────
209
+
210
+ #bindOverlay() {
211
+ this.#overlayEl = document.getElementById('compose-overlay')
212
+ this.#overlayTaEl = document.getElementById('compose-textarea')
213
+ this.#overlayPreviewEl = document.getElementById('compose-preview')
214
+ this.#composeChipsEl = document.getElementById('compose-chips')
215
+ const collapseBtn = document.getElementById('btn-compose-collapse')
216
+ const sendBtn = document.getElementById('compose-send')
217
+ const urgentBtn = document.getElementById('compose-urgent-toggle')
218
+ const attachBtn = document.getElementById('compose-attach')
219
+
220
+ if (!this.#overlayEl) return
221
+
222
+ collapseBtn?.addEventListener('click', () => this.#closeOverlay())
223
+ sendBtn?.addEventListener('click', () => { this.#submitMain(); this.#closeOverlay() })
224
+ urgentBtn?.addEventListener('click', () => this.#toggleUrgent())
225
+ attachBtn?.addEventListener('click', () => this.#fileInputEl.click())
226
+
227
+ this.#overlayTaEl?.addEventListener('keydown', e => {
228
+ if (this.#overlayMentionPicker?.isOpen) return
229
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
230
+ e.preventDefault()
231
+ this.#submitMain()
232
+ this.#closeOverlay()
233
+ }
234
+ })
235
+
236
+ this.#overlayTaEl?.addEventListener('paste', e => this.#handlePaste(e))
237
+
238
+ // Mention picker for the overlay textarea
239
+ if (this.#overlayTaEl) {
240
+ this.#overlayMentionPicker = new MentionPicker(
241
+ this.#overlayTaEl,
242
+ this.#overlayEl,
243
+ () => this.#members,
244
+ )
245
+ }
246
+
247
+ // Tab strip in overlay
248
+ this.#overlayEl.querySelectorAll('.compose-tab').forEach(btn => {
249
+ btn.addEventListener('click', () => this.#switchComposeTab(btn.dataset.tab))
250
+ })
251
+
252
+ // Chips remove in overlay
253
+ this.#composeChipsEl?.addEventListener('click', e => {
254
+ const btn = e.target.closest('.attachment-chip-remove')
255
+ if (!btn) return
256
+ this.#removeChipAt(parseInt(btn.dataset.index, 10))
257
+ })
258
+
259
+ // Drag-drop on overlay
260
+ let composeDropEl = null
261
+ const ensureDropOverlay = () => {
262
+ if (composeDropEl) return composeDropEl
263
+ composeDropEl = document.createElement('div')
264
+ composeDropEl.className = 'drop-overlay'
265
+ composeDropEl.textContent = 'Drop to attach'
266
+ this.#overlayEl.appendChild(composeDropEl)
267
+ return composeDropEl
268
+ }
269
+ this.#overlayEl.addEventListener('dragover', e => {
270
+ if (!e.dataTransfer.types.includes('Files')) return
271
+ e.preventDefault()
272
+ ensureDropOverlay().hidden = false
273
+ })
274
+ this.#overlayEl.addEventListener('dragleave', e => {
275
+ if (this.#overlayEl.contains(e.relatedTarget)) return
276
+ if (composeDropEl) composeDropEl.hidden = true
277
+ })
278
+ this.#overlayEl.addEventListener('drop', e => {
279
+ e.preventDefault()
280
+ if (composeDropEl) composeDropEl.hidden = true
281
+ const files = [...(e.dataTransfer.files ?? [])]
282
+ if (files.length) this.#uploadFiles(files)
283
+ })
284
+ }
285
+
286
+ #bindGlobalKeys() {
287
+ document.addEventListener('keydown', e => {
288
+ if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
289
+ e.preventDefault()
290
+ this.#composeOpen ? this.#closeOverlay() : this.#openOverlay()
291
+ }
292
+ })
293
+ }
294
+
295
+ // ─────────────────────────────────────────────────────────────────────────
296
+ // Submit / send
297
+ // ─────────────────────────────────────────────────────────────────────────
298
+
299
+ #submitMain({ priority } = {}) {
300
+ const text = (this.#composeOpen
301
+ ? this.#overlayTaEl?.value
302
+ : this.#textareaEl?.value
303
+ )?.trim() ?? ''
304
+
305
+ if (!text && this.#pendingAttachments.length === 0) return
306
+
307
+ dispatch('send-message', {
308
+ channelId: this.#model.currentChannelId,
309
+ text,
310
+ attachments: [...this.#pendingAttachments],
311
+ priority: priority ?? (this.#urgentMode ? 'now' : 'normal'),
312
+ })
313
+
314
+ if (this.#textareaEl) this.#textareaEl.value = ''
315
+ if (this.#overlayTaEl) this.#overlayTaEl.value = ''
316
+ this.#pendingAttachments = []
317
+ this.#renderChips()
318
+ }
319
+
320
+ // ─────────────────────────────────────────────────────────────────────────
321
+ // Paste images
322
+ // ─────────────────────────────────────────────────────────────────────────
323
+
324
+ #handlePaste(e) {
325
+ const items = [...(e.clipboardData?.items ?? [])]
326
+ const imageFiles = items
327
+ .filter(item => item.kind === 'file' && item.type.startsWith('image/'))
328
+ .map(item => {
329
+ const file = item.getAsFile()
330
+ if (!file) return null
331
+ if (!file.name) {
332
+ const ext = item.type.split('/')[1] ?? 'png'
333
+ return new File([file], `paste-${Date.now()}.${ext}`, { type: item.type })
334
+ }
335
+ return file
336
+ })
337
+ .filter(Boolean)
338
+ if (imageFiles.length === 0) return
339
+ e.preventDefault()
340
+ this.#uploadFiles(imageFiles)
341
+ }
342
+
343
+ // ─────────────────────────────────────────────────────────────────────────
344
+ // File upload
345
+ // ─────────────────────────────────────────────────────────────────────────
346
+
347
+ async #uploadFiles(files) {
348
+ for (const file of files) await this.#uploadOne(file)
349
+ }
350
+
351
+ async #uploadOne(file) {
352
+ const formData = new FormData()
353
+ formData.append('file', file)
354
+ formData.append('channel_id', this.#model.currentChannelId)
355
+
356
+ let res
357
+ try {
358
+ res = await fetch(`${window.__BASE_PATH__ ?? ''}/api/uploads`, {
359
+ method: 'POST',
360
+ body: formData,
361
+ })
362
+ } catch {
363
+ this.#showError('Upload failed: network error')
364
+ return
365
+ }
366
+
367
+ if (!res.ok) {
368
+ const body = await res.json().catch(() => ({}))
369
+ this.#showError(`Upload failed: ${body.error ?? res.statusText}`)
370
+ return
371
+ }
372
+
373
+ const attachment = await res.json()
374
+ this.#pendingAttachments.push(attachment)
375
+ this.#renderChips()
376
+ }
377
+
378
+ // ─────────────────────────────────────────────────────────────────────────
379
+ // Attachment chips
380
+ // ─────────────────────────────────────────────────────────────────────────
381
+
382
+ #renderChips() {
383
+ const html = this.#pendingAttachments.map((a, i) => `
384
+ <span class="attachment-chip" data-index="${i}">
385
+ <span class="attachment-chip-name">${escHtml(a.original_name)}</span>
386
+ <button type="button" class="attachment-chip-remove" data-index="${i}"
387
+ aria-label="Remove ${escHtml(a.original_name)}">×</button>
388
+ </span>`).join('')
389
+
390
+ this.#chipsEl.innerHTML = html
391
+ this.#chipsEl.hidden = this.#pendingAttachments.length === 0
392
+
393
+ if (this.#composeChipsEl) {
394
+ this.#composeChipsEl.innerHTML = html
395
+ this.#composeChipsEl.hidden = this.#pendingAttachments.length === 0
396
+ }
397
+ }
398
+
399
+ #removeChipAt(idx) {
400
+ this.#pendingAttachments.splice(idx, 1)
401
+ this.#renderChips()
402
+ }
403
+
404
+ #showError(msg) {
405
+ const target = this.#composeOpen ? this.#composeChipsEl : this.#chipsEl
406
+ if (!target) return
407
+ const chip = document.createElement('span')
408
+ chip.className = 'attachment-chip attachment-chip-error'
409
+ chip.textContent = msg
410
+ target.appendChild(chip)
411
+ target.hidden = false
412
+ setTimeout(() => chip.remove(), 5000)
413
+ }
414
+
415
+ // ─────────────────────────────────────────────────────────────────────────
416
+ // Urgent mode
417
+ // ─────────────────────────────────────────────────────────────────────────
418
+
419
+ #toggleUrgent() {
420
+ this.#urgentMode = !this.#urgentMode
421
+ this.#composerEl.classList.toggle('composer-urgent', this.#urgentMode)
422
+ this.#overlayEl?.classList.toggle('composer-urgent', this.#urgentMode)
423
+ document.getElementById('compose-urgent-toggle')?.classList.toggle('is-urgent', this.#urgentMode)
424
+ }
425
+
426
+ // ─────────────────────────────────────────────────────────────────────────
427
+ // Compose overlay
428
+ // ─────────────────────────────────────────────────────────────────────────
429
+
430
+ #openOverlay() {
431
+ if (this.#composeOpen || !this.#overlayEl) return
432
+ this.#composeOpen = true
433
+ if (this.#overlayTaEl) this.#overlayTaEl.value = this.#textareaEl?.value ?? ''
434
+
435
+ const messagesEl = document.getElementById('messages')
436
+ if (messagesEl) messagesEl.hidden = true
437
+ this.#composerEl.hidden = true
438
+ this.#overlayEl.hidden = false
439
+ this.#switchComposeTab('write')
440
+
441
+ requestAnimationFrame(() => {
442
+ if (!this.#overlayTaEl) return
443
+ this.#overlayTaEl.focus()
444
+ const len = this.#overlayTaEl.value.length
445
+ this.#overlayTaEl.setSelectionRange(len, len)
446
+ })
447
+ }
448
+
449
+ #closeOverlay() {
450
+ if (!this.#composeOpen || !this.#overlayEl) return
451
+ this.#composeOpen = false
452
+
453
+ const messagesEl = document.getElementById('messages')
454
+ if (messagesEl) messagesEl.hidden = false
455
+ this.#composerEl.hidden = false
456
+ this.#overlayEl.hidden = true
457
+ this.#textareaEl?.focus()
458
+ }
459
+
460
+ async #switchComposeTab(tab) {
461
+ if (!this.#overlayEl) return
462
+ this.#overlayEl.querySelectorAll('.compose-tab').forEach(btn => {
463
+ const active = btn.dataset.tab === tab
464
+ btn.classList.toggle('compose-tab--active', active)
465
+ btn.setAttribute('aria-selected', String(active))
466
+ })
467
+ if (this.#overlayTaEl) this.#overlayTaEl.hidden = tab !== 'write'
468
+ if (this.#overlayPreviewEl) this.#overlayPreviewEl.hidden = tab !== 'preview'
469
+
470
+ if (tab === 'preview') {
471
+ const text = this.#overlayTaEl?.value ?? ''
472
+ if (!text.trim()) {
473
+ if (this.#overlayPreviewEl) {
474
+ this.#overlayPreviewEl.innerHTML = '<p style="color:var(--text-muted)">Nothing to preview yet.</p>'
475
+ }
476
+ return
477
+ }
478
+ let html = null
479
+ await new Promise(resolve => {
480
+ dispatch('preview-text', { text, resolve: h => { html = h; resolve() } })
481
+ })
482
+ if (this.#overlayPreviewEl) {
483
+ this.#overlayPreviewEl.innerHTML = html ? _sanitize(html) : escHtml(text)
484
+ }
485
+ }
486
+ }
487
+ }
488
+
489
+ function _sanitize(html) {
490
+ return String(html ?? '').replaceAll('<script>', '').replaceAll('</script>', '')
491
+ }