Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(chat): preserve selection (caret) on new message input #14069

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 39 additions & 51 deletions src/components/NewMessage/NewMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
<div class="new-message-form__emoji-picker">
<NcEmojiPicker v-if="!disabled"
:close-on-select="false"
:set-return-focus="getContenteditable"
@select="addEmoji">
<NcButton :disabled="disabled"
type="tertiary"
Expand Down Expand Up @@ -87,6 +88,8 @@
@keydown.meta.up="handleEditLastMessage"
@input="handleTyping"
@paste="handlePastedFiles"
@focus="restoreSelectionRange"
@blur="preserveSelectionRange"
@submit="handleSubmit" />
</div>

Expand Down Expand Up @@ -179,7 +182,7 @@

<script>
import debounce from 'debounce'
import { toRefs, ref } from 'vue'
import { toRefs, nextTick } from 'vue'

import BellOffIcon from 'vue-material-design-icons/BellOff.vue'
import CheckIcon from 'vue-material-design-icons/Check.vue'
Expand Down Expand Up @@ -221,6 +224,7 @@ import { useChatExtrasStore } from '../../stores/chatExtras.js'
import { useGroupwareStore } from '../../stores/groupware.ts'
import { useSettingsStore } from '../../stores/settings.js'
import { fetchClipboardContent } from '../../utils/clipboard.js'
import { getCurrentSelectionRange, getRangeAtEnd, insertTextAtRange, setCurrentSelectionRange } from '../../utils/selectionRange.ts'
import { parseSpecialSymbols } from '../../utils/textParse.ts'

export default {
Expand Down Expand Up @@ -335,7 +339,8 @@ export default {
clipboardTimeStamp: null,
typingInterval: null,
wasTypingWithinInterval: false,
debouncedUpdateChatInput: debounce(this.updateChatInput, 200)
debouncedUpdateChatInput: debounce(this.updateChatInput, 200),
preservedSelectionRange: null,
}
},

Expand Down Expand Up @@ -603,6 +608,11 @@ export default {

methods: {
t,

getContenteditable() {
return this.$refs.richContenteditable.$refs.contenteditable
},

handleTyping() {
// Enable signal sending, only if indicator for this input is on
if (!this.showTypingStatus) {
Expand Down Expand Up @@ -859,53 +869,32 @@ export default {
},

/**
* Add selected emoji to text input area
*
* The emoji will be added at the current caret position, and any text
* currently selected will be replaced by the emoji. If the input area
* does not have the focus there will be no caret or selection; in that
* case the emoji will be added at the end.
*
* @param {string} emoji Emoji object
* Preserve the current selection range (caret position)
*/
addEmoji(emoji) {
// FIXME: remove after issue is resolved: https://github.com/nextcloud/nextcloud-vue/issues/3264
const temp = document.createElement('textarea')

const selection = document.getSelection()

const contentEditable = this.$refs.richContenteditable.$refs.contenteditable

// There is no select, or current selection does not start in the
// content editable element, so just append the emoji at the end.
if (!contentEditable.isSameNode(selection.anchorNode) && !contentEditable.contains(selection.anchorNode)) {
// Browsers add a "<br>" element as soon as some rich text is
// written in a content editable div (for example, if a new line
// is added the div content will be "<br><br>"), so the emoji
// has to be added before the last "<br>" (if any).
if (this.text.endsWith('<br>')) {
temp.innerHTML = this.text.slice(0, this.text.lastIndexOf('<br>')) + emoji + '<br>'
} else {
temp.innerHTML = this.text + emoji
}
this.text = temp.value
return
}

// Although due to legacy reasons the API allows several ranges the
// specification requires the selection to always have a single range.
// https://developer.mozilla.org/en-US/docs/Web/API/Selection#Multiple_ranges_in_a_selection
const range = selection.getRangeAt(0)

// Deleting the contents also collapses the range to the start.
range.deleteContents()

const emojiTextNode = document.createTextNode(emoji)
range.insertNode(emojiTextNode)
preserveSelectionRange() {
this.preservedSelectionRange = getCurrentSelectionRange()
},

this.text = contentEditable.innerHTML
/**
* Restore the preserved selection range (caret position)
*/
restoreSelectionRange() {
if (this.preservedSelectionRange) {
setCurrentSelectionRange(this.preservedSelectionRange)
this.preservedSelectionRange = null
}
},

range.setStartAfter(emojiTextNode)
/**
* Add selected emoji to the cursor position
* @param {string} emoji - Selected emoji
*/
addEmoji(emoji) {
const contenteditable = this.getContenteditable()
const range = this.preservedSelectionRange ?? getRangeAtEnd(contenteditable)
insertTextAtRange(emoji, range)
// FIXME: add a method to NcRichContenteditable to handle manual update
this.$refs.richContenteditable.updateValue(contenteditable.innerHTML)
},

handleAudioFile(payload) {
Expand Down Expand Up @@ -934,13 +923,13 @@ export default {
this.showPollDraftHandler = !this.showPollDraftHandler
},

focusInput() {
async focusInput() {
if (this.isMobileDevice) {
return
}
this.$nextTick().then(() => {
this.$refs.richContenteditable.focus()
})
await nextTick()
this.$refs.richContenteditable.focus()
this.restoreSelectionRange()
},

blurInput() {
Expand Down Expand Up @@ -1034,7 +1023,6 @@ export default {
.new-message-form {
--emoji-button-size: calc(var(--default-clickable-area) - var(--border-width-input-focused, 2px) * 2);
--emoji-button-radius: calc(var(--border-radius-element, calc(var(--button-size) / 2)) - var(--border-width-input-focused, 2px));

align-items: flex-end;
display: flex;
gap: var(--default-grid-baseline);
Expand Down
49 changes: 49 additions & 0 deletions src/utils/selectionRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

/**
* Get the current selection range if any
*/
export function getCurrentSelectionRange() {
const selection = window.getSelection()!
if (selection.rangeCount > 0) {
return selection.getRangeAt(0)
}
return null
}

/**
* Select a specific range as the current selection range
* @param range - Selection range
*/
export function setCurrentSelectionRange(range: Range) {
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(range)
}

/**
* Get a range at the end of the contenteditable element
* @param contenteditable - Contenteditable
*/
export function getRangeAtEnd(contenteditable: HTMLElement) {
const range = document.createRange()
range.selectNodeContents(contenteditable)
range.collapse()
return range
}

/**
* Insert text at a specific selection range
* @param text - Text to insert
* @param range - Selection range to insert the text to
*/
export function insertTextAtRange(text: string, range: Range) {
const textNode = document.createTextNode(text)
range.deleteContents()
range.insertNode(textNode)
range.setStartAfter(textNode)
range.collapse(true)
}
Loading