diff --git a/package.json b/package.json index b47643c..39d6736 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "@formkit/auto-animate": "1.0.0-beta.6", "@highlightjs/vue-plugin": "^2.1.0", + "@imengyu/vue3-context-menu": "^1.2.10", "@msgpack/msgpack": "^2.8.0", "@tauri-apps/api": "^1.4.0", "@unocss/reset": "^0.54.0", @@ -26,6 +27,7 @@ "@vueuse/core": "^10.0.0", "@vueuse/rxjs": "^10.0.0", "ant-design-vue": "^3.2.17", + "clipboard": "^2.0.11", "dayjs": "^1.11.7", "dexie": "^3.2.3", "highlight.js": "^11.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dac8dd6..d2ac42b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: '@highlightjs/vue-plugin': specifier: ^2.1.0 version: 2.1.0(highlight.js@11.7.0)(vue@3.2.47) + '@imengyu/vue3-context-menu': + specifier: ^1.2.10 + version: 1.2.10 '@msgpack/msgpack': specifier: ^2.8.0 version: 2.8.0 @@ -32,6 +35,9 @@ dependencies: ant-design-vue: specifier: ^3.2.17 version: 3.2.17(vue@3.2.47) + clipboard: + specifier: ^2.0.11 + version: 2.0.11 dayjs: specifier: ^1.11.7 version: 1.11.7 @@ -966,6 +972,10 @@ packages: - supports-color dev: true + /@imengyu/vue3-context-menu@1.2.10: + resolution: {integrity: sha512-L+qIoqcewQ8SPk2PMvsaMD9HWxFouij0JAqYwn19Fz7giqz0rLpWMf9aJRpQ3ysOySEKPdilg8dLYYB96QmGHA==} + dev: false + /@istanbuljs/schema@0.1.3: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} @@ -2379,6 +2389,14 @@ packages: string-width: 5.1.2 dev: true + /clipboard@2.0.11: + resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==} + dependencies: + good-listener: 1.2.2 + select: 1.1.2 + tiny-emitter: 2.1.0 + dev: false + /cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} dependencies: @@ -2676,6 +2694,10 @@ packages: resolution: {integrity: sha512-+uO4+qr7msjNNWKYPHqN/3+Dx3NFkmIzayk2L1MyZQlvgZb/J1A0fo410dpKrN2SnqFjt8n4JL8fDJE0wIgjFQ==} dev: true + /delegate@3.2.0: + resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==} + dev: false + /destr@2.0.0: resolution: {integrity: sha512-FJ9RDpf3GicEBvzI3jxc2XhHzbqD8p4ANw/1kPsFBfTvP1b7Gn/Lg1vO7R9J4IVgoMbyUmFrFGZafJ1hPZpvlg==} dev: true @@ -3468,6 +3490,12 @@ packages: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} dev: true + /good-listener@1.2.2: + resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==} + dependencies: + delegate: 3.2.0 + dev: false + /gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: @@ -5237,6 +5265,10 @@ packages: compute-scroll-into-view: 1.0.20 dev: false + /select@1.1.2: + resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==} + dev: false + /semver@5.7.1: resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==} hasBin: true @@ -5778,6 +5810,10 @@ packages: engines: {node: '>=4'} dev: true + /tiny-emitter@2.1.0: + resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} + dev: false + /tiny-glob@0.2.9: resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} dependencies: diff --git a/src/components/ChatBox.vue b/src/components/ChatBox.vue index e5568fd..47aa1f4 100644 --- a/src/components/ChatBox.vue +++ b/src/components/ChatBox.vue @@ -2,7 +2,7 @@ import { useDebounceFn } from '@vueuse/core' import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue' import { watchEffect } from 'vue' -import { onBeforeRouteLeave } from 'vue-router' +import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router' import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller' import { Behav } from '@/adapter/behav' @@ -15,9 +15,10 @@ import ChatRequest from '@/components/ChatRequest.vue' import SendButton from '@/components/SendButton.vue' import TimeSeparator from '@/components/TimeSeparator.vue' import { db } from '@/database' -import { useChatStore, useStatusStore } from '@/stores' +import { useChatStore, useStatusStore, useSessionStore } from '@/stores' import type { User, Group } from '@/database' +import type { State } from '@/stores/session' import type { PartialOptions, OverlayScrollbars } from 'overlayscrollbars' const { chatType, chatId } = defineProps<{ @@ -38,6 +39,8 @@ const chat = useChatStore() const status = useStatusStore() +const session = useSessionStore() + const chatInput = $ref | null>(null) const scroller = $ref | null>(null) @@ -125,6 +128,11 @@ function lockScroll() { scrollLock = true } +onBeforeRouteUpdate(async (to, from) => { + session.saveSessionState(from.fullPath) + await session.loadSessionState(to.fullPath, to.params.chatType as State['type'], to.params.chatId as State['id']) +}) + onBeforeRouteLeave((_, from) => { status.latelySession = from.fullPath }) diff --git a/src/components/ChatInput.vue b/src/components/ChatInput.vue index d725748..25bfec4 100644 --- a/src/components/ChatInput.vue +++ b/src/components/ChatInput.vue @@ -8,7 +8,7 @@ import { useRoute } from 'vue-router' import AudioIcon from '@/assets/audio_file.svg?url' import VideoIcon from '@/assets/video_file.svg?url' import { db } from '@/database' -import { useStatusStore } from '@/stores' +import { useStatusStore, useSessionStore } from '@/stores' import { createFileCache, getUserAvatar, getGroupAvatar, nonNullable, getUserNickname } from '@/utils' import type { Contents } from '@/adapter/content' @@ -25,6 +25,8 @@ const route = useRoute() const status = useStatusStore() +const session = useSessionStore() + const inputBox = $ref(null) onMounted(() => { @@ -106,6 +108,7 @@ function clearContent(): void { if (inputBox) { inputBox.textContent = '' } + clearReplyMessage() } /** 获取输入内容 */ @@ -123,6 +126,14 @@ function getContent(): Contents[] | null { contents.push(content) } } + if (session.state?.replyMessage) { + contents.unshift({ + type: 'reply', + data: { + message_id: session.state.replyMessage.id, + }, + }) + } return contents } @@ -221,16 +232,45 @@ const tribute = new Tribute({ return item.name + item.id }, }) + +function clearReplyMessage(): void { + if (session.state) { + session.state.replyMessage = undefined + } +} + +function onBackspace(): void { + if (inputBox?.textContent === '') { + clearReplyMessage() + } +} diff --git a/src/components/ChatMessage.vue b/src/components/ChatMessage.vue index 6fd0b4b..39b2530 100644 --- a/src/components/ChatMessage.vue +++ b/src/components/ChatMessage.vue @@ -157,6 +157,7 @@ async function pokeUser(): Promise { v-show="showMessage" class="!bg-transparent" :class="{ 'bubble-padding': !(onlyImage || onlyType('video')) }" + :message-id="scene.message_id" :messages="scene.message" :only-image="onlyImage" /> diff --git a/src/components/MessageContent.vue b/src/components/MessageContent.vue index 302036f..3c876e2 100644 --- a/src/components/MessageContent.vue +++ b/src/components/MessageContent.vue @@ -1,16 +1,20 @@ @@ -260,10 +348,4 @@ onBeforeMount(async () => { transform: scaleY(1); } } - -.reply-message { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; -} diff --git a/src/main.ts b/src/main.ts index ff0d43f..6c0758d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import { autoAnimatePlugin } from '@formkit/auto-animate/vue' +import ContextMenu from '@imengyu/vue3-context-menu' import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' import { createApp } from 'vue' @@ -8,8 +9,6 @@ import { router } from '@/router' import App from './App.vue' -import './style.css' - // eslint-disable-next-line import/no-unresolved import 'uno.css' import '@unocss/reset/tailwind.css' @@ -17,10 +16,14 @@ import 'virtual:unocss-devtools' import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' import 'overlayscrollbars/overlayscrollbars.css' import 'ant-design-vue/es/message/style/css' +import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css' + +import './style.css' createApp(App) .use(createPinia().use(piniaPluginPersistedstate)) .use(router) .use(autoAnimatePlugin) .use(VueVirtualScroller) + .use(ContextMenu) .mount('#app') diff --git a/src/router/index.ts b/src/router/index.ts index 17dcec2..d610c35 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,6 +1,8 @@ import { createRouter, createWebHistory } from 'vue-router' -import { useStatusStore } from '@/stores' +import { useStatusStore, useSessionStore } from '@/stores' + +import type { State } from '@/stores/session' export const router = createRouter({ history: createWebHistory(), @@ -29,10 +31,16 @@ export const router = createRouter({ action: () => import('@/components/ChatAction.vue'), }, props: { default: true, action: true }, - beforeEnter: (to) => { + beforeEnter: async (to) => { if (!Number(to.params.chatId)) { return '/chat' } + const session = useSessionStore() + await session.loadSessionState( + to.fullPath, + to.params.chatType as State['type'], + to.params.chatId as State['id'] + ) }, }, ], diff --git a/src/stores/index.ts b/src/stores/index.ts index a56d419..b8ac30e 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -2,3 +2,4 @@ export { useConfigStore } from './config' export { useChatStore } from './chat' export { useStatusStore } from './status' export { useAdapterStore } from './protocol' +export { useSessionStore } from './session' diff --git a/src/stores/session.ts b/src/stores/session.ts new file mode 100644 index 0000000..ef24a14 --- /dev/null +++ b/src/stores/session.ts @@ -0,0 +1,80 @@ +import { defineStore } from 'pinia' + +import { db } from '@/database' + +import type { User, Group } from '@/database' + +export interface ReplyMessageInfo { + id: string + nickname: string + message: string +} + +interface PrivateChat { + /** 会话类型 */ + readonly type: 'private' + /** 会话对象 */ + contact: User +} + +interface GroupChat { + /** 会话类型 */ + readonly type: 'group' + /** 会话对象 */ + contact: Group +} + +export type State = { + /** 会话ID */ + readonly id: string + /** 输入消息内容 */ + inputMessage?: string + /** 回复消息 */ + replyMessage?: ReplyMessageInfo +} & (PrivateChat | GroupChat) + +export const useSessionStore = defineStore( + 'session', + + () => { + /** 最近会话路由 */ + const lately = $ref('') + /** 当前会话 */ + let state = $ref(null) + /** 会话列表 */ + const states = $ref>(new Map()) + + async function createSessionState(type: State['type'], id: State['id']): Promise { + const dbGet = type === 'group' ? db.groups : db.users + const contact = await dbGet.get(id) + if (!contact) { + return + } + state = { + id, + type, + contact, + } as State + } + + async function loadSessionState(path: string, type: State['type'], id: State['id']): Promise { + state = states.get(path) || null + if (!state) { + await createSessionState(type, id) + } + } + + function saveSessionState(path: string): void { + state && states.set(path, state) + } + + return $$({ + lately, + state, + states, + createSessionState, + loadSessionState, + saveSessionState, + }) + } +) diff --git a/src/style.css b/src/style.css index ba26eb3..02ef15f 100644 --- a/src/style.css +++ b/src/style.css @@ -1,3 +1,5 @@ +@import "styles/contextmenu.css"; + @font-face { font-family: "SourceHanSans"; font-weight: 250 900; @@ -31,8 +33,5 @@ pre code.hljs { } .hljs-string.line-fold { - @apply overflow-hidden text-ellipsis; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; + @apply restrict-rows-3 } diff --git a/src/styles/contextmenu.css b/src/styles/contextmenu.css new file mode 100644 index 0000000..70e593c --- /dev/null +++ b/src/styles/contextmenu.css @@ -0,0 +1,107 @@ +.mx-context-menu.matcha-theme { + --mx-menu-placeholder-width: 1.75rem; + + /* Backgroud */ + --mx-menu-backgroud: hsla(0deg 0% 99%); + --mx-menu-hover-backgroud: hsl(206deg 96% 62%); + --mx-menu-active-backgroud: hsl(206deg 96% 62%); + --mx-menu-open-backgroud: hsl(0deg 0% 52%); + --mx-menu-open-hover-backgroud: hsl(206deg 96% 62%); + --mx-menu-divider: hsl(0deg 0% 82%); + + /* Text */ + --mx-menu-text: hsl(215deg 14% 34%); + --mx-menu-hover-text: hsl(0deg 0% 100%); + --mx-menu-active-text: hsl(0deg 0% 100%); + --mx-menu-open-text: hsl(0deg 0% 100%); + --mx-menu-open-hover-text: hsl(0deg 0% 100%); + --mx-menu-disabled-text: hsl(0deg 0% 73%); + + /* Shadow */ + --mx-menu-shadow-color: rgb(0 0 0 / 10%); + --mx-menu-backgroud-radius: 10px; + + /* Shortcut badge */ + --mx-menu-shortcut-backgroud: transparent; + --mx-menu-shortcut-backgroud-hover:transparent; + --mx-menu-shortcut-backgroud-active:transparent; + --mx-menu-shortcut-backgroud-open:transparent; + --mx-menu-shortcut-backgroud-disabled:transparent; + --mx-menu-shortcut-text: hsl(0deg 0% 26%); + --mx-menu-shortcut-text-hover: hsl(0deg 0% 100%); + --mx-menu-shortcut-text-active: hsl(0deg 0% 100%); + --mx-menu-shortcut-text-open: hsl(0deg 0% 100%); + --mx-menu-shortcut-text-disabled: hsl(0deg 0% 65%); + + /* Focus border color */ + --mx-menu-focus-color: transparent; + --mx-menu-border-color: hsl(0deg 0% 73%); + + .dark & { + /* Backgroud */ + --mx-menu-backgroud: hsl(0deg 0% 20%); + --mx-menu-hover-backgroud: hsl(216deg 99% 41%); + --mx-menu-active-backgroud: hsl(216deg 99% 41%); + --mx-menu-open-hover-backgroud: hsl(216deg 99% 41%); + --mx-menu-open-backgroud: hsl(216deg 4% 26%); + --mx-menu-divider: hsl(0deg 0% 34%); + + /* Text */ + --mx-menu-text: hsl(0deg 0% 86%); + --mx-menu-hover-text: hsl(0deg 0% 100%); + --mx-menu-active-text: hsl(0deg 0% 100%); + --mx-menu-open-text: hsl(0deg 0% 100%); + --mx-menu-open-hover-text: hsl(0deg 0% 100%); + --mx-menu-disabled-text: hsl(0deg 0% 42%); + + /* Shadow */ + --mx-menu-shadow-color: rgb(0 0 0 / 10%); + --mx-menu-backgroud-radius: 10px; + + /* Shortcut badge */ + --mx-menu-shortcut-backgroud: transparent; + --mx-menu-shortcut-backgroud-hover:transparent; + --mx-menu-shortcut-backgroud-active:transparent; + --mx-menu-shortcut-backgroud-open:transparent; + --mx-menu-shortcut-backgroud-disabled:transparent; + --mx-menu-shortcut-text: hsl(0deg 0% 85%); + --mx-menu-shortcut-text-hover: hsl(0deg 0% 100%); + --mx-menu-shortcut-text-active: hsl(0deg 0% 100%); + --mx-menu-shortcut-text-open: hsl(0deg 0% 100%); + --mx-menu-shortcut-text-disabled: hsl(0deg 0% 42%); + + /* Focus border color */ + --mx-menu-focus-color: transparent; + } + + @apply min-w-40 py-2; + box-shadow: 0 5px 7px 1px var(--mx-menu-shadow-color); + + .mx-context-menu-item { + @apply px-2 py-1 mx-2 rounded-md; + + /* Focus by keyboard */ + &.keyboard-focus { + @apply outline-none; + color: var(--mx-menu-active-text); + background-color: var(--mx-menu-active-backgroud); + + .mx-right-arrow, .mx-checked-mark { + fill: var(--mx-menu-active-text); + } + + .mx-shortcut { + color: var(--mx-menu-shortcut-text-active); + background-color: var(--mx-menu-shortcut-backgroud-active); + } + } + + &:not(:hover) .mx-icon-placeholder { + color: var(--mx-menu-hover-backgroud); + } + } + + .mx-context-menu-item-sperator { + @apply mx-3; + } +} diff --git a/unocss.config.ts b/unocss.config.ts index 716e712..8c7d4f0 100644 --- a/unocss.config.ts +++ b/unocss.config.ts @@ -4,4 +4,16 @@ import { presetScrollbar } from 'unocss-preset-scrollbar' export default defineConfig({ presets: [presetUno(), presetAttributify(), presetIcons(), presetScrollbar()], transformers: [transformerDirectives()], + rules: [ + [ + /^restrict-rows-(\d+)$/, + ([, d]) => ({ + overflow: 'hidden', + 'text-overflow': 'ellipsis', + display: '-webkit-box', + '-webkit-box-orient': 'vertical', + '-webkit-line-clamp': d, + }), + ], + ], })