Merge branch 'preview-features' into preview

This commit is contained in:
the1812 2024-09-08 23:21:19 +08:00
commit 3d56cf2259
62 changed files with 1486 additions and 463 deletions

View File

@ -1,6 +1,43 @@
<!-- spell-checker: disable -->
# 更新日志
## v2.9.2-preview
`2024-09-08`
包含 [v2.9.2](https://github.com/the1812/Bilibili-Evolved/releases/tag/v2.9.2) 的所有更新内容.
✨新增
- `弹幕转义` 支持对正斜杠的换行 (`/n`) 进行转义. (#4865)
- `自定义顶栏` 支持直接在功能中打开布局设置. (#2666)
- `高分辨率图片` 支持处理没有指定高度的图片, 支持在专栏页面中请求原图. (#2868)
- `直播间网页全屏自适应` 样式适配较低的宽度值. (#4895)
☕开发者相关
- 外部资源接入 Subresource Integrity. (#4896)
## v2.9.2
`2024-09-08`
✨新增
- `网址参数清理` 支持清理 `is_room_feed`. (PR #4886 by [dreammu](https://github.com/dreammu))
🐛修复
- 新版评论区相关功能修复: (#4843)
- 修复 `快速收起评论` 按钮错位. (#4890)
- 恢复功能: `禁用评论区搜索词`, `评论区IP属地显示`, `复制动态链接`.
- `简化评论区` 支持 Firefox.
- 样式实现使用 [Container style queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_size_and_style_queries#container_style_queries_2) 替代 [:host-context](https://developer.mozilla.org/en-US/docs/Web/CSS/:host-context), 虽然 Firefox 还是不支持, 但是能稍微标准化一点.
- 夜间模式适配
☕开发者相关
- Shadow DOM API (`./src/core/shadow-dom`) 更名为 Shadow Root API (`./src/core/shadow-root`), 模块内的功能导出单例:
- `shadowDomObserver`: 持续观测页面上的所有 Shadow DOM.
- `shadowRootStyles`: 支持将样式注入到 Shadow DOM 内部.
- Comments API 增加 `CommentAreaV3` 实现, 支持基于 Shadow DOM 的新版评论区. (#4843)
- 增加 `isContainerStyleQuerySupported` 来检测当前浏览器对 [Container style queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_size_and_style_queries#container_style_queries_2) 的支持.
- 组件样式支持在 `ComponentMetadata.instantStyles` 中声明 `shadowDom: true` 来插入到 Shadow DOM 中.
## v2.9.1-preview
`2024-08-15`

View File

@ -30,6 +30,10 @@ https://afdian.net/@the1812?tab=sponsor
| 时间 | 用户名 | 单号后4位 | 金额 |
| ------------------- | --------------------- | --------- | ------- |
| 2024.09.06 16:05:23 | 匿名 | 0752 | ¥5.00 |
| 2024.09.03 23:08:43 | *根 | 3956 | ¥20.00 |
| 2024.08.25 16:55:07 | 匿名 | 6595 | ¥10.00 |
| 2024.08.16 11:01:03 | 匿名 | 7648 | ¥30.00 |
| 2024.08.15 10:18:07 | *9 | 1653 | ¥10.00 |
| 2024.07.30 13:40:22 | Z*n | 2215 | ¥5.00 |
| 2024.07.24 09:38:46 | H*g | 7776 | ¥5.00 |

View File

@ -707,7 +707,7 @@
"type": "component",
"name": "unescapeDanmaku",
"displayName": "弹幕转义",
"description": "将弹幕中的 `\\n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.\r\n",
"description": "将弹幕中的 `\\n` 或 `/n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.\r\n",
"fullRelativePath": "../../registry/dist/components/video/danmaku/unescape.js",
"fullAbsolutePath": "registry/dist/components/video/danmaku/unescape.js"
},

View File

@ -969,7 +969,7 @@ by [@kdxcxs](https://github.com/kdxcxs)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/video/danmaku/unescape.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/video/danmaku/unescape.js)
将弹幕中的 `\n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.
将弹幕中的 `\n` `/n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.
### [视频页默认定位](../../registry/dist/components/video/default-location.js)
`videoDefaultLocation`

View File

@ -0,0 +1,5 @@
:host(bili-comments) {
#end .bottombar {
padding-bottom: 8px !important;
}
}

View File

@ -6,6 +6,7 @@ import { select } from '@/core/spin-query'
import { childListSubtree } from '@/core/observer'
const entry = async () => {
const { shadowRootStyles } = await import('@/core/shadow-root')
const { forEachFeedsCard } = await import('@/components/feeds/api')
const { childList } = await import('@/core/observer')
const commentSelector = '.bb-comment, .bili-comment-container'
@ -30,20 +31,23 @@ const entry = async () => {
commentBox.insertAdjacentElement('beforeend', button)
}
if (feedsCardsManager.managerType === 'v2') {
const existingComment = dq(card, commentSelector) as HTMLElement
const getExistingComment = () => dq(card, commentSelector) as HTMLElement
const isCommentAreaReady = () => {
const existingComment = getExistingComment()
return existingComment !== null && dq(existingComment, 'bili-comments')
}
const handler = () => {
const button = dq(card, '.bili-dyn-action.comment') as HTMLElement
button?.click()
}
if (!existingComment) {
if (!isCommentAreaReady()) {
childListSubtree(card, () => {
const panel = dq(card, commentSelector)
if (panel) {
if (isCommentAreaReady()) {
injectToComment(card, handler)
}
})
} else {
injectToComment(existingComment, handler)
injectToComment(getExistingComment(), handler)
}
return
}
@ -75,6 +79,12 @@ const entry = async () => {
forEachFeedsCard({
added: c => injectButton(c.element),
})
const style = await import('./fold-comment-shadow.scss').then(m => m.default)
shadowRootStyles.addStyle({
id: 'foldComments',
style,
})
}
export const component = defineComponentMetadata({

View File

@ -1,10 +1,30 @@
.player-full-win:not(.hide-aside-area) {
.live-room-app {
.aside-area {
container-name: aside-area;
container-type: size;
width: var(--live-chat-panel-width, 302px) !important;
}
.player-section {
width: calc(100% - var(--live-chat-panel-width, 302px)) !important;
}
@container aside-area (max-width: 290px) {
.control-panel-icon-row-new .icon-left-part-new {
.super-chat,
.like-btn {
width: 32px;
&-icon {
margin-right: 0 !important;
}
&-text {
display: none !important;
}
}
}
.chat-input-ctnr-new .medal-section {
min-width: 0 !important;
max-width: 0 !important;
}
}
}
}

View File

@ -133,6 +133,9 @@ export const component = defineComponentMetadata({
// const { addImportantStyle } = await import('@/core/style')
// addImportantStyle(style, styleID)
},
widget: {
component: () => import('./settings/Widget.vue').then(m => m.default),
},
extraOptions: () => import('./settings/ExtraOptions.vue').then(m => m.default),
plugin: {
displayName: '自定义顶栏 - 功能扩展',

View File

@ -0,0 +1,24 @@
<template>
<DefaultWidget
name="顶栏布局设置"
icon="mdi-sort"
@mouseover="loadNavbarSettings()"
@click="toggleNavbarSettings()"
/>
</template>
<script lang="ts">
import { DefaultWidget } from '@/ui'
import { loadNavbarSettings, toggleNavbarSettings } from './vm'
export default Vue.extend({
components: {
DefaultWidget,
},
methods: {
async loadNavbarSettings() {
await loadNavbarSettings()
},
toggleNavbarSettings,
},
})
</script>

View File

@ -0,0 +1,77 @@
@import './dark-definitions';
:host(bili-comment-action-buttons-renderer) {
@include color('a');
button {
@include color('a');
&:hover {
@include theme-color();
}
}
bili-icon[style*='var(--brand_blue)'] ~ #count {
@include theme-color();
}
}
:host(bili-comment-replies-renderer) {
#view-more {
@include color('a');
}
}
:host(bili-comment-user-info) {
#user-name {
@include color('e');
}
}
:host(bili-comments) {
.bottombar {
@include color('e');
&.clickable:hover {
@include theme-color();
}
}
}
:host(bili-comment-menu) {
#options li:hover {
@include background-color('3');
}
}
:host(bili-rich-text) {
#contents {
a {
@include theme-color();
}
img,
a i {
@include to-theme('blue');
}
}
}
:host(bili-comment-box) {
#pub button {
@include theme-background-color();
@include foreground-color();
&:hover,
&.active {
@include theme-background-color('80');
}
}
}
:host(bili-comments-header-renderer) {
#title #count {
@include color('a');
}
bili-text-button {
@include set-color('--_label-text-color', 'e');
&:hover {
@include set-theme-color('--_label-text-color-hover');
}
}
#sort-actions.hot bili-text-button:first-child,
#sort-actions.time bili-text-button:last-child {
@include set-theme-color('--_label-text-color');
}
.bili-comments-bottom-fixed-wrapper > div {
@include background-color('2');
@include border-color('3');
}
}

View File

@ -67,8 +67,28 @@
.bili-dyn-publishing {
@include dyn-container-skeleton();
@include background-color('4');
&__title {
&__input {
@include background-color();
@include color('e');
&::placeholder {
@include color('a');
}
}
&__indicator {
@include color('a');
}
&__close {
@include background-color('6');
}
}
&__tools {
&__item {
@include color('e');
&.active,
&:hover {
@include theme-color();
}
// 这里用 filter 会导致弹窗内容也被影响, 暂时先去掉了
// &.active,
// &:hover {
@ -79,6 +99,13 @@
}
}
}
&__settings__btn {
@include color('e');
&.active,
&:hover {
@include theme-color();
}
}
.bili-rich-textarea__inner {
@include background-color();
@include color('e');

View File

@ -1,6 +1,7 @@
import { defineComponentMetadata } from '@/components/define'
import { darkExcludes } from './dark-urls'
const name = 'darkMode'
const changeDelay = 200
const darkMetaColor = '#111'
const add = async () => {
@ -40,16 +41,15 @@ const remove = async () => {
colorSchemeMeta.content = 'light'
}
}
const entry = async () => {
setTimeout(add, changeDelay)
}
export const component = defineComponentMetadata({
name: 'darkMode',
name,
displayName: '夜间模式',
entry: () => {
setTimeout(add, changeDelay)
},
reload: () => {
setTimeout(add, changeDelay)
},
entry,
reload: entry,
unload: () => {
setTimeout(remove, changeDelay)
},
@ -66,6 +66,11 @@ export const component = defineComponentMetadata({
style: () => import('./dark-mode.important.scss'),
important: true,
},
{
name: 'dark-shadow-dom',
style: () => import('./dark-shadow-dom.scss'),
shadowDom: true,
},
],
plugin: {
displayName: '夜间模式 - 提前注入',
@ -76,8 +81,8 @@ export const component = defineComponentMetadata({
const { contentLoaded } = await import('@/core/life-cycle')
const { isComponentEnabled } = await import('@/core/settings')
contentLoaded(() => {
// 提前添加dark的class, 防止颜色抖动
if (isComponentEnabled('darkMode')) {
// 提前添加 dark class, 防止颜色抖动
if (isComponentEnabled(name)) {
document.body.classList.add('dark')
}
})

View File

@ -0,0 +1,7 @@
:host(bili-rich-text) {
#contents img,
#contents a i {
max-width: 1.4em;
max-height: 1.4em;
}
}

View File

@ -0,0 +1,5 @@
:host(bili-comment-renderer) {
bili-comment-user-sailing-card {
display: none;
}
}

View File

@ -0,0 +1,5 @@
:host(bili-comments-header-renderer) {
bili-comments-notice {
display: none;
}
}

View File

@ -0,0 +1,5 @@
:host(bili-comment-user-info) {
bili-comment-user-medal {
display: none;
}
}

View File

@ -0,0 +1,19 @@
:host(bili-comment-box) {
#pub button {
font-size: 14px;
}
}
:host(bili-checkbox) {
#label {
font-size: 15px;
}
}
:host(bili-comment-textarea) {
#input {
font-size: 13px;
}
#input,
#input::placeholder {
line-height: normal;
}
}

View File

@ -0,0 +1,5 @@
:host(bili-comment-reply-renderer) {
bili-comment-user-info {
display: block;
}
}

View File

@ -0,0 +1,5 @@
:host(bili-comment-user-info) {
#user-level {
display: none;
}
}

View File

@ -1,57 +1,64 @@
@import 'common';
$prefix: 'simplifyComments-switch';
:host-context(.comment-m-v1) {
&:host(bili-rich-text) {
#contents img, #contents a i {
max-width: 1.4em;
max-height: 1.4em;
}
:host(bili-rich-text) {
#contents img,
#contents a i {
max-width: 1.4em;
max-height: 1.4em;
}
}
&:host-context(body.#{$prefix}-replyEditor) {
&:host(bili-comment-box) {
#pub button {
font-size: 14px;
}
}
&:host(bili-checkbox) {
#label {
font-size: 15px;
}
}
&:host(bili-comment-textarea) {
#input {
font-size: 13px;
}
#input,
#input::placeholder {
line-height: normal;
}
@container style(--#{$prefix}-replyEditor: true) {
:host(bili-comment-box) {
#pub button {
font-size: 14px;
}
}
&:host-context(body.#{$prefix}-userLevel):host(bili-comment-user-info) {
:host(bili-checkbox) {
#label {
font-size: 15px;
}
}
:host(bili-comment-textarea) {
#input {
font-size: 13px;
}
#input,
#input::placeholder {
line-height: normal;
}
}
}
@container style(--#{$prefix}-userLevel: true) {
:host(bili-comment-user-info) {
#user-level {
display: none;
}
}
&:host-context(body.#{$prefix}-decorateAndTime):host(bili-comment-renderer) {
}
@container style(--#{$prefix}-decorateAndTime: true) {
:host(bili-comment-renderer) {
bili-comment-user-sailing-card {
display: none;
}
}
&:host-context(body.#{$prefix}-fansMedal):host(bili-comment-user-info) {
}
@container style(--#{$prefix}-fansMedal: true) {
:host(bili-comment-user-info) {
bili-comment-user-medal {
display: none;
}
}
&:host-context(body.#{$prefix}-subReplyNewLine):host(bili-comment-reply-renderer) {
}
@container style(--#{$prefix}-subReplyNewLine: true) {
:host(bili-comment-reply-renderer) {
bili-comment-user-info {
display: block;
}
}
&:host-context(body.#{$prefix}-eventBanner):host(bili-comments-header-renderer) {
}
@container style(--#{$prefix}-eventBanner: true) {
:host(bili-comments-header-renderer) {
bili-comments-notice {
display: none;
}

View File

@ -41,7 +41,9 @@ export const component = wrapSwitchOptions({
})({
name,
displayName: '简化评论区',
entry: async ({ metadata }) => {
entry: async ({ metadata, settings }) => {
const { addStyle, getDefaultStyleID } = await import('@/core/style')
const { isContainerStyleQuerySupported } = await import('@/core/container-query')
const { addComponentListener } = await import('@/core/settings')
addComponentListener(
metadata.name,
@ -51,10 +53,45 @@ export const component = wrapSwitchOptions({
true,
)
const { ShadowDomStyles } = await import('@/core/shadow-dom')
const v3Styles = await import('./comments-v3.scss').then(m => m.default)
const shadowDom = new ShadowDomStyles()
shadowDom.addStyle(v3Styles)
// 等 Firefox 支持 [Container Style Queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_size_and_style_queries#container_style_queries_2) 可去除此判断
if (isContainerStyleQuerySupported()) {
const { shadowRootStyles } = await import('@/core/shadow-root')
const v3Style = await import('./comments-v3.scss').then(m => m.default)
shadowRootStyles.toggleWithComponent(metadata.name, { id: name, style: v3Style })
} else {
const { shadowRootStyles } = await import('@/core/shadow-root')
const firefoxStyles = require.context('./comments-v3-firefox', false, /\.scss$/)
Object.keys(settings.options).forEach(key => {
if (!key.startsWith('switch-')) {
return
}
const id = `${component.name}.${key}`
const styleName = lodash.kebabCase(key.replace(/^switch-/, ''))
const path = `./${styleName}.scss`
if (!firefoxStyles.keys().includes(path)) {
return
}
addComponentListener(
id,
(value: boolean) => {
if (value) {
const style = firefoxStyles(path) as string
addStyle(style, styleName)
shadowRootStyles.addStyle({
id,
style,
})
} else {
document.getElementById(getDefaultStyleID(styleName))?.remove()
shadowRootStyles.removeStyle(id)
}
},
true,
)
})
}
},
instantStyles: [
{

View File

@ -0,0 +1,10 @@
:host(bili-rich-text) {
#contents a[data-type='search'] {
color: inherit !important;
cursor: inherit !important;
display: contents !important;
img {
display: none;
}
}
}

View File

@ -1,5 +1,6 @@
import { defineComponentMetadata } from '@/components/define'
import { forEachCommentArea } from '@/components/utils/comment-apis'
import { forEachCommentArea, CommentAreaV3 } from '@/components/utils/comment-apis'
import { ShadowRootEvents } from '@/core/shadow-root'
import { preventEvent } from '@/core/utils'
const name = 'disableCommentsSearchLink'
@ -13,25 +14,51 @@ export const component = defineComponentMetadata({
style: () => import('./disable-search-link.scss'),
important: true,
},
{
name,
style: () => import('./disable-search-link-shadow.scss'),
shadowDom: true,
},
],
tags: [componentsTags.utils, componentsTags.style],
entry: async () => {
prevent = true
forEachCommentArea(area => {
preventEvent(area.element, 'click', e => {
if (!(e.target instanceof HTMLElement) || !prevent) {
forEachCommentArea(async area => {
const isV3Area = area instanceof CommentAreaV3
if (isV3Area) {
area.commentAreaEntry.addEventListener(
ShadowRootEvents.Updated,
(e: CustomEvent<MutationRecord[]>) => {
const records = e.detail
records.forEach(record => {
record.addedNodes.forEach(node => {
const isCommentLink =
node instanceof HTMLAnchorElement && node.getAttribute('data-type') === 'search'
if (!isCommentLink) {
return
}
node.removeAttribute('href')
node.removeAttribute('target')
})
})
},
)
} else {
preventEvent(area.element, 'click', e => {
if (!(e.target instanceof HTMLElement) || !prevent) {
return false
}
const element = e.target as HTMLElement
if (
['.jump-link.search-word', '.icon.search-word'].some(selector =>
element.matches(selector),
)
) {
return true
}
return false
}
const element = e.target as HTMLElement
if (
['.jump-link.search-word', '.icon.search-word'].some(selector =>
element.matches(selector),
)
) {
return true
}
return false
})
})
}
})
},
reload: () => {

View File

@ -1,7 +1,7 @@
import type { ItemStopPayload, Payload } from 'dev-tools/dev-server/payload'
import { useScopedConsole } from '@/core/utils/log'
import { ComponentMetadata, componentsMap } from '@/components/component'
import { loadInstantStyle, removeStyle } from '@/core/style'
import { loadInstantStyle, removeInstantStyle } from '@/core/style'
import { autoUpdateOptions, getDevClientOptions } from './options'
import { RefreshMethod, HotReloadMethod } from './update-method'
import { monkey } from '@/core/ajax'
@ -161,10 +161,10 @@ export class DevClient extends EventTarget {
}
const reloadInstantStyles = () => {
if (oldInstantStyles.length > 0 || newInstantStyles.length > 0) {
loadInstantStyle(newComponent)
oldInstantStyles.forEach(style => {
removeStyle(style.name)
removeInstantStyle(style)
})
loadInstantStyle(newComponent)
// 修改旧的引用, 否则之前设的事件监听还是用旧样式
oldComponent.instantStyles = newInstantStyles
return true

View File

@ -11,6 +11,10 @@ const options = defineOptionsMetadata({
defaultValue: 'auto',
hidden: true,
},
originalImageInArticles: {
displayName: '在专栏中请求原图',
defaultValue: false,
},
})
export type Options = OptionsOfMetadata<typeof options>

View File

@ -1,7 +1,7 @@
import { styledComponentEntry } from '@/components/styled-component'
import { Options } from '.'
const resizeRegex = /@(\d+)[Ww]_(\d+)[Hh]/
const resizeRegex = /@(\d+)[Ww](_(\d+)[Hh])?/
/** 排除 */
const excludeSelectors = ['#certify-img1', '#certify-img2']
@ -17,6 +17,7 @@ const widthAndHeightSelectors = [
// https://github.com/the1812/Bilibili-Evolved/issues/4480
'.logo-img',
]
const originalImageInArticlesSelectors = ['.article-detail .article-content img']
const walk = (rootElement: Node, action: (node: HTMLElement) => void) => {
const walker = document.createNodeIterator(rootElement, NodeFilter.SHOW_ELEMENT)
@ -26,12 +27,21 @@ const walk = (rootElement: Node, action: (node: HTMLElement) => void) => {
node = walker.nextNode()
}
}
interface ImageResolutionHandler {
getWidth: (width: number, element: HTMLElement) => number
getHeight: (height: number, element: HTMLElement) => number
}
/**
* `element`, URL至目标DPI
* @param dpi DPI
* @param element
*/
export const imageResolution = async (dpi: number, element: HTMLElement) => {
export const imageResolution = async (
element: HTMLElement,
resolutionHandler: ImageResolutionHandler,
) => {
const { attributes } = await import('@/core/observer')
const replaceSource = (
getValue: (e: HTMLElement) => string | null,
@ -48,16 +58,22 @@ export const imageResolution = async (dpi: number, element: HTMLElement) => {
if (value.includes(',')) {
return
}
const match = value.match(resizeRegex)
if (!match) {
return
}
const [, currentWidth, currentHeight] = match
const [, currentWidth, , currentHeight] = match
const lastWidth = parseInt(element.getAttribute('data-resolution-width') || '0')
if (parseInt(currentWidth) >= lastWidth && lastWidth !== 0) {
return
}
if (element.getAttribute('width') === null && element.getAttribute('height') === null) {
if (
element.getAttribute('width') === null &&
element.getAttribute('height') === null &&
currentHeight !== undefined
) {
if (widthAndHeightSelectors.some(selector => element.matches(selector))) {
element.setAttribute('height', currentHeight)
element.setAttribute('width', currentWidth)
@ -67,10 +83,26 @@ export const imageResolution = async (dpi: number, element: HTMLElement) => {
element.setAttribute('width', currentWidth)
}
}
const newWidth = Math.round(dpi * parseInt(currentWidth)).toString()
const newHeight = Math.round(dpi * parseInt(currentHeight)).toString()
element.setAttribute('data-resolution-width', newWidth)
setValue(element, value.replace(resizeRegex, `@${newWidth}w_${newHeight}h`))
const getReplacedValue = (newWidth: number, newHeight?: number) => {
if (newWidth === Infinity || newHeight === Infinity) {
return value.replace(resizeRegex, '@')
}
if (newHeight === undefined) {
return value.replace(resizeRegex, `@${newWidth}w`)
}
return value.replace(resizeRegex, `@${newWidth}w_${newHeight}h`)
}
if (currentHeight !== undefined) {
const newWidth = resolutionHandler.getWidth(parseInt(currentWidth), element)
const newHeight = resolutionHandler.getHeight(parseInt(currentHeight), element)
element.setAttribute('data-resolution-width', newWidth.toString())
setValue(element, getReplacedValue(newWidth, newHeight))
} else {
const newWidth = resolutionHandler.getWidth(parseInt(currentWidth), element)
element.setAttribute('data-resolution-width', newWidth.toString())
setValue(element, getReplacedValue(newWidth))
}
}
attributes(element, () => {
replaceSource(
@ -95,14 +127,35 @@ export const startResolution = styledComponentEntry<Options>(
settings.options.scale === 'auto'
? window.devicePixelRatio
: parseFloat(settings.options.scale)
walk(document.body, it => imageResolution(dpi, it))
const handleResolution: ImageResolutionHandler = {
getWidth: (currentWidth, element) => {
if (
settings.options.originalImageInArticles &&
originalImageInArticlesSelectors.some(selector => element.matches(selector))
) {
return Infinity
}
return Math.round(dpi * currentWidth)
},
getHeight: (currentHeight, element) => {
if (
settings.options.originalImageInArticles &&
originalImageInArticlesSelectors.some(selector => element.matches(selector))
) {
return Infinity
}
return Math.round(dpi * currentHeight)
},
}
walk(document.body, it => imageResolution(it, handleResolution))
allMutations(records => {
records.forEach(record =>
record.addedNodes.forEach(node => {
if (node instanceof HTMLElement) {
imageResolution(dpi, node)
imageResolution(node, handleResolution)
if (node.nodeName.toUpperCase() !== 'IMG') {
walk(node, it => imageResolution(dpi, it))
walk(node, it => imageResolution(it, handleResolution))
}
}
}),

View File

@ -2,10 +2,11 @@
/* eslint-disable yoda */
import { defineComponentMetadata } from '@/components/define'
import { CommentItem, CommentReplyItem } from '@/components/utils/comment-apis'
import { select } from '@/core/spin-query'
// 新版评论区IP属地获取
const getIpLocation = (item: CommentReplyItem) => {
const reply = item.vueProps
const reply = item.frameworkSpecificProps
return reply?.reply_control?.location ?? undefined
}
@ -272,20 +273,37 @@ const observer = new MutationObserver(mutations => {
observer.observe(document.head, { childList: true })
const processItems = (items: CommentReplyItem[]) => {
items.forEach(item => {
items.forEach(async item => {
const location = getIpLocation(item)
if (location !== undefined) {
const replyTime =
if (location === undefined) {
return
}
const replyTime = await (() => {
if (item.shadowDomEntry) {
return select(() => item.shadowDomEntry.querySelector('#pubdate'), {
queryInterval: 100,
maxRetry: 30,
})
}
return (
item.element.querySelector('.reply-info>.reply-time') ??
item.element.querySelector('.sub-reply-info>.sub-reply-time')
if (replyTime.childElementCount === 0) {
// 避免在评论更新的情况下重复添加
const replyLocation = document.createElement('span')
replyLocation.style.marginLeft = `${marginLeft}px`
replyLocation.innerText = location
replyTime.appendChild(replyLocation)
}
)
})()
if (replyTime === null) {
return
}
const existingLocation = replyTime.querySelector('.ip-location') as HTMLElement | null
if (existingLocation !== null) {
existingLocation.innerText = location
return
}
const replyLocation = document.createElement('span')
replyLocation.className = 'ip-location'
replyLocation.style.marginLeft = `${marginLeft}px`
replyLocation.innerText = location
replyTime.appendChild(replyLocation)
})
}

View File

@ -74,6 +74,10 @@ const entry = async () => {
match: /\/\/live\.bilibili\.com\//,
param: 'session_id',
},
{
match: /\/\/live\.bilibili\.com\//,
param: 'is_room_feed',
},
{
match: /\/\/www\.bilibili\.com\/bangumi\//,
param: 'theme',

View File

@ -1 +1 @@
将弹幕中的 `\n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.
将弹幕中的 `\n` `/n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.

View File

@ -5,13 +5,39 @@ export const component = defineComponentMetadata({
name: 'unescapeDanmaku',
displayName: '弹幕转义',
tags: [componentsTags.video],
entry: () => {
const newLineRegex = /\\n/g
options: {
backSlash: {
defaultValue: true,
displayName: '对 \\n 转义',
},
forwardSlash: {
defaultValue: true,
displayName: '对 /n 转义',
},
},
entry: ({ settings }) => {
const newLineRegex = (() => {
if (settings.options.backSlash && settings.options.forwardSlash) {
return /\\n|\/n/g
}
if (settings.options.backSlash) {
return /\\n/g
}
return /\/n/g
})()
const setText = (element: Element, text: string): void => {
const children = [...element.children]
if (children.length > 0) {
children.forEach(child => setText(child, text))
}
const textNodes = [...element.childNodes].filter(it => it.nodeType === Node.TEXT_NODE)
textNodes.forEach(node => (node.textContent = text))
}
forEachVideoDanmaku({
added: danmaku => {
if (newLineRegex.test(danmaku.text)) {
const newText = danmaku.text.replace(newLineRegex, '\n')
danmaku.element.textContent = newText
setText(danmaku.element, newText)
}
},
})

View File

@ -1,6 +1,7 @@
import { Toast } from '@/core/toast'
import { formatFileSize, formatPercent } from '@/core/utils/formatters'
import { getOrLoad, storeNames } from './database'
import { RuntimeLibraryDefinition, RuntimeLibrary } from '@/core/runtime-library'
type OnProgress = (received: number, total: number) => void
@ -56,8 +57,21 @@ export async function httpGet(url: string, onprogress: OnProgress) {
return chunksAll
}
export async function getCacheOrFetch(key: string, url: string, loading: OnProgress) {
return getOrLoad(storeNames.cache, key, async () => httpGet(url, loading))
export async function getCacheOrFetch(
key: string,
library: RuntimeLibraryDefinition,
loading: OnProgress,
) {
return getOrLoad(storeNames.cache, key, async () => {
const content = await httpGet(library.url, loading)
const sha256 = await RuntimeLibrary.sha256(content)
if (sha256 !== library.sha256) {
throw new Error(
`Check integrity failed from ${library.url}, expected = ${library.sha256}, actual = ${sha256}`,
)
}
return content
})
}
export function toBlobUrl(buffer: Uint8Array, mimeType: string) {

View File

@ -1,5 +1,5 @@
{
"version": "2.9.1",
"version": "2.9.2",
"author": "Grant Howard, Coulomb-G",
"copyright": "[year], Grant Howard (https://github.com/the1812) & Coulomb-G (https://github.com/Coulomb-G)",
"license": "MIT",
@ -38,7 +38,7 @@
"*"
],
"require": [
"[altCdn.library.lodash]"
"[altCdn.library.lodash.url]#sha256=[altCdn.library.lodash.sha256]"
],
"icon": "[altCdn.smallLogo]",
"icon64": "[altCdn.logo]"

View File

@ -1,6 +1,7 @@
/* eslint-disable no-underscore-dangle */
import { contentLoaded, fullyLoaded } from '@/core/life-cycle'
import { select } from '@/core/spin-query'
import { setupContainerQueryFeatureDetection } from '@/core/container-query'
export const compatibilityPatch = () => {
contentLoaded(async () => {
@ -22,6 +23,8 @@ export const compatibilityPatch = () => {
const { playerPolyfill } = await import('@/components/video/player-adaptor')
playerPolyfill()
}
await setupContainerQueryFeatureDetection()
})
fullyLoaded(() => {
select('meta[name=spm_prefix]').then(spm => {

View File

@ -5,7 +5,7 @@
<VIcon
class="switch-icon"
icon="mdi-checkbox-marked-circle-outline"
:size="smallSize ? 16 : 22"
:size="smallSize ? 16 : 24"
></VIcon>
{{ options.optionDisplayName }}
</VButton>
@ -128,6 +128,7 @@ export default Vue.extend({
}
.switch-icon {
margin-right: 8px;
opacity: 0.75;
transform: scale(0.9);
}
.dim {

View File

@ -60,16 +60,16 @@ const optionsMetadata = defineOptionsMetadata({
export type Options = OptionsOfMetadata<typeof optionsMetadata>
const entry: ComponentEntry<Options> = async ({ settings: { options: opt } }) => {
const entry: ComponentEntry<Options> = async ({ settings: { options } }) => {
if (isIframe()) {
return checkerMethods
}
const now = Number(new Date())
const duration = now - opt.lastUpdateCheck
const duration = now - options.lastUpdateCheck
const isDurationExceeded = duration >= opt.minimumDuration
const isDurationExceeded = duration >= options.minimumDuration
const isVersionOutdated = new Version(meta.version).greaterThan(
new Version(opt.lastInstalledVersion),
new Version(options.lastInstalledVersion),
)
if (isDurationExceeded) {
coreApis.lifeCycle.fullyLoaded(() => silentCheckUpdate())

View File

@ -249,7 +249,9 @@ const newSwitchEntry = <O extends UnknownOptions, N extends string, S extends st
addComponentListener(
`${component.name}.${key}`,
(value: boolean) => {
document.body.classList.toggle(`${component.name}-${key}`, value)
const id = `${component.name}-${key}`
document.body.classList.toggle(id, value)
document.documentElement.style.setProperty(`--${id}`, value.toString())
},
true,
)

View File

@ -163,6 +163,21 @@ export type ComponentEntry<O extends UnknownOptions = UnknownOptions, T = unknow
context: ComponentEntryContext<O>,
) => T | Promise<T>
export interface InstantStyleDefinition {
/** 样式ID */
name: string
/** 样式内容, 可以是一个导入样式的函数 */
style: string | (() => Promise<{ default: string }>)
}
export interface DomInstantStyleDefinition extends InstantStyleDefinition {
/** 设为 `true` 则注入到 `document.body` 末尾, 否则注入到 `document.head` 末尾 */
important?: boolean
}
export interface ShadowDomInstantStyleDefinition extends InstantStyleDefinition {
/** 设为 `true` 则注入到 Shadow DOM 中 */
shadowDom?: boolean
}
/** 带有函数/复杂对象的组件信息 */
export interface FunctionalMetadata<O extends UnknownOptions = UnknownOptions> {
/** 主入口, 重新开启时不会再运行 */
@ -170,14 +185,7 @@ export interface FunctionalMetadata<O extends UnknownOptions = UnknownOptions> {
/** 导出小组件 */
widget?: Omit<Widget, 'name'>
/** 首屏样式, 会尽快注入 (before DCL) */
instantStyles?: {
/** 样式ID */
name: string
/** 样式内容, 可以是一个导入样式的函数 */
style: string | (() => Promise<{ default: string }>)
/** 设为`true`则注入到`document.body`末尾, 否则注入到`document.head`末尾 */
important?: boolean
}[]
instantStyles?: (DomInstantStyleDefinition | ShadowDomInstantStyleDefinition)[]
/** 重新开启时执行 */
reload?: Executable
/** 关闭时执行 */

View File

@ -91,8 +91,8 @@ export const uninstallComponent = async (nameOrDisplayName: string) => {
// 移除可能的 instantStyles
const { instantStyles } = components[index]
if (instantStyles) {
const { removeStyle } = await import('@/core/style')
instantStyles.forEach(s => removeStyle(s.name))
const { removeInstantStyle } = await import('@/core/style')
instantStyles.forEach(s => removeInstantStyle(s))
}
// 移除可能的 widgets
componentSettings.enabled = false

View File

@ -1,27 +1,18 @@
import { childListSubtree } from '@/core/observer'
import type { CommentItem } from '../comment-item'
import type { CommentCallbackInput, CommentCallbackPair, CommentItemCallback } from '../types'
import type { CommentReplyItem } from '../reply-item'
import { getRandomId } from '@/core/utils'
import type { CommentCallbackInput, CommentCallbackPair, CommentItemCallback } from '../types'
/** 表示一个评论区 */
export abstract class CommentArea {
/** 对应元素 */
element: HTMLElement
/** 评论列表 */
items: CommentItem[] = []
/** 与之关联的 MutationObserver */
protected observer?: MutationObserver
protected itemCallbacks: CommentCallbackPair<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 observe(): void | Promise<void>
abstract disconnect(): void | Promise<void>
abstract addMenuItem(
item: CommentReplyItem,
config: {
@ -29,80 +20,7 @@ export abstract class CommentArea {
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.itemCallbacks.forEach(c => c.added?.(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.itemCallbacks.forEach(c => c.added?.(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.itemCallbacks.forEach(c => c.removed?.(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')
}
destroy() {
this.observer?.disconnect()
this.items.forEach(item => {
this.itemCallbacks.forEach(pair => pair.removed?.(item))
})
}
): void | Promise<void>
static resolveCallbackPair<T extends (...args: unknown[]) => void>(
input: CommentCallbackInput<T>,

View File

@ -0,0 +1,87 @@
import { CommentArea } from './base'
import { childListSubtree } from '@/core/observer'
import type { CommentItem } from '../comment-item'
import { getRandomId } from '@/core/utils'
/** 表示一个基于常规 DOM 结构的评论区 */
export abstract class DomCommentArea extends CommentArea {
protected observer?: MutationObserver
protected static replyItemClasses = ['list-item.reply-wrap', 'reply-item']
protected static replyItemSelector = DomCommentArea.replyItemClasses.map(c => `.${c}`).join(',')
abstract parseCommentItem(element: HTMLElement): CommentItem
abstract getCommentId(element: HTMLElement): string
protected isCommentItem(n: Node): n is HTMLElement {
return n instanceof HTMLElement && n.matches(DomCommentArea.replyItemSelector)
}
/** 在每一轮 CommentItem 解析前调用 */
protected beforeParse(elements: HTMLElement[]) {
return lodash.noop(elements)
}
observe() {
if (this.observer) {
return
}
performance.mark('observeItems start')
const elements = dqa(this.element, DomCommentArea.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.itemCallbacks.forEach(c => c.added?.(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.itemCallbacks.forEach(c => c.added?.(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.itemCallbacks.forEach(c => c.removed?.(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')
}
disconnect() {
this.observer?.disconnect()
this.items.forEach(item => {
this.itemCallbacks.forEach(pair => pair.removed?.(item))
})
}
}

View File

@ -1,9 +1,9 @@
import { childList } from '@/core/observer'
import { CommentItem } from '../comment-item'
import { CommentReplyItem } from '../reply-item'
import { CommentArea } from './base'
import { DomCommentArea } from './dom'
export class CommentAreaV1 extends CommentArea {
export class CommentAreaV1 extends DomCommentArea {
addMenuItem(
item: CommentReplyItem,
config: { className: string; text: string; action: (e: MouseEvent) => void },
@ -44,7 +44,7 @@ export class CommentAreaV1 extends CommentArea {
content: replyElement.querySelector('.text-con').textContent,
timeText: replyElement.querySelector('.info .time, .info .time-location').textContent,
likes: parseInt(replyElement.querySelector('.info .like span').textContent),
vueProps: undefined,
frameworkSpecificProps: undefined,
})
}
const item = new CommentItem({
@ -56,7 +56,7 @@ export class CommentAreaV1 extends CommentArea {
timeText: element.querySelector('.con .info .time, .info .time-location').textContent,
likes: parseInt(element.querySelector('.con .like span').textContent),
replies: [],
vueProps: undefined,
frameworkSpecificProps: undefined,
})
if (dq(element, '.reply-box .view-more')) {
const replyBox = dq(element, '.reply-box') as HTMLElement

View File

@ -1,11 +1,11 @@
/* eslint-disable no-underscore-dangle */
import { childList } from '@/core/observer'
import { CommentItem } from '../comment-item'
import { CommentArea } from './base'
import { DomCommentArea } from './dom'
import { CommentReplyItem } from '../reply-item'
import { HTMLElementWithVue, VNodeManager } from '../vnode-manager'
export class CommentAreaV2 extends CommentArea {
export class CommentAreaV2 extends DomCommentArea {
private vnodeManager: VNodeManager
constructor(element: HTMLElement) {
@ -94,7 +94,7 @@ export class CommentAreaV2 extends CommentArea {
content: r.content.message,
time: r.ctime * 1000,
likes: r.like,
vueProps: r,
frameworkSpecificProps: r,
})
})
}
@ -110,7 +110,7 @@ export class CommentAreaV2 extends CommentArea {
return img.img_src
}),
replies: parseReplies(),
vueProps,
frameworkSpecificProps: vueProps,
})
if (item.replies.length < vueProps.rcount) {
const replyBox = dq(element, '.sub-reply-list')

View File

@ -0,0 +1,202 @@
import { ShadowDomObserver, shadowDomObserver, ShadowRootEvents } from '@/core/shadow-root'
import { CommentReplyItem } from '../reply-item'
import { CommentArea } from './base'
import { ShadowDomEntry } from '@/core/shadow-root/dom-entry'
import { CommentItem } from '../comment-item'
import { deleteValue } from '@/core/utils'
import { select } from '@/core/spin-query'
export class CommentAreaV3 extends CommentArea {
protected static commentItemSelectors = 'bili-comment-thread-renderer'
protected static commentReplyItemSelectors = 'bili-comment-reply-renderer'
protected static commentActionsSelectors = 'bili-comment-action-buttons-renderer'
protected static commentMenuSelectors = 'bili-comment-menu'
protected static MenuItemConfigSymbol = Symbol.for('CommentAreaV3.MenuItemConfigSymbol')
protected static getLitData(entry: ShadowDomEntry) {
const host = entry.element as HTMLElement & { __data: any }
// eslint-disable-next-line no-underscore-dangle
return host.__data
}
static isV3Area(element: HTMLElement) {
return element.tagName.toLowerCase() === 'bili-comments'
}
public commentAreaEntry: ShadowDomEntry
protected shadowDomObserver: ShadowDomObserver
protected itemEntryMap = new Map<ShadowDomEntry, CommentItem>()
private handleEntryAdded: (e: CustomEvent<ShadowDomEntry>) => void
private handleEntryRemoved: (e: CustomEvent<ShadowDomEntry>) => void
private areaObserverDisposer: () => void
constructor(element: HTMLElement) {
super(element)
shadowDomObserver.observe()
this.shadowDomObserver = shadowDomObserver
}
protected matchChildEntryByReplyId(
parent: ShadowDomEntry,
childSelectors: string,
replyId: string,
) {
const children = parent.querySelectorAllAsEntry(childSelectors)
return children.find(r => CommentAreaV3.getLitData(r).rpid_str === replyId)
}
protected getReplyItemElement(parent: ShadowDomEntry, replyId: string): HTMLElement {
return this.matchChildEntryByReplyId(parent, CommentAreaV3.commentReplyItemSelectors, replyId)
?.element as HTMLElement
}
protected parseCommentReplyItem(replyEntry: ShadowDomEntry): CommentReplyItem {
const replyLitData = CommentAreaV3.getLitData(replyEntry)
return new CommentReplyItem({
id: replyLitData.rpid_str,
element: replyEntry.element as HTMLElement,
userId: replyLitData.member.mid,
userName: replyLitData.member.uname,
content: replyLitData.content.message,
time: replyLitData.ctime * 1000,
likes: replyLitData.like,
frameworkSpecificProps: replyLitData,
})
}
protected parseCommentItem(entry: ShadowDomEntry): CommentItem {
const litData = CommentAreaV3.getLitData(entry)
const getReplies = () =>
entry
.querySelectorAllAsEntry(CommentAreaV3.commentReplyItemSelectors)
.map(replyEntry => this.parseCommentReplyItem(replyEntry))
const item = new CommentItem({
id: litData.rpid_str,
element: entry.element as HTMLElement,
userId: litData.member.mid,
userName: litData.member.uname,
content: litData.content.message,
time: litData.ctime * 1000,
likes: litData.like,
pictures: litData.content?.pictures?.map((img: { img_src: string }) => {
return img.img_src
}),
replies: getReplies(),
frameworkSpecificProps: litData,
})
entry.addEventListener(
ShadowRootEvents.Updated,
lodash.debounce(() => {
const newReplies = getReplies()
const hasUpdate =
item.replies.length !== newReplies.length ||
!item.replies.every(r => newReplies.some(newReply => newReply.id === r.id))
if (hasUpdate) {
item.replies = newReplies
item.dispatchRepliesUpdate(item.replies)
}
}),
)
return item
}
protected addCommentItem(entry: ShadowDomEntry) {
if (this.itemEntryMap.has(entry)) {
return
}
const commentItem = this.parseCommentItem(entry)
this.itemEntryMap.set(entry, commentItem)
this.items.push(commentItem)
this.itemCallbacks.forEach(c => c.added?.(commentItem))
}
protected removeCommentItem(entry: ShadowDomEntry) {
const itemToRemove = this.items.find(it => it.element === entry.element)
this.itemEntryMap.delete(entry)
deleteValue(this.items, it => it === itemToRemove)
this.itemCallbacks.forEach(c => c.removed?.(itemToRemove))
}
protected observeCommentItems() {
const entries = this.commentAreaEntry.querySelectorAllAsEntry(
CommentAreaV3.commentItemSelectors,
)
entries.forEach(entry => this.addCommentItem(entry))
this.handleEntryAdded = (e: CustomEvent<ShadowDomEntry>) => {
const entry = e.detail
if (entry.element.matches(CommentAreaV3.commentItemSelectors)) {
this.addCommentItem(entry)
}
}
this.commentAreaEntry.addEventListener(ShadowRootEvents.Added, this.handleEntryAdded)
this.handleEntryRemoved = (e: CustomEvent<ShadowDomEntry>) => {
const entry = e.detail
if (!this.itemEntryMap.has(entry)) {
return
}
this.removeCommentItem(entry)
}
this.commentAreaEntry.addEventListener(ShadowRootEvents.Removed, this.handleEntryRemoved)
}
observe() {
return new Promise<void>(resolve => {
this.areaObserverDisposer = this.shadowDomObserver.watchShadowDom({
added: shadowDom => {
if (shadowDom.element !== this.element) {
return
}
this.commentAreaEntry = shadowDom
this.observeCommentItems()
resolve()
},
})
})
}
disconnect() {
this.commentAreaEntry.removeEventListener(ShadowRootEvents.Added, this.handleEntryAdded)
this.commentAreaEntry.removeEventListener(ShadowRootEvents.Removed, this.handleEntryRemoved)
this.areaObserverDisposer?.()
}
async addMenuItem(
item: CommentReplyItem,
config: { className: string; text: string; action: (e: MouseEvent) => void },
) {
const itemEntry = item.shadowDomEntry
if (itemEntry === undefined) {
return
}
const actions = await select(() =>
this.matchChildEntryByReplyId(itemEntry, CommentAreaV3.commentActionsSelectors, item.id),
)
if (!actions) {
return
}
const menu = actions.querySelectorAsEntry(CommentAreaV3.commentMenuSelectors)
if (!menu) {
return
}
const list = menu.querySelector('#options')
const alreadyAdded = list.querySelector(`li.${config.className}`)
if (alreadyAdded) {
return
}
const listItem = document.createElement('li')
listItem.innerHTML = config.text
listItem.className = config.className
listItem[CommentAreaV3.MenuItemConfigSymbol] = config
listItem.addEventListener('click', e => {
config.action(e)
;(menu.element as HTMLElement).style.setProperty('--bili-comment-menu-display', null)
})
list.appendChild(listItem)
}
}

View File

@ -14,7 +14,7 @@ export class CommentAreaManager {
commentAreas: CommentArea[] = []
commentAreaCallbacks: CommentCallbackPair<CommentAreaCallback>[] = []
protected static commentAreaClasses = ['bili-comment', 'bb-comment']
protected static commentAreaSelectors = '.bili-comment, .bb-comment, bili-comments'
init() {
allMutations(records => {
@ -22,19 +22,14 @@ export class CommentAreaManager {
r.addedNodes.forEach(n => this.observeAreas(n))
})
})
dqa(CommentAreaManager.commentAreaClasses.map(c => `.${c}`).join(',')).forEach(it =>
this.observeAreas(it),
)
dqa(CommentAreaManager.commentAreaSelectors).forEach(it => this.observeAreas(it))
}
observeAreas(node: Node) {
if (
node instanceof HTMLElement &&
CommentAreaManager.commentAreaClasses.some(c => node.classList.contains(c))
) {
async observeAreas(node: Node) {
if (node instanceof HTMLElement && node.matches(CommentAreaManager.commentAreaSelectors)) {
const area = getCommentArea(node)
this.commentAreas.push(area)
area.observeItems()
await area.observe()
this.commentAreaCallbacks.forEach(c => c.added?.(area))
const [observer] = childList(area.element.parentElement, records => {
records.forEach(r => {
@ -42,7 +37,7 @@ export class CommentAreaManager {
if (removedNode === area.element) {
deleteValue(this.commentAreas, a => a === area)
this.commentAreaCallbacks.forEach(c => c.removed?.(area))
area.destroy()
area.disconnect()
observer.disconnect()
}
})

View File

@ -1,8 +1,12 @@
import type { CommentArea } from './areas/base'
import { CommentAreaV1 } from './areas/v1'
import { CommentAreaV2 } from './areas/v2'
import { CommentAreaV3 } from './areas/v3'
export const getCommentArea = (element: HTMLElement): CommentArea => {
if (CommentAreaV3.isV3Area(element)) {
return new CommentAreaV3(element)
}
if (CommentAreaV2.isV2Area(element)) {
return new CommentAreaV2(element)
}
@ -12,3 +16,4 @@ export const getCommentArea = (element: HTMLElement): CommentArea => {
export * from './areas/base'
export * from './areas/v1'
export * from './areas/v2'
export * from './areas/v3'

View File

@ -13,7 +13,9 @@ export class CommentItem extends CommentReplyItem {
/** 回复 */
replies: CommentReplyItem[]
constructor(initParams: Omit<CommentItem, keyof EventTarget | 'dispatchRepliesUpdate'>) {
constructor(
initParams: Omit<CommentItem, keyof EventTarget | 'dispatchRepliesUpdate' | 'shadowDomEntry'>,
) {
super(initParams)
this.pictures = initParams.pictures ?? []
this.replies = initParams.replies

View File

@ -1,3 +1,5 @@
import { ShadowDomEntry, ShadowDomEntrySymbol } from '@/core/shadow-root/dom-entry'
/** 表示一条评论回复 */
export class CommentReplyItem extends EventTarget {
/** 对应元素 */
@ -16,10 +18,10 @@ export class CommentReplyItem extends EventTarget {
time?: number
/** 点赞数 */
likes: number
/** 对应的 Vue Props */
vueProps: any
/** 对应的框架特定 Props (可能是 Vue 或者 Lit) */
frameworkSpecificProps: any
constructor(initParams: Omit<CommentReplyItem, keyof EventTarget>) {
constructor(initParams: Omit<CommentReplyItem, keyof EventTarget | 'shadowDomEntry'>) {
super()
this.element = initParams.element
this.id = initParams.id
@ -29,6 +31,11 @@ export class CommentReplyItem extends EventTarget {
this.timeText = initParams.timeText
this.time = initParams.time
this.likes = initParams.likes
this.vueProps = initParams.vueProps
this.frameworkSpecificProps = initParams.frameworkSpecificProps
}
/** 对应元素的 ShadowDomEntry */
get shadowDomEntry(): ShadowDomEntry {
return this.element[ShadowDomEntrySymbol]
}
}

View File

@ -0,0 +1,15 @@
export const setupContainerQueryFeatureDetection = async () => {
document.documentElement.style.setProperty('--container-query-feature-detection', 'true')
const bodyStyle = new CSSStyleSheet()
await bodyStyle.replace(
'@container style(--container-query-feature-detection) { body { --container-query-supported: true } }',
)
document.adoptedStyleSheets.push(bodyStyle)
}
export const isContainerStyleQuerySupported = lodash.once(() => {
return (
window.getComputedStyle(document.body).getPropertyValue('--container-query-supported') ===
'true'
)
})

View File

@ -1,5 +1,6 @@
import * as ajax from '@/core/ajax'
import * as cdnTypes from '@/core/cdn-types'
import * as containerQuery from '@/core/container-query'
import * as download from '@/core/download'
import * as dialog from '@/core/dialog'
import * as externalInput from '@/core/external-input'
@ -17,7 +18,7 @@ import * as spinQuery from '@/core/spin-query'
import * as style from '@/core/style'
import * as textColor from '@/core/text-color'
import * as settings from '@/core/settings'
import * as shadowDom from '@/core/shadow-dom'
import * as shadowRoot from '@/core/shadow-root'
import * as userInfo from '@/core/user-info'
import * as version from '@/core/version'
import * as commonUtils from '@/core/utils'
@ -41,6 +42,7 @@ import { pluginApis } from '@/plugins/api'
export const coreApis = {
ajax,
cdnTypes,
containerQuery,
download,
dialog,
externalInput,
@ -60,7 +62,7 @@ export const coreApis = {
userInfo,
version,
settings,
shadowDom,
shadowRoot,
toast,
themeColor,
utils: {
@ -83,6 +85,7 @@ export type CoreApis = typeof coreApis
export const externalApis = {
ajax,
...cdnTypes,
...containerQuery,
...download,
...dialog,
...externalInput,
@ -101,7 +104,7 @@ export const externalApis = {
...textColor,
...userInfo,
...version,
...shadowDom,
...shadowRoot,
settingsApis: settings,
get settings() {
return settings.settings

View File

@ -5,26 +5,54 @@ import type StreamSaverType from 'streamsaver'
import { monkey } from './ajax'
import { meta } from './meta'
export type RuntimeLibraryDefinition = { url: string; sha256: string }
export interface RuntimeLibraryConfig<LibraryType> {
url: string
library: RuntimeLibraryDefinition
getModule: (window: Window) => LibraryType
}
export class RuntimeLibrary<LibraryType> implements PromiseLike<LibraryType> {
private modulePromise: Promise<LibraryType>
constructor(public config: RuntimeLibraryConfig<LibraryType>) {}
static async sha256(content: string | BufferSource) {
const hashBuffer = await window.crypto.subtle.digest(
'SHA-256',
typeof content === 'string' ? new TextEncoder().encode(content) : content,
)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
return hashHex
}
private async checkIntegrity(content: string | BufferSource) {
const sha256 = await RuntimeLibrary.sha256(content)
if (sha256 !== this.config.library.sha256) {
throw new Error(
`[RuntimeLibrary] Check integrity failed from ${this.config.library.url}, expected = ${this.config.library.sha256}, actual = ${sha256}`,
)
}
console.log(
`[Runtime Library] Checked integrity from ${this.config.library.url}, hash = ${sha256}`,
)
}
async then<Resolve = LibraryType, Reject = never>(
resolve?: (value: LibraryType) => Resolve | PromiseLike<Resolve>,
reject?: (reason: any) => Reject | PromiseLike<Reject>,
) {
try {
const { url, getModule } = this.config
const {
library: { url },
getModule,
} = this.config
if (!this.modulePromise) {
this.modulePromise = (async () => {
console.log(`[Runtime Library] Start download from ${url}`)
const code: string = await monkey({ url })
console.log(`[Runtime Library] Downloaded from ${url} , length = ${code.length}`)
console.log(`[Runtime Library] Downloaded from ${url}, length = ${code.length}`)
await this.checkIntegrity(code)
;(function runEval() {
return eval(code)
// eslint-disable-next-line no-extra-bind
@ -41,18 +69,18 @@ export class RuntimeLibrary<LibraryType> implements PromiseLike<LibraryType> {
}
}
export const protobufLibrary = new RuntimeLibrary<typeof protobufType>({
url: meta.compilationInfo.altCdn.library.protobuf,
library: meta.compilationInfo.altCdn.library.protobuf,
getModule: window => window.protobuf,
})
export const JSZipLibrary = new RuntimeLibrary<typeof JSZipType>({
url: meta.compilationInfo.altCdn.library.jszip,
library: meta.compilationInfo.altCdn.library.jszip,
getModule: window => window.JSZip,
})
export const SortableJSLibrary = new RuntimeLibrary<typeof SortableJSType>({
url: meta.compilationInfo.altCdn.library.sortable,
library: meta.compilationInfo.altCdn.library.sortable,
getModule: window => window.Sortable,
})
export const StreamSaverLibrary = new RuntimeLibrary<typeof StreamSaverType>({
url: meta.compilationInfo.altCdn.library.streamsaver,
library: meta.compilationInfo.altCdn.library.streamsaver,
getModule: window => window.streamSaver,
})

View File

@ -1,192 +0,0 @@
import { allMutations, childListSubtree } from './observer'
import { addStyle } from './style'
import { deleteValue, getRandomId } from './utils'
enum ShadowRootEvents {
Added = 'shadowRootAdded',
Removed = 'shadowRootRemoved',
Updated = 'shadowRootUpdated',
}
interface ShadowRootEntry {
shadowRoot: ShadowRoot
observer: MutationObserver
}
interface ShadowRootStyleEntry {
shadowRoot: ShadowRoot
}
export class ShadowDomObserver extends EventTarget {
static enforceOpenRoot() {
const originalAttachShadow = Element.prototype.attachShadow
Element.prototype.attachShadow = function attachShadow(options: ShadowRootInit) {
return originalAttachShadow.call(this, {
...options,
mode: 'open',
})
}
}
private observing = false
entries: ShadowRootEntry[] = []
constructor() {
super()
}
protected queryAllShadowRoots(
root: DocumentFragment | Element = document.body,
deep = false,
): ShadowRoot[] {
return [root, ...root.querySelectorAll('*')]
.filter((e): e is Element => e instanceof Element && e.shadowRoot !== null)
.flatMap(e => {
if (deep) {
return [e.shadowRoot, ...this.queryAllShadowRoots(e.shadowRoot)]
}
return [e.shadowRoot]
})
}
protected mutationHandler(records: MutationRecord[]) {
records.forEach(record => {
record.removedNodes.forEach(node => {
if (node instanceof Element && node.shadowRoot !== null) {
this.removeEntry(node.shadowRoot)
}
})
record.addedNodes.forEach(node => {
if (node instanceof Element) {
this.queryAllShadowRoots(node).forEach(shadowRoot => {
this.addEntry(shadowRoot)
})
}
})
})
}
protected addEntry(shadowRoot: ShadowRoot) {
if (this.shadowRoots.includes(shadowRoot)) {
return
}
const shadowRootChildren = this.queryAllShadowRoots(shadowRoot)
shadowRootChildren.forEach(child => this.addEntry(child))
const [observer] = childListSubtree(shadowRoot, records => this.mutationHandler(records))
this.entries.push({
shadowRoot,
observer,
})
this.dispatchEvent(new CustomEvent('shadowRootAdded', { detail: shadowRoot }))
}
protected removeEntry(shadowRoot: ShadowRoot) {
const children = this.shadowRoots.filter(it => shadowRoot.contains(it.host))
children.forEach(child => this.removeEntry(child))
const entry = this.entries.find(it => it.shadowRoot === shadowRoot)
if (entry !== undefined) {
entry.observer.disconnect()
deleteValue(this.entries, it => it === entry)
}
}
get shadowRoots() {
return this.entries.map(entry => entry.shadowRoot)
}
addEventListener(
type: `${ShadowRootEvents}`,
callback: EventListenerOrEventListenerObject | null,
options?: AddEventListenerOptions | boolean,
): void {
super.addEventListener(type, callback, options)
}
removeEventListener(
type: `${ShadowRootEvents}`,
callback: EventListenerOrEventListenerObject | null,
options?: EventListenerOptions | boolean,
): void {
super.removeEventListener(type, callback, options)
}
forEachShadowRoot(callbacks: {
added?: (shadowRoot: ShadowRoot) => void
removed?: (shadowRoot: ShadowRoot) => void
}) {
this.shadowRoots.forEach(it => callbacks.added?.(it))
const addedListener = (e: CustomEvent<ShadowRoot>) => callbacks?.added(e.detail)
const removedListener = (e: CustomEvent<ShadowRoot>) => callbacks?.removed(e.detail)
this.addEventListener(ShadowRootEvents.Added, addedListener)
this.addEventListener(ShadowRootEvents.Removed, removedListener)
return () => {
this.removeEventListener(ShadowRootEvents.Added, addedListener)
this.removeEventListener(ShadowRootEvents.Removed, removedListener)
}
}
observe() {
if (this.observing) {
return
}
const existingRoots = this.queryAllShadowRoots()
existingRoots.forEach(root => this.addEntry(root))
allMutations(records => this.mutationHandler(records))
this.observing = true
}
disconnect() {
this.entries.forEach(entry => entry.observer.disconnect())
this.entries = []
this.observing = false
}
}
const shadowDomObserver = new ShadowDomObserver()
export class ShadowDomStyles {
observer: ShadowDomObserver = shadowDomObserver
entries: ShadowRootStyleEntry[] = []
get shadowRoots() {
return this.entries.map(entry => entry.shadowRoot)
}
protected addEntry(shadowRoot: ShadowRoot) {
this.entries.push({
shadowRoot,
})
}
protected removeEntry(shadowRoot: ShadowRoot) {
const entry = this.entries.find(it => it.shadowRoot === shadowRoot)
if (entry !== undefined) {
deleteValue(this.entries, it => it === entry)
}
}
addStyle(text: string) {
this.observer.observe()
const id = `shadow-dom-style-${getRandomId()}`
const element = addStyle(text, id)
const destroy = this.observer.forEachShadowRoot({
added: async shadowRoot => {
if (this.shadowRoots.includes(shadowRoot)) {
return
}
this.addEntry(shadowRoot)
const sheet = new CSSStyleSheet()
await sheet.replace(element.innerHTML)
shadowRoot.adoptedStyleSheets.push(sheet)
},
removed: shadowRoot => {
shadowRoot.getElementById(id)?.remove()
this.removeEntry(shadowRoot)
},
})
return () => {
destroy()
element.remove()
}
}
}

View File

@ -0,0 +1,150 @@
import { childListSubtree } from '../observer'
import { deleteValue } from '../utils'
import type { ShadowDomObserver } from './dom-observer'
import { ShadowRootObserver } from './root-observer'
import { ShadowRootEvents } from './types'
export const ShadowDomEntrySymbol = Symbol.for('ShadowDomEntry')
export type ShadowDomParent = ShadowDomEntry | ShadowDomObserver
export type ShadowDomCallback = (shadowDom: ShadowDomEntry) => void
export class ShadowDomEntry extends ShadowRootObserver {
readonly shadowRoot: ShadowRoot
readonly parent: ShadowDomParent | null = null
readonly children: ShadowDomEntry[] = []
protected observer: MutationObserver
protected callbacksMap = new Map()
constructor(shadowRoot: ShadowRoot, parent: ShadowDomParent) {
super()
this.shadowRoot = shadowRoot
this.parent = parent
this.element[ShadowDomEntrySymbol] = this
ShadowRootObserver.queryAllShadowRoots(shadowRoot)
.filter(it => it !== shadowRoot)
.map(it => this.addChild(it))
this.observe()
}
get element() {
return this.shadowRoot.host
}
get elementName() {
return this.element.tagName.toLowerCase()
}
override dispatchEvent(event: Event): boolean {
return super.dispatchEvent(event) && this.parent.dispatchEvent(event)
}
protected mutationHandler(records: MutationRecord[]) {
if (records.length > 0) {
this.dispatchEvent(new CustomEvent(ShadowRootEvents.Updated, { detail: records }))
}
records.forEach(record => {
record.removedNodes.forEach(node => {
if (node instanceof Element) {
ShadowRootObserver.queryAllShadowRoots(node).forEach(shadowRoot => {
const child = this.children.find(it => it.shadowRoot === shadowRoot)
if (child === undefined) {
return
}
this.removeChild(child)
})
}
})
record.addedNodes.forEach(node => {
if (node instanceof Element) {
ShadowRootObserver.queryAllShadowRoots(node).forEach(shadowRoot => {
this.addChild(shadowRoot)
})
}
})
})
}
addChild(childShadowRoot: ShadowRoot) {
const match = this.children.find(child => child.shadowRoot === childShadowRoot)
if (match) {
return match
}
const child = new ShadowDomEntry(childShadowRoot, this)
this.children.push(child)
this.dispatchEvent(new CustomEvent(ShadowRootEvents.Added, { detail: child }))
return child
}
removeChild(child: ShadowDomEntry) {
child.disconnect()
deleteValue(this.children, it => it === child)
this.dispatchEvent(new CustomEvent(ShadowRootEvents.Removed, { detail: child }))
}
protected queryThroughChildren<T>(predicate: (current: ShadowDomEntry) => T | null): {
entry: ShadowDomEntry | null
result: T | null
} {
const selfResult = predicate(this)
if (selfResult !== null) {
return {
entry: this,
result: selfResult,
}
}
for (const child of this.children) {
const childResult = child.queryThroughChildren(predicate)
if (childResult.result !== null) {
return childResult
}
}
return {
entry: null,
result: null,
}
}
querySelectorAsEntry(selectors: string): ShadowDomEntry | null {
const { entry } = this.queryThroughChildren(current => {
if (current === this) {
return null
}
if (current.element.matches(selectors)) {
return current
}
return null
})
return entry
}
querySelectorAllAsEntry(selectors: string): ShadowDomEntry[] {
const currentMatch = this.children.filter(child => child.element.matches(selectors))
const childrenMatch = this.children.flatMap(child => child.querySelectorAllAsEntry(selectors))
return [...currentMatch, ...childrenMatch]
}
querySelector(selectors: string): Element | null {
const { result } = this.queryThroughChildren(current =>
current.shadowRoot.querySelector(selectors),
)
return result
}
querySelectorAll(selectors: string): Element[] {
return [
...this.shadowRoot.querySelectorAll(selectors),
...this.children.flatMap(child => child.querySelectorAll(selectors)),
]
}
observe() {
const [observer] = childListSubtree(this.shadowRoot, records => {
this.mutationHandler(records)
})
this.observer = observer
}
disconnect() {
this.children.forEach(child => this.removeChild(child))
this.observer?.disconnect()
}
}

View File

@ -0,0 +1,126 @@
import { childListSubtree } from '../observer'
import { deleteValue } from '../utils'
import { ShadowDomCallback, ShadowDomEntry } from './dom-entry'
import { ShadowRootObserver } from './root-observer'
import { ShadowRootEvents } from './types'
export class ShadowDomObserver extends ShadowRootObserver {
static enforceOpenRoot() {
const originalAttachShadow = Element.prototype.attachShadow
Element.prototype.attachShadow = function attachShadow(options: ShadowRootInit) {
return originalAttachShadow.call(this, {
...options,
mode: 'open',
})
}
}
private observing = false
private rootObserver: MutationObserver | undefined = undefined
entries: ShadowDomEntry[] = []
constructor() {
super()
}
protected mutationHandler(records: MutationRecord[]) {
if (records.length > 0) {
this.dispatchEvent(new CustomEvent(ShadowRootEvents.Updated, { detail: records }))
}
records.forEach(record => {
record.removedNodes.forEach(node => {
if (node instanceof Element) {
ShadowRootObserver.queryAllShadowRoots(node).forEach(shadowRoot => {
this.removeEntryByShadowRoot(shadowRoot)
})
}
})
record.addedNodes.forEach(node => {
if (node instanceof Element) {
ShadowRootObserver.queryAllShadowRoots(node).forEach(shadowRoot => {
this.addEntry(shadowRoot)
})
}
})
})
}
protected addEntry(shadowRoot: ShadowRoot) {
const match = this.entries.find(e => e.shadowRoot === shadowRoot)
if (match !== undefined) {
return match
}
const shadowDomEntry = new ShadowDomEntry(shadowRoot, this)
shadowDomEntry.observe()
this.entries.push(shadowDomEntry)
this.dispatchEvent(new CustomEvent(ShadowRootEvents.Added, { detail: shadowDomEntry }))
return shadowDomEntry
}
protected removeEntry(entry: ShadowDomEntry) {
const match = this.entries.find(it => it === entry)
if (match === undefined) {
return
}
match.children.forEach(child => this.removeEntry(child))
match.disconnect()
deleteValue(this.entries, it => it === match)
this.dispatchEvent(new CustomEvent(ShadowRootEvents.Removed, { detail: match }))
}
protected removeEntryByShadowRoot(shadowRoot: ShadowRoot) {
const match = this.entries.find(it => it.shadowRoot === shadowRoot)
if (match === undefined) {
return
}
this.removeEntry(match)
}
forEachShadowDom(callback: ShadowDomCallback) {
const callCurrentAndNextLevel = (currentEntry: ShadowDomEntry) => {
callback(currentEntry)
currentEntry.children.forEach(child => {
callCurrentAndNextLevel(child)
})
}
this.entries.forEach(entry => {
callCurrentAndNextLevel(entry)
})
}
watchShadowDom(callbacks: { added?: ShadowDomCallback; removed?: ShadowDomCallback }) {
this.forEachShadowDom(it => callbacks.added?.(it))
const addedListener = (e: CustomEvent<ShadowDomEntry>) => callbacks?.added?.(e.detail)
const removedListener = (e: CustomEvent<ShadowDomEntry>) => callbacks?.removed?.(e.detail)
this.addEventListener(ShadowRootEvents.Added, addedListener)
this.addEventListener(ShadowRootEvents.Removed, removedListener)
return () => {
this.removeEventListener(ShadowRootEvents.Added, addedListener)
this.removeEventListener(ShadowRootEvents.Removed, removedListener)
}
}
observe() {
if (this.observing) {
return
}
const existingRoots = ShadowRootObserver.queryAllShadowRoots()
existingRoots.forEach(root => this.addEntry(root))
;[this.rootObserver] = childListSubtree(document.body, records => this.mutationHandler(records))
this.observing = true
}
disconnect() {
this.rootObserver.disconnect()
this.entries.forEach(entry => entry.disconnect())
this.entries = []
this.observing = false
}
}
export const shadowDomObserver = new ShadowDomObserver()

View File

@ -0,0 +1,3 @@
export * from './dom-observer'
export * from './styles'
export * from './types'

View File

@ -0,0 +1,36 @@
import { ShadowRootEvents } from './types'
export abstract class ShadowRootObserver extends EventTarget {
static queryAllShadowRoots(
root: DocumentFragment | Element = document.body,
deep = false,
): ShadowRoot[] {
return [root, ...root.querySelectorAll('*')]
.filter((e): e is Element => e instanceof Element && e.shadowRoot !== null)
.flatMap(e => {
if (deep) {
return [e.shadowRoot, ...ShadowRootObserver.queryAllShadowRoots(e.shadowRoot)]
}
return [e.shadowRoot]
})
}
addEventListener(
type: `${ShadowRootEvents}`,
callback: EventListenerOrEventListenerObject | null,
options?: AddEventListenerOptions | boolean,
): void {
super.addEventListener(type, callback, options)
}
removeEventListener(
type: `${ShadowRootEvents}`,
callback: EventListenerOrEventListenerObject | null,
options?: EventListenerOptions | boolean,
): void {
super.removeEventListener(type, callback, options)
}
abstract observe(): void
abstract disconnect(): void
}

View File

@ -0,0 +1,84 @@
import { addComponentListener, removeComponentListener } from '../settings'
import { deleteValue, getRandomId } from '../utils'
import { ShadowDomEntry } from './dom-entry'
import { ShadowDomObserver, shadowDomObserver } from './dom-observer'
export interface ShadowRootStyleEntry {
id: string
styleSheet: CSSStyleSheet
adoptedShadowDoms: ShadowDomEntry[]
remove: () => void
}
export interface ShadowRootStyleDefinition {
id?: string
style: string
}
export class ShadowRootStyles {
observer: ShadowDomObserver = shadowDomObserver
protected stylesMap = new Map<string, ShadowRootStyleEntry>()
protected addEntry(id: string, entry: ShadowRootStyleEntry) {
this.stylesMap.set(id, entry)
}
protected removeEntry(id: string) {
if (this.stylesMap.has(id)) {
this.stylesMap.delete(id)
}
}
async addStyle(definition: ShadowRootStyleDefinition) {
const { id, style } = definition
this.observer.observe()
const entryId = `shadow-dom-style-${id !== undefined ? lodash.kebabCase(id) : getRandomId()}`
const styleSheet = new CSSStyleSheet()
await styleSheet.replace(style)
const adoptedShadowDoms: ShadowDomEntry[] = []
const destroy = this.observer.watchShadowDom({
added: async shadowDom => {
shadowDom.shadowRoot.adoptedStyleSheets.push(styleSheet)
adoptedShadowDoms.push(shadowDom)
},
})
const entry: ShadowRootStyleEntry = {
id: entryId,
styleSheet,
adoptedShadowDoms,
remove: () => {
destroy()
adoptedShadowDoms.forEach(it =>
deleteValue(it.shadowRoot.adoptedStyleSheets, sheet => sheet === styleSheet),
)
this.removeEntry(id)
},
}
this.addEntry(id, entry)
return entry
}
removeStyle(id: string) {
if (this.stylesMap.has(id)) {
const style = this.stylesMap.get(id)
style.remove()
this.removeEntry(id)
}
}
toggleWithComponent(path: string, definition: ShadowRootStyleDefinition) {
let entry: ShadowRootStyleEntry | undefined
const handler = async (rawValue: unknown) => {
const value = Boolean(rawValue)
if (value) {
entry = await this.addStyle(definition)
} else if (entry !== undefined) {
this.removeStyle(entry.id)
}
}
addComponentListener(path, handler, true)
return () => removeComponentListener(path, handler)
}
}
export const shadowRootStyles = new ShadowRootStyles()

View File

@ -0,0 +1,5 @@
export enum ShadowRootEvents {
Added = 'shadowRootAdded',
Removed = 'shadowRootRemoved',
Updated = 'shadowRootUpdated',
}

View File

@ -1,5 +1,10 @@
import { ComponentMetadata } from '@/components/types'
import {
ComponentMetadata,
DomInstantStyleDefinition,
ShadowDomInstantStyleDefinition,
} from '@/components/types'
import { contentLoaded } from './life-cycle'
import { shadowRootStyles } from './shadow-root'
/** 为`<style>`获取默认的ID (camelCase转为kebab-case) */
export const getDefaultStyleID = (name: string) =>
@ -53,21 +58,48 @@ export const loadInstantStyle = async (
} = { head: document.head, body: document.body },
) => {
component.instantStyles?.forEach(async it => {
const styleContent = await (async () => {
if (typeof it.style === 'string') {
return it.style
}
const module = await it.style()
return module.default
})()
if ((it as ShadowDomInstantStyleDefinition).shadowDom) {
shadowRootStyles.addStyle({
id: it.name,
style: styleContent,
})
return
}
const style = document.createElement('style')
style.id = getDefaultStyleID(it.name)
if (typeof it.style === 'string') {
style.textContent = it.style
} else {
const { default: styleContent } = await it.style()
style.textContent = styleContent
}
if (it.important) {
style.textContent = styleContent
if ((it as DomInstantStyleDefinition).important) {
fragments.body.appendChild(style)
} else {
fragments.head.appendChild(style)
}
})
}
/**
*
*/
export const removeInstantStyle = (
...instantStyles: (DomInstantStyleDefinition | ShadowDomInstantStyleDefinition)[]
) => {
instantStyles.forEach(style => {
if ((style as ShadowDomInstantStyleDefinition).shadowDom) {
shadowRootStyles.removeStyle(style.name)
} else {
removeStyle(style.name)
}
})
}
/**
* , MDI图标样式,
*
@ -94,7 +126,7 @@ export const preloadStyles = lodash.once(async () => {
if (enabled) {
return loadInstantStyle(component)
}
return component.instantStyles?.forEach(style => removeStyle(style.name))
return component.instantStyles?.forEach(style => removeInstantStyle(style))
}
addComponentListener(component.name, listener)
if (isUserComponent(component)) {

View File

@ -1,6 +1,7 @@
import { FeatureBase } from '@/components/types'
import { loadFeatureCode } from './external-input'
import { meta } from './meta'
import { useScopedConsole } from './utils/log'
export enum CompareResult {
Less = -1,
@ -50,6 +51,7 @@ export class Version {
*/
export const isFeatureAcceptable = async (feature: FeatureBase | string) => {
try {
const console = useScopedConsole('isFeatureAcceptable')
if (typeof feature === 'string') {
feature = loadFeatureCode(feature) as FeatureBase
}
@ -59,6 +61,7 @@ export const isFeatureAcceptable = async (feature: FeatureBase | string) => {
}
const { version: currentVersionText } = meta.compilationInfo
const { coreVersion: requiredVersionText } = feature
console.log('currentVersion =', currentVersionText, ', requiredVersion =', requiredVersionText)
// 没有版本信息, 按旧版行为默认通过检测
if (!requiredVersionText || !currentVersionText) {
return true
@ -67,7 +70,7 @@ export const isFeatureAcceptable = async (feature: FeatureBase | string) => {
const requiredVersion = new Version(requiredVersionText)
return currentVersion.equals(requiredVersion) || currentVersion.greaterThan(requiredVersion)
} catch (error) {
console.warn('[isFeatureAcceptable] check failed, feature =', feature)
console.warn('check failed, feature =', feature)
// 版本号异常, 跳过检测
return true
}

View File

@ -10,12 +10,26 @@ export const github: CdnConfig = {
stableClient: `https://${host}/${owner}/Bilibili-Evolved/master/dist/bilibili-evolved.user.js`,
previewClient: `https://${host}/${owner}/Bilibili-Evolved/preview/dist/bilibili-evolved.preview.user.js`,
library: {
lodash: `https://${host}/lodash/lodash/4.17.21/dist/lodash.min.js`,
protobuf: `https://${host}/protobufjs/protobuf.js/v6.10.1/dist/light/protobuf.min.js`,
jszip: `https://${host}/Stuk/jszip/v3.7.1/dist/jszip.min.js`,
sortable: `https://${host}/SortableJS/Sortable/1.14.0/Sortable.min.js`,
mdi: `https://${owner}.github.io/Bilibili-Evolved/static/mdi/mdi.css`,
streamsaver: `https://${host}/jimmywarting/StreamSaver.js/2.0.6/StreamSaver.js`,
lodash: {
url: `https://${host}/lodash/lodash/4.17.21/dist/lodash.min.js`,
sha256: 'a9705dfc47c0763380d851ab1801be6f76019f6b67e40e9b873f8b4a0603f7a9',
},
protobuf: {
url: `https://${host}/protobufjs/protobuf.js/v6.10.1/dist/light/protobuf.min.js`,
sha256: '8978daf871b02d683ecaee371861702a6f31d0a4c52925b7db2bb1655a8bc7d1',
},
jszip: {
url: `https://${host}/Stuk/jszip/v3.7.1/dist/jszip.min.js`,
sha256: 'c9e4a52bac18aee4f3f90d05fbca603f5b0f5bf1ce8c45e60bb4ed3a2cb2ed86',
},
sortable: {
url: `https://${host}/SortableJS/Sortable/1.14.0/Sortable.min.js`,
sha256: '0ea5a6fbfbf5434b606878533cb7a66bcf700f0f08afe908335d0978fb63ad94',
},
streamsaver: {
url: `https://${host}/jimmywarting/StreamSaver.js/2.0.6/StreamSaver.js`,
sha256: 'a110f78e0b092481dc372901c4d57ae50681d773bc9d55e62356f9a22f17e24b',
},
// https://github.com/the1812/Bilibili-Evolved/pull/4521#discussion_r1402084486
ffmpeg: jsDelivr.library.ffmpeg,
},

View File

@ -9,16 +9,39 @@ export const jsDelivr: CdnConfig = {
stableClient: `https://${host}/gh/${owner}/Bilibili-Evolved@master/dist/bilibili-evolved.user.js`,
previewClient: `https://${host}/gh/${owner}/Bilibili-Evolved@preview/dist/bilibili-evolved.preview.user.js`,
library: {
lodash: `https://${host}/npm/lodash@4.17.21/lodash.min.js`,
protobuf: `https://${host}/npm/protobufjs@6.10.1/dist/light/protobuf.min.js`,
jszip: `https://${host}/npm/jszip@3.7.1/dist/jszip.min.js`,
sortable: `https://${host}/npm/sortablejs@1.14.0/Sortable.min.js`,
mdi: `https://${host}/gh/${owner}/Bilibili-Evolved@master/docs/static/mdi/mdi.css`,
streamsaver: `https://${host}/npm/streamsaver@2.0.6/StreamSaver.min.js`,
lodash: {
url: `https://${host}/npm/lodash@4.17.21/lodash.min.js`,
sha256: 'a9705dfc47c0763380d851ab1801be6f76019f6b67e40e9b873f8b4a0603f7a9',
},
protobuf: {
url: `https://${host}/npm/protobufjs@6.10.1/dist/light/protobuf.min.js`,
sha256: '8978daf871b02d683ecaee371861702a6f31d0a4c52925b7db2bb1655a8bc7d1',
},
jszip: {
url: `https://${host}/npm/jszip@3.7.1/dist/jszip.min.js`,
sha256: 'c9e4a52bac18aee4f3f90d05fbca603f5b0f5bf1ce8c45e60bb4ed3a2cb2ed86',
},
sortable: {
url: `https://${host}/npm/sortablejs@1.14.0/Sortable.min.js`,
sha256: '0ea5a6fbfbf5434b606878533cb7a66bcf700f0f08afe908335d0978fb63ad94',
},
streamsaver: {
url: `https://${host}/npm/streamsaver@2.0.6/StreamSaver.min.js`,
sha256: '64f465e51e5992be894c5d42330b781544eda5462069fe6be4c7421f02d28c92',
},
ffmpeg: {
worker: `https://${host}/npm/@ffmpeg/ffmpeg@0.12.4/dist/umd/814.ffmpeg.js`,
core: `https://${host}/npm/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.js`,
wasm: `https://${host}/npm/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.wasm`,
worker: {
url: `https://${host}/npm/@ffmpeg/ffmpeg@0.12.4/dist/umd/814.ffmpeg.js`,
sha256: 'baf19437171b1bccae4416e4da69fb40455b8e67142f79c8ec9da36b1de7fd8a',
},
core: {
url: `https://${host}/npm/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.js`,
sha256: '6af6b8cd8c878dec6f61f3cd6be16e88f9391dd265e51f20afea5c0f718bfba0',
},
wasm: {
url: `https://${host}/npm/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.wasm`,
sha256: '925bd7ef35d4e0f715254cd650f4cfc68c4ec6ecebf293face72b92da904ddda',
},
},
},
smallLogo: `https://${host}/gh/${owner}/Bilibili-Evolved@preview/images/logo-small.png`,

View File

@ -1,3 +1,4 @@
export type ExternalLibrary = { url: string; sha256: string }
export interface CdnConfig {
name: string
owner: string
@ -5,16 +6,15 @@ export interface CdnConfig {
stableClient: string
previewClient: string
library: {
lodash: string
protobuf: string
jszip: string
sortable: string
mdi: string
streamsaver: string
lodash: ExternalLibrary
protobuf: ExternalLibrary
jszip: ExternalLibrary
sortable: ExternalLibrary
streamsaver: ExternalLibrary
ffmpeg: {
worker: string
core: string
wasm: string
worker: ExternalLibrary
core: ExternalLibrary
wasm: ExternalLibrary
}
}
smallLogo: string