forked from AkkomaGang/akkoma-fe
#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 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
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue