${
@@ -135,7 +141,7 @@ const blackroomCreateListCon = function listCon(e, n, t) {
this._createPlatformDom(e.content.plat),
`${this._formateTime(e.ctime)}`,
e?.reply_control?.location
- ? `${e?.reply_control?.location || ''}`
+ ? `${e?.reply_control?.location || ''}`
: '',
e.lottery_id
? ''
@@ -197,7 +203,7 @@ const blackroomCreateSubReplyItem = function subReply(e, n) {
'',
`${this._formateTime(e.ctime)}`,
e?.reply_control?.location
- ? `${e?.reply_control?.location || ''}`
+ ? `${e?.reply_control?.location || ''}`
: '',
`${
e.like || ''
@@ -269,6 +275,7 @@ observer.observe(document.head, { childList: true })
const processItems = (items: CommentReplyItem[]) => {
items.forEach(item => {
const location = getIpLocation(item.element)
+ console.log(`location: ${location}`)
if (location !== undefined) {
const replyTime =
item.element.querySelector('.reply-info>.reply-time') ??
@@ -276,7 +283,7 @@ const processItems = (items: CommentReplyItem[]) => {
if (replyTime.childElementCount === 0) {
// 避免在评论更新的情况下重复添加
const replyLocation = document.createElement('span')
- replyLocation.style.marginLeft = '5px'
+ replyLocation.style.marginLeft = `${marginLeft}px`
replyLocation.innerText = location
replyTime.appendChild(replyLocation)
}
diff --git a/registry/lib/components/video/danmaku/converter/danmaku-segment.ts b/registry/lib/components/video/danmaku/converter/danmaku-segment.ts
index 1fc73b29d..82727a243 100644
--- a/registry/lib/components/video/danmaku/converter/danmaku-segment.ts
+++ b/registry/lib/components/video/danmaku/converter/danmaku-segment.ts
@@ -286,9 +286,9 @@ const decode = lodash.curry(async (type: string, blob: Blob) => {
export const decodeDanmakuSegment = decode('DmSegMobileReply')
export const decodeDanmakuView = decode('DmWebViewReply')
-// 这里为了兼容 pakku, 只能用 fetch https://github.com/xmcp/pakku.js/issues/153
+// 同时安装了 pakku 时,unsafeWindow.fetch 将调用 hook 过的 fetch 来获取修改后的弹幕
const fetchBlob = async (url: string) => {
- const response = await fetch(url, { mode: 'cors', credentials: 'include' })
+ const response = await unsafeWindow.fetch(url, { mode: 'cors', credentials: 'include' })
return response.blob()
}
export const getDanmakuView = async (aid: string | number, cid: string | number) => {
diff --git a/registry/lib/components/video/player/extend-speed/component.ts b/registry/lib/components/video/player/extend-speed/component.ts
index adf92751a..30c516fdb 100644
--- a/registry/lib/components/video/player/extend-speed/component.ts
+++ b/registry/lib/components/video/player/extend-speed/component.ts
@@ -307,7 +307,6 @@ export class ExtendSpeedComponent extends EntrySpeedComponent {
}
}
- // eslint-disable-next-line class-methods-use-this
getSpeedContextMixin({ menuListElement }: SpeedContext): Partial {
return {
query: (speed: number) =>
@@ -444,7 +443,6 @@ export class ExtendSpeedComponent extends EntrySpeedComponent {
setTimeout(() => this.forceUpdateStyle(value))
}
- // eslint-disable-next-line class-methods-use-this
protected readonly filterNativeSpeed =
() =>
({ subscribe, next }: PublishContext) => {
diff --git a/registry/lib/components/video/player/legacy-auto-play/index.ts b/registry/lib/components/video/player/legacy-auto-play/index.ts
index 608bb699e..f11ce50ff 100644
--- a/registry/lib/components/video/player/legacy-auto-play/index.ts
+++ b/registry/lib/components/video/player/legacy-auto-play/index.ts
@@ -1,4 +1,5 @@
import { defineComponentMetadata } from '@/components/define'
+import { getVueData } from '@/components/feeds/api'
import { playerAgent } from '@/components/video/player-agent'
import { childListSubtree, videoChange } from '@/core/observer'
import { select } from '@/core/spin-query'
@@ -44,9 +45,50 @@ export const component = defineComponentMetadata({
),
),
]
+
+ await playerReady()
+
+ // 初始化状态
+ const rightPanel = await Promise.any([
+ select('.right-container-inner'),
+ select('.playlist-container--right'),
+ ])
+ // 如果未找到元素,提前退出不执行后续动作
+ if (!rightPanel) {
+ console.warn('未找到 rightPanelContainer 或 playListContainer')
+ return
+ }
+
+ const checkPlayListPlayMode = async () => {
+ // 检查是不是播放列表中的最后一个视频或者最后一个分 P 视频的最后一个视频
+ const videoSequentialNumber = dq('.list-count')
+ const sequentialNumbers = videoSequentialNumber.innerHTML.split('/')
+ // 检查最后一个元素是单个视频还是多 P 视频
+ const lastVedioElement = dq(
+ '.action-list .action-list-inner .action-list-item-wrap:last-child .action-list-item .actionlist-item-inner',
+ )
+
+ let isLastVideo = false
+ if (lastVedioElement.classList.contains('singlep-list-item-inner')) {
+ isLastVideo = lastVedioElement.classList.contains('siglep-active')
+ } else {
+ isLastVideo =
+ lastVedioElement.children[1].lastElementChild.classList.contains(
+ 'multip-list-item-active',
+ )
+ }
+
+ const shouldContinue = !(sequentialNumbers[0] >= sequentialNumbers[1] && isLastVideo)
+
+ const app = document.getElementById('app')
+ const vueInstance = getVueData(app)
+ // 不用判断当前状态是什么,直接将将是否需要继续播放赋值 vue 实例中的 continuousPlay
+ vueInstance.setContinuousPlay(shouldContinue)
+ }
+
const isChecked = (container: HTMLElement) =>
Boolean(container.querySelector('.switch-button.on') || container.matches(':checked'))
- await playerReady()
+
const checkPlayMode = async () => {
const element = (await select(
[...autoPlayControls.disable, ...autoPlayControls.enable].join(','),
@@ -62,16 +104,19 @@ export const component = defineComponentMetadata({
element.click()
}
}
- videoChange(async () => {
- checkPlayMode()
- const video = (await playerAgent.query.video.element()) as HTMLVideoElement
- video?.addEventListener('play', checkPlayMode, { once: true })
- })
- const rightPanelContainer = await select('.right-container-inner')
- if (!rightPanelContainer) {
- console.warn('未找到 rightPanelContainer')
- return
+
+ const checkRightPanelPlayMode = async () => {
+ rightPanel.classList.contains('right-container-inner')
+ ? checkPlayMode()
+ : checkPlayListPlayMode()
}
- childListSubtree(rightPanelContainer, () => checkPlayMode())
+
+ videoChange(async () => {
+ checkRightPanelPlayMode()
+ const video = (await playerAgent.query.video.element()) as HTMLVideoElement
+ video?.addEventListener('play', checkRightPanelPlayMode, { once: true })
+ })
+
+ childListSubtree(rightPanel, () => checkRightPanelPlayMode())
},
})
diff --git a/registry/lib/components/video/player/remove-popup/index.md b/registry/lib/components/video/player/remove-popup/index.md
index 23fc7f5c2..cb42396d1 100644
--- a/registry/lib/components/video/player/remove-popup/index.md
+++ b/registry/lib/components/video/player/remove-popup/index.md
@@ -1 +1 @@
-删除视频播放器中出现的各种弹窗, 类别可在选项中分别选择.
+删除视频播放器中出现的各种弹窗, 类别可在选项中分别选择. 如果之前点了收起弹窗, 则收起后的小弹窗则会直接删除. (不受类别选择影响)
diff --git a/registry/lib/components/video/player/remove-popup/index.ts b/registry/lib/components/video/player/remove-popup/index.ts
index 341d23774..033cc7a91 100644
--- a/registry/lib/components/video/player/remove-popup/index.ts
+++ b/registry/lib/components/video/player/remove-popup/index.ts
@@ -8,6 +8,13 @@ export const component = defineComponentMetadata({
entry: ({ settings, metadata }) => {
const { options } = settings
const { kebabCase } = lodash
+ addComponentListener(
+ metadata.name,
+ (enabled: boolean) => {
+ document.body.classList.toggle(kebabCase(metadata.name), enabled)
+ },
+ true,
+ )
Object.keys(options).forEach(optionName => {
addComponentListener(
`${metadata.name}.${optionName}`,
diff --git a/registry/lib/components/video/player/remove-popup/remove-popup.scss b/registry/lib/components/video/player/remove-popup/remove-popup.scss
index 67deb331c..3ebdd629f 100644
--- a/registry/lib/components/video/player/remove-popup/remove-popup.scss
+++ b/registry/lib/components/video/player/remove-popup/remove-popup.scss
@@ -1,5 +1,9 @@
body {
&.remove-player-popup {
+ // https://www.bilibili.com/video/BV1WA4m1L7xa/
+ .bili-cmd-shrink {
+ display: none !important;
+ }
// https://www.bilibili.com/video/BV1V54y1b7yW/
&-combo-likes {
.bili-guide,
diff --git a/registry/lib/components/video/player/show-upload-time/index.ts b/registry/lib/components/video/player/show-upload-time/index.ts
index 1a831fe44..91f293f13 100644
--- a/registry/lib/components/video/player/show-upload-time/index.ts
+++ b/registry/lib/components/video/player/show-upload-time/index.ts
@@ -1,8 +1,7 @@
import { defineComponentMetadata } from '@/components/define'
import { childList, urlChange } from '@/core/observer'
-import { playerReady } from '@/core/utils'
+import { playerReady, getVue2Data } from '@/core/utils'
import { videoUrls } from '@/core/utils/urls'
-import { getVueData } from '@/components/feeds/api'
import { VideoInfo } from '@/components/video/video-info'
import { useScopedConsole } from '@/core/utils/log'
import { addComponentListener, getComponentSettings } from '@/core/settings'
@@ -147,7 +146,7 @@ export const component = defineComponentMetadata({
const getRecoList = () => {
const reco_list = dq('#reco_list')
- let recoList: RecommendList = getVueData(reco_list)
+ let recoList: RecommendList = getVue2Data(reco_list)
if (recoList.isOpen === undefined) {
recoList = recoList.$children[0]
if (recoList.isOpen === undefined) {
diff --git a/registry/lib/plugins/feeds/filter/hide-charge-feeds/index.ts b/registry/lib/plugins/feeds/filter/hide-charge-feeds/index.ts
index 2f16e5a3b..f047b748d 100644
--- a/registry/lib/plugins/feeds/filter/hide-charge-feeds/index.ts
+++ b/registry/lib/plugins/feeds/filter/hide-charge-feeds/index.ts
@@ -4,10 +4,11 @@ export const plugin: PluginMetadata = {
name: 'feedsFilter.pluginBlocks.chargeFeeds',
displayName: '动态过滤器 - 移除充电专属动态',
async setup() {
- const { forEachFeedsCard, getVueData } = await import('@/components/feeds/api')
+ const { getVue2Data } = await import('@/core/utils')
+ const { forEachFeedsCard } = await import('@/components/feeds/api')
forEachFeedsCard({
added: card => {
- const vueData = getVueData(card.element)
+ const vueData = getVue2Data(card.element)
const blockedType: number | null = lodash.get(
vueData,
'data.modules.module_dynamic.major.blocked.blocked_type',
diff --git a/registry/lib/plugins/video/download/wasm-output/handler.ts b/registry/lib/plugins/video/download/wasm-output/handler.ts
index e49907635..d4353e233 100644
--- a/registry/lib/plugins/video/download/wasm-output/handler.ts
+++ b/registry/lib/plugins/video/download/wasm-output/handler.ts
@@ -34,21 +34,40 @@ async function load(toast: Toast) {
),
})
}
-export async function run(name: string, videoUrl: string, audioUrl: string, toast: Toast) {
+export async function run(
+ name: string,
+ toast: Toast,
+ videoUrl: string,
+ audioUrl: string,
+ isFlac: boolean,
+) {
if (!ffmpeg.loaded) {
await load(toast)
}
ffmpeg.writeFile('video', await httpGet(videoUrl, toastProgress(toast, '正在下载视频流')))
ffmpeg.writeFile('audio', await httpGet(audioUrl, toastProgress(toast, '正在下载音频流')))
-
toast.message = '混流中……'
- await ffmpeg.exec(['-i', 'video', '-i', 'audio', '-c:v', 'copy', '-c:a', 'copy', 'output.mp4'])
+ const outputExt = isFlac ? 'mkv' : 'mp4'
+ name = isFlac ? name.replace(/.[^/.]+$/, `.${outputExt}`) : name
+ await ffmpeg.exec([
+ '-i',
+ 'video',
+ '-i',
+ 'audio',
+ '-c:v',
+ 'copy',
+ '-c:a',
+ isFlac ? 'flac' : 'copy',
+ '-f',
+ isFlac ? 'matroska' : 'mp4',
+ `output.${outputExt}`,
+ ])
- const output = await ffmpeg.readFile('output.mp4')
+ const output = await ffmpeg.readFile(`output.${outputExt}`)
const outputBlob = new Blob([output], {
- type: 'video/mp4',
+ type: isFlac ? 'video/x-matroska' : 'video/mp4',
})
toast.message = '完成!'
diff --git a/registry/lib/plugins/video/download/wasm-output/index.ts b/registry/lib/plugins/video/download/wasm-output/index.ts
index 2cb04ac88..12ad5fe00 100644
--- a/registry/lib/plugins/video/download/wasm-output/index.ts
+++ b/registry/lib/plugins/video/download/wasm-output/index.ts
@@ -1,7 +1,9 @@
-import { Toast } from '@/core/toast'
+import { Toast, ToastType } from '@/core/toast'
import { PluginMetadata } from '@/plugins/plugin'
import { DownloadVideoOutput } from '../../../../components/video/download/types'
import { run } from './handler'
+import { getComponentSettings } from '@/core/settings'
+import type { Options } from '../../../../components/video/download'
const title = 'WASM 混流输出'
const desc = '使用 WASM 在浏览器中下载并合并音视频'
@@ -22,12 +24,35 @@ export const plugin: PluginMetadata = {
description: `${desc},运行过程中请勿关闭页面,初次使用或清除缓存后需要加载约 30 MB 的 WASM 文件`,
runAction: async action => {
const fragments = action.infos.flatMap(it => it.titledFragments)
- if (fragments.length !== 2) {
- Toast.error('仅支持 DASH 格式', title)
+ const { dashAudioExtension, dashFlacAudioExtension, dashVideoExtension } =
+ getComponentSettings('downloadVideo').options
+
+ if (
+ fragments.length > 2 ||
+ (fragments.length === 2 &&
+ !(
+ fragments[0].extension === dashVideoExtension &&
+ (fragments[1].extension === dashAudioExtension ||
+ fragments[1].extension === dashFlacAudioExtension)
+ ))
+ ) {
+ Toast.error('仅支持单个视频下载', title).duration = 1500
} else {
const toast = Toast.info('正在加载', title)
try {
- await run(fragments[0].title, fragments[0].url, fragments[1].url, toast)
+ if (fragments.length === 2) {
+ await run(
+ fragments[0].title,
+ toast,
+ fragments[0].url,
+ fragments[1].url,
+ fragments[1].extension === dashFlacAudioExtension,
+ ) // [dash]
+ } else {
+ toast.type = ToastType.Error
+ toast.message = '仅支持 dash 格式视频下载'
+ toast.duration = 1500
+ }
} catch (error) {
toast.close()
Toast.error(String(error), title)
diff --git a/src/client/common.meta.json b/src/client/common.meta.json
index 52ad430b1..79de1e9bc 100644
--- a/src/client/common.meta.json
+++ b/src/client/common.meta.json
@@ -1,5 +1,5 @@
{
- "version": "2.8.9",
+ "version": "2.8.10",
"author": "Grant Howard, Coulomb-G",
"copyright": "[year], Grant Howard (https://github.com/the1812) & Coulomb-G (https://github.com/Coulomb-G)",
"license": "MIT",
diff --git a/src/components/feeds/api/manager/base.ts b/src/components/feeds/api/manager/base.ts
index 4bf1ed314..10901b435 100644
--- a/src/components/feeds/api/manager/base.ts
+++ b/src/components/feeds/api/manager/base.ts
@@ -5,9 +5,8 @@ import { ListAdaptorKey, FeedsCardsListAdaptor } from './adaptor'
export const feedsCardCallbacks: Required[] = []
-export const getVueData = (el: any) =>
- // eslint-disable-next-line no-underscore-dangle
- el.__vue__ ?? el.parentElement.__vue__ ?? el.children[0].__vue__
+// TODO: 仅做兼容性导出, v2.8.11 之后可移除
+export { getVue2Data as getVueData } from '@/core/utils'
export const createNodeValidator =
(className: string) =>
diff --git a/src/components/feeds/api/manager/v1.ts b/src/components/feeds/api/manager/v1.ts
index 775d26231..cce91e297 100644
--- a/src/components/feeds/api/manager/v1.ts
+++ b/src/components/feeds/api/manager/v1.ts
@@ -1,6 +1,7 @@
import { childList } from '@/core/observer'
+import { getVue2Data } from '@/core/utils'
import { sq } from '@/core/spin-query'
-import { FeedsCardsManager, FeedsCardsManagerEventType, getVueData } from './base'
+import { FeedsCardsManager, FeedsCardsManagerEventType } from './base'
import { feedsCardTypes, isRepostType, FeedsCard, FeedsCardType } from '../types'
const getFeedsCardType = (element: HTMLElement) => {
@@ -85,7 +86,7 @@ const parseCard = async (element: HTMLElement): Promise => {
}
const el = await sq(
() => element,
- (it: any) => Boolean(getVueData(it) || !element.parentNode),
+ (it: any) => Boolean(getVue2Data(it) || !element.parentNode),
{ queryInterval: 100 },
)
if (element.parentNode === null) {
@@ -93,10 +94,10 @@ const parseCard = async (element: HTMLElement): Promise => {
return ''
}
if (el === null) {
- console.warn(el, element, getVueData(el), element.parentNode)
+ console.warn(el, element, getVue2Data(el), element.parentNode)
return ''
}
- const vueData = getVueData(el)
+ const vueData = getVue2Data(el)
if (type === feedsCardTypes.repost) {
const currentText = vueData.card.item.content
const repostData = getRepostData(vueData)
@@ -135,7 +136,7 @@ const parseCard = async (element: HTMLElement): Promise => {
element.setAttribute('data-type', card.type.id.toString())
if (isRepostType(card)) {
const currentUsername = card.username
- const vueData = getVueData(card.element)
+ const vueData = getVue2Data(card.element)
const repostUsername = lodash.get(vueData, 'card.origin_user.info.uname', '')
if (currentUsername === repostUsername) {
element.setAttribute('data-self-repost', 'true')
diff --git a/src/components/feeds/api/manager/v2.ts b/src/components/feeds/api/manager/v2.ts
index 872ec79bf..06c45e130 100644
--- a/src/components/feeds/api/manager/v2.ts
+++ b/src/components/feeds/api/manager/v2.ts
@@ -1,12 +1,7 @@
import { childList } from '@/core/observer'
import { descendingStringSort } from '@/core/utils/sort'
-import { pascalCase } from '@/core/utils'
-import {
- createNodeValidator,
- FeedsCardsManager,
- FeedsCardsManagerEventType,
- getVueData,
-} from './base'
+import { pascalCase, getVue2Data } from '@/core/utils'
+import { createNodeValidator, FeedsCardsManager, FeedsCardsManagerEventType } from './base'
import { FeedsCard, FeedsCardType, feedsCardTypes, isRepostType } from '../types'
import { selectAll } from '@/core/spin-query'
@@ -78,7 +73,7 @@ const getText = (dynamicModule: any, cardType: FeedsCardType) => {
return combineText(mainText, typeText)
}
const parseCard = async (element: HTMLElement): Promise => {
- const vueData = getVueData(element)
+ const vueData = getVue2Data(element)
const parseModules = (rawModules: any) => {
if (Array.isArray(rawModules)) {
return Object.fromEntries(
@@ -157,7 +152,7 @@ export class FeedsCardsManagerV2 extends FeedsCardsManager {
if (!isNodeValid(node)) {
return
}
- const vueData = getVueData(node)
+ const vueData = getVue2Data(node)
if (!vueData) {
return
}
diff --git a/src/components/i18n/dom-translator.ts b/src/components/i18n/dom-translator.ts
index 9b95f4ec8..57d8c5675 100644
--- a/src/components/i18n/dom-translator.ts
+++ b/src/components/i18n/dom-translator.ts
@@ -12,15 +12,11 @@ export class Translator {
static map: Map
static regex: [RegExp, string][]
- // eslint-disable-next-line class-methods-use-this
protected accepts = (node: Node) => node.nodeType === Node.ELEMENT_NODE
- // eslint-disable-next-line class-methods-use-this
protected getValue = (node: Node) => node.nodeValue
- // eslint-disable-next-line class-methods-use-this
protected setValue = (node: Node, value: string) => {
node.nodeValue = value
}
- // eslint-disable-next-line class-methods-use-this
protected getElement = (node: Node) => node as Element
translate(node: Node) {
let value = this.getValue(node)
@@ -103,23 +99,17 @@ export class Translator {
}
}
export class TextNodeTranslator extends Translator {
- // eslint-disable-next-line class-methods-use-this
accepts = (node: Node) => node.nodeType === Node.TEXT_NODE
- // eslint-disable-next-line class-methods-use-this
getElement = (node: Node) => node.parentElement
}
export class TitleTranslator extends Translator {
- // eslint-disable-next-line class-methods-use-this
getValue = (node: Node) => (node as Element).getAttribute('title')
- // eslint-disable-next-line class-methods-use-this
setValue = (node: Node, value: string) => {
;(node as Element).setAttribute('title', value)
}
}
export class PlaceholderTranslator extends Translator {
- // eslint-disable-next-line class-methods-use-this
getValue = (node: Node) => (node as Element).getAttribute('placeholder')
- // eslint-disable-next-line class-methods-use-this
setValue = (node: Node, value: string) => {
;(node as Element).setAttribute('placeholder', value)
}
diff --git a/src/components/utils/comment-apis.ts b/src/components/utils/comment-apis.ts
index 8dc0423e6..5f129f96c 100644
--- a/src/components/utils/comment-apis.ts
+++ b/src/components/utils/comment-apis.ts
@@ -1,307 +1,25 @@
-import { dqa } from '@/core/utils'
-import { allMutations, childList, childListSubtree } from '@/core/observer'
import { contentLoaded } from '@/core/life-cycle'
+import { CommentAreaManager } from './comment/comment-area-manager'
+import { CommentAreaCallback, CommentItemCallback } from './comment/types'
+import { CommentReplyItem } from './comment/reply-item'
-/** 表示一条评论回复 */
-export class CommentReplyItem extends EventTarget {
- /** 对应元素 */
- element: HTMLElement
- /** 评论ID */
- id: string
- /** 评论者UID */
- userId: string
- /** 用户名 */
- userName: string
- /** 评论内容 */
- content: string
- /** (仅 v1) 评论时间 (文本) */
- timeText?: string
- /** (仅 v2) 评论时间戳 */
- time?: number
- /** 点赞数 */
- likes: number
-
- constructor(initParams: Omit) {
- super()
- this.element = initParams.element
- this.id = initParams.id
- this.userId = initParams.userId
- this.userName = initParams.userName
- this.content = initParams.content
- this.timeText = initParams.timeText
- this.time = initParams.time
- this.likes = initParams.likes
- }
-}
-
-/** 评论更新的事件类型 */
-export const RepliesUpdateEventType = 'repliesUpdate'
-/** 评论更新的事件回调类型 */
-export type RepliesUpdateEventCallback = (event: CustomEvent) => void
-const createRepliesUpdateEvent = (replies: CommentReplyItem[]) => {
- return new CustomEvent(RepliesUpdateEventType, {
- detail: replies,
- })
-}
-/** 表示一条评论 */
-export class CommentItem extends CommentReplyItem {
- /** 评论图片 */
- pictures?: string[]
- /** 回复 */
- replies: CommentReplyItem[]
-
- constructor(initParams: Omit) {
- super(initParams)
- this.pictures = initParams.pictures
- this.replies = initParams.replies
- }
-
- addEventListener(
- type: typeof RepliesUpdateEventType,
- callback: RepliesUpdateEventCallback | null,
- options?: EventListenerOptions | boolean,
- ) {
- super.addEventListener(type, callback, options)
- }
-
- removeEventListener(
- type: typeof RepliesUpdateEventType,
- callback: RepliesUpdateEventCallback | null,
- options?: EventListenerOptions | boolean,
- ) {
- super.removeEventListener(type, callback, options)
- }
-
- /** 触发评论更新事件 */
- dispatchRepliesUpdate(replies: CommentReplyItem[]) {
- return super.dispatchEvent(createRepliesUpdateEvent(replies))
- }
-}
-export type CommentItemCallback = (item: CommentItem) => void
-/** 表示一个评论区 */
-export interface CommentArea {
- /** 对应元素 */
- element: HTMLElement
- /** 评论列表 */
- items: CommentItem[]
- /** 与之关联的 MutationObserver */
- observer?: MutationObserver
-}
-/** 当前页面所有的评论区列表 */
-export const commentAreas: CommentArea[] = []
-export type CommentAreaCallback = (area: CommentArea) => void
-
-const commentAreaClasses = ['bili-comment', 'bb-comment']
-const replyItemClasses = ['list-item.reply-wrap', 'reply-item']
-const itemAddedCallbacks: CommentItemCallback[] = []
-const itemRemovedCallbacks: CommentItemCallback[] = []
-const commentAreaCallbacks: CommentAreaCallback[] = []
-
-/** (v2) 获取 Vue 数据 (评论 / 回复) */
-const getVueData = (element: HTMLElement) => {
- // eslint-disable-next-line no-underscore-dangle
- const props = (element as any).__vueParentComponent?.props
- return props?.reply ?? props?.subReply
-}
-/** (v2) 获取回复对应的元素 */
-const getReplyItemElement = (parent: HTMLElement, replyId: string) => {
- const [replyElement] = dqa(parent, '.sub-reply-item').filter(
- (it: HTMLElement) => getVueData(it).rpid_str === replyId,
- )
- return replyElement as HTMLElement
-}
-/** (v1 / v2) 获取评论 ID */
-const getCommentId = (element: HTMLElement) => {
- const attributeId = element.getAttribute('data-id')
- if (attributeId) {
- return attributeId
- }
- const vueData = getVueData(element)
- return vueData?.rpid_str ?? ''
-}
-
-/** (v2) 解析评论对象 */
-const parseCommentItemV2 = (element: HTMLElement) => {
- const vueData = getVueData(element)
- if (!vueData) {
- throw new Error('Invalid comment item')
- }
- const parseReplies = () => {
- if (!vueData.replies) {
- return []
- }
- return vueData.replies.map((r: any): CommentReplyItem => {
- return new CommentReplyItem({
- id: r.rpid_str,
- element: getReplyItemElement(element, r.rpid_str),
- userId: r.member.mid,
- userName: r.member.uname,
- content: r.content.message,
- time: r.ctime * 1000,
- likes: r.like,
- })
- })
- }
- const item = new CommentItem({
- id: vueData.rpid_str,
- element,
- userId: vueData.member.mid,
- userName: vueData.member.uname,
- content: vueData.content.message,
- time: vueData.ctime * 1000,
- likes: vueData.like,
- pictures: vueData.content?.pictures?.map(img => {
- return img.img_src
- }),
- replies: parseReplies(),
- })
- if (item.replies.length < vueData.rcount) {
- const replyBox = dq(element, '.sub-reply-list')
- childList(replyBox, records => {
- item.replies = parseReplies()
- if (records.length !== 0) {
- item.dispatchRepliesUpdate(item.replies)
- }
- })
- }
- return item
-}
-
-/** (v1 / v2) 解析评论对象 */
-const parseCommentItem = (element: HTMLElement) => {
- const user = element.querySelector('.con .user .name') as HTMLElement
- if (!user) {
- return parseCommentItemV2(element)
- }
- const parseReplyItem = (replyElement: HTMLElement) => {
- const replyFace = replyElement.querySelector('.reply-face') as HTMLElement
- const replyUser = replyElement.querySelector('.reply-con .user .name') as HTMLElement
- return new CommentReplyItem({
- id: replyElement.getAttribute('data-id'),
- element: replyElement,
- userId: replyFace.getAttribute('data-usercard-mid'),
- userName: replyUser.textContent,
- content: replyElement.querySelector('.text-con').textContent,
- timeText: replyElement.querySelector('.info .time, .info .time-location').textContent,
- likes: parseInt(replyElement.querySelector('.info .like span').textContent),
- })
- }
- const item = new CommentItem({
- id: element.getAttribute('data-id'),
- element,
- userId: user.getAttribute('data-usercard-mid'),
- userName: user.textContent,
- content: element.querySelector('.con .text').textContent,
- timeText: element.querySelector('.con .info .time, .info .time-location').textContent,
- likes: parseInt(element.querySelector('.con .like span').textContent),
- replies: [],
- })
- if (dq(element, '.reply-box .view-more')) {
- const replyBox = dq(element, '.reply-box') as HTMLElement
- childList(replyBox, records => {
- item.replies = dqa(element, '.reply-box .reply-item').map(parseReplyItem)
- if (records.length !== 0) {
- item.dispatchRepliesUpdate(item.replies)
- }
- })
- } else {
- item.replies = dqa(element, '.reply-box .reply-item').map(parseReplyItem)
- }
- return item
-}
-
-const observeItems = (area: CommentArea) => {
- if (area.observer) {
- return
- }
- const replyItemSelector = replyItemClasses.map(c => `.${c}`).join(',')
- area.items = dqa(area.element, replyItemSelector).map(parseCommentItem)
- area.items.forEach(item => {
- itemAddedCallbacks.forEach(c => c(item))
- })
- ;[area.observer] = childListSubtree(area.element, records => {
- records.forEach(r => {
- const isCommentItem = (n: Node): n is HTMLElement =>
- n instanceof HTMLElement && n.matches(replyItemSelector)
- r.addedNodes.forEach(n => {
- if (isCommentItem(n)) {
- const commentItem = parseCommentItem(n)
- area.items.push(commentItem)
- itemAddedCallbacks.forEach(c => c(commentItem))
- }
- })
- r.removedNodes.forEach(n => {
- if (isCommentItem(n)) {
- const id = getCommentId(n)
- const index = area.items.findIndex(item => item.id === id)
- if (index !== -1) {
- const [commentItem] = area.items.splice(index, 1)
- itemRemovedCallbacks.forEach(c => c(commentItem))
- }
- }
- })
- })
- })
-}
-const observeAreas = (node: Node) => {
- if (node instanceof HTMLElement && commentAreaClasses.some(c => node.classList.contains(c))) {
- const area: CommentArea = {
- element: node,
- items: [],
- }
- commentAreas.push(area)
- observeItems(area)
- commentAreaCallbacks.forEach(c => c(area))
- }
-}
-const init = () => {
- allMutations(records => {
- records.forEach(r => {
- r.addedNodes.forEach(n => observeAreas(n))
- })
- })
- dqa(commentAreaClasses.map(c => `.${c}`).join(',')).forEach(observeAreas)
-}
-contentLoaded(init)
+export const commentAreaManager = new CommentAreaManager()
+contentLoaded(() => {
+ commentAreaManager.init()
+})
/** 为每一个评论区执行操作 */
export const forEachCommentArea = (callback: CommentAreaCallback) => {
- commentAreas.forEach(it => callback(it))
- commentAreaCallbacks.push(callback)
+ commentAreaManager.forEachCommentArea(callback)
}
/** 为每一条评论执行操作 (不包含评论的回复) */
export const forEachCommentItem = (callbacks: {
added?: CommentItemCallback
removed?: CommentItemCallback
}) => {
- const { added, removed } = callbacks
- forEachCommentArea(area => {
- if (added) {
- area.items.forEach(item => added(item))
- itemAddedCallbacks.push(added)
- }
- if (removed) {
- itemRemovedCallbacks.push(removed)
- }
- })
+ commentAreaManager.forEachCommentItem(callbacks)
}
-const addMenuItemV2 = (...params: Parameters) => {
- const [item, config] = params
- const operationList = dq(item.element, '.operation-list') as HTMLUListElement
- const { className, text, action } = config
- if (!operationList || dq(operationList, `.${className}`)) {
- return
- }
- const menuItem = document.createElement('li')
- menuItem.classList.add(className, 'operation-option')
- menuItem.innerHTML = `${text}`
- menuItem.addEventListener('click', event => {
- action(event)
- operationList.style.display = 'none'
- })
- operationList.appendChild(menuItem)
-}
/**
* 向评论的菜单中添加菜单项
* @param item 评论
@@ -315,21 +33,5 @@ export const addMenuItem = (
action: (e: MouseEvent) => void
},
) => {
- const operationList = dq(item.element, '.opera-list ul') as HTMLUListElement
- if (!operationList) {
- addMenuItemV2(item, config)
- return
- }
- const { className, text, action } = config
- if (dq(operationList, `.${className}`)) {
- return
- }
- const menuItem = document.createElement('li')
- menuItem.classList.add(className)
- menuItem.textContent = text
- menuItem.addEventListener('click', event => {
- action(event)
- operationList.style.display = 'none'
- })
- operationList.appendChild(menuItem)
+ commentAreaManager.forEachCommentArea(area => area.addMenuItem(item, config))
}
diff --git a/src/components/utils/comment/areas/base.ts b/src/components/utils/comment/areas/base.ts
new file mode 100644
index 000000000..3130a07bb
--- /dev/null
+++ b/src/components/utils/comment/areas/base.ts
@@ -0,0 +1,111 @@
+import { childListSubtree } from '@/core/observer'
+import type { CommentItem } from '../comment-item'
+import type { CommentItemCallback } from '../types'
+import type { CommentReplyItem } from '../reply-item'
+import { getRandomId } from '@/core/utils'
+
+/** 表示一个评论区 */
+export abstract class CommentArea {
+ /** 对应元素 */
+ element: HTMLElement
+ /** 评论列表 */
+ items: CommentItem[] = []
+ /** 与之关联的 MutationObserver */
+ protected observer?: MutationObserver
+ protected itemAddedCallbacks: CommentItemCallback[] = []
+ protected itemRemovedCallbacks: CommentItemCallback[] = []
+ protected static replyItemClasses = ['list-item.reply-wrap', 'reply-item']
+ protected static replyItemSelector = CommentArea.replyItemClasses.map(c => `.${c}`).join(',')
+
+ constructor(element: HTMLElement) {
+ this.element = element
+ }
+
+ abstract parseCommentItem(element: HTMLElement): CommentItem
+ abstract getCommentId(element: HTMLElement): string
+ abstract addMenuItem(
+ item: CommentReplyItem,
+ config: {
+ className: string
+ text: string
+ action: (e: MouseEvent) => void
+ },
+ ): void
+
+ protected isCommentItem(n: Node): n is HTMLElement {
+ return n instanceof HTMLElement && n.matches(CommentArea.replyItemSelector)
+ }
+
+ /** 在每一轮 CommentItem 解析前调用 */
+ protected beforeParse(elements: HTMLElement[]) {
+ return lodash.noop(elements)
+ }
+
+ observeItems() {
+ if (this.observer) {
+ return
+ }
+ performance.mark('observeItems start')
+ const elements = dqa(this.element, CommentArea.replyItemSelector) as HTMLElement[]
+ if (elements.length > 0) {
+ this.beforeParse(elements)
+ this.items = elements.map(it => this.parseCommentItem(it as HTMLElement))
+ }
+ this.items.forEach(item => {
+ this.itemAddedCallbacks.forEach(c => c(item))
+ })
+ ;[this.observer] = childListSubtree(this.element, records => {
+ const observerCallId = getRandomId()
+ performance.mark(`observeItems subtree start ${observerCallId}`)
+ const addedCommentElements: HTMLElement[] = []
+ const removedCommentElements: HTMLElement[] = []
+ records.forEach(r => {
+ r.addedNodes.forEach(n => {
+ if (this.isCommentItem(n)) {
+ addedCommentElements.push(n)
+ }
+ })
+ r.removedNodes.forEach(n => {
+ if (this.isCommentItem(n)) {
+ removedCommentElements.push(n)
+ }
+ })
+ })
+ if (addedCommentElements.length > 0) {
+ this.beforeParse(addedCommentElements)
+ }
+ addedCommentElements.forEach(n => {
+ const commentItem = this.parseCommentItem(n)
+ this.items.push(commentItem)
+ this.itemAddedCallbacks.forEach(c => c(commentItem))
+ })
+ removedCommentElements.forEach(n => {
+ const id = this.getCommentId(n)
+ const index = this.items.findIndex(item => item.id === id)
+ if (index !== -1) {
+ const [commentItem] = this.items.splice(index, 1)
+ this.itemRemovedCallbacks.forEach(c => c(commentItem))
+ }
+ })
+ performance.mark(`observeItems subtree end ${observerCallId}`)
+ performance.measure(
+ `observeItems subtree ${observerCallId}`,
+ `observeItems subtree start ${observerCallId}`,
+ `observeItems subtree end ${observerCallId}`,
+ )
+ })
+ performance.mark('observeItems end')
+ performance.measure('observeItems', 'observeItems start', 'observeItems end')
+ }
+
+ forEachCommentItem(callbacks: { added?: CommentItemCallback; removed?: CommentItemCallback }) {
+ const { added, removed } = callbacks
+ if (added) {
+ this.items.forEach(item => added(item))
+ this.itemAddedCallbacks.push(added)
+ }
+ if (removed) {
+ this.itemRemovedCallbacks.push(removed)
+ }
+ }
+}
diff --git a/src/components/utils/comment/areas/v1.ts b/src/components/utils/comment/areas/v1.ts
new file mode 100644
index 000000000..2e1861c87
--- /dev/null
+++ b/src/components/utils/comment/areas/v1.ts
@@ -0,0 +1,79 @@
+import { childList } from '@/core/observer'
+import { CommentItem } from '../comment-item'
+import { CommentReplyItem } from '../reply-item'
+import { CommentArea } from './base'
+
+export class CommentAreaV1 extends CommentArea {
+ addMenuItem(
+ item: CommentReplyItem,
+ config: { className: string; text: string; action: (e: MouseEvent) => void },
+ ): void {
+ if (!this.element.contains(item.element)) {
+ return
+ }
+ const operationList = dq(item.element, '.opera-list ul') as HTMLUListElement
+ if (!operationList) {
+ throw new Error('Invalid operation list')
+ }
+ const { className, text, action } = config
+ if (dq(operationList, `.${className}`)) {
+ return
+ }
+ const menuItem = document.createElement('li')
+ menuItem.classList.add(className)
+ menuItem.textContent = text
+ menuItem.addEventListener('click', event => {
+ action(event)
+ operationList.style.display = 'none'
+ })
+ operationList.appendChild(menuItem)
+ }
+ parseCommentItem(element: HTMLElement): CommentItem {
+ const user = element.querySelector('.con .user .name') as HTMLElement
+ if (!user) {
+ throw new Error('Invalid comment item')
+ }
+ const parseReplyItem = (replyElement: HTMLElement) => {
+ const replyFace = replyElement.querySelector('.reply-face') as HTMLElement
+ const replyUser = replyElement.querySelector('.reply-con .user .name') as HTMLElement
+ return new CommentReplyItem({
+ id: replyElement.getAttribute('data-id'),
+ element: replyElement,
+ userId: replyFace.getAttribute('data-usercard-mid'),
+ userName: replyUser.textContent,
+ content: replyElement.querySelector('.text-con').textContent,
+ timeText: replyElement.querySelector('.info .time, .info .time-location').textContent,
+ likes: parseInt(replyElement.querySelector('.info .like span').textContent),
+ })
+ }
+ const item = new CommentItem({
+ id: element.getAttribute('data-id'),
+ element,
+ userId: user.getAttribute('data-usercard-mid'),
+ userName: user.textContent,
+ content: element.querySelector('.con .text').textContent,
+ timeText: element.querySelector('.con .info .time, .info .time-location').textContent,
+ likes: parseInt(element.querySelector('.con .like span').textContent),
+ replies: [],
+ })
+ if (dq(element, '.reply-box .view-more')) {
+ const replyBox = dq(element, '.reply-box') as HTMLElement
+ childList(replyBox, records => {
+ item.replies = dqa(element, '.reply-box .reply-item').map(parseReplyItem)
+ if (records.length !== 0) {
+ item.dispatchRepliesUpdate(item.replies)
+ }
+ })
+ } else {
+ item.replies = dqa(element, '.reply-box .reply-item').map(parseReplyItem)
+ }
+ return item
+ }
+ getCommentId(element: HTMLElement): string {
+ const attributeId = element.getAttribute('data-id')
+ if (!attributeId) {
+ throw new Error('Invalid comment item')
+ }
+ return attributeId
+ }
+}
diff --git a/src/components/utils/comment/areas/v2.ts b/src/components/utils/comment/areas/v2.ts
new file mode 100644
index 000000000..5401eaacd
--- /dev/null
+++ b/src/components/utils/comment/areas/v2.ts
@@ -0,0 +1,157 @@
+/* eslint-disable no-underscore-dangle */
+import { childList } from '@/core/observer'
+import { CommentItem } from '../comment-item'
+import { CommentArea } from './base'
+import { CommentReplyItem } from '../reply-item'
+import { HTMLElementWithVue, VNodeManager } from '../vnode-manager'
+
+export class CommentAreaV2 extends CommentArea {
+ private vnodeManager: VNodeManager
+
+ constructor(element: HTMLElement) {
+ super(element)
+ this.vnodeManager = new VNodeManager(this.getVNodeRoot(element as HTMLElementWithVue))
+ this.vnodeManager.isEnd = vnode => {
+ if (vnode.props?.reply) {
+ return vnode.props?.reply.replies.length === 0
+ }
+ return Boolean(vnode.props?.subReply)
+ }
+ }
+
+ protected beforeParse(elements: HTMLElementWithVue[]) {
+ const rootElements = new Set()
+ elements.forEach(replyElement => {
+ rootElements.add(this.vnodeManager.traverseToRoot(replyElement))
+ dqa(replyElement, '.sub-reply-item').forEach(subReplyElement => {
+ rootElements.add(this.vnodeManager.traverseToRoot(subReplyElement as HTMLElementWithVue))
+ })
+ })
+ rootElements.forEach(rootElement => this.vnodeManager.exposeVNode(rootElement._vnode))
+ }
+
+ /** 获取 Vue 数据 (评论 / 回复) */
+ protected getReplyFromVueData(element: HTMLElement) {
+ const props = (element as HTMLElementWithVue)._vnode?.props
+ return props?.reply ?? props?.subReply
+ }
+
+ /** 获取回复对应的元素 */
+ protected getReplyItemElement(parent: HTMLElement, replyId: string) {
+ const [replyElement] = dqa(parent, '.sub-reply-item').filter(
+ (it: HTMLElement) => this.getReplyFromVueData(it).rpid_str === replyId,
+ )
+ return replyElement as HTMLElement
+ }
+
+ protected getVNodeRoot(element: HTMLElementWithVue) {
+ if (element.__vue_app__) {
+ return element
+ }
+ if (element.parentElement.__vue_app__) {
+ return element.parentElement
+ }
+ return null
+ }
+
+ addMenuItem(
+ item: CommentReplyItem,
+ config: { className: string; text: string; action: (e: MouseEvent) => void },
+ ): void {
+ if (!this.element.contains(item.element)) {
+ return
+ }
+ const operationList = dq(item.element, '.operation-list') as HTMLUListElement
+ const { className, text, action } = config
+ if (!operationList || dq(operationList, `.${className}`)) {
+ return
+ }
+ const menuItem = document.createElement('li')
+ menuItem.classList.add(className, 'operation-option')
+ menuItem.innerHTML = `${text}`
+ menuItem.addEventListener('click', event => {
+ action(event)
+ operationList.style.display = 'none'
+ })
+ operationList.appendChild(menuItem)
+ }
+
+ parseCommentItem(element: HTMLElement): CommentItem {
+ const vueData = this.getReplyFromVueData(element)
+ if (!vueData) {
+ throw new Error('Invalid comment item')
+ }
+ const parseReplies = () => {
+ if (!vueData.replies) {
+ return []
+ }
+ return vueData.replies.map((r: any): CommentReplyItem => {
+ return new CommentReplyItem({
+ id: r.rpid_str,
+ element: this.getReplyItemElement(element, r.rpid_str),
+ userId: r.member.mid,
+ userName: r.member.uname,
+ content: r.content.message,
+ time: r.ctime * 1000,
+ likes: r.like,
+ })
+ })
+ }
+ const item = new CommentItem({
+ id: vueData.rpid_str,
+ element,
+ userId: vueData.member.mid,
+ userName: vueData.member.uname,
+ content: vueData.content.message,
+ time: vueData.ctime * 1000,
+ likes: vueData.like,
+ pictures: vueData.content?.pictures?.map((img: any) => {
+ return img.img_src
+ }),
+ replies: parseReplies(),
+ })
+ if (item.replies.length < vueData.rcount) {
+ const replyBox = dq(element, '.sub-reply-list')
+ childList(replyBox, records => {
+ performance.mark(`parseReplies start ${item.id}`)
+ const addedCommentElements: HTMLElement[] = []
+ records.forEach(r => {
+ r.addedNodes.forEach(n => {
+ if (n instanceof HTMLElement && n.matches('.sub-reply-item')) {
+ addedCommentElements.push(n)
+ }
+ })
+ })
+ if (addedCommentElements.length === 0) {
+ return
+ }
+ this.beforeParse(addedCommentElements)
+ item.replies = parseReplies()
+ if (records.length !== 0) {
+ item.dispatchRepliesUpdate(item.replies)
+ }
+ performance.mark(`parseReplies end ${item.id}`)
+ performance.measure(
+ `parseReplies ${item.id}`,
+ `parseReplies start ${item.id}`,
+ `parseReplies end ${item.id}`,
+ )
+ })
+ }
+ return item
+ }
+ getCommentId(element: HTMLElement): string {
+ const vueData = this.getReplyFromVueData(element)
+ if (!vueData?.rpid_str) {
+ throw new Error('Invalid comment item')
+ }
+ return vueData.rpid_str
+ }
+
+ static isV2Area(element: HTMLElement) {
+ return (
+ (element as HTMLElementWithVue).__vue_app__ ||
+ (element as HTMLElementWithVue).parentElement?.__vue_app__
+ )
+ }
+}
diff --git a/src/components/utils/comment/comment-area-manager.ts b/src/components/utils/comment/comment-area-manager.ts
new file mode 100644
index 000000000..2f8a32fe5
--- /dev/null
+++ b/src/components/utils/comment/comment-area-manager.ts
@@ -0,0 +1,44 @@
+import { allMutations } from '@/core/observer'
+import { getCommentArea } from './comment-area'
+import { CommentAreaCallback, CommentItemCallback } from './types'
+import { CommentArea } from './areas/base'
+
+export class CommentAreaManager {
+ /** 当前页面所有的评论区列表 */
+ commentAreas: CommentArea[] = []
+ commentAreaCallbacks: CommentAreaCallback[] = []
+
+ protected static commentAreaClasses = ['bili-comment', 'bb-comment']
+
+ init() {
+ allMutations(records => {
+ records.forEach(r => {
+ r.addedNodes.forEach(n => this.observeAreas(n))
+ })
+ })
+ dqa(CommentAreaManager.commentAreaClasses.map(c => `.${c}`).join(',')).forEach(it =>
+ this.observeAreas(it),
+ )
+ }
+
+ observeAreas(node: Node) {
+ if (
+ node instanceof HTMLElement &&
+ CommentAreaManager.commentAreaClasses.some(c => node.classList.contains(c))
+ ) {
+ const area = getCommentArea(node)
+ this.commentAreas.push(area)
+ area.observeItems()
+ this.commentAreaCallbacks.forEach(c => c(area))
+ }
+ }
+
+ forEachCommentArea(callback: CommentAreaCallback) {
+ this.commentAreas.forEach(it => callback(it))
+ this.commentAreaCallbacks.push(callback)
+ }
+
+ forEachCommentItem(callbacks: { added?: CommentItemCallback; removed?: CommentItemCallback }) {
+ this.forEachCommentArea(area => area.forEachCommentItem(callbacks))
+ }
+}
diff --git a/src/components/utils/comment/comment-area.ts b/src/components/utils/comment/comment-area.ts
new file mode 100644
index 000000000..1ce91d399
--- /dev/null
+++ b/src/components/utils/comment/comment-area.ts
@@ -0,0 +1,10 @@
+import type { CommentArea } from './areas/base'
+import { CommentAreaV1 } from './areas/v1'
+import { CommentAreaV2 } from './areas/v2'
+
+export const getCommentArea = (element: HTMLElement): CommentArea => {
+ if (CommentAreaV2.isV2Area(element)) {
+ return new CommentAreaV2(element)
+ }
+ return new CommentAreaV1(element)
+}
diff --git a/src/components/utils/comment/comment-item.ts b/src/components/utils/comment/comment-item.ts
new file mode 100644
index 000000000..91dd561c1
--- /dev/null
+++ b/src/components/utils/comment/comment-item.ts
@@ -0,0 +1,42 @@
+import { CommentReplyItem } from './reply-item'
+import { RepliesUpdateEventCallback, RepliesUpdateEventType } from './types'
+
+const createRepliesUpdateEvent = (replies: CommentReplyItem[]) => {
+ return new CustomEvent(RepliesUpdateEventType, {
+ detail: replies,
+ })
+}
+/** 表示一条评论 */
+export class CommentItem extends CommentReplyItem {
+ /** 评论图片 */
+ pictures?: string[]
+ /** 回复 */
+ replies: CommentReplyItem[]
+
+ constructor(initParams: Omit) {
+ super(initParams)
+ this.pictures = initParams.pictures ?? []
+ this.replies = initParams.replies
+ }
+
+ addEventListener(
+ type: typeof RepliesUpdateEventType,
+ callback: RepliesUpdateEventCallback | null,
+ options?: EventListenerOptions | boolean,
+ ) {
+ super.addEventListener(type, callback, options)
+ }
+
+ removeEventListener(
+ type: typeof RepliesUpdateEventType,
+ callback: RepliesUpdateEventCallback | null,
+ options?: EventListenerOptions | boolean,
+ ) {
+ super.removeEventListener(type, callback, options)
+ }
+
+ /** 触发评论更新事件 */
+ dispatchRepliesUpdate(replies: CommentReplyItem[]) {
+ return super.dispatchEvent(createRepliesUpdateEvent(replies))
+ }
+}
diff --git a/src/components/utils/comment/reply-item.ts b/src/components/utils/comment/reply-item.ts
new file mode 100644
index 000000000..3f73f9f35
--- /dev/null
+++ b/src/components/utils/comment/reply-item.ts
@@ -0,0 +1,31 @@
+/** 表示一条评论回复 */
+export class CommentReplyItem extends EventTarget {
+ /** 对应元素 */
+ element: HTMLElement
+ /** 评论ID */
+ id: string
+ /** 评论者UID */
+ userId: string
+ /** 用户名 */
+ userName: string
+ /** 评论内容 */
+ content: string
+ /** (仅 v1) 评论时间 (文本) */
+ timeText?: string
+ /** (仅 v2) 评论时间戳 */
+ time?: number
+ /** 点赞数 */
+ likes: number
+
+ constructor(initParams: Omit) {
+ super()
+ this.element = initParams.element
+ this.id = initParams.id
+ this.userId = initParams.userId
+ this.userName = initParams.userName
+ this.content = initParams.content
+ this.timeText = initParams.timeText
+ this.time = initParams.time
+ this.likes = initParams.likes
+ }
+}
diff --git a/src/components/utils/comment/types.ts b/src/components/utils/comment/types.ts
new file mode 100644
index 000000000..c11dddfc5
--- /dev/null
+++ b/src/components/utils/comment/types.ts
@@ -0,0 +1,10 @@
+import type { CommentReplyItem } from './reply-item'
+import type { CommentItem } from './comment-item'
+import type { CommentArea } from './comment-area'
+
+/** 评论更新的事件类型 */
+export const RepliesUpdateEventType = 'repliesUpdate'
+/** 评论更新的事件回调类型 */
+export type RepliesUpdateEventCallback = (event: CustomEvent) => void
+export type CommentItemCallback = (item: CommentItem) => void
+export type CommentAreaCallback = (area: CommentArea) => void
diff --git a/src/components/utils/comment/vnode-manager.ts b/src/components/utils/comment/vnode-manager.ts
new file mode 100644
index 000000000..406a2c289
--- /dev/null
+++ b/src/components/utils/comment/vnode-manager.ts
@@ -0,0 +1,103 @@
+/* eslint-disable no-underscore-dangle */
+export const VNodeTargets = Symbol.for('VNodeTargets')
+export interface HTMLElementWithVue extends HTMLElement {
+ _vnode?: any
+ __vue_app__?: any
+ parentElement: HTMLElementWithVue | null
+ [VNodeTargets]?: HTMLElementWithVue[]
+}
+export interface Vue3Vnode {
+ type: string | symbol
+ el?: HTMLElementWithVue
+ props?: Record
+ component?: {
+ subTree?: Vue3Vnode
+ }
+ children?: Vue3Vnode[]
+}
+
+export class VNodeManager {
+ public isEnd: (vnode: Vue3Vnode) => boolean = () => true
+
+ constructor(public rootElement: HTMLElementWithVue) {}
+
+ /** 从结束元素向上写入递归范围 */
+ traverseToRoot(element: HTMLElementWithVue): HTMLElementWithVue {
+ performance.mark('traverseToRoot')
+ if (element._vnode) {
+ return element
+ }
+ let currentElement = element
+ while (currentElement.parentElement !== null && currentElement !== this.rootElement) {
+ if (currentElement.parentElement[VNodeTargets]) {
+ if (!currentElement.parentElement[VNodeTargets].includes(currentElement)) {
+ currentElement.parentElement[VNodeTargets].push(currentElement)
+ }
+ if (currentElement.parentElement._vnode) {
+ return currentElement.parentElement
+ }
+ // 如果其他 element 已经标记过此处, 可以直接 break 掉完成循环
+ break
+ }
+
+ currentElement.parentElement[VNodeTargets] = [currentElement]
+ if (currentElement.parentElement._vnode) {
+ return currentElement.parentElement
+ }
+ currentElement = currentElement.parentElement
+ }
+ return this.rootElement
+ }
+
+ /** 解开组件实例的包装 */
+ private unwrapSubtree(vnode: Vue3Vnode): Vue3Vnode {
+ if (vnode.component?.subTree) {
+ performance.mark('unwrapSubtree')
+ return this.unwrapSubtree(vnode.component?.subTree)
+ }
+ return vnode
+ }
+
+ /**
+ * 将评论区 VNode 进行暴露
+ * @see https://github.com/the1812/Bilibili-Evolved/issues/4690#issuecomment-2059485344
+ */
+ exposeVNode(vnode: Vue3Vnode = this.rootElement._vnode) {
+ performance.mark('exposeVNode')
+ if (vnode.el && !vnode.el._vnode) {
+ vnode.el._vnode = vnode
+ }
+ if (this.isEnd(vnode)) {
+ return
+ }
+
+ const unwrapped = this.unwrapSubtree(vnode)
+ if (!Array.isArray(unwrapped.children)) {
+ return
+ }
+ const nextChildren = unwrapped.children.filter(child => {
+ // fragment 需要将 VNodeTargets 直接向下传递一层
+ if (child.type === Symbol.for('v-fgt')) {
+ child.el[VNodeTargets] = unwrapped.el?.[VNodeTargets]
+ return true
+ }
+ // 模板树需要 flatMap 并向下传递一层
+ if (!child.el) {
+ child[VNodeTargets] = unwrapped.el?.[VNodeTargets]?.flatMap(
+ element => element?.[VNodeTargets],
+ )
+ return true
+ }
+
+ // 没有 el 时属于来自模板树的传递
+ if (!unwrapped.el) {
+ return unwrapped[VNodeTargets]?.includes(child.el)
+ }
+ return unwrapped.el?.[VNodeTargets]?.includes(child.el)
+ })
+
+ nextChildren.forEach(child => {
+ this.exposeVNode(child)
+ })
+ }
+}
diff --git a/src/components/video/player-agent/base.ts b/src/components/video/player-agent/base.ts
index 0046002b8..cce5fa52f 100644
--- a/src/components/video/player-agent/base.ts
+++ b/src/components/video/player-agent/base.ts
@@ -1,4 +1,3 @@
-/* eslint-disable class-methods-use-this */
import { select } from '@/core/spin-query'
import { raiseEvent } from '@/core/utils'
import {
diff --git a/src/components/video/video-info.ts b/src/components/video/video-info.ts
index af1bb7400..f8b63cad8 100644
--- a/src/components/video/video-info.ts
+++ b/src/components/video/video-info.ts
@@ -68,7 +68,6 @@ export class VideoInfo {
}
/** @deprecated */
- // eslint-disable-next-line class-methods-use-this
get subtitles(): {
id: number
languageCode: string
diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts
index b881e9fd7..b8adab044 100644
--- a/src/core/utils/index.ts
+++ b/src/core/utils/index.ts
@@ -407,7 +407,6 @@ export class DoubleClickEvent {
singleClickHandler: (e: MouseEvent) => void = none
private clickedOnce = false
- // eslint-disable-next-line class-methods-use-this
private readonly stopPropagationHandler = (e: MouseEvent) => {
e.stopImmediatePropagation()
}
@@ -701,3 +700,8 @@ export const simulateClick = (target: EventTarget, eventParams?: PointerEventIni
target.dispatchEvent(mouseUpEvent)
target.dispatchEvent(clickEventEvent)
}
+
+/** 尝试获取元素对应的 Vue Data (仅适用于 Vue 2 组件) */
+export const getVue2Data = (el: any) =>
+ // eslint-disable-next-line no-underscore-dangle
+ el.__vue__ ?? el.parentElement.__vue__ ?? el.children[0].__vue__ ?? el.__vueParentComponent