link opening with shift enter
This commit is contained in:
@@ -39,7 +39,18 @@
|
||||
>
|
||||
✓
|
||||
</BaseButton>
|
||||
|
||||
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="open-url-button"
|
||||
@click="$emit('open-url')"
|
||||
aria-label="Open URL in focused message"
|
||||
:disabled="disabled"
|
||||
>
|
||||
URL
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@@ -70,6 +81,7 @@ defineEmits<{
|
||||
'camera': []
|
||||
'voice': []
|
||||
'toggle-check': []
|
||||
'open-url': []
|
||||
'send': []
|
||||
}>()
|
||||
</script>
|
||||
@@ -82,9 +94,11 @@ defineEmits<{
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Mobile-only for the checked toggle button */
|
||||
.input-actions [aria-label="Toggle check on focused message"] { display: none; }
|
||||
/* Mobile-only for the checked toggle button and open URL button */
|
||||
.input-actions [aria-label="Toggle check on focused message"],
|
||||
.input-actions .open-url-button { display: none; }
|
||||
@media (max-width: 480px) {
|
||||
.input-actions [aria-label="Toggle check on focused message"] { display: inline-flex; }
|
||||
.input-actions [aria-label="Toggle check on focused message"],
|
||||
.input-actions .open-url-button { display: inline-flex; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
@camera="$emit('camera')"
|
||||
@voice="$emit('voice')"
|
||||
@toggle-check="$emit('toggle-check')"
|
||||
@open-url="$emit('open-url')"
|
||||
@send="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
@@ -37,6 +38,7 @@ const emit = defineEmits<{
|
||||
'camera': []
|
||||
'voice': []
|
||||
'toggle-check': []
|
||||
'open-url': []
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
@@ -67,6 +67,7 @@ interface Props {
|
||||
const emit = defineEmits<{
|
||||
'open-dialog': [message: ExtendedMessage | UnsentMessage]
|
||||
'open-dialog-edit': [message: ExtendedMessage | UnsentMessage]
|
||||
'open-links': [links: string[], message: ExtendedMessage | UnsentMessage]
|
||||
'focus': []
|
||||
}>()
|
||||
|
||||
@@ -195,7 +196,42 @@ const handleClick = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract URLs from text content
|
||||
const extractUrls = (text: string): string[] => {
|
||||
const urlRegex = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/gi
|
||||
const matches = text.match(urlRegex) || []
|
||||
// Remove duplicates
|
||||
return [...new Set(matches)]
|
||||
}
|
||||
|
||||
// Handle Shift+Enter: open URL(s) or fall back to edit
|
||||
const handleOpenUrl = () => {
|
||||
if (props.isUnsent) return
|
||||
|
||||
const urls = extractUrls(props.message.content)
|
||||
|
||||
if (urls.length === 0) {
|
||||
// No links found, fall back to edit
|
||||
emit('open-dialog-edit', props.message)
|
||||
} else if (urls.length === 1) {
|
||||
// Single link, open directly
|
||||
window.open(urls[0], '_blank', 'noopener,noreferrer')
|
||||
toastStore.success('Opening link')
|
||||
} else {
|
||||
// Multiple links, emit event for selection dialog
|
||||
emit('open-links', urls, props.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
// Handle Shift+Enter for opening URLs
|
||||
if (event.shiftKey && event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleOpenUrl()
|
||||
return
|
||||
}
|
||||
|
||||
// Don't interfere with normal keyboard shortcuts (Ctrl+C, Ctrl+V, etc.)
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
return
|
||||
@@ -206,7 +242,7 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
toggleChecked()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (event.key === 'c') {
|
||||
// Copy message content (only when no modifiers are pressed)
|
||||
navigator.clipboard.writeText(props.message.content)
|
||||
@@ -341,6 +377,11 @@ const toggleChecked = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Expose methods for external use (e.g., mobile button)
|
||||
defineExpose({
|
||||
handleOpenUrl,
|
||||
extractUrls
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
:aria-selected="index === focusedMessageIndex ? 'true' : 'false'"
|
||||
@focus="focusedMessageIndex = index"
|
||||
@open-dialog="emit('open-message-dialog', $event)"
|
||||
@open-dialog-edit="emit('open-message-dialog-edit', $event)" />
|
||||
@open-dialog-edit="emit('open-message-dialog-edit', $event)"
|
||||
@open-links="(links, msg) => emit('open-links', links, msg)" />
|
||||
|
||||
<!-- Unsent Messages -->
|
||||
<MessageItem v-for="(unsentMsg, index) in unsentMessages" :key="unsentMsg.id" :message="unsentMsg"
|
||||
@@ -16,7 +17,8 @@
|
||||
:aria-selected="(messages.length + index) === focusedMessageIndex ? 'true' : 'false'"
|
||||
:data-message-index="messages.length + index" @focus="focusedMessageIndex = messages.length + index"
|
||||
@open-dialog="emit('open-message-dialog', $event)"
|
||||
@open-dialog-edit="emit('open-message-dialog-edit', $event)" />
|
||||
@open-dialog-edit="emit('open-message-dialog-edit', $event)"
|
||||
@open-links="(links, msg) => emit('open-links', links, msg)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -35,6 +37,7 @@ const emit = defineEmits<{
|
||||
'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
|
||||
'open-message-dialog': [message: ExtendedMessage | UnsentMessage]
|
||||
'open-message-dialog-edit': [message: ExtendedMessage | UnsentMessage]
|
||||
'open-links': [links: string[], message: ExtendedMessage | UnsentMessage]
|
||||
}>()
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -246,10 +249,42 @@ const getFocusedMessage = (): ExtendedMessage | UnsentMessage | null => {
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract URLs from text content
|
||||
const extractUrls = (text: string): string[] => {
|
||||
const urlRegex = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/gi
|
||||
const matches = text.match(urlRegex) || []
|
||||
return [...new Set(matches)]
|
||||
}
|
||||
|
||||
// Handle open URL for the focused message (for mobile button)
|
||||
const handleOpenUrlFocused = (): { action: 'none' | 'single' | 'multiple', urls: string[], message: ExtendedMessage | UnsentMessage | null } => {
|
||||
const message = getFocusedMessage()
|
||||
if (!message) {
|
||||
return { action: 'none', urls: [], message: null }
|
||||
}
|
||||
|
||||
// Don't allow URL opening for unsent messages
|
||||
if ('channelId' in message) {
|
||||
return { action: 'none', urls: [], message }
|
||||
}
|
||||
|
||||
const urls = extractUrls(message.content)
|
||||
|
||||
if (urls.length === 0) {
|
||||
return { action: 'none', urls: [], message }
|
||||
} else if (urls.length === 1) {
|
||||
return { action: 'single', urls, message }
|
||||
} else {
|
||||
return { action: 'multiple', urls, message }
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
scrollToBottom,
|
||||
focusMessageById,
|
||||
getFocusedMessage
|
||||
getFocusedMessage,
|
||||
handleOpenUrlFocused,
|
||||
extractUrls
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
144
frontend-vue/src/components/dialogs/LinkSelectionDialog.vue
Normal file
144
frontend-vue/src/components/dialogs/LinkSelectionDialog.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="link-selection-dialog">
|
||||
<p class="link-selection-dialog__description">
|
||||
Select a link to open:
|
||||
</p>
|
||||
|
||||
<div class="link-selection-dialog__links">
|
||||
<button
|
||||
v-for="(link, index) in links"
|
||||
:key="index"
|
||||
class="link-selection-dialog__link"
|
||||
@click="openLink(link)"
|
||||
:title="link"
|
||||
>
|
||||
<span class="link-selection-dialog__link-text">{{ formatLink(link) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="link-selection-dialog__actions">
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Cancel
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
interface Props {
|
||||
links: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const formatLink = (url: string): string => {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
// Show domain + pathname, truncate if too long
|
||||
let display = parsed.hostname + parsed.pathname
|
||||
if (display.length > 50) {
|
||||
display = display.slice(0, 47) + '...'
|
||||
}
|
||||
return display
|
||||
} catch {
|
||||
// If URL parsing fails, truncate the raw URL
|
||||
return url.length > 50 ? url.slice(0, 47) + '...' : url
|
||||
}
|
||||
}
|
||||
|
||||
const openLink = (url: string) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
toastStore.success('Opening link')
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.link-selection-dialog {
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.link-selection-dialog__description {
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link-selection-dialog__links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.link-selection-dialog__link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
color: #1d4ed8;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.link-selection-dialog__link:hover,
|
||||
.link-selection-dialog__link:focus {
|
||||
background: #eff6ff;
|
||||
border-color: #3b82f6;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.link-selection-dialog__link-text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.link-selection-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.link-selection-dialog__description {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.link-selection-dialog__link {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.link-selection-dialog__link:hover,
|
||||
.link-selection-dialog__link:focus {
|
||||
background: #1e3a5f;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.link-selection-dialog__actions {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -56,6 +56,7 @@
|
||||
ref="messagesContainer"
|
||||
@open-message-dialog="handleOpenMessageDialog"
|
||||
@open-message-dialog-edit="handleOpenMessageDialogEdit"
|
||||
@open-links="handleOpenLinks"
|
||||
/>
|
||||
|
||||
<!-- Message Input -->
|
||||
@@ -65,6 +66,7 @@
|
||||
@camera="showCameraDialog = true"
|
||||
@voice="showVoiceDialog = true"
|
||||
@toggle-check="handleToggleCheckFocused"
|
||||
@open-url="handleOpenUrlFocused"
|
||||
ref="messageInput"
|
||||
/>
|
||||
</div>
|
||||
@@ -134,6 +136,13 @@
|
||||
@move="handleMoveMessage"
|
||||
/>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog v-model:show="showLinkDialog" title="Open Link">
|
||||
<LinkSelectionDialog
|
||||
:links="selectedLinks"
|
||||
@close="showLinkDialog = false"
|
||||
/>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -166,6 +175,7 @@ import VoiceRecordingDialog from '@/components/dialogs/VoiceRecordingDialog.vue'
|
||||
import CameraCaptureDialog from '@/components/dialogs/CameraCaptureDialog.vue'
|
||||
import ChannelInfoDialog from '@/components/dialogs/ChannelInfoDialog.vue'
|
||||
import MessageDialog from '@/components/dialogs/MessageDialog.vue'
|
||||
import LinkSelectionDialog from '@/components/dialogs/LinkSelectionDialog.vue'
|
||||
|
||||
// Types
|
||||
import type { ExtendedMessage, UnsentMessage, Channel } from '@/types'
|
||||
@@ -198,8 +208,10 @@ const showFileDialog = ref(false)
|
||||
const showVoiceDialog = ref(false)
|
||||
const showMessageDialog = ref(false)
|
||||
const showCameraDialog = ref(false)
|
||||
const showLinkDialog = ref(false)
|
||||
const selectedMessage = ref<ExtendedMessage | null>(null)
|
||||
const shouldStartEditing = ref(false)
|
||||
const selectedLinks = ref<string[]>([])
|
||||
|
||||
// Mobile sidebar state
|
||||
const sidebarOpen = ref(false)
|
||||
@@ -345,6 +357,38 @@ const handleToggleCheckFocused = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle opening links from a message (when multiple links found)
|
||||
const handleOpenLinks = (links: string[], message: ExtendedMessage | UnsentMessage) => {
|
||||
selectedLinks.value = links
|
||||
showLinkDialog.value = true
|
||||
}
|
||||
|
||||
// Handle open URL button press (mobile) - triggers URL opening for focused message
|
||||
const handleOpenUrlFocused = () => {
|
||||
const result = messagesContainer.value?.handleOpenUrlFocused?.()
|
||||
if (!result || !result.message) {
|
||||
toastStore.info('No message is focused')
|
||||
return
|
||||
}
|
||||
|
||||
if (result.action === 'none') {
|
||||
// No links found, fall back to edit mode
|
||||
if ('created_at' in result.message) {
|
||||
handleOpenMessageDialogEdit(result.message)
|
||||
} else {
|
||||
toastStore.info('No links found in this message')
|
||||
}
|
||||
} else if (result.action === 'single') {
|
||||
// Single link, open directly
|
||||
window.open(result.urls[0], '_blank', 'noopener,noreferrer')
|
||||
toastStore.success('Opening link')
|
||||
} else if (result.action === 'multiple') {
|
||||
// Multiple links, show selection dialog
|
||||
selectedLinks.value = result.urls
|
||||
showLinkDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const selectChannel = async (channelId: number) => {
|
||||
console.log('Selecting channel:', channelId)
|
||||
await appStore.setCurrentChannel(channelId)
|
||||
|
||||
Reference in New Issue
Block a user