mirror of
https://github.com/the1812/Bilibili-Evolved.git
synced 2025-11-04 21:22:45 +08:00
Merge branch 'preview-features' into preview
This commit is contained in:
commit
3d56cf2259
37
CHANGELOG.md
37
CHANGELOG.md
@ -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`
|
||||
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
:host(bili-comments) {
|
||||
#end .bottombar {
|
||||
padding-bottom: 8px !important;
|
||||
}
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: '自定义顶栏 - 功能扩展',
|
||||
|
||||
@ -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>
|
||||
77
registry/lib/components/style/dark-mode/dark-shadow-dom.scss
Normal file
77
registry/lib/components/style/dark-mode/dark-shadow-dom.scss
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
|
||||
@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
:host(bili-rich-text) {
|
||||
#contents img,
|
||||
#contents a i {
|
||||
max-width: 1.4em;
|
||||
max-height: 1.4em;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
:host(bili-comment-renderer) {
|
||||
bili-comment-user-sailing-card {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
:host(bili-comments-header-renderer) {
|
||||
bili-comments-notice {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
:host(bili-comment-user-info) {
|
||||
bili-comment-user-medal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
:host(bili-comment-reply-renderer) {
|
||||
bili-comment-user-info {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
:host(bili-comment-user-info) {
|
||||
#user-level {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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: () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -11,6 +11,10 @@ const options = defineOptionsMetadata({
|
||||
defaultValue: 'auto',
|
||||
hidden: true,
|
||||
},
|
||||
originalImageInArticles: {
|
||||
displayName: '在专栏中请求原图',
|
||||
defaultValue: false,
|
||||
},
|
||||
})
|
||||
|
||||
export type Options = OptionsOfMetadata<typeof options>
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1 +1 @@
|
||||
将弹幕中的 `\n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.
|
||||
将弹幕中的 `\n` 或 `/n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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]"
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
/** 关闭时执行 */
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>,
|
||||
|
||||
87
src/components/utils/comment/areas/dom.ts
Normal file
87
src/components/utils/comment/areas/dom.ts
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
202
src/components/utils/comment/areas/v3.ts
Normal file
202
src/components/utils/comment/areas/v3.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
15
src/core/container-query.ts
Normal file
15
src/core/container-query.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
150
src/core/shadow-root/dom-entry.ts
Normal file
150
src/core/shadow-root/dom-entry.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
126
src/core/shadow-root/dom-observer.ts
Normal file
126
src/core/shadow-root/dom-observer.ts
Normal 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()
|
||||
3
src/core/shadow-root/index.ts
Normal file
3
src/core/shadow-root/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './dom-observer'
|
||||
export * from './styles'
|
||||
export * from './types'
|
||||
36
src/core/shadow-root/root-observer.ts
Normal file
36
src/core/shadow-root/root-observer.ts
Normal 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
|
||||
}
|
||||
84
src/core/shadow-root/styles.ts
Normal file
84
src/core/shadow-root/styles.ts
Normal 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()
|
||||
5
src/core/shadow-root/types.ts
Normal file
5
src/core/shadow-root/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export enum ShadowRootEvents {
|
||||
Added = 'shadowRootAdded',
|
||||
Removed = 'shadowRootRemoved',
|
||||
Updated = 'shadowRootUpdated',
|
||||
}
|
||||
@ -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)) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user