diff --git a/package.json b/package.json index 90d49282..bc1b2706 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@types/react-dom": "18.2.15", "@types/semver": "^7.5.8", "@webgpu/types": "^0.1.49", + "async-mutex": "^0.5.0", "dotenv": "^16.4.5", "esbuild": "^0.20.2", "gify-parse": "^1.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index caa0b650..0f902cb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: '@webgpu/types': specifier: ^0.1.49 version: 0.1.49 + async-mutex: + specifier: ^0.5.0 + version: 0.5.0 dotenv: specifier: ^16.4.5 version: 16.4.5 @@ -2015,6 +2018,9 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + autoprefixer@10.4.19: resolution: {integrity: sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==} engines: {node: ^10 || ^12 || >=14} @@ -3886,9 +3892,6 @@ packages: tslib@2.3.1: resolution: {integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==} - tslib@2.6.2: - resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} - tslib@2.7.0: resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} @@ -4698,7 +4701,7 @@ snapshots: '@motionone/easing': 10.17.0 '@motionone/types': 10.17.0 '@motionone/utils': 10.17.0 - tslib: 2.6.2 + tslib: 2.7.0 '@motionone/dom@10.12.0': dependencies: @@ -4707,18 +4710,18 @@ snapshots: '@motionone/types': 10.17.0 '@motionone/utils': 10.17.0 hey-listen: 1.0.8 - tslib: 2.6.2 + tslib: 2.7.0 '@motionone/easing@10.17.0': dependencies: '@motionone/utils': 10.17.0 - tslib: 2.6.2 + tslib: 2.7.0 '@motionone/generators@10.17.0': dependencies: '@motionone/types': 10.17.0 '@motionone/utils': 10.17.0 - tslib: 2.6.2 + tslib: 2.7.0 '@motionone/types@10.17.0': {} @@ -4726,7 +4729,7 @@ snapshots: dependencies: '@motionone/types': 10.17.0 hey-listen: 1.0.8 - tslib: 2.6.2 + tslib: 2.7.0 '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': optional: true @@ -6202,12 +6205,16 @@ snapshots: aria-hidden@1.2.4: dependencies: - tslib: 2.6.2 + tslib: 2.7.0 aria-query@5.3.2: {} array-union@2.1.0: {} + async-mutex@0.5.0: + dependencies: + tslib: 2.7.0 + autoprefixer@10.4.19(postcss@8.4.38): dependencies: browserslist: 4.23.0 @@ -6764,13 +6771,13 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) style-value-types: 5.0.0 - tslib: 2.6.2 + tslib: 2.7.0 optionalDependencies: '@emotion/is-prop-valid': 0.8.8 framesync@6.0.1: dependencies: - tslib: 2.6.2 + tslib: 2.7.0 fs-constants@1.0.0: {} @@ -7582,7 +7589,7 @@ snapshots: framesync: 6.0.1 hey-listen: 1.0.8 style-value-types: 5.0.0 - tslib: 2.6.2 + tslib: 2.7.0 popper.js@1.16.1: {} @@ -7989,7 +7996,7 @@ snapshots: style-value-types@5.0.0: dependencies: hey-listen: 1.0.8 - tslib: 2.6.2 + tslib: 2.7.0 stylis@4.2.0: {} @@ -8152,8 +8159,6 @@ snapshots: tslib@2.3.1: {} - tslib@2.6.2: {} - tslib@2.7.0: {} tsup@7.2.0(@swc/core@1.7.35(@swc/helpers@0.5.13))(postcss@8.4.38)(typescript@5.2.2): diff --git a/src/features/jimaku/components/ButtonArea.tsx b/src/features/jimaku/components/ButtonArea.tsx index e30b7411..71c54669 100644 --- a/src/features/jimaku/components/ButtonArea.tsx +++ b/src/features/jimaku/components/ButtonArea.tsx @@ -12,6 +12,7 @@ import { toast } from "sonner/dist"; import { sendMessager } from "~utils/messaging"; import { sendForward } from "~background/forwards"; import { sleep } from "~utils/misc"; +import { useQuerySelector } from "~hooks/dom"; export type ButtonAreaProps = { clearJimaku: VoidFunction @@ -50,6 +51,11 @@ function ButtonArea({ clearJimaku, jimakus }: ButtonAreaProps): JSX.Element { sendForward('pages', 'jimaku-summarize', { roomId: info.room, jimakus: jimakus.map(j => j.text) }) } + const upperHeaderAreaElement = useQuerySelector(upperHeaderArea) + if (info.isTheme && upperHeaderAreaElement === null) { + console.warn(`找不到上方标题界面元素 ${upperHeaderArea},可能无法插入切換按鈕列表的按钮`) + } + return ( {show && ( @@ -80,11 +86,11 @@ function ButtonArea({ clearJimaku, jimakus }: ButtonAreaProps): JSX.Element { )} )} - {info.isTheme && document.querySelector(upperHeaderArea) !== null && createPortal( + {info.isTheme && upperHeaderAreaElement !== null && createPortal( setShow(!show)} /> , - document.querySelector(upperHeaderArea) + upperHeaderAreaElement )} ) diff --git a/src/features/jimaku/components/JimakuList.tsx b/src/features/jimaku/components/JimakuList.tsx index 735bdba4..edd8750d 100644 --- a/src/features/jimaku/components/JimakuList.tsx +++ b/src/features/jimaku/components/JimakuList.tsx @@ -1,7 +1,7 @@ import type React from "react"; import { Item, Menu, useContextMenu, type ItemParams } from 'react-contexify'; import { toast } from 'sonner/dist'; -import { useKeepBottom } from '~hooks/keep-bottom'; +import { useKeepBottom } from '~hooks/dom'; import { useScrollOptimizer } from '~hooks/optimizer'; import { getSettingStorage, setSettingStorage } from '~utils/storage'; diff --git a/src/features/jimaku/components/JimakuVisibleButton.tsx b/src/features/jimaku/components/JimakuVisibleButton.tsx index e8222457..7f50c45e 100644 --- a/src/features/jimaku/components/JimakuVisibleButton.tsx +++ b/src/features/jimaku/components/JimakuVisibleButton.tsx @@ -1,6 +1,7 @@ import { createPortal } from 'react-dom' import styled from '@emotion/styled' import type { SettingSchema } from "~options/fragments/developer" +import { useQuerySelector } from "~hooks/dom" export type JimakuVisibleButtonProps = { toggle: VoidFunction @@ -21,7 +22,7 @@ const Div = styled.div` function JimakuVisibleButton({ toggle, visible, dev }: JimakuVisibleButtonProps): JSX.Element { - const element = document.querySelector(dev.elements.upperInputArea) + const element = useQuerySelector(dev.elements.upperInputArea) if (!element) { console.warn(`找不到元素 ${dev.elements.upperInputArea},部分功能可能无法正常工作`) return null diff --git a/src/features/jimaku/index.tsx b/src/features/jimaku/index.tsx index cbd8441c..ecc6ac4e 100644 --- a/src/features/jimaku/index.tsx +++ b/src/features/jimaku/index.tsx @@ -14,6 +14,7 @@ import type { FeatureHookRender } from ".."; import JimakuCaptureLayer from './components/JimakuCaptureLayer'; import JimakuAreaSkeleton from './components/JimakuAreaSkeleton'; import JimakuAreaSkeletonError from './components/JimakuAreaSkeletonError'; +import { useQuerySelector } from "~hooks/dom"; @@ -35,7 +36,7 @@ export function App(): JSX.Element { const dev = settings['settings.developer'] - const danmakuArea = document.querySelector(dev.elements.danmakuArea) + const danmakuArea = useQuerySelector(dev.elements.danmakuArea) if (!danmakuArea) { toast.warning(`找不到弹幕区域 ${dev.elements.danmakuArea},部分功能可能无法正常工作`) } diff --git a/src/features/recorder/components/RecorderButton.tsx b/src/features/recorder/components/RecorderButton.tsx index 11e5bf7c..58796860 100644 --- a/src/features/recorder/components/RecorderButton.tsx +++ b/src/features/recorder/components/RecorderButton.tsx @@ -4,6 +4,7 @@ import { useContext, useState, type MutableRefObject } from "react" import TailwindScope from "~components/TailwindScope" import ContentContext from "~contexts/ContentContexts" import RecorderFeatureContext from "~contexts/RecorderFeatureContext" +import { useQuerySelector } from "~hooks/dom" import { useForceRender } from "~hooks/force-update" import { useComputedStyle, useContrast } from "~hooks/styles" import type { Recorder } from "~types/media" @@ -25,7 +26,7 @@ function RecorderButton(props: RecorderButtonProps): JSX.Element { const [recording, setRecording] = useState(false) const update = useForceRender() const { headInfoArea } = settings['settings.developer'].elements - const { backgroundImage } = useComputedStyle(document.querySelector(headInfoArea)) + const { backgroundImage } = useComputedStyle(useQuerySelector(headInfoArea)) useInterval(() => { if (!recorder.current) return diff --git a/src/features/recorder/components/RecorderLayer.tsx b/src/features/recorder/components/RecorderLayer.tsx index 1908c83b..9f4cc412 100644 --- a/src/features/recorder/components/RecorderLayer.tsx +++ b/src/features/recorder/components/RecorderLayer.tsx @@ -16,6 +16,7 @@ import { randomString } from '~utils/misc' import createRecorder from "../recorders" import ProgressText from "./ProgressText" import RecorderButton from "./RecorderButton" +import { useQuerySelector } from "~hooks/dom" export type RecorderLayerProps = { urls: StreamUrls @@ -202,7 +203,11 @@ function RecorderLayer(props: RecorderLayerProps): JSX.Element { screenshot() }) - if (hiddenUI || document.querySelector(upperHeaderArea) === null) { + const upperHeaderAreaElement = useQuerySelector(upperHeaderArea) + if (hiddenUI || upperHeaderAreaElement === null) { + if (!hiddenUI) { + console.warn(upperHeaderArea, 'is not attached yet') + } return null } @@ -212,7 +217,7 @@ function RecorderLayer(props: RecorderLayerProps): JSX.Element { record={clipRecord} screenshot={screenshot} />, - document.querySelector(upperHeaderArea) + upperHeaderAreaElement ) } diff --git a/src/hooks/keep-bottom.ts b/src/hooks/dom.ts similarity index 60% rename from src/hooks/keep-bottom.ts rename to src/hooks/dom.ts index 341eeb1f..bc957c0a 100644 --- a/src/hooks/keep-bottom.ts +++ b/src/hooks/dom.ts @@ -1,3 +1,4 @@ +import { useInterval } from "@react-hooks-library/core" import { useCallback, useEffect, useRef, useState } from 'react' /** @@ -57,4 +58,43 @@ export function useKeepBottom(enabled: boolean, calculate }, []) return { ref: refCallback, element: ref, keepBottom } +} + + + + +/** + * Custom hook that queries the DOM for an element matching the given selector. + * Optionally, it can remount and re-query the DOM at a specified interval. + * + * @param {string} selector - The CSS selector to query the DOM. + * @param {boolean} [remount=false] - If true, the hook will re-query the DOM at the specified interval. + * @returns {Element | null} - The DOM element matching the selector, or null if no element is found. + * + * @example + * // Usage in a React component + * const MyComponent = () => { + * const element = useQuerySelector('#my-element', true); + * + * useEffect(() => { + * if (element) { + * console.log('Element found:', element); + * } + * }, [element]); + * + * return
Check the console for the element.
; + * }; + */ +export function useQuerySelector(selector: string, remount: boolean = false): E | null { + + const [element, setElement] = useState(document.querySelector(selector)) + + useInterval(() => { + const el = document.querySelector(selector) + if (el) { + setElement(el) + } + }, 500, { paused: !remount && !!element, immediate: true }) + + return element as E } \ No newline at end of file diff --git a/src/hooks/styles.ts b/src/hooks/styles.ts index b63b3425..b8e65e82 100644 --- a/src/hooks/styles.ts +++ b/src/hooks/styles.ts @@ -2,7 +2,7 @@ import { useMemo } from "react"; export function useComputedStyle(element: Element): CSSStyleDeclaration { - return useMemo(() => window.getComputedStyle(element), [element]); + return useMemo(() => element ? window.getComputedStyle(element) : {} as CSSStyleDeclaration, [element]); } export function useContrast(background: Element) { diff --git a/src/options/index.tsx b/src/options/index.tsx index 216c3a0e..8aa33e2c 100644 --- a/src/options/index.tsx +++ b/src/options/index.tsx @@ -105,6 +105,9 @@ function SettingPage(): JSX.Element { if (!(settings instanceof Object)) { throw new Error('导入的设定文件格式错误。') } + if (Object.keys(settings).length === 0) { + throw new Error('导入的设定文件格式错误。') + } if (!Object.keys(settings).every(key => (fragmentKeys as string[]).includes(key))) { throw new Error('导入的设定文件格式错误。') } @@ -117,8 +120,7 @@ function SettingPage(): JSX.Element { })(); toast.promise(importing, { loading: '正在导入设定...', - success: '设定已经导入成功。', - error: err => '导入设定失败: ' + err.message + success: '设定已经导入成功。' }) await importing if (!processing) { diff --git a/tests/helpers/bilibili-api.ts b/tests/helpers/bilibili-api.ts index 2466dd67..3f488729 100644 --- a/tests/helpers/bilibili-api.ts +++ b/tests/helpers/bilibili-api.ts @@ -1,7 +1,7 @@ import { request, type APIRequestContext } from "@playwright/test"; -import { sendInternal } from "~background/messages"; +import { Mutex } from 'async-mutex'; import type { StreamUrls } from "~background/messages/get-stream-urls"; -import type { V1Response, StreamUrlResponse } from "~types/bilibili"; +import type { StreamUrlResponse, V1Response } from "~types/bilibili"; import logger from "./logger"; export interface LiveRoomInfo { @@ -35,6 +35,8 @@ export default class BilbiliApi { return new BilbiliApi(context) } + private readonly mutex = new Mutex() + /** * 构造BilbiliApi的新实例。 * @param context - API请求的上下文。 @@ -48,9 +50,14 @@ export default class BilbiliApi { * @throws 如果获取操作失败,则抛出错误。 */ private async fetch(path: string): Promise { - const res = await this.context.get(path) - if (!res.ok()) throw new Error(`获取bilibili API失败:${res.statusText()}`) - return await res.json() + const release = await this.mutex.acquire() + try { + const res = await this.context.get(path) + if (!res.ok()) throw new Error(`获取bilibili API失败:${res.statusText()}`) + return await res.json() + } finally { + release() + } } /** diff --git a/tests/pages/options.spec.ts b/tests/pages/options.spec.ts index aa895435..8024fd50 100644 --- a/tests/pages/options.spec.ts +++ b/tests/pages/options.spec.ts @@ -4,6 +4,7 @@ import { expect, test } from '@tests/fixtures/background' import BilibiliPage from '@tests/helpers/bilibili-page' import logger from '@tests/helpers/logger' import { getSuperChatList } from '@tests/utils/playwright' +import { mkdir, writeFile } from 'fs/promises' import type { MV2Settings } from '~migrations/schema' test.beforeEach(async ({ page, extensionId }) => { @@ -146,13 +147,45 @@ test('測試導出導入設定', async ({ page }) => { await expect(inputLineGap).toHaveValue('7') }) +// 向下兼容,即舊版設定檔沒有某些新設定區塊,依然可以導入 +test('測試導入向下兼容設定', async ({ page }) => { + await mkdir('out', { recursive: true }) + + { + logger.info('正在嘗試導入空設定....') + await writeFile('out/empty.json', '{}') + + const fileChoosing = page.waitForEvent('filechooser') + await page.getByText('导入设定').click() + const fileChooser = await fileChoosing + await fileChooser.setFiles('out/empty.json') + + await page.getByText('导入的设定文件格式错误。').waitFor({ state: 'visible' }) + } + + await page.reload({ waitUntil: 'domcontentloaded' }) + + { + logger.info('正在嘗試導入正確設定....') + await writeFile('out/valid.json', JSON.stringify({ 'settings.version': {} })) + + const fileChoosing = page.waitForEvent('filechooser') + await page.getByText('导入设定').click() + const fileChooser = await fileChoosing + await fileChooser.setFiles('out/valid.json') + + await page.getByText('设定已经导入成功。').waitFor({ state: 'visible' }) + } + +}) + test('測試清空數據庫', async ({ page, front: room, api }) => { await page.bringToFront() const feature = page.getByText('功能设定') await feature.click() - + const btns = await page.locator('section#settings\\.features').getByText('启用离线记录').all() for (const btn of btns) { await btn.click() @@ -517,7 +550,7 @@ test('測試导航', async ({ page, serviceWorker }) => { test('測試點擊使用指南', async ({ context, page }) => { await page.getByText('功能设定').click() - + const tutorial = context.waitForEvent('page') await page.getByText('使用指南').click() const tutorialPage = await tutorial