#255 - make AutoCompleteInput component
This commit is contained in:
parent
da0a5535eb
commit
fb0cf64549
4 changed files with 196 additions and 149 deletions
147
src/components/autocomplete_input/autocomplete_input.js
Normal file
147
src/components/autocomplete_input/autocomplete_input.js
Normal file
|
@ -0,0 +1,147 @@
|
|||
import Completion from '../../services/completion/completion.js'
|
||||
import { take, filter, map } from 'lodash'
|
||||
|
||||
const AutoCompleteInput = {
|
||||
props: [
|
||||
'classObj',
|
||||
'value',
|
||||
'autoResize',
|
||||
'drop',
|
||||
'dragoverPrevent',
|
||||
'paste',
|
||||
'keydownMetaEnter',
|
||||
'keyupCtrlEnter'
|
||||
],
|
||||
components: {},
|
||||
mounted () {
|
||||
this.autoResize && this.resize(this.$refs.textarea)
|
||||
const textLength = this.$refs.textarea.value.length
|
||||
this.$refs.textarea.setSelectionRange(textLength, textLength)
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
caret: 0,
|
||||
highlighted: 0,
|
||||
text: this.value
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
users () {
|
||||
return this.$store.state.users.users
|
||||
},
|
||||
emoji () {
|
||||
return this.$store.state.instance.emoji || []
|
||||
},
|
||||
customEmoji () {
|
||||
return this.$store.state.instance.customEmoji || []
|
||||
},
|
||||
textAtCaret () {
|
||||
return (this.wordAtCaret || {}).word || ''
|
||||
},
|
||||
wordAtCaret () {
|
||||
const word = Completion.wordAtPosition(this.text, this.caret - 1) || {}
|
||||
return word
|
||||
},
|
||||
candidates () {
|
||||
const firstchar = this.textAtCaret.charAt(0)
|
||||
if (firstchar === '@') {
|
||||
const query = this.textAtCaret.slice(1).toUpperCase()
|
||||
const matchedUsers = filter(this.users, (user) => {
|
||||
return user.screen_name.toUpperCase().startsWith(query) ||
|
||||
user.name && user.name.toUpperCase().startsWith(query)
|
||||
})
|
||||
if (matchedUsers.length <= 0) {
|
||||
return false
|
||||
}
|
||||
// eslint-disable-next-line camelcase
|
||||
return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({
|
||||
// eslint-disable-next-line camelcase
|
||||
screen_name: `@${screen_name}`,
|
||||
name: name,
|
||||
img: profile_image_url_original,
|
||||
highlighted: index === this.highlighted
|
||||
}))
|
||||
} else if (firstchar === ':') {
|
||||
if (this.textAtCaret === ':') { return }
|
||||
const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1)))
|
||||
if (matchedEmoji.length <= 0) {
|
||||
return false
|
||||
}
|
||||
return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({
|
||||
screen_name: `:${shortcode}:`,
|
||||
name: '',
|
||||
utf: utf || '',
|
||||
// eslint-disable-next-line camelcase
|
||||
img: utf ? '' : this.$store.state.instance.server + image_url,
|
||||
highlighted: index === this.highlighted
|
||||
}))
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setCaret ({target: {selectionStart}}) {
|
||||
this.caret = selectionStart
|
||||
},
|
||||
cycleBackward (e) {
|
||||
const len = this.candidates.length || 0
|
||||
if (len > 0) {
|
||||
e.preventDefault()
|
||||
this.highlighted -= 1
|
||||
if (this.highlighted < 0) {
|
||||
this.highlighted = this.candidates.length - 1
|
||||
}
|
||||
} else {
|
||||
this.highlighted = 0
|
||||
}
|
||||
},
|
||||
cycleForward (e) {
|
||||
const len = this.candidates.length || 0
|
||||
if (len > 0) {
|
||||
if (e.shiftKey) { return }
|
||||
e.preventDefault()
|
||||
this.highlighted += 1
|
||||
if (this.highlighted >= len) {
|
||||
this.highlighted = 0
|
||||
}
|
||||
} else {
|
||||
this.highlighted = 0
|
||||
}
|
||||
},
|
||||
replace (replacement) {
|
||||
this.text = Completion.replaceWord(this.text, this.wordAtCaret, replacement)
|
||||
const el = this.$el.querySelector('textarea')
|
||||
el.focus()
|
||||
this.caret = 0
|
||||
},
|
||||
replaceCandidate (e) {
|
||||
const len = this.candidates.length || 0
|
||||
if (this.textAtCaret === ':' || e.ctrlKey) { return }
|
||||
if (len > 0) {
|
||||
e.preventDefault()
|
||||
const candidate = this.candidates[this.highlighted]
|
||||
const replacement = candidate.utf || (candidate.screen_name + ' ')
|
||||
this.text = Completion.replaceWord(this.text, this.wordAtCaret, replacement)
|
||||
const el = this.$el.querySelector('textarea')
|
||||
el.focus()
|
||||
this.caret = 0
|
||||
this.highlighted = 0
|
||||
}
|
||||
},
|
||||
resize (e) {
|
||||
const target = e.target || e
|
||||
if (!(target instanceof window.Element)) { return }
|
||||
const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) +
|
||||
Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1))
|
||||
// Auto is needed to make textbox shrink when removing lines
|
||||
target.style.height = 'auto'
|
||||
target.style.height = `${target.scrollHeight - vertPadding}px`
|
||||
if (target.value === '') {
|
||||
target.style.height = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AutoCompleteInput
|
34
src/components/autocomplete_input/autocomplete_input.vue
Normal file
34
src/components/autocomplete_input/autocomplete_input.vue
Normal file
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<div>
|
||||
<textarea
|
||||
ref="textarea"
|
||||
:value="text"
|
||||
@input="text = $event.target.value, $emit('input', $event.target.value), autoResize && resize($event)"
|
||||
@click="setCaret"
|
||||
@keyup="setCaret" :placeholder="$t('post_status.default')" rows="1" :class="classObj"
|
||||
@keydown.down="cycleForward"
|
||||
@keydown.up="cycleBackward"
|
||||
@keydown.shift.tab="cycleBackward"
|
||||
@keydown.tab="cycleForward"
|
||||
@keydown.enter="replaceCandidate"
|
||||
@drop="drop && drop()"
|
||||
@dragover.prevent="dragoverPrevent && dragoverPrevent()"
|
||||
@paste="paste && paste()"
|
||||
@keydown.meta.enter="keydownMetaEnter && keydownMetaEnter()"
|
||||
@keyup.ctrl.enter="keyupCtrlEnter && keyupCtrlEnter()">
|
||||
</textarea>
|
||||
<div style="position:relative;" v-if="candidates">
|
||||
<div class="autocomplete-panel">
|
||||
<div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))">
|
||||
<div class="autocomplete" :class="{ highlighted: candidate.highlighted }">
|
||||
<span v-if="candidate.img"><img :src="candidate.img"></img></span>
|
||||
<span v-else>{{candidate.utf}}</span>
|
||||
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./autocomplete_input.js"></script>
|
|
@ -1,8 +1,8 @@
|
|||
import statusPoster from '../../services/status_poster/status_poster.service.js'
|
||||
import MediaUpload from '../media_upload/media_upload.vue'
|
||||
import AutoCompleteInput from '../autocomplete_input/autocomplete_input.vue'
|
||||
import fileTypeService from '../../services/file_type/file_type.service.js'
|
||||
import Completion from '../../services/completion/completion.js'
|
||||
import { take, filter, reject, map, uniqBy } from 'lodash'
|
||||
import { reject, map, uniqBy } from 'lodash'
|
||||
|
||||
const buildMentionsString = ({user, attentions}, currentUser) => {
|
||||
let allAttentions = [...attentions]
|
||||
|
@ -28,13 +28,10 @@ const PostStatusForm = {
|
|||
'subject'
|
||||
],
|
||||
components: {
|
||||
MediaUpload
|
||||
MediaUpload,
|
||||
AutoCompleteInput
|
||||
},
|
||||
mounted () {
|
||||
this.resize(this.$refs.textarea)
|
||||
const textLength = this.$refs.textarea.value.length
|
||||
this.$refs.textarea.setSelectionRange(textLength, textLength)
|
||||
|
||||
if (this.replyTo) {
|
||||
this.$refs.textarea.focus()
|
||||
}
|
||||
|
@ -61,15 +58,13 @@ const PostStatusForm = {
|
|||
submitDisabled: false,
|
||||
error: null,
|
||||
posting: false,
|
||||
highlighted: 0,
|
||||
newStatus: {
|
||||
spoilerText: this.subject || '',
|
||||
status: statusText,
|
||||
nsfw: false,
|
||||
files: [],
|
||||
visibility: scope
|
||||
},
|
||||
caret: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
@ -81,59 +76,6 @@ const PostStatusForm = {
|
|||
direct: { selected: this.newStatus.visibility === 'direct' }
|
||||
}
|
||||
},
|
||||
candidates () {
|
||||
const firstchar = this.textAtCaret.charAt(0)
|
||||
if (firstchar === '@') {
|
||||
const query = this.textAtCaret.slice(1).toUpperCase()
|
||||
const matchedUsers = filter(this.users, (user) => {
|
||||
return user.screen_name.toUpperCase().startsWith(query) ||
|
||||
user.name && user.name.toUpperCase().startsWith(query)
|
||||
})
|
||||
if (matchedUsers.length <= 0) {
|
||||
return false
|
||||
}
|
||||
// eslint-disable-next-line camelcase
|
||||
return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({
|
||||
// eslint-disable-next-line camelcase
|
||||
screen_name: `@${screen_name}`,
|
||||
name: name,
|
||||
img: profile_image_url_original,
|
||||
highlighted: index === this.highlighted
|
||||
}))
|
||||
} else if (firstchar === ':') {
|
||||
if (this.textAtCaret === ':') { return }
|
||||
const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1)))
|
||||
if (matchedEmoji.length <= 0) {
|
||||
return false
|
||||
}
|
||||
return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({
|
||||
screen_name: `:${shortcode}:`,
|
||||
name: '',
|
||||
utf: utf || '',
|
||||
// eslint-disable-next-line camelcase
|
||||
img: utf ? '' : this.$store.state.instance.server + image_url,
|
||||
highlighted: index === this.highlighted
|
||||
}))
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
textAtCaret () {
|
||||
return (this.wordAtCaret || {}).word || ''
|
||||
},
|
||||
wordAtCaret () {
|
||||
const word = Completion.wordAtPosition(this.newStatus.status, this.caret - 1) || {}
|
||||
return word
|
||||
},
|
||||
users () {
|
||||
return this.$store.state.users.users
|
||||
},
|
||||
emoji () {
|
||||
return this.$store.state.instance.emoji || []
|
||||
},
|
||||
customEmoji () {
|
||||
return this.$store.state.instance.customEmoji || []
|
||||
},
|
||||
statusLength () {
|
||||
return this.newStatus.status.length
|
||||
},
|
||||
|
@ -174,53 +116,8 @@ const PostStatusForm = {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
replace (replacement) {
|
||||
this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement)
|
||||
const el = this.$el.querySelector('textarea')
|
||||
el.focus()
|
||||
this.caret = 0
|
||||
},
|
||||
replaceCandidate (e) {
|
||||
const len = this.candidates.length || 0
|
||||
if (this.textAtCaret === ':' || e.ctrlKey) { return }
|
||||
if (len > 0) {
|
||||
e.preventDefault()
|
||||
const candidate = this.candidates[this.highlighted]
|
||||
const replacement = candidate.utf || (candidate.screen_name + ' ')
|
||||
this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement)
|
||||
const el = this.$el.querySelector('textarea')
|
||||
el.focus()
|
||||
this.caret = 0
|
||||
this.highlighted = 0
|
||||
}
|
||||
},
|
||||
cycleBackward (e) {
|
||||
const len = this.candidates.length || 0
|
||||
if (len > 0) {
|
||||
e.preventDefault()
|
||||
this.highlighted -= 1
|
||||
if (this.highlighted < 0) {
|
||||
this.highlighted = this.candidates.length - 1
|
||||
}
|
||||
} else {
|
||||
this.highlighted = 0
|
||||
}
|
||||
},
|
||||
cycleForward (e) {
|
||||
const len = this.candidates.length || 0
|
||||
if (len > 0) {
|
||||
if (e.shiftKey) { return }
|
||||
e.preventDefault()
|
||||
this.highlighted += 1
|
||||
if (this.highlighted >= len) {
|
||||
this.highlighted = 0
|
||||
}
|
||||
} else {
|
||||
this.highlighted = 0
|
||||
}
|
||||
},
|
||||
setCaret ({target: {selectionStart}}) {
|
||||
this.caret = selectionStart
|
||||
postStatus () {
|
||||
this.postStatus(this.newStatus)
|
||||
},
|
||||
postStatus (newStatus) {
|
||||
if (this.posting) { return }
|
||||
|
@ -305,18 +202,6 @@ const PostStatusForm = {
|
|||
fileDrag (e) {
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
},
|
||||
resize (e) {
|
||||
const target = e.target || e
|
||||
if (!(target instanceof window.Element)) { return }
|
||||
const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) +
|
||||
Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1))
|
||||
// Auto is needed to make textbox shrink when removing lines
|
||||
target.style.height = 'auto'
|
||||
target.style.height = `${target.scrollHeight - vertPadding}px`
|
||||
if (target.value === '') {
|
||||
target.style.height = null
|
||||
}
|
||||
},
|
||||
clearError () {
|
||||
this.error = null
|
||||
},
|
||||
|
|
|
@ -16,22 +16,14 @@
|
|||
:placeholder="$t('post_status.content_warning')"
|
||||
v-model="newStatus.spoilerText"
|
||||
class="form-cw">
|
||||
<textarea
|
||||
ref="textarea"
|
||||
@click="setCaret"
|
||||
@keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control"
|
||||
@keydown.down="cycleForward"
|
||||
@keydown.up="cycleBackward"
|
||||
@keydown.shift.tab="cycleBackward"
|
||||
@keydown.tab="cycleForward"
|
||||
@keydown.enter="replaceCandidate"
|
||||
@keydown.meta.enter="postStatus(newStatus)"
|
||||
@keyup.ctrl.enter="postStatus(newStatus)"
|
||||
@drop="fileDrop"
|
||||
@dragover.prevent="fileDrag"
|
||||
@input="resize"
|
||||
@paste="paste">
|
||||
</textarea>
|
||||
<auto-complete-input v-model="newStatus.status"
|
||||
:classObj="{ 'form-control': true }"
|
||||
:autoResize="true"
|
||||
:drop="fileDrop"
|
||||
:dragoverPrevent="fileDrag"
|
||||
:paste="paste"
|
||||
:keydownMetaEnter="postStatus"
|
||||
:keyupCtrlEnter="postStatus"/>
|
||||
<div class="visibility-tray">
|
||||
<span class="text-format" v-if="formattingOptionsEnabled">
|
||||
<label for="post-content-type" class="select">
|
||||
|
@ -52,17 +44,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:relative;" v-if="candidates">
|
||||
<div class="autocomplete-panel">
|
||||
<div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))">
|
||||
<div class="autocomplete" :class="{ highlighted: candidate.highlighted }">
|
||||
<span v-if="candidate.img"><img :src="candidate.img"></img></span>
|
||||
<span v-else>{{candidate.utf}}</span>
|
||||
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='form-bottom'>
|
||||
<media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload>
|
||||
|
||||
|
|
Loading…
Reference in a new issue