Rewrite Modals/NewToot/Status with composition API

This commit is contained in:
AkiraFukushima 2022-04-29 23:47:49 +09:00
parent 71a8e4c488
commit 29f3776c08
No known key found for this signature in database
GPG Key ID: B6E51BAC4DE1A957
4 changed files with 183 additions and 192 deletions

View File

@ -17,14 +17,13 @@
</div> </div>
</div> </div>
<Status <Status
v-model="status" :modelValue="status"
@update:modelValue="status = $event"
:opened="newTootModal" :opened="newTootModal"
:fixCursorPos="hashtagInserting" :fixCursorPos="hashtagInserting"
:height="statusHeight" :height="statusHeight"
@paste="onPaste" @paste="onPaste"
@toot="toot" @toot="toot"
@pickerOpened="innerElementOpened"
@suggestOpened="innerElementOpened"
/> />
</el-form> </el-form>
<Poll v-if="openPoll" v-model:polls="polls" v-model:expire="pollExpire" @addPoll="addPoll" @removePoll="removePoll" ref="poll"></Poll> <Poll v-if="openPoll" v-model:polls="polls" v-model:expire="pollExpire" @addPoll="addPoll" @removePoll="removePoll" ref="poll"></Poll>

View File

@ -1,15 +1,17 @@
<template> <template>
<div class="status"> <div class="status">
<textarea <textarea
v-model="status" :value="modelValue"
ref="status" @input="$emit('update:modelValue', $event.target.value)"
@paste="onPaste" ref="statusRef"
@paste="$emit('paste', $event)"
v-on:input="startSuggest" v-on:input="startSuggest"
:placeholder="$t('modals.new_toot.status')" :placeholder="$t('modals.new_toot.status')"
role="textbox" role="textbox"
contenteditable="true" contenteditable="true"
aria-multiline="true" aria-multiline="true"
:style="`height: ${height}px`" :style="`height: ${height}px`"
v-focus
autofocus autofocus
> >
</textarea> </textarea>
@ -19,7 +21,7 @@
v-for="(item, index) in filteredSuggestion" v-for="(item, index) in filteredSuggestion"
:key="index" :key="index"
@click="insertItem(item)" @click="insertItem(item)"
@mouseover="highlightedIndex = index" @mouseover="suggestHighlight(index)"
:class="{ highlighted: highlightedIndex === index }" :class="{ highlighted: highlightedIndex === index }"
> >
<span v-if="item.image"> <span v-if="item.image">
@ -59,23 +61,24 @@
</div> </div>
</template> </template>
<script> <script lang="ts">
import 'emoji-mart-vue-fast/css/emoji-mart.css' import 'emoji-mart-vue-fast/css/emoji-mart.css'
import data from 'emoji-mart-vue-fast/data/all.json' import data from 'emoji-mart-vue-fast/data/all.json'
import { mapState, mapGetters } from 'vuex' import { defineComponent, computed, toRefs, ref } from 'vue'
import { Picker, EmojiIndex } from 'emoji-mart-vue-fast/src' import { Picker, EmojiIndex } from 'emoji-mart-vue-fast/src'
import suggestText from '@/utils/suggestText' import suggestText from '@/utils/suggestText'
import { useStore } from '@/store'
import { MUTATION_TYPES, ACTION_TYPES } from '@/store/TimelineSpace/Modals/NewToot/Status'
const emojiIndex = new EmojiIndex(data) export default defineComponent({
export default {
name: 'status', name: 'status',
components: { components: {
Picker Picker
}, },
props: { props: {
value: { modelValue: {
type: String type: String,
default: ''
}, },
opened: { opened: {
type: Boolean, type: Boolean,
@ -90,194 +93,145 @@ export default {
default: 120 default: 120
} }
}, },
data() { setup(props, ctx) {
return { const space = 'TimelineSpace/Modals/NewToot/Status'
highlightedIndex: 0, const store = useStore()
openEmojiPicker: false, const emojiIndex = new EmojiIndex(data)
emojiIndex: emojiIndex const { modelValue } = toRefs(props)
const highlightedIndex = ref(0)
const statusRef = ref<HTMLTextAreaElement>()
const filteredAccounts = computed(() => store.state.TimelineSpace.Modals.NewToot.Status.filteredAccounts)
const filteredHashtags = computed(() => store.state.TimelineSpace.Modals.NewToot.Status.filteredHashtags)
const filteredSuggestion = computed(() => store.state.TimelineSpace.Modals.NewToot.Status.filteredSuggestion)
const openSuggest = computed({
get: () => store.state.TimelineSpace.Modals.NewToot.Status.openSuggest,
set: (value: boolean) => store.commit(`${space}/${MUTATION_TYPES.CHANGE_OPEN_SUGGEST}`, value)
})
const startIndex = computed(() => store.state.TimelineSpace.Modals.NewToot.Status.startIndex)
const matchWord = computed(() => store.state.TimelineSpace.Modals.NewToot.Status.matchWord)
const pickerEmojis = computed(() => store.getters[`${space}/pickerEmojis`])
const closeSuggest = () => {
store.dispatch(`${space}/${ACTION_TYPES.CLOSE_SUGGEST}`)
if (openSuggest.value) {
highlightedIndex.value = 0
}
ctx.emit('suggestOpened', false)
} }
}, const suggestAccount = async (start: number, word: string) => {
computed: { try {
...mapState('TimelineSpace/Modals/NewToot/Status', { await store.dispatch(`${space}/${ACTION_TYPES.SUGGEST_ACCOUNT}`, { word: word, start: start })
filteredAccounts: state => state.filteredAccounts, ctx.emit('suggestOpened', true)
filteredHashtags: state => state.filteredHashtags, return true
openSuggest: state => state.openSuggest, } catch (err) {
startIndex: state => state.startIndex, console.log(err)
matchWord: state => state.matchWord, return false
filteredSuggestion: state => state.filteredSuggestion
}),
...mapGetters('TimelineSpace/Modals/NewToot/Status', ['pickerEmojis']),
status: {
get: function () {
return this.value
},
set: function (value) {
this.$emit('input', value)
} }
} }
}, const suggestHashtag = async (start: number, word: string) => {
mounted() { try {
// When change account, the new toot modal is recreated. await store.dispatch(`${space}/${ACTION_TYPES.SUGGEST_HASHTAG}`, { word: word, start: start })
// So can not catch open event in watch. ctx.emit('suggestOpened', true)
this.$refs.status.focus() return true
if (this.fixCursorPos) { } catch (err) {
this.$refs.status.setSelectionRange(0, 0) console.log(err)
} return false
},
watch: {
opened: function (newState, oldState) {
if (!oldState && newState) {
this.$nextTick(function () {
this.$refs.status.focus()
if (this.fixCursorPos) {
this.$refs.status.setSelectionRange(0, 0)
}
})
} else if (oldState && !newState) {
this.closeSuggest()
} }
} }
}, const suggestEmoji = async (start: number, word: string) => {
methods: { try {
async startSuggest(e) { store.dispatch(`${space}/${ACTION_TYPES.SUGGEST_EMOJI}`, { word: word, start: start })
const currentValue = e.target.value ctx.emit('suggestOpened', true)
// Start suggest after user stop writing return true
setTimeout(async () => { } catch (err) {
if (currentValue === this.status) { console.log(err)
await this.suggest(e) return false
} }
}, 700) }
}, const suggest = async (e: Event) => {
async suggest(e) { const target = e.target as HTMLInputElement
// e.target.sectionStart: Cursor position // e.target.sectionStart: Cursor position
// e.target.value: current value of the textarea // e.target.value: current value of the textarea
const [start, word] = suggestText(e.target.value, e.target.selectionStart) const [start, word] = suggestText(target.value, target.selectionStart!)
if (!start || !word) { if (!start || !word) {
this.closeSuggest() closeSuggest()
return false return false
} }
switch (word.charAt(0)) { switch (word.charAt(0)) {
case ':': case ':':
await this.suggestEmoji(start, word) await suggestEmoji(start, word)
return true return true
case '@': case '@':
await this.suggestAccount(start, word) await suggestAccount(start, word)
return true return true
case '#': case '#':
await this.suggestHashtag(start, word) await suggestHashtag(start, word)
return true return true
default: default:
return false return false
} }
}, }
async suggestAccount(start, word) { const startSuggest = (e: Event) => {
try { const currentValue = (e.target as HTMLInputElement).value
await this.$store.dispatch('TimelineSpace/Modals/NewToot/Status/suggestAccount', { word: word, start: start }) // Start suggest after user stop writing
this.$emit('suggestOpened', true) setTimeout(async () => {
return true if (currentValue === modelValue.value) {
} catch (err) { await suggest(e)
console.log(err) }
return false }, 700)
} }
},
async suggestHashtag(start, word) { const suggestHighlight = (index: number) => {
try {
await this.$store.dispatch('TimelineSpace/Modals/NewToot/Status/suggestHashtag', { word: word, start: start })
this.$emit('suggestOpened', true)
return true
} catch (err) {
console.log(err)
return false
}
},
suggestEmoji(start, word) {
try {
this.$store.dispatch('TimelineSpace/Modals/NewToot/Status/suggestEmoji', { word: word, start: start })
this.$emit('suggestOpened', true)
return true
} catch (err) {
this.closeSuggest()
return false
}
},
closeSuggest() {
this.$store.dispatch('TimelineSpace/Modals/NewToot/Status/closeSuggest')
if (this.openSuggest) {
this.highlightedIndex = 0
}
this.$emit('suggestOpened', false)
},
suggestHighlight(index) {
if (index < 0) { if (index < 0) {
this.highlightedIndex = 0 highlightedIndex.value = 0
} else if (index >= this.filteredSuggestion.length) { } else if (index >= filteredSuggestion.value.length) {
this.highlightedIndex = this.filteredSuggestion.length - 1 highlightedIndex.value = filteredSuggestion.value.length - 1
} else { } else {
this.highlightedIndex = index highlightedIndex.value = index
} }
}, }
insertItem(item) { const insertItem = item => {
console.log('inserted', item.name)
if (item.code) { if (item.code) {
const str = `${this.status.slice(0, this.startIndex - 1)}${item.code} ${this.status.slice(this.startIndex + this.matchWord.length)}` const str = `${modelValue.value.slice(0, startIndex.value - 1)}${item.code} ${modelValue.value.slice(
this.status = str startIndex.value + matchWord.value.length
)}`
ctx.emit('update:modelValue', str)
} else { } else {
const str = `${this.status.slice(0, this.startIndex - 1)}${item.name} ${this.status.slice(this.startIndex + this.matchWord.length)}` const str = `${modelValue.value.slice(0, startIndex.value - 1)}${item.name} ${modelValue.value.slice(
this.status = str startIndex.value + matchWord.value.length
)}`
console.log(str)
ctx.emit('update:modelValue', str)
} }
this.closeSuggest() closeSuggest()
}, }
selectCurrentItem() { const selectEmoji = emoji => {
const item = this.filteredSuggestion[this.highlightedIndex] const current = statusRef.value?.selectionStart
this.insertItem(item)
},
onPaste(e) {
this.$emit('paste', e)
},
handleKey(event) {
const current = event.target.selectionStart
switch (event.srcKey) {
case 'up':
this.suggestHighlight(this.highlightedIndex - 1)
break
case 'down':
this.suggestHighlight(this.highlightedIndex + 1)
break
case 'enter':
this.selectCurrentItem()
break
case 'esc':
this.closeSuggest()
break
case 'left':
event.target.setSelectionRange(current - 1, current - 1)
break
case 'right':
event.target.setSelectionRange(current + 1, current + 1)
break
case 'linux':
case 'mac':
this.$emit('toot')
break
default:
return true
}
},
toggleEmojiPicker() {
this.openEmojiPicker = !this.openEmojiPicker
this.$emit('pickerOpened', this.openEmojiPicker)
},
selectEmoji(emoji) {
const current = this.$refs.status.selectionStart
if (emoji.native) { if (emoji.native) {
this.status = `${this.status.slice(0, current)}${emoji.native} ${this.status.slice(current)}` ctx.emit('update:modelValue', `${modelValue.value.slice(0, current)}${emoji.native} ${modelValue.value.slice(current)}`)
} else { } else {
// Custom emoji don't have natvie code // Custom emoji don't have natvie code
this.status = `${this.status.slice(0, current)}${emoji.name} ${this.status.slice(current)}` ctx.emit('update:modelValue', `${modelValue.value.slice(0, current)}${emoji.name} ${modelValue.value.slice(current)}`)
} }
this.hideEmojiPicker() }
return {
emojiIndex,
highlightedIndex,
filteredAccounts,
filteredHashtags,
filteredSuggestion,
pickerEmojis,
openSuggest,
startSuggest,
suggestHighlight,
insertItem,
selectEmoji
} }
} }
} })
</script> </script>
<style lang="scss"> <style lang="scss">

View File

@ -1,4 +1,5 @@
import emojidata from 'unicode-emoji-json/data-by-emoji.json' import { EmojiIndex } from 'emoji-mart-vue-fast'
import emojidata from 'emoji-mart-vue-fast/data/all.json'
import generator, { MegalodonInterface } from 'megalodon' import generator, { MegalodonInterface } from 'megalodon'
import { Module, MutationTree, ActionTree, GetterTree } from 'vuex' import { Module, MutationTree, ActionTree, GetterTree } from 'vuex'
import { RootState } from '@/store/index' import { RootState } from '@/store/index'
@ -7,7 +8,19 @@ import { InsertAccountCache } from '~/src/types/insertAccountCache'
import { CachedAccount } from '~/src/types/cachedAccount' import { CachedAccount } from '~/src/types/cachedAccount'
import { MyWindow } from '~/src/types/global' import { MyWindow } from '~/src/types/global'
const win = (window as any) as MyWindow const win = window as any as MyWindow
const emojiIndex = new EmojiIndex(emojidata)
type EmojiMartEmoji = {
id: string
name: string
colons: string
text: string
emoticons: Array<string>
skin: any
native: string
}
type Suggest = { type Suggest = {
name: string name: string
@ -27,8 +40,8 @@ export type StatusState = {
filteredHashtags: Array<SuggestHashtag> filteredHashtags: Array<SuggestHashtag>
filteredEmojis: Array<SuggestEmoji> filteredEmojis: Array<SuggestEmoji>
openSuggest: boolean openSuggest: boolean
startIndex: number | null startIndex: number
matchWord: string | null matchWord: string
client: MegalodonInterface | null client: MegalodonInterface | null
} }
@ -38,8 +51,8 @@ const state = (): StatusState => ({
filteredHashtags: [], filteredHashtags: [],
filteredEmojis: [], filteredEmojis: [],
openSuggest: false, openSuggest: false,
startIndex: null, startIndex: 0,
matchWord: null, matchWord: '',
client: null client: null
}) })
@ -113,10 +126,10 @@ const mutations: MutationTree<StatusState> = {
[MUTATION_TYPES.CHANGE_OPEN_SUGGEST]: (state, value: boolean) => { [MUTATION_TYPES.CHANGE_OPEN_SUGGEST]: (state, value: boolean) => {
state.openSuggest = value state.openSuggest = value
}, },
[MUTATION_TYPES.CHANGE_START_INDEX]: (state, index: number | null) => { [MUTATION_TYPES.CHANGE_START_INDEX]: (state, index: number) => {
state.startIndex = index state.startIndex = index
}, },
[MUTATION_TYPES.CHANGE_MATCH_WORD]: (state, word: string | null) => { [MUTATION_TYPES.CHANGE_MATCH_WORD]: (state, word: string) => {
state.matchWord = word state.matchWord = word
}, },
[MUTATION_TYPES.FILTERED_SUGGESTION_FROM_HASHTAGS]: state => { [MUTATION_TYPES.FILTERED_SUGGESTION_FROM_HASHTAGS]: state => {
@ -144,8 +157,16 @@ type WordStart = {
start: number start: number
} }
export const ACTION_TYPES = {
SUGGEST_ACCOUNT: 'suggestAccount',
SUGGEST_HASHTAG: 'suggestHashtag',
SUGGEST_EMOJI: 'suggestEmoji',
CANCEL_REQUEST: 'cancelRequest',
CLOSE_SUGGEST: 'closeSuggest'
}
const actions: ActionTree<StatusState, RootState> = { const actions: ActionTree<StatusState, RootState> = {
suggestAccount: async ({ commit, rootState, dispatch }, wordStart: WordStart) => { [ACTION_TYPES.SUGGEST_ACCOUNT]: async ({ commit, rootState, dispatch }, wordStart: WordStart) => {
dispatch('cancelRequest') dispatch('cancelRequest')
commit(MUTATION_TYPES.CLEAR_FILTERED_ACCOUNTS) commit(MUTATION_TYPES.CLEAR_FILTERED_ACCOUNTS)
commit(MUTATION_TYPES.FILTERED_SUGGESTION_FROM_ACCOUNTS) commit(MUTATION_TYPES.FILTERED_SUGGESTION_FROM_ACCOUNTS)
@ -188,7 +209,7 @@ const actions: ActionTree<StatusState, RootState> = {
} }
await Promise.all([searchCache(), searchAPI()]) await Promise.all([searchCache(), searchAPI()])
}, },
suggestHashtag: async ({ commit, rootState, dispatch }, wordStart: WordStart) => { [ACTION_TYPES.SUGGEST_HASHTAG]: async ({ commit, rootState, dispatch }, wordStart: WordStart) => {
dispatch('cancelRequest') dispatch('cancelRequest')
commit(MUTATION_TYPES.CLEAR_FILTERED_HASHTAGS) commit(MUTATION_TYPES.CLEAR_FILTERED_HASHTAGS)
commit(MUTATION_TYPES.FILTERED_SUGGESTION_FROM_HASHTAGS) commit(MUTATION_TYPES.FILTERED_SUGGESTION_FROM_HASHTAGS)
@ -231,16 +252,29 @@ const actions: ActionTree<StatusState, RootState> = {
} }
await Promise.all([searchCache(), searchAPI()]) await Promise.all([searchCache(), searchAPI()])
}, },
suggestEmoji: ({ commit, rootState }, wordStart: WordStart) => { [ACTION_TYPES.SUGGEST_EMOJI]: ({ commit, rootState }, wordStart: WordStart) => {
const { word, start } = wordStart const { word, start } = wordStart
// Find native emojis // Find native emojis
const filteredEmojiName: Array<string> = Object.keys(emojidata).filter((emoji: string) => `:${emojidata[emoji].name}:`.includes(word)) const foundEmoji: EmojiMartEmoji = emojiIndex.findEmoji(word)
const filteredNativeEmoji: Array<SuggestEmoji> = filteredEmojiName.map((emoji: string) => { if (foundEmoji) {
return { return {
name: `:${emojidata[emoji].name}:`, name: foundEmoji.colons,
code: emoji code: foundEmoji.native
} }
}) }
let filteredNativeEmoji: Array<SuggestEmoji> = []
const regexp = word.match(/^:(.+)/)
if (regexp && regexp.length > 1) {
const emojiName = regexp[1]
const filteredEmoji: Array<EmojiMartEmoji> = emojiIndex.search(emojiName)
filteredNativeEmoji = filteredEmoji.map((emoji: EmojiMartEmoji) => {
return {
name: emoji.colons,
code: emoji.native
}
})
}
// Find custom emojis // Find custom emojis
const filteredCustomEmoji: Array<Suggest> = rootState.TimelineSpace.emojis const filteredCustomEmoji: Array<Suggest> = rootState.TimelineSpace.emojis
.map(emoji => { .map(emoji => {
@ -264,16 +298,16 @@ const actions: ActionTree<StatusState, RootState> = {
commit(MUTATION_TYPES.FILTERED_SUGGESTION_FROM_EMOJIS) commit(MUTATION_TYPES.FILTERED_SUGGESTION_FROM_EMOJIS)
return filtered return filtered
}, },
cancelRequest: ({ state }) => { [ACTION_TYPES.CANCEL_REQUEST]: ({ state }) => {
if (state.client) { if (state.client) {
state.client.cancel() state.client.cancel()
} }
}, },
closeSuggest: ({ commit, dispatch }) => { [ACTION_TYPES.CLOSE_SUGGEST]: ({ commit, dispatch }) => {
dispatch('cancelRequest') dispatch('cancelRequest')
commit(MUTATION_TYPES.CHANGE_OPEN_SUGGEST, false) commit(MUTATION_TYPES.CHANGE_OPEN_SUGGEST, false)
commit(MUTATION_TYPES.CHANGE_START_INDEX, null) commit(MUTATION_TYPES.CHANGE_START_INDEX, 0)
commit(MUTATION_TYPES.CHANGE_MATCH_WORD, null) commit(MUTATION_TYPES.CHANGE_MATCH_WORD, '')
commit(MUTATION_TYPES.CLEAR_FILTERED_SUGGESTION) commit(MUTATION_TYPES.CLEAR_FILTERED_SUGGESTION)
commit(MUTATION_TYPES.CLEAR_FILTERED_ACCOUNTS) commit(MUTATION_TYPES.CLEAR_FILTERED_ACCOUNTS)
commit(MUTATION_TYPES.CLEAR_FILTERED_HASHTAGS) commit(MUTATION_TYPES.CLEAR_FILTERED_HASHTAGS)

View File

@ -1,6 +1,10 @@
// https://github.com/tootsuite/mastodon/blob/master/app/javascript/mastodon/components/autosuggest_textarea.js // https://github.com/tootsuite/mastodon/blob/master/app/javascript/mastodon/components/autosuggest_textarea.js
const textAtCursorMatch = (str, cursorPosition, separators = ['@', '#', ':']) => { const textAtCursorMatch = (
let word str: string,
cursorPosition: number,
separators: Array<string> = ['@', '#', ':']
): [number | null, string | null] => {
let word: string
const left = str.slice(0, cursorPosition).search(/\S+$/) const left = str.slice(0, cursorPosition).search(/\S+$/)
const right = str.slice(cursorPosition).search(/\s/) const right = str.slice(cursorPosition).search(/\s/)