diff options
| author | HJ <30-hj@users.noreply.git.pleroma.social> | 2019-09-26 05:27:59 +0000 |
|---|---|---|
| committer | HJ <30-hj@users.noreply.git.pleroma.social> | 2019-09-26 05:27:59 +0000 |
| commit | 501208d350a9bd0fbcafb13d70a2fa6182fb8cf3 (patch) | |
| tree | beb8a09c2033d5c3da6dcf49a1fe259e1986c4d0 /src | |
| parent | e55645aec16f083e4eedf6b01954b79689c244f1 (diff) | |
| parent | 836cb817d18f5e3ee6dab446633b8e05733c2a3a (diff) | |
Merge branch 'emoji-selector-update' into 'develop'
Emoji selector update
Closes #101
See merge request pleroma/pleroma-fe!895
Diffstat (limited to 'src')
25 files changed, 972 insertions, 155 deletions
diff --git a/src/boot/after_store.js b/src/boot/after_store.js index 5cb2acba..490ac4d0 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -184,7 +184,7 @@ const getStaticEmoji = async ({ store }) => { imageUrl: false, replacement: values[key] } - }) + }).sort((a, b) => a.displayText - b.displayText) store.dispatch('setInstanceOption', { name: 'emoji', value: emoji }) } else { throw (res) @@ -203,14 +203,16 @@ const getCustomEmoji = async ({ store }) => { if (res.ok) { const result = await res.json() const values = Array.isArray(result) ? Object.assign({}, ...result) : result - const emoji = Object.keys(values).map((key) => { - const imageUrl = values[key].image_url + const emoji = Object.entries(values).map(([key, value]) => { + const imageUrl = value.image_url return { displayText: key, - imageUrl: imageUrl ? store.state.instance.server + imageUrl : values[key], + imageUrl: imageUrl ? store.state.instance.server + imageUrl : value, + tags: imageUrl ? value.tags.sort((a, b) => a > b ? 1 : 0) : ['utf'], replacement: `:${key}: ` } - }) + // Technically could use tags but those are kinda useless right now, should have been "pack" field, that would be more useful + }).sort((a, b) => a.displayText.toLowerCase() > b.displayText.toLowerCase() ? 1 : 0) store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji }) store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true }) } else { diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji_input/emoji_input.js index fab64a69..a586b819 100644 --- a/src/components/emoji-input/emoji-input.js +++ b/src/components/emoji_input/emoji_input.js @@ -1,5 +1,7 @@ import Completion from '../../services/completion/completion.js' +import EmojiPicker from '../emoji_picker/emoji_picker.vue' import { take } from 'lodash' +import { findOffset } from '../../services/offset_finder/offset_finder.service.js' /** * EmojiInput - augmented inputs for emoji and autocomplete support in inputs @@ -52,6 +54,31 @@ const EmojiInput = { */ required: true, type: String + }, + enableEmojiPicker: { + /** + * Enables emoji picker support, this implies that custom emoji are supported + */ + required: false, + type: Boolean, + default: false + }, + hideEmojiButton: { + /** + * intended to use with external picker trigger, i.e. you have a button outside + * input that will open up the picker, see triggerShowPicker() + */ + required: false, + type: Boolean, + default: false + }, + enableStickerPicker: { + /** + * Enables sticker picker support, only makes sense when enableEmojiPicker=true + */ + required: false, + type: Boolean, + default: false } }, data () { @@ -60,10 +87,20 @@ const EmojiInput = { highlighted: 0, caret: 0, focused: false, - blurTimeout: null + blurTimeout: null, + showPicker: false, + temporarilyHideSuggestions: false, + keepOpen: false, + disableClickOutside: false } }, + components: { + EmojiPicker + }, computed: { + padEmoji () { + return this.$store.state.config.padEmoji + }, suggestions () { const firstchar = this.textAtCaret.charAt(0) if (this.textAtCaret === firstchar) { return [] } @@ -79,8 +116,12 @@ const EmojiInput = { highlighted: index === this.highlighted })) }, - showPopup () { - return this.focused && this.suggestions && this.suggestions.length > 0 + showSuggestions () { + return this.focused && + this.suggestions && + this.suggestions.length > 0 && + !this.showPicker && + !this.temporarilyHideSuggestions }, textAtCaret () { return (this.wordAtCaret || {}).word || '' @@ -104,6 +145,7 @@ const EmojiInput = { input.elm.addEventListener('paste', this.onPaste) input.elm.addEventListener('keyup', this.onKeyUp) input.elm.addEventListener('keydown', this.onKeyDown) + input.elm.addEventListener('click', this.onClickInput) input.elm.addEventListener('transitionend', this.onTransition) input.elm.addEventListener('compositionupdate', this.onCompositionUpdate) }, @@ -115,16 +157,80 @@ const EmojiInput = { input.elm.removeEventListener('paste', this.onPaste) input.elm.removeEventListener('keyup', this.onKeyUp) input.elm.removeEventListener('keydown', this.onKeyDown) + input.elm.removeEventListener('click', this.onClickInput) input.elm.removeEventListener('transitionend', this.onTransition) input.elm.removeEventListener('compositionupdate', this.onCompositionUpdate) } }, methods: { + triggerShowPicker () { + this.showPicker = true + this.$nextTick(() => { + this.scrollIntoView() + }) + // This temporarily disables "click outside" handler + // since external trigger also means click originates + // from outside, thus preventing picker from opening + this.disableClickOutside = true + setTimeout(() => { + this.disableClickOutside = false + }, 0) + }, + togglePicker () { + this.input.elm.focus() + this.showPicker = !this.showPicker + if (this.showPicker) { + this.scrollIntoView() + } + }, replace (replacement) { const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement) this.$emit('input', newValue) this.caret = 0 }, + insert ({ insertion, keepOpen }) { + const before = this.value.substring(0, this.caret) || '' + const after = this.value.substring(this.caret) || '' + + /* Using a bit more smart approach to padding emojis with spaces: + * - put a space before cursor if there isn't one already, unless we + * are at the beginning of post or in spam mode + * - put a space after emoji if there isn't one already unless we are + * in spam mode + * + * The idea is that when you put a cursor somewhere in between sentence + * inserting just ' :emoji: ' will add more spaces to post which might + * break the flow/spacing, as well as the case where user ends sentence + * with a space before adding emoji. + * + * Spam mode is intended for creating multi-part emojis and overall spamming + * them, masto seem to be rendering :emoji::emoji: correctly now so why not + */ + const isSpaceRegex = /\s/ + const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0 ? ' ' : '' + const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : '' + + const newValue = [ + before, + spaceBefore, + insertion, + spaceAfter, + after + ].join('') + this.keepOpen = keepOpen + this.$emit('input', newValue) + const position = this.caret + (insertion + spaceAfter + spaceBefore).length + if (!keepOpen) { + this.input.elm.focus() + } + + this.$nextTick(function () { + // Re-focus inputbox after clicking suggestion + // Set selection right after the replacement instead of the very end + this.input.elm.setSelectionRange(position, position) + this.caret = position + }) + }, replaceText (e, suggestion) { const len = this.suggestions.length || 0 if (this.textAtCaret.length === 1) { return } @@ -148,7 +254,7 @@ const EmojiInput = { }, cycleBackward (e) { const len = this.suggestions.length || 0 - if (len > 0) { + if (len > 1) { this.highlighted -= 1 if (this.highlighted < 0) { this.highlighted = this.suggestions.length - 1 @@ -160,7 +266,7 @@ const EmojiInput = { }, cycleForward (e) { const len = this.suggestions.length || 0 - if (len > 0) { + if (len > 1) { this.highlighted += 1 if (this.highlighted >= len) { this.highlighted = 0 @@ -170,6 +276,37 @@ const EmojiInput = { this.highlighted = 0 } }, + scrollIntoView () { + const rootRef = this.$refs['picker'].$el + /* Scroller is either `window` (replies in TL), sidebar (main post form, + * replies in notifs) or mobile post form. Note that getting and setting + * scroll is different for `Window` and `Element`s + */ + const scrollerRef = this.$el.closest('.sidebar-scroller') || + this.$el.closest('.post-form-modal-view') || + window + const currentScroll = scrollerRef === window + ? scrollerRef.scrollY + : scrollerRef.scrollTop + const scrollerHeight = scrollerRef === window + ? scrollerRef.innerHeight + : scrollerRef.offsetHeight + + const scrollerBottomBorder = currentScroll + scrollerHeight + // We check where the bottom border of root element is, this uses findOffset + // to find offset relative to scrollable container (scroller) + const rootBottomBorder = rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top + + const bottomDelta = Math.max(0, rootBottomBorder - scrollerBottomBorder) + // could also check top delta but there's no case for it + const targetScroll = currentScroll + bottomDelta + + if (scrollerRef === window) { + scrollerRef.scroll(0, targetScroll) + } else { + scrollerRef.scrollTop = targetScroll + } + }, onTransition (e) { this.resize() }, @@ -191,50 +328,93 @@ const EmojiInput = { this.blurTimeout = null } + if (!this.keepOpen) { + this.showPicker = false + } this.focused = true this.setCaret(e) this.resize() + this.temporarilyHideSuggestions = false }, onKeyUp (e) { + const { key } = e this.setCaret(e) this.resize() + + // Setting hider in keyUp to prevent suggestions from blinking + // when moving away from suggested spot + if (key === 'Escape') { + this.temporarilyHideSuggestions = true + } else { + this.temporarilyHideSuggestions = false + } }, onPaste (e) { this.setCaret(e) this.resize() }, onKeyDown (e) { - this.setCaret(e) - this.resize() - const { ctrlKey, shiftKey, key } = e - if (key === 'Tab') { - if (shiftKey) { + // Disable suggestions hotkeys if suggestions are hidden + if (!this.temporarilyHideSuggestions) { + if (key === 'Tab') { + if (shiftKey) { + this.cycleBackward(e) + } else { + this.cycleForward(e) + } + } + if (key === 'ArrowUp') { this.cycleBackward(e) - } else { + } else if (key === 'ArrowDown') { this.cycleForward(e) } + if (key === 'Enter') { + if (!ctrlKey) { + this.replaceText(e) + } + } } - if (key === 'ArrowUp') { - this.cycleBackward(e) - } else if (key === 'ArrowDown') { - this.cycleForward(e) - } - if (key === 'Enter') { - if (!ctrlKey) { - this.replaceText(e) + // Probably add optional keyboard controls for emoji picker? + + // Escape hides suggestions, if suggestions are hidden it + // de-focuses the element (i.e. default browser behavior) + if (key === 'Escape') { + if (!this.temporarilyHideSuggestions) { + this.input.elm.focus() } } + + this.showPicker = false + this.resize() }, onInput (e) { + this.showPicker = false this.setCaret(e) + this.resize() this.$emit('input', e.target.value) }, onCompositionUpdate (e) { + this.showPicker = false this.setCaret(e) this.resize() this.$emit('input', e.target.value) }, + onClickInput (e) { + this.showPicker = false + }, + onClickOutside (e) { + if (this.disableClickOutside) return + this.showPicker = false + }, + onStickerUploaded (e) { + this.showPicker = false + this.$emit('sticker-uploaded', e) + }, + onStickerUploadFailed (e) { + this.showPicker = false + this.$emit('sticker-upload-Failed', e) + }, setCaret ({ target: { selectionStart } }) { this.caret = selectionStart }, @@ -243,6 +423,7 @@ const EmojiInput = { if (!panel) return const { offsetHeight, offsetTop } = this.input.elm this.$refs.panel.style.top = (offsetTop + offsetHeight) + 'px' + this.$refs.picker.$el.style.top = (offsetTop + offsetHeight) + 'px' } } } diff --git a/src/components/emoji-input/emoji-input.vue b/src/components/emoji_input/emoji_input.vue index 48739ec8..13530e8b 100644 --- a/src/components/emoji-input/emoji-input.vue +++ b/src/components/emoji_input/emoji_input.vue @@ -1,10 +1,32 @@ <template> - <div class="emoji-input"> + <div + v-click-outside="onClickOutside" + class="emoji-input" + > <slot /> + <template v-if="enableEmojiPicker"> + <div + v-if="!hideEmojiButton" + class="emoji-picker-icon" + @click.prevent="togglePicker" + > + <i class="icon-smile" /> + </div> + <EmojiPicker + v-if="enableEmojiPicker" + ref="picker" + :class="{ hide: !showPicker }" + :enable-sticker-picker="enableStickerPicker" + class="emoji-picker-panel" + @emoji="insert" + @sticker-uploaded="onStickerUploaded" + @sticker-upload-failed="onStickerUploadFailed" + /> + </template> <div ref="panel" class="autocomplete-panel" - :class="{ hide: !showPopup }" + :class="{ hide: !showSuggestions }" > <div class="autocomplete-panel-body"> <div @@ -31,7 +53,7 @@ </div> </template> -<script src="./emoji-input.js"></script> +<script src="./emoji_input.js"></script> <style lang="scss"> @import '../../_variables.scss'; @@ -39,11 +61,36 @@ .emoji-input { display: flex; flex-direction: column; + position: relative; + + .emoji-picker-icon { + position: absolute; + top: 0; + right: 0; + margin: .2em .25em; + font-size: 16px; + cursor: pointer; + line-height: 24px; + + &:hover i { + color: $fallback--text; + color: var(--text, $fallback--text); + } + } + .emoji-picker-panel { + position: absolute; + z-index: 20; + margin-top: 2px; + + &.hide { + display: none + } + } .autocomplete { &-panel { position: absolute; - z-index: 9; + z-index: 20; margin-top: 2px; &.hide { diff --git a/src/components/emoji-input/suggestor.js b/src/components/emoji_input/suggestor.js index aec5c39d..aec5c39d 100644 --- a/src/components/emoji-input/suggestor.js +++ b/src/components/emoji_input/suggestor.js diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js new file mode 100644 index 00000000..824412dd --- /dev/null +++ b/src/components/emoji_picker/emoji_picker.js @@ -0,0 +1,115 @@ + +const filterByKeyword = (list, keyword = '') => { + return list.filter(x => x.displayText.includes(keyword)) +} + +const EmojiPicker = { + props: { + enableStickerPicker: { + required: false, + type: Boolean, + default: false + } + }, + data () { + return { + labelKey: String(Math.random() * 100000), + keyword: '', + activeGroup: 'custom', + showingStickers: false, + groupsScrolledClass: 'scrolled-top', + keepOpen: false + } + }, + components: { + StickerPicker: () => import('../sticker_picker/sticker_picker.vue') + }, + methods: { + onEmoji (emoji) { + const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement + this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen }) + }, + highlight (key) { + const ref = this.$refs['group-' + key] + const top = ref[0].offsetTop + this.setShowStickers(false) + this.activeGroup = key + this.$nextTick(() => { + this.$refs['emoji-groups'].scrollTop = top + 1 + }) + }, + scrolledGroup (e) { + const target = (e && e.target) || this.$refs['emoji-groups'] + const top = target.scrollTop + 5 + if (target.scrollTop <= 5) { + this.groupsScrolledClass = 'scrolled-top' + } else if (target.scrollTop >= target.scrollTopMax - 5) { + this.groupsScrolledClass = 'scrolled-bottom' + } else { + this.groupsScrolledClass = 'scrolled-middle' + } + this.$nextTick(() => { + this.emojisView.forEach(group => { + const ref = this.$refs['group-' + group.id] + if (ref[0].offsetTop <= top) { + this.activeGroup = group.id + } + }) + }) + }, + toggleStickers () { + this.showingStickers = !this.showingStickers + }, + setShowStickers (value) { + this.showingStickers = value + }, + onStickerUploaded (e) { + this.$emit('sticker-uploaded', e) + }, + onStickerUploadFailed (e) { + this.$emit('sticker-upload-failed', e) + } + }, + watch: { + keyword () { + this.scrolledGroup() + } + }, + computed: { + activeGroupView () { + return this.showingStickers ? '' : this.activeGroup + }, + stickersAvailable () { + if (this.$store.state.instance.stickers) { + return this.$store.state.instance.stickers.length > 0 + } + return 0 + }, + emojis () { + const standardEmojis = this.$store.state.instance.emoji || [] + const customEmojis = this.$store.state.instance.customEmoji || [] + return [ + { + id: 'custom', + text: this.$t('emoji.custom'), + icon: 'icon-smile', + emojis: filterByKeyword(customEmojis, this.keyword) + }, + { + id: 'standard', + text: this.$t('emoji.unicode'), + icon: 'icon-picture', + emojis: filterByKeyword(standardEmojis, this.keyword) + } + ] + }, + emojisView () { + return this.emojis.filter(value => value.emojis.length > 0) + }, + stickerPickerEnabled () { + return (this.$store.state.instance.stickers || []).length !== 0 + } + } +} + +export default EmojiPicker diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss new file mode 100644 index 00000000..b0ed00e9 --- /dev/null +++ b/src/components/emoji_picker/emoji_picker.scss @@ -0,0 +1,165 @@ +@import '../../_variables.scss'; + +.emoji-picker { + display: flex; + flex-direction: column; + position: absolute; + right: 0; + left: 0; + height: 320px; + margin: 0 !important; + z-index: 1; + + .keep-open { + padding: 7px; + line-height: normal; + } + .keep-open-label { + padding: 0 7px; + display: flex; + } + + .heading { + display: flex; + height: 32px; + padding: 10px 7px 5px; + } + + .content { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: 0px; + } + + .emoji-tabs { + flex-grow: 1; + } + + .additional-tabs { + border-left: 1px solid; + border-left-color: $fallback--icon; + border-left-color: var(--icon, $fallback--icon); + padding-left: 7px; + flex: 0 0 0; + } + + .additional-tabs, + .emoji-tabs { + display: block; + min-width: 0; + flex-basis: auto; + flex-shrink: 1; + + &-item { + padding: 0 7px; + cursor: pointer; + font-size: 24px; + + &.disabled { + opacity: 0.5; + pointer-events: none; + } + &.active { + border-bottom: 4px solid; + + i { + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } + } + } + } + + .sticker-picker { + flex: 1 1 0 + } + + .stickers, + .emoji { + &-content { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: 0; + + &.hidden { + opacity: 0; + pointer-events: none; + position: absolute; + } + } + } + + .emoji { + &-search { + padding: 5px; + flex: 0 0 0; + + input { + width: 100%; + } + } + + &-groups { + flex: 1 1 1px; + position: relative; + overflow: auto; + user-select: none; + mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat, + linear-gradient(to bottom, white 0, transparent 100%) top no-repeat, + linear-gradient(to top, white, white); + transition: mask-size 150ms; + mask-size: 100% 20px, 100% 20px, auto; + // Autoprefixed seem to ignore this one, and also syntax is different + -webkit-mask-composite: xor; + mask-composite: exclude; + &.scrolled { + &-top { + mask-size: 100% 20px, 100% 0, auto; + } + &-bottom { + mask-size: 100% 0, 100% 20px, auto; + } + } + } + + &-group { + display: flex; + align-items: center; + flex-wrap: wrap; + padding-left: 5px; + justify-content: left; + + &-title { + font-size: 12px; + width: 100%; + margin: 0; + &.disabled { + display: none; + } + } + } + + &-item { + width: 32px; + height: 32px; + box-sizing: border-box; + display: flex; + font-size: 32px; + align-items: center; + justify-content: center; + margin: 4px; + + cursor: pointer; + + img { + object-fit: contain; + max-width: 100%; + max-height: 100%; + } + } + + } + +} diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue new file mode 100644 index 00000000..42f20130 --- /dev/null +++ b/src/components/emoji_picker/emoji_picker.vue @@ -0,0 +1,110 @@ +<template> + <div class="emoji-picker panel panel-default panel-body"> + <div class="heading"> + <span class="emoji-tabs"> + <span + v-for="group in emojis" + :key="group.id" + class="emoji-tabs-item" + :class="{ + active: activeGroupView === group.id, + disabled: group.emojis.length === 0 + }" + :title="group.text" + @click.prevent="highlight(group.id)" + > + <i :class="group.icon" /> + </span> + </span> + <span + v-if="stickerPickerEnabled" + class="additional-tabs" + > + <span + class="stickers-tab-icon additional-tabs-item" + :class="{active: showingStickers}" + :title="$t('emoji.stickers')" + @click.prevent="toggleStickers" + > + <i class="icon-star" /> + </span> + </span> + </div> + <div class="content"> + <div + class="emoji-content" + :class="{hidden: showingStickers}" + > + <div class="emoji-search"> + <input + v-model="keyword" + type="text" + class="form-control" + :placeholder="$t('emoji.search_emoji')" + > + </div> + <div + ref="emoji-groups" + class="emoji-groups" + :class="groupsScrolledClass" + @scroll="scrolledGroup" + > + <div + v-for="group in emojisView" + :key="group.id" + class="emoji-group" + > + <h6 + :ref="'group-' + group.id" + class="emoji-group-title" + > + {{ group.text }} + </h6> + <span + v-for="emoji in group.emojis" + :key="group.id + emoji.displayText" + :title="emoji.displayText" + class="emoji-item" + @click.stop.prevent="onEmoji(emoji)" + > + <span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span> + <img + v-else + :src="emoji.imageUrl" + > + </span> + </div> + </div> + <div + class="keep-open" + > + <input + :id="labelKey + 'keep-open'" + v-model="keepOpen" + type="checkbox" + > + <label + class="keep-open-label" + :for="labelKey + 'keep-open'" + > + <div class="keep-open-label-text"> + {{ $t('emoji.keep_open') }} + </div> + </label> + </div> + </div> + <div + v-if="showingStickers" + class="stickers-content" + > + <sticker-picker + @uploaded="onStickerUploaded" + @upload-failed="onStickerUploadFailed" + /> + </div> + </div> + </div> +</template> + +<script src="./emoji_picker.js"></script> +<style lang="scss" src="./emoji_picker.scss"></style> diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index ac32ae83..1dda7bc1 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -31,12 +31,14 @@ <script src="./media_upload.js" ></script> <style> - .media-upload { - font-size: 26px; - min-width: 50px; - } +.media-upload { + .icon-upload { + cursor: pointer; + } - .icon-upload { - cursor: pointer; - } + label { + display: block; + width: 100%; + } +} </style> diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index dc4b419c..9b2a9c90 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -1,12 +1,12 @@ import statusPoster from '../../services/status_poster/status_poster.service.js' import MediaUpload from '../media_upload/media_upload.vue' import ScopeSelector from '../scope_selector/scope_selector.vue' -import EmojiInput from '../emoji-input/emoji-input.vue' +import EmojiInput from '../emoji_input/emoji_input.vue' import PollForm from '../poll/poll_form.vue' -import StickerPicker from '../sticker_picker/sticker_picker.vue' import fileTypeService from '../../services/file_type/file_type.service.js' +import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { reject, map, uniqBy } from 'lodash' -import suggestor from '../emoji-input/suggestor.js' +import suggestor from '../emoji_input/suggestor.js' const buildMentionsString = ({ user, attentions = [] }, currentUser) => { let allAttentions = [...attentions] @@ -35,7 +35,6 @@ const PostStatusForm = { MediaUpload, EmojiInput, PollForm, - StickerPicker, ScopeSelector }, mounted () { @@ -84,8 +83,7 @@ const PostStatusForm = { contentType }, caret: 0, - pollFormVisible: false, - stickerPickerVisible: false + pollFormVisible: false } }, computed: { @@ -161,12 +159,6 @@ const PostStatusForm = { safeDMEnabled () { return this.$store.state.instance.safeDM }, - stickersAvailable () { - if (this.$store.state.instance.stickers) { - return this.$store.state.instance.stickers.length > 0 - } - return 0 - }, pollsAvailable () { return this.$store.state.instance.pollsAvailable && this.$store.state.instance.pollLimits.max_options >= 2 @@ -222,7 +214,6 @@ const PostStatusForm = { poll: {} } this.pollFormVisible = false - this.stickerPickerVisible = false this.$refs.mediaUpload.clearFile() this.clearPollForm() this.$emit('posted') @@ -239,7 +230,6 @@ const PostStatusForm = { addMediaFile (fileInfo) { this.newStatus.files.push(fileInfo) this.enableSubmit() - this.stickerPickerVisible = false }, removeMediaFile (fileInfo) { let index = this.newStatus.files.indexOf(fileInfo) @@ -260,6 +250,7 @@ const PostStatusForm = { return fileTypeService.fileType(fileInfo.mimetype) }, paste (e) { + this.resize(e) if (e.clipboardData.files.length > 0) { // prevent pasting of file as text e.preventDefault() @@ -278,20 +269,96 @@ const PostStatusForm = { fileDrag (e) { e.dataTransfer.dropEffect = 'copy' }, + onEmojiInputInput (e) { + this.$nextTick(() => { + this.resize(this.$refs['textarea']) + }) + }, resize (e) { const target = e.target || e if (!(target instanceof window.Element)) { return } + + // Reset to default height for empty form, nothing else to do here. + if (target.value === '') { + target.style.height = null + this.$refs['emoji-input'].resize() + return + } + + const rootRef = this.$refs['root'] + /* Scroller is either `window` (replies in TL), sidebar (main post form, + * replies in notifs) or mobile post form. Note that getting and setting + * scroll is different for `Window` and `Element`s + */ + const scrollerRef = this.$el.closest('.sidebar-scroller') || + this.$el.closest('.post-form-modal-view') || + window + + // Getting info about padding we have to account for, removing 'px' part const topPaddingStr = window.getComputedStyle(target)['padding-top'] const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom'] - // Remove "px" at the end of the values - const vertPadding = Number(topPaddingStr.substr(0, topPaddingStr.length - 2)) + - Number(bottomPaddingStr.substr(0, bottomPaddingStr.length - 2)) - // Auto is needed to make textbox shrink when removing lines + const topPadding = Number(topPaddingStr.substring(0, topPaddingStr.length - 2)) + const bottomPadding = Number(bottomPaddingStr.substring(0, bottomPaddingStr.length - 2)) + const vertPadding = topPadding + bottomPadding + + const oldHeightStr = target.style.height || '' + const oldHeight = Number(oldHeightStr.substring(0, oldHeightStr.length - 2)) + + /* Explanation: + * + * https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight + * scrollHeight returns element's scrollable content height, i.e. visible + * element + overscrolled parts of it. We use it to determine when text + * inside the textarea exceeded its height, so we can set height to prevent + * overscroll, i.e. make textarea grow with the text. HOWEVER, since we + * explicitly set new height, scrollHeight won't go below that, so we can't + * SHRINK the textarea when there's extra space. To workaround that we set + * height to 'auto' which makes textarea tiny again, so that scrollHeight + * will match text height again. HOWEVER, shrinking textarea can screw with + * the scroll since there might be not enough padding around root to even + * warrant a scroll, so it will jump to 0 and refuse to move anywhere, + * so we check current scroll position before shrinking and then restore it + * with needed delta. + */ + + // this part has to be BEFORE the content size update + const currentScroll = scrollerRef === window + ? scrollerRef.scrollY + : scrollerRef.scrollTop + const scrollerHeight = scrollerRef === window + ? scrollerRef.innerHeight + : scrollerRef.offsetHeight + const scrollerBottomBorder = currentScroll + scrollerHeight + + // BEGIN content size update target.style.height = 'auto' - target.style.height = `${target.scrollHeight - vertPadding}px` - if (target.value === '') { - target.style.height = null + const newHeight = target.scrollHeight - vertPadding + target.style.height = `${newHeight}px` + // END content size update + + // We check where the bottom border of root element is, this uses findOffset + // to find offset relative to scrollable container (scroller) + const rootBottomBorder = rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top + + const textareaSizeChangeDelta = newHeight - oldHeight || 0 + const isBottomObstructed = scrollerBottomBorder < rootBottomBorder + const rootChangeDelta = rootBottomBorder - scrollerBottomBorder + const totalDelta = textareaSizeChangeDelta + + (isBottomObstructed ? rootChangeDelta : 0) + + const targetScroll = currentScroll + totalDelta + + if (scrollerRef === window) { + scrollerRef.scroll(0, targetScroll) + } else { + scrollerRef.scrollTop = targetScroll } + + this.$refs['emoji-input'].resize() + }, + showEmojiPicker () { + this.$refs['textarea'].focus() + this.$refs['emoji-input'].triggerShowPicker() }, clearError () { this.error = null @@ -299,14 +366,6 @@ const PostStatusForm = { changeVis (visibility) { this.newStatus.visibility = visibility }, - toggleStickerPicker () { - this.stickerPickerVisible = !this.stickerPickerVisible - }, - clearStickerPicker () { - if (this.$refs.stickerPicker) { - this.$refs.stickerPicker.clear() - } - }, togglePollForm () { this.pollFormVisible = !this.pollFormVisible }, diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index d29d47e4..4916d988 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -1,5 +1,8 @@ <template> - <div class="post-status-form"> + <div + ref="root" + class="post-status-form" + > <form autocomplete="off" @submit.prevent="postStatus(newStatus)" @@ -61,6 +64,7 @@ <EmojiInput v-if="newStatus.spoilerText || alwaysShowSubject" v-model="newStatus.spoilerText" + enable-emoji-picker :suggest="emojiSuggestor" class="form-control" > @@ -73,9 +77,16 @@ > </EmojiInput> <EmojiInput + ref="emoji-input" v-model="newStatus.status" :suggest="emojiUserSuggestor" class="form-control main-input" + enable-emoji-picker + hide-emoji-button + enable-sticker-picker + @input="onEmojiInputInput" + @sticker-uploaded="addMediaFile" + @sticker-upload-failed="uploadFailed" > <textarea ref="textarea" @@ -89,6 +100,7 @@ @drop="fileDrop" @dragover.prevent="fileDrag" @input="resize" + @compositionupdate="resize" @paste="paste" /> <p @@ -152,30 +164,29 @@ <div class="form-bottom-left"> <media-upload ref="mediaUpload" + class="media-upload-icon" :drop-files="dropFiles" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" /> <div - v-if="stickersAvailable" - class="sticker-icon" + class="emoji-icon" > <i - :title="$t('stickers.add_sticker')" - class="icon-picture btn btn-default" - :class="{ selected: stickerPickerVisible }" - @click="toggleStickerPicker" + :title="$t('emoji.add_emoji')" + class="icon-smile btn btn-default" + @click="showEmojiPicker" /> </div> <div v-if="pollsAvailable" class="poll-icon" + :class="{ selected: pollFormVisible }" > <i :title="$t('polls.add_poll')" class="icon-chart-bar btn btn-default" - :class="pollFormVisible && 'selected'" @click="togglePollForm" /> </div> @@ -258,11 +269,6 @@ <label for="filesSensitive">{{ $t('post_status.attachments_sensitive') }}</label> </div> </form> - <sticker-picker - v-if="stickerPickerVisible" - ref="stickerPicker" - @uploaded="addMediaFile" - /> </div> </template> @@ -299,6 +305,7 @@ .post-status-form { .form-bottom { display: flex; + justify-content: space-between; padding: 0.5em; height: 32px; @@ -316,6 +323,9 @@ .form-bottom-left { display: flex; flex: 1; + padding-right: 7px; + margin-right: 7px; + max-width: 10em; } .text-format { @@ -325,19 +335,38 @@ } } - .poll-icon, .sticker-icon { + .media-upload-icon, .poll-icon, .emoji-icon { font-size: 26px; flex: 1; - .selected { - color: $fallback--lightText; - color: var(--lightText, $fallback--lightText); + i { + display: block; + width: 100%; + } + + &.selected, &:hover { + // needs to be specific to override icon default color + i, label { + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } } } - .sticker-icon { - flex: 0; - min-width: 50px; + // Order is not necessary but a good indicator + .media-upload-icon { + order: 1; + text-align: left; + } + + .emoji-icon { + order: 2; + text-align: center; + } + + .poll-icon { + order: 3; + text-align: right; } .icon-chart-bar { @@ -369,6 +398,13 @@ } } + .status-input-wrapper { + display: flex; + position: relative; + width: 100%; + flex-direction: column; + } + .attachments { padding: 0 0.5em; @@ -444,10 +480,6 @@ box-sizing: content-box; } - .form-post-body:focus { - min-height: 48px; - } - .main-input { position: relative; } diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js index c4aa45b2..b6540d7e 100644 --- a/src/components/settings/settings.js +++ b/src/components/settings/settings.js @@ -16,6 +16,7 @@ const settings = { return { hideAttachmentsLocal: user.hideAttachments, + padEmojiLocal: user.padEmoji, hideAttachmentsInConvLocal: user.hideAttachmentsInConv, maxThumbnails: user.maxThumbnails, hideNsfwLocal: user.hideNsfw, @@ -127,6 +128,9 @@ const settings = { hideAttachmentsLocal (value) { this.$store.dispatch('setOption', { name: 'hideAttachments', value }) }, + padEmojiLocal (value) { + this.$store.dispatch('setOption', { name: 'padEmoji', value }) + }, hideAttachmentsInConvLocal (value) { this.$store.dispatch('setOption', { name: 'hideAttachmentsInConv', value }) }, diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index 744ec566..6d87a060 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -198,6 +198,14 @@ > <label for="autohideFloatingPostButton">{{ $t('settings.autohide_floating_post_button') }}</label> </li> + <li> + <input + id="padEmoji" + v-model="padEmojiLocal" + type="checkbox" + > + <label for="padEmoji">{{ $t('settings.pad_emoji') }}</label> + </li> </ul> </div> diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 64218f6e..93f37a49 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -413,7 +413,7 @@ v-if="replying" class="container" > - <post-status-form + <PostStatusForm class="reply-body" :reply-to="status.id" :attentions="status.attentions" @@ -665,6 +665,15 @@ $status-margin: 0.75em; height: 220px; overflow-x: hidden; overflow-y: hidden; + z-index: 1; + .status-content { + height: 100%; + mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat, + linear-gradient(to top, white, white); + // Autoprefixed seem to ignore this one, and also syntax is different + -webkit-mask-composite: xor; + mask-composite: exclude; + } } .tall-status-hider { @@ -676,12 +685,7 @@ $status-margin: 0.75em; width: 100%; text-align: center; line-height: 110px; - background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%); - background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%); - &_focused { - background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--lightBg 80%); - background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--lightBg, $fallback--lightBg) 80%); - } + z-index: 2; } .status-unhider, .cw-status-hider { diff --git a/src/components/sticker_picker/sticker_picker.js b/src/components/sticker_picker/sticker_picker.js index a6dcded3..8daf3f07 100644 --- a/src/components/sticker_picker/sticker_picker.js +++ b/src/components/sticker_picker/sticker_picker.js @@ -3,9 +3,9 @@ import statusPosterService from '../../services/status_poster/status_poster.serv import TabSwitcher from '../tab_switcher/tab_switcher.js' const StickerPicker = { - components: [ + components: { TabSwitcher - ], + }, data () { return { meta: { diff --git a/src/components/sticker_picker/sticker_picker.vue b/src/components/sticker_picker/sticker_picker.vue index 938204c8..323855b9 100644 --- a/src/components/sticker_picker/sticker_picker.vue +++ b/src/components/sticker_picker/sticker_picker.vue @@ -2,32 +2,30 @@ <div class="sticker-picker" > - <div - class="sticker-picker-panel" + <tab-switcher + class="tab-switcher" + :render-only-focused="true" + scrollable-tabs > - <tab-switcher - :render-only-focused="true" + <div + v-for="stickerpack in pack" + :key="stickerpack.path" + :image-tooltip="stickerpack.meta.title" + :image="stickerpack.path + stickerpack.meta.tabIcon" + class="sticker-picker-content" > <div - v-for="stickerpack in pack" - :key="stickerpack.path" - :image-tooltip="stickerpack.meta.title" - :image="stickerpack.path + stickerpack.meta.tabIcon" - class="sticker-picker-content" + v-for="sticker in stickerpack.meta.stickers" + :key="sticker" + class="sticker" + @click.stop.prevent="pick(stickerpack.path + sticker, stickerpack.meta.title)" > - <div - v-for="sticker in stickerpack.meta.stickers" - :key="sticker" - class="sticker" - @click="pick(stickerpack.path + sticker, stickerpack.meta.title)" + <img + :src="stickerpack.path + sticker" > - <img - :src="stickerpack.path + sticker" - > - </div> </div> - </tab-switcher> - </div> + </div> + </tab-switcher> </div> </template> @@ -37,22 +35,24 @@ @import '../../_variables.scss'; .sticker-picker { - .sticker-picker-panel { - display: inline-block; - width: 100%; - .sticker-picker-content { - max-height: 300px; - overflow-y: scroll; - overflow-x: auto; - .sticker { - display: inline-block; - width: 20%; - height: 20%; - img { - width: 100%; - &:hover { - filter: drop-shadow(0 0 5px var(--link, $fallback--link)); - } + width: 100%; + position: relative; + .tab-switcher { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + .sticker-picker-content { + .sticker { + display: inline-block; + width: 20%; + height: 20%; + img { + width: 100%; + &:hover { + filter: drop-shadow(0 0 5px var(--link, $fallback--link)); } } } diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.js index 08d5d08f..3ca316b9 100644 --- a/src/components/tab_switcher/tab_switcher.js +++ b/src/components/tab_switcher/tab_switcher.js @@ -4,7 +4,26 @@ import './tab_switcher.scss' export default Vue.component('tab-switcher', { name: 'TabSwitcher', - props: ['renderOnlyFocused', 'onSwitch', 'activeTab'], + props: { + renderOnlyFocused: { + required: false, + type: Boolean, + default: false + }, + onSwitch: { + required: false, + type: Function + }, + activeTab: { + required: false, + type: String + }, + scrollableTabs: { + required: false, + type: Boolean, + default: false + } + }, data () { return { active: this.$slots.default.findIndex(_ => _.tag) @@ -28,7 +47,8 @@ export default Vue.component('tab-switcher', { }, methods: { activateTab (index) { - return () => { + return (e) => { + e.preventDefault() if (typeof this.onSwitch === 'function') { this.onSwitch.call(null, this.$slots.default[index].key) } @@ -87,7 +107,7 @@ export default Vue.component('tab-switcher', { <div class="tabs"> {tabs} </div> - <div class="contents"> + <div class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}> {contents} </div> </div> diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss index 4eeb42e0..3e5eacd5 100644 --- a/src/components/tab_switcher/tab_switcher.scss +++ b/src/components/tab_switcher/tab_switcher.scss @@ -1,10 +1,21 @@ @import '../../_variables.scss'; .tab-switcher { + display: flex; + flex-direction: column; + .contents { + flex: 1 0 auto; + min-height: 0px; + .hidden { display: none; } + + &.scrollable-tabs { + flex-basis: 0; + overflow-y: auto; + } } .tabs { display: flex; diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index 0c200ad1..9c931c01 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -37,19 +37,10 @@ export default { const rgb = (typeof color === 'string') ? hex2rgb(color) : color const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .5)` - const gradient = [ - [tintColor, this.hideBio ? '60%' : ''], - this.hideBio ? [ - color, '100%' - ] : [ - tintColor, '' - ] - ].map(_ => _.join(' ')).join(', ') - return { backgroundColor: `rgb(${Math.floor(rgb.r * 0.53)}, ${Math.floor(rgb.g * 0.56)}, ${Math.floor(rgb.b * 0.59)})`, backgroundImage: [ - `linear-gradient(to bottom, ${gradient})`, + `linear-gradient(to bottom, ${tintColor}, ${tintColor})`, `url(${this.user.cover_photo})` ].join(', ') } diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index f25d16d3..5b6f66e7 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -2,8 +2,12 @@ <div class="user-card" :class="classes" - :style="style" > + <div + :class="{ 'hide-bio': hideBio }" + :style="style" + class="background-image" + /> <div class="panel-heading"> <div class="user-info"> <div class="container"> @@ -307,7 +311,7 @@ @import '../../_variables.scss'; .user-card { - background-size: cover; + position: relative; .panel-heading { padding: .5em 0; @@ -316,14 +320,35 @@ background: transparent; flex-direction: column; align-items: stretch; + // create new stacking context + position: relative; } .panel-body { word-wrap: break-word; - background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%); - background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%); border-bottom-right-radius: inherit; border-bottom-left-radius: inherit; + // create new stacking context + position: relative; + } + + .background-image { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + mask: linear-gradient(to top, white, transparent) bottom no-repeat, + linear-gradient(to top, white, white); + // Autoprefixed seem to ignore this one, and also syntax is different + -webkit-mask-composite: xor; + mask-composite: exclude; + background-size: cover; + mask-size: 100% 60%; + + &.hide-bio { + mask-size: 100% 40px; + } } p { diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue index e859d612..e9f08015 100644 --- a/src/components/user_panel/user_panel.vue +++ b/src/components/user_panel/user_panel.vue @@ -11,7 +11,7 @@ rounded="top" /> <div class="panel-footer"> - <post-status-form /> + <PostStatusForm /> </div> </div> <auth-form diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index b5a7f0df..ae04ce73 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -11,8 +11,8 @@ import BlockCard from '../block_card/block_card.vue' import MuteCard from '../mute_card/mute_card.vue' import SelectableList from '../selectable_list/selectable_list.vue' import ProgressButton from '../progress_button/progress_button.vue' -import EmojiInput from '../emoji-input/emoji-input.vue' -import suggestor from '../emoji-input/suggestor.js' +import EmojiInput from '../emoji_input/emoji_input.vue' +import suggestor from '../emoji_input/suggestor.js' import Autosuggest from '../autosuggest/autosuggest.vue' import Importer from '../importer/importer.vue' import Exporter from '../exporter/exporter.vue' diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index 34ea8569..97833acb 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -32,6 +32,7 @@ <p>{{ $t('settings.name') }}</p> <EmojiInput v-model="newName" + enable-emoji-picker :suggest="emojiSuggestor" > <input @@ -43,6 +44,7 @@ <p>{{ $t('settings.bio') }}</p> <EmojiInput v-model="newBio" + enable-emoji-picker :suggest="emojiUserSuggestor" > <textarea diff --git a/src/i18n/en.json b/src/i18n/en.json index e4af507e..3d81fdca 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -106,8 +106,14 @@ "expired": "Poll ended {0} ago", "not_enough_options": "Too few unique options in poll" }, - "stickers": { - "add_sticker": "Add Sticker" + "emoji": { + "stickers": "Stickers", + "emoji": "Emoji", + "keep_open": "Keep picker open", + "search_emoji": "Search for an emoji", + "add_emoji": "Insert emoji", + "custom": "Custom emoji", + "unicode": "Unicode emoji" }, "interactions": { "favs_repeats": "Repeats and Favorites", @@ -226,6 +232,7 @@ "delete_account_error": "There was an issue deleting your account. If this persists please contact your instance administrator.", "delete_account_instructions": "Type your password in the input below to confirm account deletion.", "avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels.", + "pad_emoji": "Pad emoji with spaces when adding from picker", "export_theme": "Save preset", "filtering": "Filtering", "filtering_explanation": "All statuses containing these words will be muted, one per line", diff --git a/src/modules/config.js b/src/modules/config.js index 2bfad8f6..cf04d14f 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -7,6 +7,7 @@ const defaultState = { colors: {}, hideMutedPosts: undefined, // instance default collapseMessageWithSubject: undefined, // instance default + padEmoji: true, hideAttachments: false, hideAttachmentsInConv: false, maxThumbnails: 16, diff --git a/src/services/offset_finder/offset_finder.service.js b/src/services/offset_finder/offset_finder.service.js new file mode 100644 index 00000000..9034f8c8 --- /dev/null +++ b/src/services/offset_finder/offset_finder.service.js @@ -0,0 +1,31 @@ +export const findOffset = (child, parent, { top = 0, left = 0 } = {}, ignorePadding = true) => { + const result = { + top: top + child.offsetTop, + left: left + child.offsetLeft + } + if (!ignorePadding && child !== window) { + const { topPadding, leftPadding } = findPadding(child) + result.top += ignorePadding ? 0 : topPadding + result.left += ignorePadding ? 0 : leftPadding + } + + if (child.offsetParent && (parent === window || parent.contains(child.offsetParent) || parent === child.offsetParent)) { + return findOffset(child.offsetParent, parent, result, false) + } else { + if (parent !== window) { + const { topPadding, leftPadding } = findPadding(parent) + result.top += topPadding + result.left += leftPadding + } + return result + } +} + +const findPadding = (el) => { + const topPaddingStr = window.getComputedStyle(el)['padding-top'] + const topPadding = Number(topPaddingStr.substring(0, topPaddingStr.length - 2)) + const leftPaddingStr = window.getComputedStyle(el)['padding-left'] + const leftPadding = Number(leftPaddingStr.substring(0, leftPaddingStr.length - 2)) + + return { topPadding, leftPadding } +} |
