Merge branch '255-emoji-input' into 'develop'

#255 - add emoji input component

Closes #255

See merge request pleroma/pleroma-fe!706
This commit is contained in:
Shpuld Shpludson 2019-03-28 21:09:48 +00:00
commit 25370083b6
7 changed files with 261 additions and 68 deletions

View file

@ -767,3 +767,54 @@ nav {
.btn.btn-default { .btn.btn-default {
min-height: 28px; min-height: 28px;
} }
.autocomplete {
&-panel {
position: relative;
&-body {
margin: 0 0.5em 0 0.5em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
position: absolute;
z-index: 1;
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
// this doesn't match original but i don't care, making it uniform.
box-shadow: var(--popupShadow);
min-width: 75%;
background: $fallback--bg;
background: var(--bg, $fallback--bg);
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
}
&-item {
cursor: pointer;
padding: 0.2em 0.4em 0.2em 0.4em;
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
display: flex;
img {
width: 24px;
height: 24px;
object-fit: contain;
}
span {
line-height: 24px;
margin: 0 0.1em 0 0.2em;
}
small {
margin-left: .5em;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
&.highlighted {
background-color: $fallback--fg;
background-color: var(--lightBg, $fallback--fg);
}
}
}

View file

@ -0,0 +1,107 @@
import Completion from '../../services/completion/completion.js'
import { take, filter, map } from 'lodash'
const EmojiInput = {
props: [
'value',
'placeholder',
'type',
'classname'
],
data () {
return {
highlighted: 0,
caret: 0
}
},
computed: {
suggestions () {
const firstchar = this.textAtCaret.charAt(0)
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) => ({
shortcode: `:${shortcode}:`,
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.value, this.caret - 1) || {}
return word
},
emoji () {
return this.$store.state.instance.emoji || []
},
customEmoji () {
return this.$store.state.instance.customEmoji || []
}
},
methods: {
replace (replacement) {
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
this.caret = 0
},
replaceEmoji (e) {
const len = this.suggestions.length || 0
if (this.textAtCaret === ':' || e.ctrlKey) { return }
if (len > 0) {
e.preventDefault()
const emoji = this.suggestions[this.highlighted]
const replacement = emoji.utf || (emoji.shortcode + ' ')
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
this.caret = 0
this.highlighted = 0
}
},
cycleBackward (e) {
const len = this.suggestions.length || 0
if (len > 0) {
e.preventDefault()
this.highlighted -= 1
if (this.highlighted < 0) {
this.highlighted = this.suggestions.length - 1
}
} else {
this.highlighted = 0
}
},
cycleForward (e) {
const len = this.suggestions.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
}
},
onKeydown (e) {
e.stopPropagation()
},
onInput (e) {
this.$emit('input', e.target.value)
},
setCaret ({target: {selectionStart}}) {
this.caret = selectionStart
}
}
}
export default EmojiInput

View file

@ -0,0 +1,64 @@
<template>
<div class="emoji-input">
<input
v-if="type !== 'textarea'"
:class="classname"
:type="type"
:value="value"
:placeholder="placeholder"
@input="onInput"
@click="setCaret"
@keyup="setCaret"
@keydown="onKeydown"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceEmoji"
/>
<textarea
v-else
:class="classname"
:value="value"
:placeholder="placeholder"
@input="onInput"
@click="setCaret"
@keyup="setCaret"
@keydown="onKeydown"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceEmoji"
></textarea>
<div class="autocomplete-panel" v-if="suggestions">
<div class="autocomplete-panel-body">
<div
v-for="(emoji, index) in suggestions"
:key="index"
@click="replace(emoji.utf || (emoji.shortcode + ' '))"
class="autocomplete-item"
:class="{ highlighted: emoji.highlighted }"
>
<span v-if="emoji.img">
<img :src="emoji.img" />
</span>
<span v-else>{{emoji.utf}}</span>
<span>{{emoji.shortcode}}</span>
</div>
</div>
</div>
</div>
</template>
<script src="./emoji-input.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.emoji-input {
.form-control {
width: 100%;
}
}
</style>

View file

@ -1,5 +1,6 @@
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 EmojiInput from '../emoji-input/emoji-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 Completion from '../../services/completion/completion.js'
import { take, filter, reject, map, uniqBy } from 'lodash' import { take, filter, reject, map, uniqBy } from 'lodash'
@ -28,7 +29,8 @@ const PostStatusForm = {
'subject' 'subject'
], ],
components: { components: {
MediaUpload MediaUpload,
EmojiInput
}, },
mounted () { mounted () {
this.resize(this.$refs.textarea) this.resize(this.$refs.textarea)

View file

@ -10,12 +10,13 @@
<router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link> <router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link>
</i18n> </i18n>
<p v-if="this.newStatus.visibility == 'direct'" class="visibility-notice">{{ $t('post_status.direct_warning') }}</p> <p v-if="this.newStatus.visibility == 'direct'" class="visibility-notice">{{ $t('post_status.direct_warning') }}</p>
<input <EmojiInput
v-if="newStatus.spoilerText || alwaysShowSubject" v-if="newStatus.spoilerText || alwaysShowSubject"
type="text" type="text"
:placeholder="$t('post_status.content_warning')" :placeholder="$t('post_status.content_warning')"
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
class="form-cw"> classname="form-control"
/>
<textarea <textarea
ref="textarea" ref="textarea"
@click="setCaret" @click="setCaret"
@ -55,17 +56,21 @@
</div> </div>
</div> </div>
</div> </div>
<div style="position:relative;" v-if="candidates"> <div class="autocomplete-panel" v-if="candidates">
<div class="autocomplete-panel"> <div class="autocomplete-panel-body">
<div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))"> <div
<div class="autocomplete" :class="{ highlighted: candidate.highlighted }"> v-for="(candidate, index) in candidates"
<span v-if="candidate.img"><img :src="candidate.img"></img></span> :key="index"
@click="replace(candidate.utf || (candidate.screen_name + ' '))"
class="autocomplete-item"
:class="{ highlighted: candidate.highlighted }"
>
<span v-if="candidate.img"><img :src="candidate.img" /></span>
<span v-else>{{candidate.utf}}</span> <span v-else>{{candidate.utf}}</span>
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span> <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
</div> </div>
</div> </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>
@ -261,50 +266,5 @@
cursor: pointer; cursor: pointer;
z-index: 4; z-index: 4;
} }
.autocomplete-panel {
margin: 0 0.5em 0 0.5em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
position: absolute;
z-index: 1;
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
// this doesn't match original but i don't care, making it uniform.
box-shadow: var(--popupShadow);
min-width: 75%;
background: $fallback--bg;
background: var(--bg, $fallback--bg);
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
.autocomplete {
cursor: pointer;
padding: 0.2em 0.4em 0.2em 0.4em;
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
display: flex;
img {
width: 24px;
height: 24px;
object-fit: contain;
}
span {
line-height: 24px;
margin: 0 0.1em 0 0.2em;
}
small {
margin-left: .5em;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
&.highlighted {
background-color: $fallback--fg;
background-color: var(--lightBg, $fallback--fg);
}
}
} }
</style> </style>

View file

@ -7,6 +7,7 @@ import StyleSwitcher from '../style_switcher/style_switcher.vue'
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
import BlockCard from '../block_card/block_card.vue' import BlockCard from '../block_card/block_card.vue'
import MuteCard from '../mute_card/mute_card.vue' import MuteCard from '../mute_card/mute_card.vue'
import EmojiInput from '../emoji-input/emoji-input.vue'
import withSubscription from '../../hocs/with_subscription/with_subscription' import withSubscription from '../../hocs/with_subscription/with_subscription'
import withList from '../../hocs/with_list/with_list' import withList from '../../hocs/with_list/with_list'
@ -69,7 +70,8 @@ const UserSettings = {
TabSwitcher, TabSwitcher,
ImageCropper, ImageCropper,
BlockList, BlockList,
MuteList MuteList,
EmojiInput
}, },
computed: { computed: {
user () { user () {

View file

@ -22,9 +22,18 @@
<div class="setting-item" > <div class="setting-item" >
<h2>{{$t('settings.name_bio')}}</h2> <h2>{{$t('settings.name_bio')}}</h2>
<p>{{$t('settings.name')}}</p> <p>{{$t('settings.name')}}</p>
<input class='name-changer' id='username' v-model="newName"></input> <EmojiInput
type="text"
v-model="newName"
id="username"
classname="name-changer"
/>
<p>{{$t('settings.bio')}}</p> <p>{{$t('settings.bio')}}</p>
<textarea class="bio" v-model="newBio"></textarea> <EmojiInput
type="textarea"
v-model="newBio"
classname="bio"
/>
<p> <p>
<input type="checkbox" v-model="newLocked" id="account-locked"> <input type="checkbox" v-model="newLocked" id="account-locked">
<label for="account-locked">{{$t('settings.lock_account_description')}}</label> <label for="account-locked">{{$t('settings.lock_account_description')}}</label>
@ -61,7 +70,7 @@
<h2>{{$t('settings.avatar')}}</h2> <h2>{{$t('settings.avatar')}}</h2>
<p class="visibility-notice">{{$t('settings.avatar_size_instruction')}}</p> <p class="visibility-notice">{{$t('settings.avatar_size_instruction')}}</p>
<p>{{$t('settings.current_avatar')}}</p> <p>{{$t('settings.current_avatar')}}</p>
<img :src="user.profile_image_url_original" class="current-avatar"></img> <img :src="user.profile_image_url_original" class="current-avatar" />
<p>{{$t('settings.set_new_avatar')}}</p> <p>{{$t('settings.set_new_avatar')}}</p>
<button class="btn" type="button" id="pick-avatar" v-show="pickAvatarBtnVisible">{{$t('settings.upload_a_photo')}}</button> <button class="btn" type="button" id="pick-avatar" v-show="pickAvatarBtnVisible">{{$t('settings.upload_a_photo')}}</button>
<image-cropper trigger="#pick-avatar" :submitHandler="submitAvatar" @open="pickAvatarBtnVisible=false" @close="pickAvatarBtnVisible=true" /> <image-cropper trigger="#pick-avatar" :submitHandler="submitAvatar" @open="pickAvatarBtnVisible=false" @close="pickAvatarBtnVisible=true" />
@ -69,12 +78,11 @@
<div class="setting-item"> <div class="setting-item">
<h2>{{$t('settings.profile_banner')}}</h2> <h2>{{$t('settings.profile_banner')}}</h2>
<p>{{$t('settings.current_profile_banner')}}</p> <p>{{$t('settings.current_profile_banner')}}</p>
<img :src="user.cover_photo" class="banner"></img> <img :src="user.cover_photo" class="banner" />
<p>{{$t('settings.set_new_profile_banner')}}</p> <p>{{$t('settings.set_new_profile_banner')}}</p>
<img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview"> <img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview" />
</img>
<div> <div>
<input type="file" @change="uploadFile('banner', $event)" ></input> <input type="file" @change="uploadFile('banner', $event)" />
</div> </div>
<i class=" icon-spin4 animate-spin uploading" v-if="bannerUploading"></i> <i class=" icon-spin4 animate-spin uploading" v-if="bannerUploading"></i>
<button class="btn btn-default" v-else-if="bannerPreview" @click="submitBanner">{{$t('general.submit')}}</button> <button class="btn btn-default" v-else-if="bannerPreview" @click="submitBanner">{{$t('general.submit')}}</button>
@ -86,10 +94,9 @@
<div class="setting-item"> <div class="setting-item">
<h2>{{$t('settings.profile_background')}}</h2> <h2>{{$t('settings.profile_background')}}</h2>
<p>{{$t('settings.set_new_profile_background')}}</p> <p>{{$t('settings.set_new_profile_background')}}</p>
<img class="bg" v-bind:src="backgroundPreview" v-if="backgroundPreview"> <img class="bg" v-bind:src="backgroundPreview" v-if="backgroundPreview" />
</img>
<div> <div>
<input type="file" @change="uploadFile('background', $event)" ></input> <input type="file" @change="uploadFile('background', $event)" />
</div> </div>
<i class=" icon-spin4 animate-spin uploading" v-if="backgroundUploading"></i> <i class=" icon-spin4 animate-spin uploading" v-if="backgroundUploading"></i>
<button class="btn btn-default" v-else-if="backgroundPreview" @click="submitBg">{{$t('general.submit')}}</button> <button class="btn btn-default" v-else-if="backgroundPreview" @click="submitBg">{{$t('general.submit')}}</button>
@ -165,7 +172,7 @@
<h2>{{$t('settings.follow_import')}}</h2> <h2>{{$t('settings.follow_import')}}</h2>
<p>{{$t('settings.import_followers_from_a_csv_file')}}</p> <p>{{$t('settings.import_followers_from_a_csv_file')}}</p>
<form> <form>
<input type="file" ref="followlist" v-on:change="followListChange"></input> <input type="file" ref="followlist" v-on:change="followListChange" />
</form> </form>
<i class=" icon-spin4 animate-spin uploading" v-if="followListUploading"></i> <i class=" icon-spin4 animate-spin uploading" v-if="followListUploading"></i>
<button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button> <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button>