Merge branch 'emoji-selector-update' into 'develop'
Emoji selector update Closes #101 See merge request pleroma/pleroma-fe!895
This commit is contained in:
commit
501208d350
44 changed files with 1161 additions and 180 deletions
13
CHANGELOG.md
Normal file
13
CHANGELOG.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Changelog
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- Emoji picker
|
||||
- Started changelog anew
|
||||
### Changed
|
||||
- changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes
|
||||
### Fixed
|
||||
- improved hotkey behavior on autocomplete popup
|
|
@ -23,6 +23,15 @@ Posts will contain the text you are posting, but some content will be modified:
|
|||
**Depending on your instance some of the options might not be available or have different defaults**
|
||||
|
||||
Let's clear up some basic stuff. When you post something it's called a **post** or it could be called a **status** or even a **toot** or a **prööt** depending on whom you ask. Post has body/content but it also has some other stuff in it - from attachments, visibility scope, subject line.
|
||||
* **Emoji** are small images embedded in text, there are two major types of emoji: [unicode emoji](https://en.wikipedia.org/wiki/Emoji) and custom emoji. While unicode emoji are universal and standardized, they can appear differently depending on where you are using them or may not appear at all on older systems. Custom emoji are more *fun* kind - instance administrator can define many images as *custom emoji* for their users. This works very simple - custom emoji is defined by its *shortcode* and an image, so that any shortcode enclosed in colons get replaced with image if such shortcode exist.
|
||||
Let's say there's `:pleroma:` emoji defined on instance. That means
|
||||
> First time using :pleroma: pleroma!
|
||||
|
||||
will become
|
||||
> First time using ![pleroma](./example_emoji.png) pleroma!
|
||||
|
||||
Note that you can only use emoji defined on your instance, you cannot "copy" someone else's emoji, and will have to ask your administrator to copy emoji from other instance to yours.
|
||||
Lastly, there's two convenience options for emoji: an emoji picker (smiley face to the right of "submit" button) and autocomplete suggestions - when you start typing :shortcode: it will automatically try to suggest you emoj and complete the shortcode for you if you select one. **Note** that if emoji doesn't show up in suggestions nor in emoji picker it means there's no such emoji on your instance, if shortcode doesn't match any defined emoji it will appear as text.
|
||||
* **Attachments** are fairly simple - you can attach any file to a post as long as the file is within maximum size limits. If you're uploading explicit material you can mark all of your attachments as sensitive (or add `#nsfw` tag) - it will hide the images and videos behind a warning so that it won't be displayed instantly.
|
||||
* **Subject line** also known as **CW** (Content Warning) could be used as a header to the post and/or to warn others about contents of the post having something that might upset somebody or something among those lines. Several applications allow to hide post content leaving only subject line visible. As a side-effect using subject line will also mark your images as sensitive (see above).
|
||||
* **Visiblity scope** controls who will be able to see your posts. There are four scopes available:
|
||||
|
|
BIN
docs/example_emoji.png
Normal file
BIN
docs/example_emoji.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 491 B |
|
@ -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 {
|
||||
|
|
|
@ -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,23 +328,35 @@ 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
|
||||
// Disable suggestions hotkeys if suggestions are hidden
|
||||
if (!this.temporarilyHideSuggestions) {
|
||||
if (key === 'Tab') {
|
||||
if (shiftKey) {
|
||||
this.cycleBackward(e)
|
||||
|
@ -225,16 +374,47 @@ const EmojiInput = {
|
|||
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.setCaret(e)
|
||||
this.$emit('input', e.target.value)
|
||||
},
|
||||
onCompositionUpdate (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'
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
115
src/components/emoji_picker/emoji_picker.js
Normal file
115
src/components/emoji_picker/emoji_picker.js
Normal file
|
@ -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
|
165
src/components/emoji_picker/emoji_picker.scss
Normal file
165
src/components/emoji_picker/emoji_picker.scss
Normal file
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
110
src/components/emoji_picker/emoji_picker.vue
Normal file
110
src/components/emoji_picker/emoji_picker.vue
Normal file
|
@ -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>
|
|
@ -32,11 +32,13 @@
|
|||
|
||||
<style>
|
||||
.media-upload {
|
||||
font-size: 26px;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.icon-upload {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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 }
|
||||
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
|
||||
target.style.height = 'auto'
|
||||
target.style.height = `${target.scrollHeight - vertPadding}px`
|
||||
|
||||
// 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']
|
||||
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'
|
||||
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
|
||||
},
|
||||
|
|
|
@ -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 {
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
<template>
|
||||
<div
|
||||
class="sticker-picker"
|
||||
>
|
||||
<div
|
||||
class="sticker-picker-panel"
|
||||
>
|
||||
<tab-switcher
|
||||
class="tab-switcher"
|
||||
:render-only-focused="true"
|
||||
scrollable-tabs
|
||||
>
|
||||
<div
|
||||
v-for="stickerpack in pack"
|
||||
|
@ -19,7 +18,7 @@
|
|||
v-for="sticker in stickerpack.meta.stickers"
|
||||
:key="sticker"
|
||||
class="sticker"
|
||||
@click="pick(stickerpack.path + sticker, stickerpack.meta.title)"
|
||||
@click.stop.prevent="pick(stickerpack.path + sticker, stickerpack.meta.title)"
|
||||
>
|
||||
<img
|
||||
:src="stickerpack.path + sticker"
|
||||
|
@ -28,7 +27,6 @@
|
|||
</div>
|
||||
</tab-switcher>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./sticker_picker.js"></script>
|
||||
|
@ -37,13 +35,16 @@
|
|||
@import '../../_variables.scss';
|
||||
|
||||
.sticker-picker {
|
||||
.sticker-picker-panel {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
.tab-switcher {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
.sticker-picker-content {
|
||||
max-height: 300px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: auto;
|
||||
.sticker {
|
||||
display: inline-block;
|
||||
width: 20%;
|
||||
|
@ -57,6 +58,5 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(', ')
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
rounded="top"
|
||||
/>
|
||||
<div class="panel-footer">
|
||||
<post-status-form />
|
||||
<PostStatusForm />
|
||||
</div>
|
||||
</div>
|
||||
<auth-form
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -7,6 +7,7 @@ const defaultState = {
|
|||
colors: {},
|
||||
hideMutedPosts: undefined, // instance default
|
||||
collapseMessageWithSubject: undefined, // instance default
|
||||
padEmoji: true,
|
||||
hideAttachments: false,
|
||||
hideAttachmentsInConv: false,
|
||||
maxThumbnails: 16,
|
||||
|
|
31
src/services/offset_finder/offset_finder.service.js
Normal file
31
src/services/offset_finder/offset_finder.service.js
Normal file
|
@ -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 }
|
||||
}
|
0
static/font/LICENSE.txt
Executable file → Normal file
0
static/font/LICENSE.txt
Executable file → Normal file
0
static/font/README.txt
Executable file → Normal file
0
static/font/README.txt
Executable file → Normal file
6
static/font/config.json
Executable file → Normal file
6
static/font/config.json
Executable file → Normal file
|
@ -240,6 +240,12 @@
|
|||
"code": 59419,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "d862a10e1448589215be19702f98f2c1",
|
||||
"css": "smile",
|
||||
"code": 61720,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "671f29fa10dda08074a4c6a341bb4f39",
|
||||
"css": "bell-alt",
|
||||
|
|
0
static/font/css/animation.css
Executable file → Normal file
0
static/font/css/animation.css
Executable file → Normal file
1
static/font/css/fontello-codes.css
vendored
Executable file → Normal file
1
static/font/css/fontello-codes.css
vendored
Executable file → Normal file
|
@ -38,6 +38,7 @@
|
|||
.icon-bell-alt:before { content: '\f0f3'; } /* '' */
|
||||
.icon-plus-squared:before { content: '\f0fe'; } /* '' */
|
||||
.icon-reply:before { content: '\f112'; } /* '' */
|
||||
.icon-smile:before { content: '\f118'; } /* '' */
|
||||
.icon-lock-open-alt:before { content: '\f13e'; } /* '' */
|
||||
.icon-ellipsis:before { content: '\f141'; } /* '' */
|
||||
.icon-play-circled:before { content: '\f144'; } /* '' */
|
||||
|
|
14
static/font/css/fontello-embedded.css
vendored
Executable file → Normal file
14
static/font/css/fontello-embedded.css
vendored
Executable file → Normal file
File diff suppressed because one or more lines are too long
1
static/font/css/fontello-ie7-codes.css
vendored
Executable file → Normal file
1
static/font/css/fontello-ie7-codes.css
vendored
Executable file → Normal file
|
@ -38,6 +38,7 @@
|
|||
.icon-bell-alt { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-plus-squared { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-reply { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-smile { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-lock-open-alt { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-ellipsis { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-play-circled { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
|
|
1
static/font/css/fontello-ie7.css
vendored
Executable file → Normal file
1
static/font/css/fontello-ie7.css
vendored
Executable file → Normal file
|
@ -49,6 +49,7 @@
|
|||
.icon-bell-alt { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-plus-squared { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-reply { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-smile { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-lock-open-alt { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-ellipsis { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-play-circled { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
|
|
16
static/font/css/fontello.css
vendored
Executable file → Normal file
16
static/font/css/fontello.css
vendored
Executable file → Normal file
|
@ -1,11 +1,11 @@
|
|||
@font-face {
|
||||
font-family: 'fontello';
|
||||
src: url('../font/fontello.eot?4060331');
|
||||
src: url('../font/fontello.eot?4060331#iefix') format('embedded-opentype'),
|
||||
url('../font/fontello.woff2?4060331') format('woff2'),
|
||||
url('../font/fontello.woff?4060331') format('woff'),
|
||||
url('../font/fontello.ttf?4060331') format('truetype'),
|
||||
url('../font/fontello.svg?4060331#fontello') format('svg');
|
||||
src: url('../font/fontello.eot?94788965');
|
||||
src: url('../font/fontello.eot?94788965#iefix') format('embedded-opentype'),
|
||||
url('../font/fontello.woff2?94788965') format('woff2'),
|
||||
url('../font/fontello.woff?94788965') format('woff'),
|
||||
url('../font/fontello.ttf?94788965') format('truetype'),
|
||||
url('../font/fontello.svg?94788965#fontello') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
@ -15,7 +15,7 @@
|
|||
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
||||
@font-face {
|
||||
font-family: 'fontello';
|
||||
src: url('../font/fontello.svg?4060331#fontello') format('svg');
|
||||
src: url('../font/fontello.svg?94788965#fontello') format('svg');
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
@ -83,7 +83,6 @@
|
|||
.icon-pin:before { content: '\e819'; } /* '' */
|
||||
.icon-wrench:before { content: '\e81a'; } /* '' */
|
||||
.icon-chart-bar:before { content: '\e81b'; } /* '' */
|
||||
.icon-zoom-in:before { content: '\e81c'; } /* '' */
|
||||
.icon-spin3:before { content: '\e832'; } /* '' */
|
||||
.icon-spin4:before { content: '\e834'; } /* '' */
|
||||
.icon-link-ext:before { content: '\f08e'; } /* '' */
|
||||
|
@ -94,6 +93,7 @@
|
|||
.icon-bell-alt:before { content: '\f0f3'; } /* '' */
|
||||
.icon-plus-squared:before { content: '\f0fe'; } /* '' */
|
||||
.icon-reply:before { content: '\f112'; } /* '' */
|
||||
.icon-smile:before { content: '\f118'; } /* '' */
|
||||
.icon-lock-open-alt:before { content: '\f13e'; } /* '' */
|
||||
.icon-ellipsis:before { content: '\f141'; } /* '' */
|
||||
.icon-play-circled:before { content: '\f144'; } /* '' */
|
||||
|
|
16
static/font/demo.html
Executable file → Normal file
16
static/font/demo.html
Executable file → Normal file
|
@ -229,11 +229,11 @@ body {
|
|||
}
|
||||
@font-face {
|
||||
font-family: 'fontello';
|
||||
src: url('./font/fontello.eot?25455785');
|
||||
src: url('./font/fontello.eot?25455785#iefix') format('embedded-opentype'),
|
||||
url('./font/fontello.woff?25455785') format('woff'),
|
||||
url('./font/fontello.ttf?25455785') format('truetype'),
|
||||
url('./font/fontello.svg?25455785#fontello') format('svg');
|
||||
src: url('./font/fontello.eot?31206390');
|
||||
src: url('./font/fontello.eot?31206390#iefix') format('embedded-opentype'),
|
||||
url('./font/fontello.woff?31206390') format('woff'),
|
||||
url('./font/fontello.ttf?31206390') format('truetype'),
|
||||
url('./font/fontello.svg?31206390#fontello') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
@ -340,21 +340,21 @@ body {
|
|||
<div class="the-icons span3" title="Code: 0xe81b"><i class="demo-icon icon-chart-bar"></i> <span class="i-name">icon-chart-bar</span><span class="i-code">0xe81b</span></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="the-icons span3" title="Code: 0xe81c"><i class="demo-icon icon-zoom-in"></i> <span class="i-name">icon-zoom-in</span><span class="i-code">0xe81c</span></div>
|
||||
<div class="the-icons span3" title="Code: 0xe832"><i class="demo-icon icon-spin3 animate-spin"></i> <span class="i-name">icon-spin3</span><span class="i-code">0xe832</span></div>
|
||||
<div class="the-icons span3" title="Code: 0xe834"><i class="demo-icon icon-spin4 animate-spin"></i> <span class="i-name">icon-spin4</span><span class="i-code">0xe834</span></div>
|
||||
<div class="the-icons span3" title="Code: 0xf08e"><i class="demo-icon icon-link-ext"></i> <span class="i-name">icon-link-ext</span><span class="i-code">0xf08e</span></div>
|
||||
<div class="the-icons span3" title="Code: 0xf08f"><i class="demo-icon icon-link-ext-alt"></i> <span class="i-name">icon-link-ext-alt</span><span class="i-code">0xf08f</span></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="the-icons span3" title="Code: 0xf08f"><i class="demo-icon icon-link-ext-alt"></i> <span class="i-name">icon-link-ext-alt</span><span class="i-code">0xf08f</span></div>
|
||||
<div class="the-icons span3" title="Code: 0xf0c9"><i class="demo-icon icon-menu"></i> <span class="i-name">icon-menu</span><span class="i-code">0xf0c9</span></div>
|
||||
<div class="the-icons span3" title="Code: 0xf0e0"><i class="demo-icon icon-mail-alt"></i> <span class="i-name">icon-mail-alt</span><span class="i-code">0xf0e0</span></div>
|
||||
<div class="the-icons span3" title="Code: 0xf0e5"><i class="demo-icon icon-comment-empty"></i> <span class="i-name">icon-comment-empty</span><span class="i-code">0xf0e5</span></div>
|
||||
<div class="the-icons span3" title="Code: 0xf0f3"><i class="demo-icon icon-bell-alt"></i> <span class="i-name">icon-bell-alt</span><span class="i-code">0xf0f3</span></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="the-icons span3" title="Code: 0xf0f3"><i class="demo-icon icon-bell-alt"></i> <span class="i-name">icon-bell-alt</span><span class="i-code">0xf0f3</span></div>
|
||||
<div class="the-icons span3" title="Code: 0xf0fe"><i class="demo-icon icon-plus-squared"></i> <span class="i-name">icon-plus-squared</span><span class="i-code">0xf0fe</span></div>
|
||||
<div class="the-icons span3" title="Code: 0xf112"><i class="demo-icon icon-reply"></i> <span class="i-name">icon-reply</span><span class="i-code">0xf112</span></div>
|
||||
<div class="the-icons span3" title="Code: 0xf118"><i class="demo-icon icon-smile"></i> <span class="i-name">icon-smile</span><span class="i-code">0xf118</span></div>
|
||||
<div class="the-icons span3" title="Code: 0xf13e"><i class="demo-icon icon-lock-open-alt"></i> <span class="i-name">icon-lock-open-alt</span><span class="i-code">0xf13e</span></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
|
BIN
static/font/font/fontello.eot
Executable file → Normal file
BIN
static/font/font/fontello.eot
Executable file → Normal file
Binary file not shown.
2
static/font/font/fontello.svg
Executable file → Normal file
2
static/font/font/fontello.svg
Executable file → Normal file
|
@ -84,6 +84,8 @@
|
|||
|
||||
<glyph glyph-name="reply" unicode="" d="M1000 232q0-93-71-252-1-4-6-13t-7-17-7-12q-7-10-16-10-8 0-13 6t-5 14q0 5 1 15t2 13q3 38 3 69 0 56-10 101t-27 77-45 56-59 39-74 24-86 12-98 3h-125v-143q0-14-10-25t-26-11-25 11l-285 286q-11 10-11 25t11 25l285 286q11 10 25 10t26-10 10-25v-143h125q398 0 488-225 30-75 30-186z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="smile" unicode="" d="M633 257q-21-67-77-109t-127-41-128 41-77 109q-4 14 3 27t21 18q14 4 27-2t17-22q14-44 52-72t85-28 84 28 52 72q4 15 18 22t27 2 21-18 2-27z m-276 243q0-30-21-51t-50-21-51 21-21 51 21 50 51 21 50-21 21-50z m286 0q0-30-21-51t-51-21-50 21-21 51 21 50 50 21 51-21 21-50z m143-143q0 73-29 139t-76 114-114 76-138 28-139-28-114-76-76-114-29-139 29-139 76-113 114-77 139-28 138 28 114 77 76 113 29 139z m71 0q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />
|
||||
|
||||
<glyph glyph-name="lock-open-alt" unicode="" d="M589 428q23 0 38-15t16-38v-322q0-22-16-37t-38-16h-535q-23 0-38 16t-16 37v322q0 22 16 38t38 15h17v179q0 103 74 177t176 73 177-73 73-177q0-14-10-25t-25-11h-36q-14 0-25 11t-11 25q0 59-42 101t-101 42-101-42-41-101v-179h410z" horiz-adv-x="642.9" />
|
||||
|
||||
<glyph glyph-name="ellipsis" unicode="" d="M214 446v-107q0-22-15-38t-38-15h-107q-23 0-38 15t-16 38v107q0 23 16 38t38 16h107q22 0 38-16t15-38z m286 0v-107q0-22-16-38t-38-15h-107q-22 0-38 15t-15 38v107q0 23 15 38t38 16h107q23 0 38-16t16-38z m286 0v-107q0-22-16-38t-38-15h-107q-22 0-38 15t-16 38v107q0 23 16 38t38 16h107q23 0 38-16t16-38z" horiz-adv-x="785.7" />
|
||||
|
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
BIN
static/font/font/fontello.ttf
Executable file → Normal file
BIN
static/font/font/fontello.ttf
Executable file → Normal file
Binary file not shown.
BIN
static/font/font/fontello.woff
Executable file → Normal file
BIN
static/font/font/fontello.woff
Executable file → Normal file
Binary file not shown.
BIN
static/font/font/fontello.woff2
Executable file → Normal file
BIN
static/font/font/fontello.woff2
Executable file → Normal file
Binary file not shown.
131
test/unit/specs/components/emoji_input.spec.js
Normal file
131
test/unit/specs/components/emoji_input.spec.js
Normal file
|
@ -0,0 +1,131 @@
|
|||
import { shallowMount, createLocalVue } from '@vue/test-utils'
|
||||
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
|
||||
|
||||
const generateInput = (value, padEmoji = true) => {
|
||||
const localVue = createLocalVue()
|
||||
localVue.directive('click-outside', () => {})
|
||||
const wrapper = shallowMount(EmojiInput, {
|
||||
propsData: {
|
||||
suggest: () => [],
|
||||
enableEmojiPicker: true,
|
||||
value
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
state: {
|
||||
config: {
|
||||
padEmoji
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
slots: {
|
||||
default: '<input />'
|
||||
},
|
||||
localVue
|
||||
})
|
||||
return [wrapper, localVue]
|
||||
}
|
||||
|
||||
describe('EmojiInput', () => {
|
||||
describe('insertion mechanism', () => {
|
||||
it('inserts string at the end with trailing space', () => {
|
||||
const initialString = 'Testing'
|
||||
const [wrapper] = generateInput(initialString)
|
||||
const input = wrapper.find('input')
|
||||
input.setValue(initialString)
|
||||
wrapper.setData({ caret: initialString.length })
|
||||
wrapper.vm.insert({ insertion: '(test)', keepOpen: false })
|
||||
expect(wrapper.emitted().input[0][0]).to.eql('Testing (test) ')
|
||||
})
|
||||
|
||||
it('inserts string at the end with trailing space (source has a trailing space)', () => {
|
||||
const initialString = 'Testing '
|
||||
const [wrapper] = generateInput(initialString)
|
||||
const input = wrapper.find('input')
|
||||
input.setValue(initialString)
|
||||
wrapper.setData({ caret: initialString.length })
|
||||
wrapper.vm.insert({ insertion: '(test)', keepOpen: false })
|
||||
expect(wrapper.emitted().input[0][0]).to.eql('Testing (test) ')
|
||||
})
|
||||
|
||||
it('inserts string at the begginning without leading space', () => {
|
||||
const initialString = 'Testing'
|
||||
const [wrapper] = generateInput(initialString)
|
||||
const input = wrapper.find('input')
|
||||
input.setValue(initialString)
|
||||
wrapper.setData({ caret: 0 })
|
||||
wrapper.vm.insert({ insertion: '(test)', keepOpen: false })
|
||||
expect(wrapper.emitted().input[0][0]).to.eql('(test) Testing')
|
||||
})
|
||||
|
||||
it('inserts string between words without creating extra spaces', () => {
|
||||
const initialString = 'Spurdo Sparde'
|
||||
const [wrapper] = generateInput(initialString)
|
||||
const input = wrapper.find('input')
|
||||
input.setValue(initialString)
|
||||
wrapper.setData({ caret: 6 })
|
||||
wrapper.vm.insert({ insertion: ':ebin:', keepOpen: false })
|
||||
expect(wrapper.emitted().input[0][0]).to.eql('Spurdo :ebin: Sparde')
|
||||
})
|
||||
|
||||
it('inserts string between words without creating extra spaces (other caret)', () => {
|
||||
const initialString = 'Spurdo Sparde'
|
||||
const [wrapper] = generateInput(initialString)
|
||||
const input = wrapper.find('input')
|
||||
input.setValue(initialString)
|
||||
wrapper.setData({ caret: 7 })
|
||||
wrapper.vm.insert({ insertion: ':ebin:', keepOpen: false })
|
||||
expect(wrapper.emitted().input[0][0]).to.eql('Spurdo :ebin: Sparde')
|
||||
})
|
||||
|
||||
it('inserts string without any padding if padEmoji setting is set to false', () => {
|
||||
const initialString = 'Eat some spam!'
|
||||
const [wrapper] = generateInput(initialString, false)
|
||||
const input = wrapper.find('input')
|
||||
input.setValue(initialString)
|
||||
wrapper.setData({ caret: initialString.length, keepOpen: false })
|
||||
wrapper.vm.insert({ insertion: ':spam:' })
|
||||
expect(wrapper.emitted().input[0][0]).to.eql('Eat some spam!:spam:')
|
||||
})
|
||||
|
||||
it('correctly sets caret after insertion at beginning', (done) => {
|
||||
const initialString = '1234'
|
||||
const [wrapper, vue] = generateInput(initialString)
|
||||
const input = wrapper.find('input')
|
||||
input.setValue(initialString)
|
||||
wrapper.setData({ caret: 0 })
|
||||
wrapper.vm.insert({ insertion: '1234', keepOpen: false })
|
||||
vue.nextTick(() => {
|
||||
expect(wrapper.vm.caret).to.eql(5)
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('correctly sets caret after insertion at end', (done) => {
|
||||
const initialString = '1234'
|
||||
const [wrapper, vue] = generateInput(initialString)
|
||||
const input = wrapper.find('input')
|
||||
input.setValue(initialString)
|
||||
wrapper.setData({ caret: initialString.length })
|
||||
wrapper.vm.insert({ insertion: '1234', keepOpen: false })
|
||||
vue.nextTick(() => {
|
||||
expect(wrapper.vm.caret).to.eql(10)
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('correctly sets caret after insertion if padEmoji setting is set to false', (done) => {
|
||||
const initialString = '1234'
|
||||
const [wrapper, vue] = generateInput(initialString, false)
|
||||
const input = wrapper.find('input')
|
||||
input.setValue(initialString)
|
||||
wrapper.setData({ caret: initialString.length })
|
||||
wrapper.vm.insert({ insertion: '1234', keepOpen: false })
|
||||
vue.nextTick(() => {
|
||||
expect(wrapper.vm.caret).to.eql(8)
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue