#255 - make AutoCompleteInput component

This commit is contained in:
Xiaofeng An 2019-02-08 10:49:14 -05:00
parent da0a5535eb
commit fb0cf64549
4 changed files with 196 additions and 149 deletions

View 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

View 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>

View file

@ -1,8 +1,8 @@
import statusPoster from '../../services/status_poster/status_poster.service.js' import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue' 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 fileTypeService from '../../services/file_type/file_type.service.js'
import Completion from '../../services/completion/completion.js' import { reject, map, uniqBy } from 'lodash'
import { take, filter, reject, map, uniqBy } from 'lodash'
const buildMentionsString = ({user, attentions}, currentUser) => { const buildMentionsString = ({user, attentions}, currentUser) => {
let allAttentions = [...attentions] let allAttentions = [...attentions]
@ -28,13 +28,10 @@ const PostStatusForm = {
'subject' 'subject'
], ],
components: { components: {
MediaUpload MediaUpload,
AutoCompleteInput
}, },
mounted () { mounted () {
this.resize(this.$refs.textarea)
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
if (this.replyTo) { if (this.replyTo) {
this.$refs.textarea.focus() this.$refs.textarea.focus()
} }
@ -61,15 +58,13 @@ const PostStatusForm = {
submitDisabled: false, submitDisabled: false,
error: null, error: null,
posting: false, posting: false,
highlighted: 0,
newStatus: { newStatus: {
spoilerText: this.subject || '', spoilerText: this.subject || '',
status: statusText, status: statusText,
nsfw: false, nsfw: false,
files: [], files: [],
visibility: scope visibility: scope
}, }
caret: 0
} }
}, },
computed: { computed: {
@ -81,59 +76,6 @@ const PostStatusForm = {
direct: { selected: this.newStatus.visibility === 'direct' } 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 () { statusLength () {
return this.newStatus.status.length return this.newStatus.status.length
}, },
@ -174,53 +116,8 @@ const PostStatusForm = {
} }
}, },
methods: { methods: {
replace (replacement) { postStatus () {
this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) this.postStatus(this.newStatus)
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 (newStatus) { postStatus (newStatus) {
if (this.posting) { return } if (this.posting) { return }
@ -305,18 +202,6 @@ const PostStatusForm = {
fileDrag (e) { fileDrag (e) {
e.dataTransfer.dropEffect = 'copy' 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 () { clearError () {
this.error = null this.error = null
}, },

View file

@ -16,22 +16,14 @@
:placeholder="$t('post_status.content_warning')" :placeholder="$t('post_status.content_warning')"
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
class="form-cw"> class="form-cw">
<textarea <auto-complete-input v-model="newStatus.status"
ref="textarea" :classObj="{ 'form-control': true }"
@click="setCaret" :autoResize="true"
@keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control" :drop="fileDrop"
@keydown.down="cycleForward" :dragoverPrevent="fileDrag"
@keydown.up="cycleBackward" :paste="paste"
@keydown.shift.tab="cycleBackward" :keydownMetaEnter="postStatus"
@keydown.tab="cycleForward" :keyupCtrlEnter="postStatus"/>
@keydown.enter="replaceCandidate"
@keydown.meta.enter="postStatus(newStatus)"
@keyup.ctrl.enter="postStatus(newStatus)"
@drop="fileDrop"
@dragover.prevent="fileDrag"
@input="resize"
@paste="paste">
</textarea>
<div class="visibility-tray"> <div class="visibility-tray">
<span class="text-format" v-if="formattingOptionsEnabled"> <span class="text-format" v-if="formattingOptionsEnabled">
<label for="post-content-type" class="select"> <label for="post-content-type" class="select">
@ -52,17 +44,6 @@
</div> </div>
</div> </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'> <div class='form-bottom'>
<media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload> <media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload>