@joewinke/jatui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/package.json +46 -0
  2. package/src/lib/components/AudioWaveform.svelte +694 -0
  3. package/src/lib/components/AvailabilityModal.svelte +173 -0
  4. package/src/lib/components/Badge.svelte +38 -0
  5. package/src/lib/components/BookingForm.svelte +276 -0
  6. package/src/lib/components/Button.svelte +72 -0
  7. package/src/lib/components/CalendarPicker.svelte +284 -0
  8. package/src/lib/components/Card.svelte +67 -0
  9. package/src/lib/components/CharacterCounter.svelte +82 -0
  10. package/src/lib/components/ChipInput.svelte +596 -0
  11. package/src/lib/components/ColorSelector.svelte +163 -0
  12. package/src/lib/components/ConfirmModal.svelte +75 -0
  13. package/src/lib/components/CountdownTimer.svelte +94 -0
  14. package/src/lib/components/DateRangePicker.svelte +192 -0
  15. package/src/lib/components/Drawer.svelte +110 -0
  16. package/src/lib/components/FilterDropdown.svelte +202 -0
  17. package/src/lib/components/ImageUpload.svelte +97 -0
  18. package/src/lib/components/InlineEdit.svelte +283 -0
  19. package/src/lib/components/LazyImage.svelte +122 -0
  20. package/src/lib/components/LoadingSpinner.svelte +102 -0
  21. package/src/lib/components/Modal.svelte +208 -0
  22. package/src/lib/components/PhoneInput.svelte +92 -0
  23. package/src/lib/components/ResizableDivider.svelte +305 -0
  24. package/src/lib/components/ResizablePanel.svelte +302 -0
  25. package/src/lib/components/SearchDropdown.svelte +341 -0
  26. package/src/lib/components/SelectInput.svelte +215 -0
  27. package/src/lib/components/SignaturePad.svelte +171 -0
  28. package/src/lib/components/SortDropdown.svelte +148 -0
  29. package/src/lib/components/Sparkline.svelte +107 -0
  30. package/src/lib/components/SpeechForm.svelte +114 -0
  31. package/src/lib/components/StatusBadge.svelte +155 -0
  32. package/src/lib/components/TextArea.svelte +143 -0
  33. package/src/lib/components/TextInput.svelte +108 -0
  34. package/src/lib/components/ThemeSelector.svelte +195 -0
  35. package/src/lib/components/TimeSlotPicker.svelte +162 -0
  36. package/src/lib/components/VoicePlayer.svelte +420 -0
  37. package/src/lib/components/messaging/Avatar.svelte +81 -0
  38. package/src/lib/components/messaging/ChannelInfoModal.svelte +163 -0
  39. package/src/lib/components/messaging/ChannelList.svelte +107 -0
  40. package/src/lib/components/messaging/ChannelMemberAvatarStack.svelte +69 -0
  41. package/src/lib/components/messaging/ChannelMembersModal.svelte +182 -0
  42. package/src/lib/components/messaging/CreateChannelModal.svelte +190 -0
  43. package/src/lib/components/messaging/DirectMessageList.svelte +145 -0
  44. package/src/lib/components/messaging/EmojiSelector.svelte +260 -0
  45. package/src/lib/components/messaging/MentionAutocomplete.svelte +193 -0
  46. package/src/lib/components/messaging/MessageAttachment.svelte +270 -0
  47. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +243 -0
  48. package/src/lib/components/messaging/MessageInput.svelte +451 -0
  49. package/src/lib/components/messaging/MessageItem.svelte +338 -0
  50. package/src/lib/components/messaging/MessageThread.svelte +306 -0
  51. package/src/lib/components/messaging/NotificationSettingsModal.svelte +234 -0
  52. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +118 -0
  53. package/src/lib/components/messaging/StartDMModal.svelte +100 -0
  54. package/src/lib/components/messaging/ThreadPanel.svelte +153 -0
  55. package/src/lib/index.ts +185 -0
  56. package/src/lib/types/booking.ts +143 -0
  57. package/src/lib/types/messaging.ts +459 -0
  58. package/src/lib/utils/currency.ts +20 -0
  59. package/src/lib/utils/daisyuiColors.ts +243 -0
  60. package/src/lib/utils/dateFormatters.ts +153 -0
  61. package/src/lib/utils/mentionParser.ts +188 -0
  62. package/src/lib/utils/phoneFormat.ts +74 -0
@@ -0,0 +1,451 @@
1
+ <!--
2
+ MessageInput Component
3
+
4
+ Rich text input for composing and sending messages.
5
+ Supports mentions, attachments, and drag/drop.
6
+ All searches and uploads are via callbacks.
7
+ -->
8
+
9
+ <script lang="ts">
10
+ import type { QuotedMessage, PendingAttachment, MentionItem, AttachmentCallbacks } from '../../types/messaging'
11
+ import {
12
+ findPartialMention,
13
+ createMentionString,
14
+ replaceMentionInContent,
15
+ renderMentionsAsHTML,
16
+ extractPlainText,
17
+ parseMentions,
18
+ } from '../../utils/mentionParser'
19
+ import MentionAutocomplete from './MentionAutocomplete.svelte'
20
+ import QuotedMessageDisplay from './QuotedMessageDisplay.svelte'
21
+
22
+ interface Props {
23
+ placeholder?: string
24
+ disabled?: boolean
25
+ maxLength?: number
26
+ compact?: boolean
27
+ quotedMessage?: QuotedMessage
28
+ showPrioritySelector?: boolean
29
+ attachmentCallbacks?: AttachmentCallbacks
30
+ organizationId?: string
31
+ onsend?: (data: {
32
+ content: string
33
+ priority?: string
34
+ quotedMessageId?: string
35
+ attachments?: PendingAttachment[]
36
+ }) => void
37
+ ontyping?: (data: { isTyping: boolean }) => void
38
+ onquoteremove?: () => void
39
+ onsearchmentions?: (query: string) => Promise<MentionItem[]>
40
+ }
41
+
42
+ let {
43
+ placeholder = 'Type a message...',
44
+ disabled = false,
45
+ maxLength = 4000,
46
+ compact = false,
47
+ quotedMessage,
48
+ showPrioritySelector = false,
49
+ attachmentCallbacks,
50
+ organizationId,
51
+ onsend,
52
+ ontyping,
53
+ onquoteremove,
54
+ onsearchmentions,
55
+ }: Props = $props()
56
+
57
+ let selectedPriority = $state<'low' | 'normal' | 'high' | 'urgent'>('normal')
58
+ let attachments = $state<PendingAttachment[]>([])
59
+ let isDragging = $state(false)
60
+ let fileInputRef = $state<HTMLInputElement | null>(null)
61
+ let inputText = $state('')
62
+ let contentEditableDiv: HTMLDivElement
63
+ let isTyping = $state(false)
64
+ let typingTimeout: ReturnType<typeof setTimeout>
65
+
66
+ let showMentionAutocomplete = $state(false)
67
+ let mentionQuery = $state('')
68
+ let mentionStartIndex = $state(0)
69
+ let cursorPosition = $state(0)
70
+ let autocompletePosition = $state({ x: 0, y: 0 })
71
+ let isInternalUpdate = false
72
+
73
+ function autoResize() {
74
+ if (contentEditableDiv) {
75
+ const maxHeight = compact ? 120 : 200
76
+ if (contentEditableDiv.scrollHeight > maxHeight) {
77
+ contentEditableDiv.style.maxHeight = maxHeight + 'px'
78
+ contentEditableDiv.style.overflowY = 'auto'
79
+ } else {
80
+ contentEditableDiv.style.maxHeight = 'none'
81
+ contentEditableDiv.style.overflowY = 'visible'
82
+ }
83
+ }
84
+ }
85
+
86
+ export function focus() {
87
+ if (contentEditableDiv) {
88
+ contentEditableDiv.focus()
89
+ const selection = window.getSelection()
90
+ if (selection) {
91
+ const range = document.createRange()
92
+ range.selectNodeContents(contentEditableDiv)
93
+ range.collapse(false)
94
+ selection.removeAllRanges()
95
+ selection.addRange(range)
96
+ }
97
+ }
98
+ }
99
+
100
+ export async function addFiles(files: FileList | File[]) {
101
+ await processFiles(files)
102
+ }
103
+
104
+ function handleInput() {
105
+ if (!contentEditableDiv || isInternalUpdate) return
106
+
107
+ const newText = contentEditableDiv.innerText || ''
108
+ const updatedInputText = reconstructMarkdownWithNewText(inputText, newText)
109
+ inputText = updatedInputText
110
+
111
+ const hasMentions = inputText.includes('[@')
112
+ if (hasMentions) {
113
+ isInternalUpdate = true
114
+ const renderedHTML = renderMentionsWithDegradedStyling(inputText)
115
+ if (contentEditableDiv.innerHTML !== renderedHTML) {
116
+ const selection = window.getSelection()
117
+ const cursorOffset = selection?.rangeCount ? getAbsoluteCursorPosition(selection.getRangeAt(0)) : 0
118
+ contentEditableDiv.innerHTML = renderedHTML
119
+ setContentEditableCursor(cursorOffset)
120
+ }
121
+ isInternalUpdate = false
122
+ }
123
+
124
+ autoResize()
125
+ updateCursorPosition()
126
+ checkForMentions()
127
+
128
+ if (!isTyping && inputText.trim()) {
129
+ isTyping = true
130
+ ontyping?.({ isTyping: true })
131
+ }
132
+ if (typingTimeout) clearTimeout(typingTimeout)
133
+ typingTimeout = setTimeout(() => { isTyping = false; ontyping?.({ isTyping: false }) }, 3000)
134
+ }
135
+
136
+ function reconstructMarkdownWithNewText(currentMarkdown: string, newPlainText: string): string {
137
+ const existingMentions = parseMentions(currentMarkdown)
138
+ if (existingMentions.length === 0) return newPlainText
139
+
140
+ const currentPlainText = extractPlainText(currentMarkdown)
141
+ if (newPlainText.startsWith(currentPlainText)) return currentMarkdown + newPlainText.substring(currentPlainText.length)
142
+ if (currentPlainText.startsWith(newPlainText)) {
143
+ const deletedLength = currentPlainText.length - newPlainText.length
144
+ return currentMarkdown.substring(0, currentMarkdown.length - deletedLength)
145
+ }
146
+
147
+ // Complex edit — try to preserve mentions
148
+ const mentionMap = new Map<string, string>()
149
+ existingMentions.forEach((m) => {
150
+ mentionMap.set(`@${m.displayName}`, `[@${m.displayName}](${m.type}:${m.id})`)
151
+ })
152
+ let result = newPlainText
153
+ mentionMap.forEach((markdownSyntax, displayName) => {
154
+ if (result.includes(displayName)) result = result.replace(displayName, markdownSyntax)
155
+ })
156
+ return result
157
+ }
158
+
159
+ function updateCursorPosition() {
160
+ if (contentEditableDiv) {
161
+ const selection = window.getSelection()
162
+ if (selection && selection.rangeCount > 0) cursorPosition = getAbsoluteCursorPosition(selection.getRangeAt(0))
163
+ }
164
+ }
165
+
166
+ function getAbsoluteCursorPosition(range: Range): number {
167
+ const walker = document.createTreeWalker(contentEditableDiv, NodeFilter.SHOW_TEXT)
168
+ let pos = 0
169
+ let node = walker.nextNode()
170
+ while (node) {
171
+ if (node === range.startContainer) return pos + range.startOffset
172
+ pos += node.textContent?.length || 0
173
+ node = walker.nextNode()
174
+ }
175
+ return pos
176
+ }
177
+
178
+ function checkForMentions() {
179
+ const partialMention = findPartialMention(inputText, cursorPosition)
180
+ if (partialMention) {
181
+ mentionStartIndex = partialMention.start
182
+ mentionQuery = partialMention.query
183
+ updateAutocompletePosition()
184
+ showMentionAutocomplete = true
185
+ } else {
186
+ showMentionAutocomplete = false
187
+ }
188
+ }
189
+
190
+ function renderMentionsWithDegradedStyling(content: string): string {
191
+ let result = renderMentionsAsHTML(content)
192
+ const degradedMentionRegex = /(?<!>)[@$#!~>/+]\w+(?![^<]*<\/a>)/g
193
+ result = result.replace(degradedMentionRegex, (match) => `<span class="degraded-mention">${match}</span>`)
194
+ return result
195
+ }
196
+
197
+ function updateAutocompletePosition() {
198
+ if (!contentEditableDiv) return
199
+ const selection = window.getSelection()
200
+ if (!selection || selection.rangeCount === 0) return
201
+ try {
202
+ const range = selection.getRangeAt(0)
203
+ const rect = range.getBoundingClientRect()
204
+ autocompletePosition = { x: rect.left, y: rect.bottom + window.scrollY }
205
+ } catch {
206
+ const rect = contentEditableDiv.getBoundingClientRect()
207
+ autocompletePosition = { x: rect.left, y: rect.bottom + window.scrollY }
208
+ }
209
+ }
210
+
211
+ function handleKeyDown(event: KeyboardEvent) {
212
+ if (showMentionAutocomplete && ['ArrowUp', 'ArrowDown', 'Tab', 'Enter', 'Escape'].includes(event.key)) return
213
+ if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendMessage() }
214
+ }
215
+
216
+ function handleMentionSelect(event: { result: MentionItem }) {
217
+ const { result } = event
218
+ const mentionString = createMentionString(result.type, result.id, result.name) + ' '
219
+ const endIndex = mentionStartIndex + 1 + mentionQuery.length
220
+ const { newContent, newCursorPosition } = replaceMentionInContent(inputText, mentionStartIndex, endIndex, mentionString)
221
+
222
+ isInternalUpdate = true
223
+ inputText = newContent
224
+ if (contentEditableDiv) {
225
+ contentEditableDiv.innerHTML = renderMentionsWithDegradedStyling(inputText)
226
+ setTimeout(() => {
227
+ setContentEditableCursor(newCursorPosition)
228
+ contentEditableDiv.focus()
229
+ setTimeout(() => { isInternalUpdate = false; updateCursorPosition() }, 10)
230
+ }, 0)
231
+ }
232
+ showMentionAutocomplete = false
233
+ }
234
+
235
+ function setContentEditableCursor(position: number) {
236
+ if (!contentEditableDiv) return
237
+ const textContent = contentEditableDiv.innerText || ''
238
+ if (position >= textContent.length - 1) {
239
+ const range = document.createRange()
240
+ const selection = window.getSelection()
241
+ try { range.selectNodeContents(contentEditableDiv); range.collapse(false); selection?.removeAllRanges(); selection?.addRange(range) } catch {}
242
+ return
243
+ }
244
+ const walker = document.createTreeWalker(contentEditableDiv, NodeFilter.SHOW_TEXT)
245
+ let currentPos = 0
246
+ while (walker.nextNode()) {
247
+ const node = walker.currentNode
248
+ const nodeLength = node.textContent?.length || 0
249
+ if (currentPos + nodeLength >= position) {
250
+ const range = document.createRange()
251
+ const selection = window.getSelection()
252
+ try {
253
+ const offset = Math.min(position - currentPos, nodeLength)
254
+ range.setStart(node, offset); range.setEnd(node, offset)
255
+ selection?.removeAllRanges(); selection?.addRange(range)
256
+ } catch {}
257
+ return
258
+ }
259
+ currentPos += nodeLength
260
+ }
261
+ }
262
+
263
+ function sendMessage() {
264
+ const content = inputText.trim()
265
+ if ((!content && attachments.length === 0) || disabled) return
266
+
267
+ onsend?.({
268
+ content,
269
+ priority: showPrioritySelector ? selectedPriority : undefined,
270
+ quotedMessageId: quotedMessage?.id,
271
+ attachments: attachments.length > 0 ? attachments : undefined,
272
+ })
273
+
274
+ inputText = ''
275
+ selectedPriority = 'normal'
276
+ attachments = []
277
+ if (contentEditableDiv) contentEditableDiv.innerHTML = ''
278
+ autoResize()
279
+ if (quotedMessage) onquoteremove?.()
280
+ if (isTyping) { isTyping = false; ontyping?.({ isTyping: false }) }
281
+ if (typingTimeout) clearTimeout(typingTimeout)
282
+ }
283
+
284
+ function handleFileUpload() { fileInputRef?.click() }
285
+
286
+ function handleFileSelect(e: Event) {
287
+ const input = e.target as HTMLInputElement
288
+ if (input.files && input.files.length > 0) { processFiles(input.files); input.value = '' }
289
+ }
290
+
291
+ async function processFiles(files: FileList | File[]) {
292
+ if (disabled) return
293
+ const fileArray = Array.from(files)
294
+ const acceptedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4', 'video/webm', 'video/quicktime', 'application/pdf']
295
+ const maxSize = 25 * 1024 * 1024
296
+
297
+ for (const file of fileArray) {
298
+ if (!acceptedTypes.includes(file.type) || file.size > maxSize) continue
299
+ let preview: string | undefined
300
+ if (file.type.startsWith('image/')) {
301
+ preview = await new Promise<string | undefined>((resolve) => {
302
+ const reader = new FileReader()
303
+ reader.onload = (e) => resolve(e.target?.result as string)
304
+ reader.onerror = () => resolve(undefined)
305
+ reader.readAsDataURL(file)
306
+ })
307
+ }
308
+ attachments = [...attachments, { id: crypto.randomUUID(), file, preview, uploading: false, progress: 0 }]
309
+ }
310
+ }
311
+
312
+ function removeAttachment(id: string) { attachments = attachments.filter((a) => a.id !== id) }
313
+
314
+ function handleDragEnter(e: DragEvent) { e.preventDefault(); e.stopPropagation(); isDragging = true }
315
+ function handleDragLeave(e: DragEvent) {
316
+ e.preventDefault(); e.stopPropagation()
317
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
318
+ if (e.clientX <= rect.left || e.clientX >= rect.right || e.clientY <= rect.top || e.clientY >= rect.bottom) isDragging = false
319
+ }
320
+ function handleDragOver(e: DragEvent) { e.preventDefault(); e.stopPropagation() }
321
+ async function handleDrop(e: DragEvent) {
322
+ e.preventDefault(); e.stopPropagation(); isDragging = false
323
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) await processFiles(e.dataTransfer.files)
324
+ }
325
+
326
+ function getFileTypeIcon(file: File): string { if (file.type.startsWith('image/')) return '🖼'; if (file.type.startsWith('video/')) return '🎬'; return '📄' }
327
+
328
+ const characterCount = $derived(inputText.length)
329
+ const showCharacterCount = $derived(characterCount > maxLength * 0.8)
330
+ const isOverLimit = $derived(characterCount > maxLength)
331
+
332
+ $effect(() => { if (inputText) autoResize() })
333
+ </script>
334
+
335
+ <input bind:this={fileInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,video/quicktime,application/pdf" multiple class="hidden" onchange={handleFileSelect} {disabled} />
336
+
337
+ <div class="flex flex-col gap-2 relative" ondragenter={handleDragEnter} ondragleave={handleDragLeave} ondragover={handleDragOver} ondrop={handleDrop}>
338
+ {#if isDragging}
339
+ <div class="absolute inset-0 z-50 bg-primary/10 border-2 border-dashed border-primary rounded-lg flex items-center justify-center pointer-events-none">
340
+ <div class="text-center">
341
+ <div class="text-2xl mb-2">📎</div>
342
+ <p class="text-sm font-medium text-primary">Drop files to attach</p>
343
+ </div>
344
+ </div>
345
+ {/if}
346
+
347
+ {#if quotedMessage}
348
+ <QuotedMessageDisplay {quotedMessage} mode="preview" onremove={onquoteremove} />
349
+ {/if}
350
+
351
+ {#if attachments.length > 0}
352
+ <div class="flex flex-wrap gap-2 p-2 bg-base-200 rounded-lg">
353
+ {#each attachments as att (att.id)}
354
+ <div class="relative group bg-base-100 rounded-lg overflow-hidden border border-base-300 w-16 h-16">
355
+ {#if att.preview}
356
+ <img src={att.preview} alt={att.file.name} class="w-full h-full object-cover" />
357
+ {:else}
358
+ <div class="w-full h-full flex items-center justify-center bg-base-200">
359
+ <span class="text-xl">{getFileTypeIcon(att.file)}</span>
360
+ </div>
361
+ {/if}
362
+ <button type="button" class="absolute top-0.5 right-0.5 btn btn-circle btn-xs bg-base-100/80 hover:bg-error hover:text-error-content opacity-0 group-hover:opacity-100 transition-opacity" onclick={() => removeAttachment(att.id)} title="Remove">✕</button>
363
+ {#if att.uploading}
364
+ <div class="absolute inset-0 bg-black/50 flex items-center justify-center"><span class="loading loading-spinner loading-xs text-white"></span></div>
365
+ {/if}
366
+ </div>
367
+ {/each}
368
+ </div>
369
+ {/if}
370
+
371
+ {#if showCharacterCount}
372
+ <div class="text-right">
373
+ <span class="text-xs" class:text-warning={characterCount > maxLength * 0.9} class:text-error={isOverLimit}>{characterCount} / {maxLength}</span>
374
+ </div>
375
+ {/if}
376
+
377
+ <div class="flex items-end gap-2 {compact ? 'p-2' : 'p-3'} border border-base-300 rounded-lg focus-within:border-primary transition-colors">
378
+ {#if !compact}
379
+ <button type="button" class="btn btn-ghost btn-sm btn-circle flex-shrink-0" onclick={handleFileUpload} title="Attach file" {disabled}>📎</button>
380
+ {/if}
381
+
382
+ <div
383
+ bind:this={contentEditableDiv}
384
+ contenteditable={!disabled}
385
+ class="flex-1 bg-transparent border-none outline-none {compact ? 'min-h-[20px]' : 'min-h-[24px]'} leading-6 message-content"
386
+ class:text-error={isOverLimit}
387
+ class:opacity-50={disabled}
388
+ class:pointer-events-none={disabled}
389
+ data-placeholder={placeholder}
390
+ style="max-height: {compact ? '120px' : '200px'}; overflow-y: auto;"
391
+ oninput={handleInput}
392
+ onkeydown={handleKeyDown}
393
+ onclick={updateCursorPosition}
394
+ onkeyup={updateCursorPosition}
395
+ onpaste={(e) => {
396
+ e.preventDefault()
397
+ const text = e.clipboardData?.getData('text/plain') || ''
398
+ const selection = window.getSelection()
399
+ if (selection && selection.rangeCount > 0) {
400
+ const range = selection.getRangeAt(0)
401
+ range.deleteContents()
402
+ range.insertNode(document.createTextNode(text))
403
+ range.collapse(false)
404
+ }
405
+ handleInput()
406
+ }}
407
+ aria-label={placeholder}
408
+ role="textbox"
409
+ aria-multiline="true"
410
+ tabindex="0"
411
+ ></div>
412
+
413
+ {#if showPrioritySelector}
414
+ <select bind:value={selectedPriority} class="select select-sm {selectedPriority === 'urgent' ? 'select-error' : selectedPriority === 'high' ? 'select-warning' : 'select-ghost'} flex-shrink-0 max-w-[100px]" {disabled}>
415
+ <option value="normal">Normal</option>
416
+ <option value="high">High</option>
417
+ <option value="urgent">Urgent</option>
418
+ </select>
419
+ {/if}
420
+
421
+ <button type="button" class="btn btn-primary {compact ? 'btn-xs' : 'btn-sm'} btn-circle flex-shrink-0" onclick={sendMessage} disabled={disabled || (!inputText.trim() && attachments.length === 0) || isOverLimit} title="Send message (Enter)">
422
+ ➤
423
+ </button>
424
+ </div>
425
+
426
+ <div class="text-xs text-base-content/50">
427
+ <span class="font-medium">Enter</span> to send •
428
+ <span class="font-medium">Shift + Enter</span> for new line •
429
+ <span class="font-medium">@ $ # +</span> to mention
430
+ </div>
431
+ </div>
432
+
433
+ {#if onsearchmentions}
434
+ <MentionAutocomplete
435
+ query={mentionQuery}
436
+ position={autocompletePosition}
437
+ visible={showMentionAutocomplete}
438
+ onsearch={onsearchmentions}
439
+ onselect={handleMentionSelect}
440
+ onclose={() => (showMentionAutocomplete = false)}
441
+ />
442
+ {/if}
443
+
444
+ <style>
445
+ div[contenteditable='true'] { white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }
446
+ div[contenteditable='true']:empty:before { content: attr(data-placeholder); color: oklch(var(--bc) / 0.5); pointer-events: none; }
447
+ div[contenteditable='true']:focus:empty:before { content: attr(data-placeholder); color: oklch(var(--bc) / 0.3); }
448
+ div[contenteditable='true']::-webkit-scrollbar { width: 6px; }
449
+ div[contenteditable='true']::-webkit-scrollbar-track { background: transparent; }
450
+ div[contenteditable='true']::-webkit-scrollbar-thumb { background-color: oklch(var(--bc) / 0.2); border-radius: 3px; }
451
+ </style>