完成自定义表情前端

This commit is contained in:
John Smith 2022-02-27 14:45:19 +08:00
parent 3da8cc4227
commit 903cfecab9
13 changed files with 277 additions and 45 deletions

View File

@ -336,7 +336,7 @@ export default class ChatClientDirect {
medalLevel: roomId === this.roomId ? medalLevel : 0,
id: getUuid4Hex(),
translation: '',
emoticon: info[0][13].url || null // TODO 改成对象?
emoticon: info[0][13].url || null
}
this.onAddText(data)
}

View File

@ -130,7 +130,7 @@ export default class ChatClientRelay {
let contentType = data[13]
let contentTypeParams = data[14]
if (contentType === CONTENT_TYPE_EMOTICON) {
emoticon = contentTypeParams[0] // TODO 改成对象?
emoticon = contentTypeParams[0]
}
data = {

View File

@ -19,7 +19,6 @@ const CONTENTS = [
'有一说一,这件事大家懂的都懂,不懂的,说了你也不明白,不如不说', '让我看看', '我柜子动了,我不玩了'
]
// TODO 改成对象?
const EMOTICONS = [
'/static/img/emoticons/233.png',
'/static/img/emoticons/miaoa.png',

View File

@ -9,11 +9,14 @@
:isInMemberMessage="false" :authorName="authorName" :authorType="authorType" :privilegeType="privilegeType"
></author-chip>
<span id="message" class="style-scope yt-live-chat-text-message-renderer">
<template v-if="!emoticon">{{ content }}</template>
<img v-else class="emoji yt-formatted-string style-scope yt-live-chat-text-message-renderer"
:src="emoticon" :alt="content" shared-tooltip-text="" id="emoji"
>
<el-badge :value="repeated" :max="99" v-show="repeated > 1" class="style-scope yt-live-chat-text-message-renderer"
<template v-for="(content, index) in richContent">
<span :key="index" v-if="content.type === CONTENT_TYPE_TEXT">{{ content.text }}</span>
<img :key="index" v-else-if="content.type === CONTENT_TYPE_IMAGE"
class="emoji yt-formatted-string style-scope yt-live-chat-text-message-renderer"
:src="content.url" :alt="content.text" :shared-tooltip-text="content.text" :id="`emoji-${content.text}`"
>
</template>
<el-badge :value="repeated" :max="99" v-if="repeated > 1" class="style-scope yt-live-chat-text-message-renderer"
:style="{ '--repeated-mark-color': repeatedMarkColor }"
></el-badge>
</span>
@ -42,11 +45,16 @@ export default {
time: Date,
authorName: String,
authorType: Number,
content: String,
emoticon: String,
richContent: Array,
privilegeType: Number,
repeated: Number
},
data() {
return {
CONTENT_TYPE_TEXT: constants.CONTENT_TYPE_TEXT,
CONTENT_TYPE_IMAGE: constants.CONTENT_TYPE_IMAGE
}
},
computed: {
timeText() {
return utils.getTimeTextHourMin(this.time)

View File

@ -34,6 +34,9 @@ export const MESSAGE_TYPE_SUPER_CHAT = 3
export const MESSAGE_TYPE_DEL = 4
export const MESSAGE_TYPE_UPDATE = 5
export const CONTENT_TYPE_TEXT = 0
export const CONTENT_TYPE_IMAGE = 1
// 美元 -> 人民币 汇率
const EXCHANGE_RATE = 7
export const PRICE_CONFIGS = [
@ -139,6 +142,17 @@ export function getShowContent(message) {
return message.content
}
export function getShowRichContent(message) {
let richContent = [...message.richContent]
if (message.translation) {
richContent.push({
type: CONTENT_TYPE_TEXT,
text: `${message.translation}`
})
}
return richContent
}
export function getGiftShowContent(message, showGiftName) {
if (!showGiftName) {
return ''

View File

@ -12,24 +12,37 @@
<template v-for="message in messages">
<text-message :key="message.id" v-if="message.type === MESSAGE_TYPE_TEXT"
class="style-scope yt-live-chat-item-list-renderer"
:avatarUrl="message.avatarUrl" :time="message.time" :authorName="message.authorName"
:authorType="message.authorType" :content="getShowContent(message)" :emoticon="message.emoticon"
:privilegeType="message.privilegeType" :repeated="message.repeated"
:time="message.time"
:avatarUrl="message.avatarUrl"
:authorName="message.authorName"
:authorType="message.authorType"
:privilegeType="message.privilegeType"
:richContent="getShowRichContent(message)"
:repeated="message.repeated"
></text-message>
<paid-message :key="message.id" v-else-if="message.type === MESSAGE_TYPE_GIFT"
class="style-scope yt-live-chat-item-list-renderer"
:price="message.price" :avatarUrl="message.avatarUrl" :authorName="getShowAuthorName(message)"
:time="message.time" :content="getGiftShowContent(message)"
:time="message.time"
:avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)"
:price="message.price"
:content="getGiftShowContent(message)"
></paid-message>
<membership-item :key="message.id" v-else-if="message.type === MESSAGE_TYPE_MEMBER"
class="style-scope yt-live-chat-item-list-renderer"
:avatarUrl="message.avatarUrl" :authorName="getShowAuthorName(message)" :privilegeType="message.privilegeType"
:title="message.title" :time="message.time"
:time="message.time"
:avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)"
:privilegeType="message.privilegeType"
:title="message.title"
></membership-item>
<paid-message :key="message.id" v-else-if="message.type === MESSAGE_TYPE_SUPER_CHAT"
class="style-scope yt-live-chat-item-list-renderer"
:price="message.price" :avatarUrl="message.avatarUrl" :authorName="getShowAuthorName(message)"
:time="message.time" :content="getShowContent(message)"
:time="message.time"
:avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)"
:price="message.price"
:content="getShowContent(message)"
></paid-message>
</template>
</div>
@ -137,6 +150,7 @@ export default {
return constants.getGiftShowContent(message, this.showGiftName)
},
getShowContent: constants.getShowContent,
getShowRichContent: constants.getShowRichContent,
getShowAuthorName: constants.getShowAuthorName,
addMessage(message) {

View File

@ -1,10 +1,10 @@
export default {
sidebar: {
home: 'Home',
stylegen: 'Style generator',
stylegen: 'Style Generator',
help: 'Help',
projectAddress: 'Project address',
giftRecordOfficial: 'Official Super Chat record',
projectAddress: 'Project Address',
giftRecordOfficial: 'Official Super Chat Record',
},
home: {
roomIdEmpty: "Room ID can't be empty",
@ -38,8 +38,13 @@ export default {
pinyin: 'Pinyin',
kana: 'Kana',
emoticon: 'Custom Emotes',
emoticonKeyword: 'Emote Code',
emoticonUrl: 'URL',
operation: 'Operation',
addEmoticon: 'Add emote',
roomUrl: 'Room URL',
copy: 'Copy',
enterRoom: 'Enter room',
enterTestRoom: 'Enter test room',
exportConfig: 'Export config',
@ -63,7 +68,7 @@ export default {
showAvatars: 'Show avatars',
avatarSize: 'Avatar size',
userNames: 'User names',
userNames: 'User Names',
showUserNames: 'Show user names',
font: 'Font',
fontSize: 'Font size',
@ -91,7 +96,7 @@ export default {
moderatorMessageBgColor: 'Moderator background color',
memberMessageBgColor: 'Member background color',
scAndNewMember: 'Super Chat / New member',
scAndNewMember: 'Super Chat / New Member',
firstLineFont: 'First line font',
firstLineFontSize: 'First line font size',
firstLineLineHeight: 'First line line height (0 for default)',

View File

@ -38,8 +38,13 @@ export default {
pinyin: 'ピンイン',
kana: '仮名',
emoticon: 'カスタムスタンプ',
emoticonKeyword: '置き換えるキーワード',
emoticonUrl: 'URL',
operation: '操作',
addEmoticon: 'スタンプを追加',
roomUrl: 'ルームのURL',
copy: 'コピー',
enterRoom: 'ルームに入る',
enterTestRoom: 'テストルームに入る',
exportConfig: 'コンフィグの導出',

View File

@ -38,8 +38,13 @@ export default {
pinyin: '拼音',
kana: '日文假名',
emoticon: '自定义表情',
emoticonKeyword: '替换关键词',
emoticonUrl: 'URL',
operation: '操作',
addEmoticon: '添加表情',
roomUrl: '房间URL',
copy: '复制',
enterRoom: '进入房间',
enterTestRoom: '进入测试房间',
exportConfig: '导出配置',

View File

@ -1,9 +1,9 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import {
Aside, Autocomplete, Badge, Button, Card, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
Aside, Autocomplete, Badge, Button, ButtonGroup, Card, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
Input, Main, Menu, MenuItem, Message, Option, OptionGroup, Radio, RadioGroup, Row, Select, Scrollbar,
Slider, Submenu, Switch, TabPane, Tabs, Tooltip
Slider, Submenu, Switch, Table, TableColumn, TabPane, Tabs, Tooltip
} from 'element-ui'
import axios from 'axios'
@ -28,6 +28,7 @@ Vue.use(Aside)
Vue.use(Autocomplete)
Vue.use(Badge)
Vue.use(Button)
Vue.use(ButtonGroup)
Vue.use(Card)
Vue.use(Col)
Vue.use(ColorPicker)
@ -50,6 +51,8 @@ Vue.use(Scrollbar)
Vue.use(Slider)
Vue.use(Submenu)
Vue.use(Switch)
Vue.use(Table)
Vue.use(TableColumn)
Vue.use(TabPane)
Vue.use(Tabs)
Vue.use(Tooltip)

View File

@ -0,0 +1,58 @@
export class Trie {
constructor() {
this._root = this._createNode()
}
_createNode() {
return {
children: {}, // char -> node
value: null
}
}
set(key, value) {
if (key === '') {
throw new Error('key is empty')
}
let node = this._root
for (let char of key) {
let nextNode = node.children[char]
if (nextNode === undefined) {
nextNode = node.children[char] = this._createNode()
}
node = nextNode
}
node.value = value
}
get(key) {
let node = this._root
for (let char of key) {
let nextNode = node.children[char]
if (nextNode === undefined) {
return null
}
node = nextNode
}
return node.value
}
has(key) {
return this.get(key) !== null
}
greedyMatch(str) {
let node = this._root
for (let char of str) {
let nextNode = node.children[char]
if (nextNode === undefined) {
return null
}
if (nextNode.value !== null) {
return nextNode.value
}
node = nextNode
}
return null
}
}

View File

@ -114,6 +114,32 @@
</el-radio-group>
</el-form-item>
</el-tab-pane>
<el-tab-pane :label="$t('home.emoticon')">
<el-table :data="form.emoticons">
<el-table-column prop="keyword" :label="$t('home.emoticonKeyword')" width="170">
<template slot-scope="scope">
<el-input v-model="scope.row.keyword"></el-input>
</template>
</el-table-column>
<el-table-column prop="url" :label="$t('home.emoticonUrl')">
<template slot-scope="scope">
<el-input v-model="scope.row.url"></el-input>
</template>
</el-table-column>
<el-table-column :label="$t('home.operation')" width="170">
<template slot-scope="scope">
<el-button-group>
<el-button type="primary" icon="el-icon-upload2" disabled @click="uploadEmoticon(scope.row)"></el-button>
<el-button type="danger" icon="el-icon-minus" @click="delEmoticon(scope.$index)"></el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
<p>
<el-button type="primary" icon="el-icon-plus" @click="addEmoticon">{{$t('home.addEmoticon')}}</el-button>
</p>
</el-tab-pane>
</el-tabs>
</el-form>
</p>
@ -123,11 +149,11 @@
<el-form :model="form" label-width="150px">
<el-form-item :label="$t('home.roomUrl')">
<el-input ref="roomUrlInput" readonly :value="obsRoomUrl" style="width: calc(100% - 8em); margin-right: 1em;"></el-input>
<el-button type="primary" @click="copyUrl">{{$t('home.copy')}}</el-button>
<el-button type="primary" icon="el-icon-copy-document" @click="copyUrl"></el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" :disabled="!roomUrl" @click="enterRoom">{{$t('home.enterRoom')}}</el-button>
<el-button :disabled="!roomUrl" @click="enterTestRoom">{{$t('home.enterTestRoom')}}</el-button>
<el-button @click="enterTestRoom">{{$t('home.enterTestRoom')}}</el-button>
<el-button @click="exportConfig">{{$t('home.exportConfig')}}</el-button>
<el-button @click="importConfig">{{$t('home.importConfig')}}</el-button>
</el-form-item>
@ -192,6 +218,20 @@ export default {
this.$message.error(`Failed to fetch server information: ${e}`)
}
},
addEmoticon() {
this.form.emoticons.push({
keyword: '[Kappa]',
url: ''
})
},
delEmoticon(index) {
this.form.emoticons.splice(index, 1)
},
uploadEmoticon() {
// TODO WIP
},
enterRoom() {
window.open(this.roomUrl, `room ${this.form.roomId}`, 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
},
@ -199,7 +239,7 @@ export default {
window.open(this.getRoomUrl(true), 'test room', 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
},
getRoomUrl(isTestRoom) {
if (isTestRoom && this.form.roomId === '') {
if (!isTestRoom && this.form.roomId === '') {
return ''
}

View File

@ -5,6 +5,7 @@
<script>
import * as i18n from '@/i18n'
import { mergeConfig, toBool, toInt } from '@/utils'
import * as trie from '@/utils/trie'
import * as pronunciation from '@/utils/pronunciation'
import * as chatConfig from '@/api/chatConfig'
import ChatClientTest from '@/api/chat/ChatClientTest'
@ -36,11 +37,34 @@ export default {
}
},
computed: {
blockKeywords() {
return this.config.blockKeywords.split('\n').filter(val => val)
blockKeywordsTrie() {
let blockKeywords = this.config.blockKeywords.split('\n')
let res = new trie.Trie()
for (let keyword of blockKeywords) {
if (keyword !== '') {
res.set(keyword, true)
}
}
return res
},
blockUsers() {
return this.config.blockUsers.split('\n').filter(val => val)
blockUsersTrie() {
let blockUsers = this.config.blockUsers.split('\n')
let res = new trie.Trie()
for (let user of blockUsers) {
if (user !== '') {
res.set(user, true)
}
}
return res
},
emoticonsTrie() {
let res = new trie.Trie()
for (let emoticon of this.config.emoticons) {
if (emoticon.keyword !== '' && emoticon.url !== '') {
res.set(emoticon.keyword, emoticon)
}
}
return res
}
},
mounted() {
@ -147,7 +171,7 @@ export default {
authorName: data.authorName,
authorType: data.authorType,
content: data.content,
emoticon: data.emoticon,
richContent: this.getRichContent(data),
privilegeType: data.privilegeType,
repeated: 1,
translation: data.translation
@ -245,20 +269,17 @@ export default {
return this.filterByAuthorName(data.authorName)
},
filterByContent(content) {
for (let keyword of this.blockKeywords) {
if (content.indexOf(keyword) !== -1) {
let blockKeywordsTrie = this.blockKeywordsTrie
for (let i = 0; i < content.length; i++) {
let remainContent = content.substring(i)
if (blockKeywordsTrie.greedyMatch(remainContent) !== null) {
return false
}
}
return true
},
filterByAuthorName(authorName) {
for (let user of this.blockUsers) {
if (authorName === user) {
return false
}
}
return true
return !this.blockUsersTrie.has(authorName)
},
mergeSimilarText(content) {
if (!this.config.mergeSimilarDanmaku) {
@ -277,6 +298,66 @@ export default {
return ''
}
return this.pronunciationConverter.getPronunciation(text)
},
getRichContent(data) {
let richContent = []
// B
if (data.emoticon !== null) {
richContent.push({
type: constants.CONTENT_TYPE_IMAGE,
text: data.content,
url: data.emoticon
})
return richContent
}
//
if (this.config.emoticons.length === 0) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
text: data.content
})
return richContent
}
//
let emoticonsTrie = this.emoticonsTrie
let startPos = 0
let pos = 0
while (pos < data.content.length) {
let remainContent = data.content.substring(pos)
let matchEmoticon = emoticonsTrie.greedyMatch(remainContent)
if (matchEmoticon === null) {
pos++
continue
}
//
if (pos !== startPos) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
text: data.content.slice(startPos, pos)
})
}
//
richContent.push({
type: constants.CONTENT_TYPE_IMAGE,
text: matchEmoticon.keyword,
url: matchEmoticon.url
})
pos += matchEmoticon.keyword.length
startPos = pos
}
//
if (pos !== startPos) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
text: data.content.slice(startPos, pos)
})
}
return richContent
}
}
}