logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe
commit: 2bc1cc9ff9700df4e9938c86274d75a8a8f59e19
parent: 8cce505cf53a84b82c388ac006a39e4e636b6110
Author: Shpuld Shpludson <shp@cock.li>
Date:   Tue, 12 Feb 2019 14:55:18 +0000

Merge branch 'fix/no-autocomplete-in-non-post-forms' into 'develop'

#255 - implement autocomplete in non post forms

See merge request pleroma/pleroma-fe!551

Diffstat:

Asrc/components/autocomplete_input/autocomplete_input.js150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/autocomplete_input/autocomplete_input.vue104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/post_status_form/post_status_form.js129+++++--------------------------------------------------------------------------
Msrc/components/post_status_form/post_status_form.vue84++++++++++---------------------------------------------------------------------
Msrc/components/user_settings/user_settings.js4+++-
Msrc/components/user_settings/user_settings.vue4++--
6 files changed, 276 insertions(+), 199 deletions(-)

diff --git a/src/components/autocomplete_input/autocomplete_input.js b/src/components/autocomplete_input/autocomplete_input.js @@ -0,0 +1,150 @@ +import Completion from '../../services/completion/completion.js' +import { take, filter, map } from 'lodash' + +const AutoCompleteInput = { + props: [ + 'id', + 'classObj', + 'value', + 'placeholder', + 'autoResize', + 'multiline', + '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') || this.$el.querySelector('input') + 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 diff --git a/src/components/autocomplete_input/autocomplete_input.vue b/src/components/autocomplete_input/autocomplete_input.vue @@ -0,0 +1,104 @@ +<template> + <div style="display: flex; flex-direction: column;"> + <textarea + v-if="multiline" + ref="textarea" + rows="1" + :value="text" :class="classObj" :id="id" :placeholder="placeholder" + @input="text = $event.target.value, $emit('input', $event.target.value), autoResize && resize($event)" + @click="setCaret" + @keyup="setCaret" + @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> + <input + v-else + ref="textarea" + :value="text" :class="classObj" :id="id" :placeholder="placeholder" + @input="text = $event.target.value, $emit('input', $event.target.value), autoResize && resize($event)" + @click="setCaret" + @keyup="setCaret" + @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()"/> + <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> + +<style lang="scss"> +@import '../../_variables.scss'; + +.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; + border-radius: $fallback--avatarRadius; + border-radius: var(--avatarRadius, $fallback--avatarRadius); + 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> diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js @@ -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 + postStatusCopy () { + 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 }, diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue @@ -16,22 +16,16 @@ :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 }" + :placeholder="$t('post_status.default')" + :autoResize="true" + :multiline="true" + :drop="fileDrop" + :dragoverPrevent="fileDrag" + :paste="paste" + :keydownMetaEnter="postStatusCopy" + :keyupCtrlEnter="postStatusCopy"/> <div class="visibility-tray"> <span class="text-format" v-if="formattingOptionsEnabled"> <label for="post-content-type" class="select"> @@ -52,17 +46,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> @@ -250,52 +233,5 @@ cursor: pointer; 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; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - 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> diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js @@ -2,6 +2,7 @@ import { unescape } from 'lodash' import TabSwitcher from '../tab_switcher/tab_switcher.js' import StyleSwitcher from '../style_switcher/style_switcher.vue' +import AutoCompleteInput from '../autocomplete_input/autocomplete_input.vue' import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' const UserSettings = { @@ -41,7 +42,8 @@ const UserSettings = { }, components: { StyleSwitcher, - TabSwitcher + TabSwitcher, + AutoCompleteInput }, computed: { user () { diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue @@ -9,9 +9,9 @@ <div class="setting-item" > <h2>{{$t('settings.name_bio')}}</h2> <p>{{$t('settings.name')}}</p> - <input class='name-changer' id='username' v-model="newName"></input> + <auto-complete-input :classObj="{ 'name-changer': true }" :id="'username'" v-model="newName"/> <p>{{$t('settings.bio')}}</p> - <textarea class="bio" v-model="newBio"></textarea> + <auto-complete-input :classObj="{ bio: true }" v-model="newBio" :multiline="true"/> <p> <input type="checkbox" v-model="newLocked" id="account-locked"> <label for="account-locked">{{$t('settings.lock_account_description')}}</label>