diff --git a/CHANGELOG.md b/CHANGELOG.md
index d95254c06..c6bf39b5c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,43 @@
# 更新日志
+## 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`
diff --git a/doc/donate.md b/doc/donate.md
index 73930da0f..37b7baac8 100644
--- a/doc/donate.md
+++ b/doc/donate.md
@@ -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 |
diff --git a/doc/features/features.json b/doc/features/features.json
index 8d1829830..176fc61fe 100644
--- a/doc/features/features.json
+++ b/doc/features/features.json
@@ -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"
},
diff --git a/doc/features/features.md b/doc/features/features.md
index ee4dcfa8b..570495ddd 100644
--- a/doc/features/features.md
+++ b/doc/features/features.md
@@ -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`
diff --git a/registry/lib/components/feeds/fold-comments/fold-comment-shadow.scss b/registry/lib/components/feeds/fold-comments/fold-comment-shadow.scss
new file mode 100644
index 000000000..5a42d49de
--- /dev/null
+++ b/registry/lib/components/feeds/fold-comments/fold-comment-shadow.scss
@@ -0,0 +1,5 @@
+:host(bili-comments) {
+ #end .bottombar {
+ padding-bottom: 8px !important;
+ }
+}
diff --git a/registry/lib/components/feeds/fold-comments/index.ts b/registry/lib/components/feeds/fold-comments/index.ts
index 26b688d33..4ddce9bc2 100644
--- a/registry/lib/components/feeds/fold-comments/index.ts
+++ b/registry/lib/components/feeds/fold-comments/index.ts
@@ -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({
diff --git a/registry/lib/components/live/chat-panel-fit/chat-panel-fit.scss b/registry/lib/components/live/chat-panel-fit/chat-panel-fit.scss
index a07c769cd..f3d150d8d 100644
--- a/registry/lib/components/live/chat-panel-fit/chat-panel-fit.scss
+++ b/registry/lib/components/live/chat-panel-fit/chat-panel-fit.scss
@@ -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;
+ }
+ }
}
}
diff --git a/registry/lib/components/style/custom-navbar/index.ts b/registry/lib/components/style/custom-navbar/index.ts
index 98eaf0579..4e9385bd7 100644
--- a/registry/lib/components/style/custom-navbar/index.ts
+++ b/registry/lib/components/style/custom-navbar/index.ts
@@ -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: '自定义顶栏 - 功能扩展',
diff --git a/registry/lib/components/style/custom-navbar/settings/Widget.vue b/registry/lib/components/style/custom-navbar/settings/Widget.vue
new file mode 100644
index 000000000..286ee464e
--- /dev/null
+++ b/registry/lib/components/style/custom-navbar/settings/Widget.vue
@@ -0,0 +1,24 @@
+
+
+
+
diff --git a/registry/lib/components/style/dark-mode/dark-shadow-dom.scss b/registry/lib/components/style/dark-mode/dark-shadow-dom.scss
new file mode 100644
index 000000000..b0bdd7861
--- /dev/null
+++ b/registry/lib/components/style/dark-mode/dark-shadow-dom.scss
@@ -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');
+ }
+}
diff --git a/registry/lib/components/style/dark-mode/dark-slice-16.scss b/registry/lib/components/style/dark-mode/dark-slice-16.scss
index a78cceddd..58c1f092b 100644
--- a/registry/lib/components/style/dark-mode/dark-slice-16.scss
+++ b/registry/lib/components/style/dark-mode/dark-slice-16.scss
@@ -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');
diff --git a/registry/lib/components/style/dark-mode/index.ts b/registry/lib/components/style/dark-mode/index.ts
index 756b81d78..512afbb95 100644
--- a/registry/lib/components/style/dark-mode/index.ts
+++ b/registry/lib/components/style/dark-mode/index.ts
@@ -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')
}
})
diff --git a/registry/lib/components/style/simplify/comments/comments-v3-firefox/base.scss b/registry/lib/components/style/simplify/comments/comments-v3-firefox/base.scss
new file mode 100644
index 000000000..48b783918
--- /dev/null
+++ b/registry/lib/components/style/simplify/comments/comments-v3-firefox/base.scss
@@ -0,0 +1,7 @@
+:host(bili-rich-text) {
+ #contents img,
+ #contents a i {
+ max-width: 1.4em;
+ max-height: 1.4em;
+ }
+}
diff --git a/registry/lib/components/style/simplify/comments/comments-v3-firefox/decorate-and-time.scss b/registry/lib/components/style/simplify/comments/comments-v3-firefox/decorate-and-time.scss
new file mode 100644
index 000000000..36c478517
--- /dev/null
+++ b/registry/lib/components/style/simplify/comments/comments-v3-firefox/decorate-and-time.scss
@@ -0,0 +1,5 @@
+:host(bili-comment-renderer) {
+ bili-comment-user-sailing-card {
+ display: none;
+ }
+}
diff --git a/registry/lib/components/style/simplify/comments/comments-v3-firefox/event-banner.scss b/registry/lib/components/style/simplify/comments/comments-v3-firefox/event-banner.scss
new file mode 100644
index 000000000..52e807658
--- /dev/null
+++ b/registry/lib/components/style/simplify/comments/comments-v3-firefox/event-banner.scss
@@ -0,0 +1,5 @@
+:host(bili-comments-header-renderer) {
+ bili-comments-notice {
+ display: none;
+ }
+}
diff --git a/registry/lib/components/style/simplify/comments/comments-v3-firefox/fans-medal.scss b/registry/lib/components/style/simplify/comments/comments-v3-firefox/fans-medal.scss
new file mode 100644
index 000000000..272ccf823
--- /dev/null
+++ b/registry/lib/components/style/simplify/comments/comments-v3-firefox/fans-medal.scss
@@ -0,0 +1,5 @@
+:host(bili-comment-user-info) {
+ bili-comment-user-medal {
+ display: none;
+ }
+}
diff --git a/registry/lib/components/style/simplify/comments/comments-v3-firefox/reply-editor.scss b/registry/lib/components/style/simplify/comments/comments-v3-firefox/reply-editor.scss
new file mode 100644
index 000000000..4c80fd8d1
--- /dev/null
+++ b/registry/lib/components/style/simplify/comments/comments-v3-firefox/reply-editor.scss
@@ -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;
+ }
+}
diff --git a/registry/lib/components/style/simplify/comments/comments-v3-firefox/sub-reply-new-line.scss b/registry/lib/components/style/simplify/comments/comments-v3-firefox/sub-reply-new-line.scss
new file mode 100644
index 000000000..bcc3f0a72
--- /dev/null
+++ b/registry/lib/components/style/simplify/comments/comments-v3-firefox/sub-reply-new-line.scss
@@ -0,0 +1,5 @@
+:host(bili-comment-reply-renderer) {
+ bili-comment-user-info {
+ display: block;
+ }
+}
diff --git a/registry/lib/components/style/simplify/comments/comments-v3-firefox/user-level.scss b/registry/lib/components/style/simplify/comments/comments-v3-firefox/user-level.scss
new file mode 100644
index 000000000..0f928b419
--- /dev/null
+++ b/registry/lib/components/style/simplify/comments/comments-v3-firefox/user-level.scss
@@ -0,0 +1,5 @@
+:host(bili-comment-user-info) {
+ #user-level {
+ display: none;
+ }
+}
diff --git a/registry/lib/components/style/simplify/comments/comments-v3.scss b/registry/lib/components/style/simplify/comments/comments-v3.scss
index b9c428e9a..d66962ff2 100644
--- a/registry/lib/components/style/simplify/comments/comments-v3.scss
+++ b/registry/lib/components/style/simplify/comments/comments-v3.scss
@@ -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;
}
diff --git a/registry/lib/components/style/simplify/comments/index.ts b/registry/lib/components/style/simplify/comments/index.ts
index 8efcf7e19..4a395d89e 100644
--- a/registry/lib/components/style/simplify/comments/index.ts
+++ b/registry/lib/components/style/simplify/comments/index.ts
@@ -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: [
{
diff --git a/registry/lib/components/utils/comments/disable-search-link/disable-search-link-shadow.scss b/registry/lib/components/utils/comments/disable-search-link/disable-search-link-shadow.scss
new file mode 100644
index 000000000..9e058a702
--- /dev/null
+++ b/registry/lib/components/utils/comments/disable-search-link/disable-search-link-shadow.scss
@@ -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;
+ }
+ }
+}
diff --git a/registry/lib/components/utils/comments/disable-search-link/index.ts b/registry/lib/components/utils/comments/disable-search-link/index.ts
index 19395fec2..e69253caf 100644
--- a/registry/lib/components/utils/comments/disable-search-link/index.ts
+++ b/registry/lib/components/utils/comments/disable-search-link/index.ts
@@ -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) => {
+ 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: () => {
diff --git a/registry/lib/components/utils/dev-client/client.ts b/registry/lib/components/utils/dev-client/client.ts
index 5b69dcadf..1a2aec3ed 100644
--- a/registry/lib/components/utils/dev-client/client.ts
+++ b/registry/lib/components/utils/dev-client/client.ts
@@ -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
diff --git a/registry/lib/components/utils/image-resolution/index.ts b/registry/lib/components/utils/image-resolution/index.ts
index f42d2bc44..8158ecf56 100644
--- a/registry/lib/components/utils/image-resolution/index.ts
+++ b/registry/lib/components/utils/image-resolution/index.ts
@@ -11,6 +11,10 @@ const options = defineOptionsMetadata({
defaultValue: 'auto',
hidden: true,
},
+ originalImageInArticles: {
+ displayName: '在专栏中请求原图',
+ defaultValue: false,
+ },
})
export type Options = OptionsOfMetadata
diff --git a/registry/lib/components/utils/image-resolution/resolution.ts b/registry/lib/components/utils/image-resolution/resolution.ts
index 0d910a177..bdc04d042 100644
--- a/registry/lib/components/utils/image-resolution/resolution.ts
+++ b/registry/lib/components/utils/image-resolution/resolution.ts
@@ -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(
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))
}
}
}),
diff --git a/registry/lib/components/utils/ip-show/index.ts b/registry/lib/components/utils/ip-show/index.ts
index d08e9f33e..599dee567 100644
--- a/registry/lib/components/utils/ip-show/index.ts
+++ b/registry/lib/components/utils/ip-show/index.ts
@@ -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)
})
}
diff --git a/registry/lib/components/utils/url-params-clean/index.ts b/registry/lib/components/utils/url-params-clean/index.ts
index 50d6d6586..144064f64 100644
--- a/registry/lib/components/utils/url-params-clean/index.ts
+++ b/registry/lib/components/utils/url-params-clean/index.ts
@@ -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',
diff --git a/registry/lib/components/video/danmaku/unescape/index.md b/registry/lib/components/video/danmaku/unescape/index.md
index e84e41531..f2cd57af7 100644
--- a/registry/lib/components/video/danmaku/unescape/index.md
+++ b/registry/lib/components/video/danmaku/unescape/index.md
@@ -1 +1 @@
-将弹幕中的 `\n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.
+将弹幕中的 `\n` 或 `/n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.
diff --git a/registry/lib/components/video/danmaku/unescape/index.ts b/registry/lib/components/video/danmaku/unescape/index.ts
index 4e8118923..35ed7c08d 100644
--- a/registry/lib/components/video/danmaku/unescape/index.ts
+++ b/registry/lib/components/video/danmaku/unescape/index.ts
@@ -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)
}
},
})
diff --git a/registry/lib/plugins/video/download/wasm-output/utils.ts b/registry/lib/plugins/video/download/wasm-output/utils.ts
index fb02e15ad..fe2ec33d7 100644
--- a/registry/lib/plugins/video/download/wasm-output/utils.ts
+++ b/registry/lib/plugins/video/download/wasm-output/utils.ts
@@ -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) {
diff --git a/src/client/common.meta.json b/src/client/common.meta.json
index 0c3a9889f..16baa7869 100644
--- a/src/client/common.meta.json
+++ b/src/client/common.meta.json
@@ -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]"
diff --git a/src/client/compatibility.ts b/src/client/compatibility.ts
index 883e33e82..c8b2d2996 100644
--- a/src/client/compatibility.ts
+++ b/src/client/compatibility.ts
@@ -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 => {
diff --git a/src/components/SwitchOptions.vue b/src/components/SwitchOptions.vue
index 152b871fc..68fe1d161 100644
--- a/src/components/SwitchOptions.vue
+++ b/src/components/SwitchOptions.vue
@@ -5,7 +5,7 @@
{{ options.optionDisplayName }}
@@ -128,6 +128,7 @@ export default Vue.extend({
}
.switch-icon {
margin-right: 8px;
+ opacity: 0.75;
transform: scale(0.9);
}
.dim {
diff --git a/src/components/auto-update/index.ts b/src/components/auto-update/index.ts
index fa4538bde..fdabf7f7d 100644
--- a/src/components/auto-update/index.ts
+++ b/src/components/auto-update/index.ts
@@ -60,16 +60,16 @@ const optionsMetadata = defineOptionsMetadata({
export type Options = OptionsOfMetadata
-const entry: ComponentEntry = async ({ settings: { options: opt } }) => {
+const entry: ComponentEntry = 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())
diff --git a/src/components/switch-options.ts b/src/components/switch-options.ts
index 12e573bb5..9435293b4 100644
--- a/src/components/switch-options.ts
+++ b/src/components/switch-options.ts
@@ -249,7 +249,9 @@ const newSwitchEntry = {
- 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,
)
diff --git a/src/components/types.ts b/src/components/types.ts
index 3eaee6ebc..a63f61faa 100644
--- a/src/components/types.ts
+++ b/src/components/types.ts
@@ -163,6 +163,21 @@ export type ComponentEntry,
) => T | Promise
+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 {
/** 主入口, 重新开启时不会再运行 */
@@ -170,14 +185,7 @@ export interface FunctionalMetadata {
/** 导出小组件 */
widget?: Omit
/** 首屏样式, 会尽快注入 (before DCL) */
- instantStyles?: {
- /** 样式ID */
- name: string
- /** 样式内容, 可以是一个导入样式的函数 */
- style: string | (() => Promise<{ default: string }>)
- /** 设为`true`则注入到`document.body`末尾, 否则注入到`document.head`末尾 */
- important?: boolean
- }[]
+ instantStyles?: (DomInstantStyleDefinition | ShadowDomInstantStyleDefinition)[]
/** 重新开启时执行 */
reload?: Executable
/** 关闭时执行 */
diff --git a/src/components/user-component.ts b/src/components/user-component.ts
index 357aef233..2eeb2a49b 100644
--- a/src/components/user-component.ts
+++ b/src/components/user-component.ts
@@ -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
diff --git a/src/components/utils/comment/areas/base.ts b/src/components/utils/comment/areas/base.ts
index 1535690d1..d4fc38bf7 100644
--- a/src/components/utils/comment/areas/base.ts
+++ b/src/components/utils/comment/areas/base.ts
@@ -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[] = []
- 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
+ abstract disconnect(): void | Promise
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
static resolveCallbackPair void>(
input: CommentCallbackInput,
diff --git a/src/components/utils/comment/areas/dom.ts b/src/components/utils/comment/areas/dom.ts
new file mode 100644
index 000000000..53bef42f3
--- /dev/null
+++ b/src/components/utils/comment/areas/dom.ts
@@ -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))
+ })
+ }
+}
diff --git a/src/components/utils/comment/areas/v1.ts b/src/components/utils/comment/areas/v1.ts
index 60090ece7..bd1b395c4 100644
--- a/src/components/utils/comment/areas/v1.ts
+++ b/src/components/utils/comment/areas/v1.ts
@@ -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
diff --git a/src/components/utils/comment/areas/v2.ts b/src/components/utils/comment/areas/v2.ts
index 7f1bc2f39..ef3b7990a 100644
--- a/src/components/utils/comment/areas/v2.ts
+++ b/src/components/utils/comment/areas/v2.ts
@@ -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')
diff --git a/src/components/utils/comment/areas/v3.ts b/src/components/utils/comment/areas/v3.ts
new file mode 100644
index 000000000..8d0b741e6
--- /dev/null
+++ b/src/components/utils/comment/areas/v3.ts
@@ -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()
+
+ private handleEntryAdded: (e: CustomEvent) => void
+ private handleEntryRemoved: (e: CustomEvent) => 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) => {
+ const entry = e.detail
+ if (entry.element.matches(CommentAreaV3.commentItemSelectors)) {
+ this.addCommentItem(entry)
+ }
+ }
+ this.commentAreaEntry.addEventListener(ShadowRootEvents.Added, this.handleEntryAdded)
+ this.handleEntryRemoved = (e: CustomEvent) => {
+ const entry = e.detail
+ if (!this.itemEntryMap.has(entry)) {
+ return
+ }
+ this.removeCommentItem(entry)
+ }
+ this.commentAreaEntry.addEventListener(ShadowRootEvents.Removed, this.handleEntryRemoved)
+ }
+
+ observe() {
+ return new Promise(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)
+ }
+}
diff --git a/src/components/utils/comment/comment-area-manager.ts b/src/components/utils/comment/comment-area-manager.ts
index 0873659fc..1d2b00904 100644
--- a/src/components/utils/comment/comment-area-manager.ts
+++ b/src/components/utils/comment/comment-area-manager.ts
@@ -14,7 +14,7 @@ export class CommentAreaManager {
commentAreas: CommentArea[] = []
commentAreaCallbacks: CommentCallbackPair[] = []
- 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()
}
})
diff --git a/src/components/utils/comment/comment-area.ts b/src/components/utils/comment/comment-area.ts
index 9bcf1deee..6193c2369 100644
--- a/src/components/utils/comment/comment-area.ts
+++ b/src/components/utils/comment/comment-area.ts
@@ -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'
diff --git a/src/components/utils/comment/comment-item.ts b/src/components/utils/comment/comment-item.ts
index 91dd561c1..d46cce276 100644
--- a/src/components/utils/comment/comment-item.ts
+++ b/src/components/utils/comment/comment-item.ts
@@ -13,7 +13,9 @@ export class CommentItem extends CommentReplyItem {
/** 回复 */
replies: CommentReplyItem[]
- constructor(initParams: Omit) {
+ constructor(
+ initParams: Omit,
+ ) {
super(initParams)
this.pictures = initParams.pictures ?? []
this.replies = initParams.replies
diff --git a/src/components/utils/comment/reply-item.ts b/src/components/utils/comment/reply-item.ts
index c15bbc241..b50c0f4fd 100644
--- a/src/components/utils/comment/reply-item.ts
+++ b/src/components/utils/comment/reply-item.ts
@@ -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) {
+ constructor(initParams: Omit) {
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]
}
}
diff --git a/src/core/container-query.ts b/src/core/container-query.ts
new file mode 100644
index 000000000..69ef9a287
--- /dev/null
+++ b/src/core/container-query.ts
@@ -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'
+ )
+})
diff --git a/src/core/core-apis.ts b/src/core/core-apis.ts
index aa400dfd1..29246cec7 100644
--- a/src/core/core-apis.ts
+++ b/src/core/core-apis.ts
@@ -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
diff --git a/src/core/runtime-library.ts b/src/core/runtime-library.ts
index 73f2a3140..4ba753410 100644
--- a/src/core/runtime-library.ts
+++ b/src/core/runtime-library.ts
@@ -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 {
- url: string
+ library: RuntimeLibraryDefinition
getModule: (window: Window) => LibraryType
}
+
export class RuntimeLibrary implements PromiseLike {
private modulePromise: Promise
constructor(public config: RuntimeLibraryConfig) {}
+ 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?: (value: LibraryType) => Resolve | PromiseLike,
reject?: (reason: any) => Reject | PromiseLike,
) {
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 implements PromiseLike {
}
}
export const protobufLibrary = new RuntimeLibrary({
- url: meta.compilationInfo.altCdn.library.protobuf,
+ library: meta.compilationInfo.altCdn.library.protobuf,
getModule: window => window.protobuf,
})
export const JSZipLibrary = new RuntimeLibrary({
- url: meta.compilationInfo.altCdn.library.jszip,
+ library: meta.compilationInfo.altCdn.library.jszip,
getModule: window => window.JSZip,
})
export const SortableJSLibrary = new RuntimeLibrary({
- url: meta.compilationInfo.altCdn.library.sortable,
+ library: meta.compilationInfo.altCdn.library.sortable,
getModule: window => window.Sortable,
})
export const StreamSaverLibrary = new RuntimeLibrary({
- url: meta.compilationInfo.altCdn.library.streamsaver,
+ library: meta.compilationInfo.altCdn.library.streamsaver,
getModule: window => window.streamSaver,
})
diff --git a/src/core/shadow-dom.ts b/src/core/shadow-dom.ts
deleted file mode 100644
index 98c788d0f..000000000
--- a/src/core/shadow-dom.ts
+++ /dev/null
@@ -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) => callbacks?.added(e.detail)
- const removedListener = (e: CustomEvent) => 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()
- }
- }
-}
diff --git a/src/core/shadow-root/dom-entry.ts b/src/core/shadow-root/dom-entry.ts
new file mode 100644
index 000000000..505f7b36b
--- /dev/null
+++ b/src/core/shadow-root/dom-entry.ts
@@ -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(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()
+ }
+}
diff --git a/src/core/shadow-root/dom-observer.ts b/src/core/shadow-root/dom-observer.ts
new file mode 100644
index 000000000..8d9184d6b
--- /dev/null
+++ b/src/core/shadow-root/dom-observer.ts
@@ -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) => callbacks?.added?.(e.detail)
+ const removedListener = (e: CustomEvent) => 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()
diff --git a/src/core/shadow-root/index.ts b/src/core/shadow-root/index.ts
new file mode 100644
index 000000000..6e25d7470
--- /dev/null
+++ b/src/core/shadow-root/index.ts
@@ -0,0 +1,3 @@
+export * from './dom-observer'
+export * from './styles'
+export * from './types'
diff --git a/src/core/shadow-root/root-observer.ts b/src/core/shadow-root/root-observer.ts
new file mode 100644
index 000000000..e5173b8f4
--- /dev/null
+++ b/src/core/shadow-root/root-observer.ts
@@ -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
+}
diff --git a/src/core/shadow-root/styles.ts b/src/core/shadow-root/styles.ts
new file mode 100644
index 000000000..cbe942435
--- /dev/null
+++ b/src/core/shadow-root/styles.ts
@@ -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()
+
+ 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()
diff --git a/src/core/shadow-root/types.ts b/src/core/shadow-root/types.ts
new file mode 100644
index 000000000..93d22c238
--- /dev/null
+++ b/src/core/shadow-root/types.ts
@@ -0,0 +1,5 @@
+export enum ShadowRootEvents {
+ Added = 'shadowRootAdded',
+ Removed = 'shadowRootRemoved',
+ Updated = 'shadowRootUpdated',
+}
diff --git a/src/core/style.ts b/src/core/style.ts
index dc6afb9a3..e24403283 100644
--- a/src/core/style.ts
+++ b/src/core/style.ts
@@ -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'
/** 为`