diff --git a/CHANGELOG.md b/CHANGELOG.md index d95254c066..c6bf39b5c6 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 73930da0f7..37b7baac80 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/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 0000000000..5a42d49de7 --- /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 26b688d334..4ddce9bc21 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/style/dark-mode/dark-shadow-dom.scss b/registry/lib/components/style/dark-mode/dark-shadow-dom.scss new file mode 100644 index 0000000000..b0bdd7861a --- /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 a78cceddd3..58c1f092bb 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 756b81d78f..512afbb956 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 0000000000..48b783918e --- /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 0000000000..36c4785176 --- /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 0000000000..52e8076582 --- /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 0000000000..272ccf823b --- /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 0000000000..4c80fd8d14 --- /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 0000000000..bcc3f0a726 --- /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 0000000000..0f928b4191 --- /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 b9c428e9a7..d66962ff22 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; - } - } - } - &:host-context(body.#{$prefix}-userLevel):host(bili-comment-user-info) { +@container style(--#{$prefix}-replyEditor: true) { + :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}-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 8efcf7e19a..4a395d89e8 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 0000000000..9e058a702d --- /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 19395fec21..e69253caf0 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 5b69dcadf8..1a2aec3edd 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/ip-show/index.ts b/registry/lib/components/utils/ip-show/index.ts index d08e9f33e5..599dee5672 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 50d6d65869..144064f641 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/src/client/common.meta.json b/src/client/common.meta.json index 0c3a9889f9..3b3e06411f 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", diff --git a/src/client/compatibility.ts b/src/client/compatibility.ts index 883e33e822..c8b2d29969 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/auto-update/index.ts b/src/components/auto-update/index.ts index fa4538bdef..fdabf7f7d6 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 12e573bb5d..9435293b42 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 3eaee6ebce..a63f61faa6 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 357aef233c..2eeb2a49bb 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 1535690d19..d4fc38bf7f 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 0000000000..53bef42f32 --- /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 60090ece72..bd1b395c43 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 7f1bc2f39c..ef3b7990a2 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 0000000000..8d0b741e69 --- /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 0873659fc8..1d2b009045 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 9bcf1deee9..6193c23698 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 91dd561c1a..d46cce276f 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 c15bbc2411..b50c0f4fd9 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 0000000000..69ef9a287a --- /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 aa400dfd19..29246cec77 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/shadow-dom.ts b/src/core/shadow-dom.ts deleted file mode 100644 index 98c788d0f1..0000000000 --- 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 0000000000..505f7b36bf --- /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 0000000000..8d9184d6b4 --- /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 0000000000..6e25d7470d --- /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 0000000000..e5173b8f44 --- /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 0000000000..cbe942435d --- /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 0000000000..93d22c238f --- /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 dc6afb9a38..e244032836 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' /** 为`