@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.
- package/package.json +46 -0
- package/src/lib/components/AudioWaveform.svelte +694 -0
- package/src/lib/components/AvailabilityModal.svelte +173 -0
- package/src/lib/components/Badge.svelte +38 -0
- package/src/lib/components/BookingForm.svelte +276 -0
- package/src/lib/components/Button.svelte +72 -0
- package/src/lib/components/CalendarPicker.svelte +284 -0
- package/src/lib/components/Card.svelte +67 -0
- package/src/lib/components/CharacterCounter.svelte +82 -0
- package/src/lib/components/ChipInput.svelte +596 -0
- package/src/lib/components/ColorSelector.svelte +163 -0
- package/src/lib/components/ConfirmModal.svelte +75 -0
- package/src/lib/components/CountdownTimer.svelte +94 -0
- package/src/lib/components/DateRangePicker.svelte +192 -0
- package/src/lib/components/Drawer.svelte +110 -0
- package/src/lib/components/FilterDropdown.svelte +202 -0
- package/src/lib/components/ImageUpload.svelte +97 -0
- package/src/lib/components/InlineEdit.svelte +283 -0
- package/src/lib/components/LazyImage.svelte +122 -0
- package/src/lib/components/LoadingSpinner.svelte +102 -0
- package/src/lib/components/Modal.svelte +208 -0
- package/src/lib/components/PhoneInput.svelte +92 -0
- package/src/lib/components/ResizableDivider.svelte +305 -0
- package/src/lib/components/ResizablePanel.svelte +302 -0
- package/src/lib/components/SearchDropdown.svelte +341 -0
- package/src/lib/components/SelectInput.svelte +215 -0
- package/src/lib/components/SignaturePad.svelte +171 -0
- package/src/lib/components/SortDropdown.svelte +148 -0
- package/src/lib/components/Sparkline.svelte +107 -0
- package/src/lib/components/SpeechForm.svelte +114 -0
- package/src/lib/components/StatusBadge.svelte +155 -0
- package/src/lib/components/TextArea.svelte +143 -0
- package/src/lib/components/TextInput.svelte +108 -0
- package/src/lib/components/ThemeSelector.svelte +195 -0
- package/src/lib/components/TimeSlotPicker.svelte +162 -0
- package/src/lib/components/VoicePlayer.svelte +420 -0
- package/src/lib/components/messaging/Avatar.svelte +81 -0
- package/src/lib/components/messaging/ChannelInfoModal.svelte +163 -0
- package/src/lib/components/messaging/ChannelList.svelte +107 -0
- package/src/lib/components/messaging/ChannelMemberAvatarStack.svelte +69 -0
- package/src/lib/components/messaging/ChannelMembersModal.svelte +182 -0
- package/src/lib/components/messaging/CreateChannelModal.svelte +190 -0
- package/src/lib/components/messaging/DirectMessageList.svelte +145 -0
- package/src/lib/components/messaging/EmojiSelector.svelte +260 -0
- package/src/lib/components/messaging/MentionAutocomplete.svelte +193 -0
- package/src/lib/components/messaging/MessageAttachment.svelte +270 -0
- package/src/lib/components/messaging/MessageAttachmentUpload.svelte +243 -0
- package/src/lib/components/messaging/MessageInput.svelte +451 -0
- package/src/lib/components/messaging/MessageItem.svelte +338 -0
- package/src/lib/components/messaging/MessageThread.svelte +306 -0
- package/src/lib/components/messaging/NotificationSettingsModal.svelte +234 -0
- package/src/lib/components/messaging/QuotedMessageDisplay.svelte +118 -0
- package/src/lib/components/messaging/StartDMModal.svelte +100 -0
- package/src/lib/components/messaging/ThreadPanel.svelte +153 -0
- package/src/lib/index.ts +185 -0
- package/src/lib/types/booking.ts +143 -0
- package/src/lib/types/messaging.ts +459 -0
- package/src/lib/utils/currency.ts +20 -0
- package/src/lib/utils/daisyuiColors.ts +243 -0
- package/src/lib/utils/dateFormatters.ts +153 -0
- package/src/lib/utils/mentionParser.ts +188 -0
- 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>
|