From 87102cd3b0bd92b88580694fb8081e348a5dcc0a Mon Sep 17 00:00:00 2001 From: qwqcode <22412567+qwqcode@users.noreply.github.com> Date: Sun, 17 Dec 2023 01:10:22 +0800 Subject: [PATCH] refactor(ui/layer): better layer implements and independence (#662) --- .../artalk/src/components/checker/index.ts | 21 +-- ui/packages/artalk/src/layer/index.ts | 8 +- ui/packages/artalk/src/layer/layer-manager.ts | 32 ++++ ui/packages/artalk/src/layer/layer.ts | 158 ++---------------- .../artalk/src/layer/scrollbar-helper.ts | 24 +++ ui/packages/artalk/src/layer/sidebar-layer.ts | 129 +++++++------- ui/packages/artalk/src/layer/wrap.ts | 77 +++++++++ .../artalk/src/plugins/admin-only-elem.ts | 5 - ui/packages/artalk/src/service.ts | 16 +- 9 files changed, 241 insertions(+), 229 deletions(-) create mode 100644 ui/packages/artalk/src/layer/layer-manager.ts create mode 100644 ui/packages/artalk/src/layer/scrollbar-helper.ts create mode 100644 ui/packages/artalk/src/layer/wrap.ts diff --git a/ui/packages/artalk/src/components/checker/index.ts b/ui/packages/artalk/src/components/checker/index.ts index 2f49864de..0bfe36e0c 100644 --- a/ui/packages/artalk/src/components/checker/index.ts +++ b/ui/packages/artalk/src/components/checker/index.ts @@ -1,7 +1,8 @@ import type Api from '@/api' import Dialog from '@/components/dialog' -import Layer from '@/layer' import $t from '@/i18n' +import type { ContextApi } from '~/types' +import type { Layer } from '@/layer' import * as Utils from '@/lib/utils' import CaptchaChecker from './captcha' import AdminChecker from './admin' @@ -18,6 +19,7 @@ export interface CheckerPayload { } export interface CheckerLauncherOptions { + getCtx: () => ContextApi getApi: () => Api getIframeURLBase: () => string onReload: () => void @@ -27,8 +29,6 @@ export interface CheckerLauncherOptions { * Checker 发射台 */ export default class CheckerLauncher { - public launched: Checker[] = [] - constructor(private opts: CheckerLauncherOptions) { } public checkCaptcha(payload: CheckerCaptchaPayload) { @@ -43,12 +43,8 @@ export default class CheckerLauncher { } public fire(checker: Checker, payload: CheckerPayload, postFire?: (c: CheckerCtx) => void) { - if (this.launched.includes(checker)) return // 阻止同时 fire 相同的 checker - this.launched.push(checker) - - // 创建层 - const layer = new Layer(`checker-${new Date().getTime()}`) - layer.setMaskClickHide(false) + // 显示层 + const layer = this.opts.getCtx().get('layerManager').create(`checker-${new Date().getTime()}`) layer.show() // 构建 Checker 的上下文 @@ -58,8 +54,7 @@ export default class CheckerLauncher { set: (key, val) => { checkerStore[key] = val }, get: (key) => (checkerStore[key]), getOpts: () => (this.opts), - getApi: () => (this.opts.getApi()), - getLayer: () => layer, + getApi: () => this.opts.getApi(), hideInteractInput: () => { hideInteractInput = true }, @@ -167,8 +162,7 @@ export default class CheckerLauncher { // 关闭 checker 对话框 private close(checker: Checker, layer: Layer) { - layer.disposeNow() - this.launched = this.launched.filter(c => c !== checker) + layer.destroy() } } @@ -192,7 +186,6 @@ export interface CheckerCtx { set(key: K, val: CheckerStore[K]): void getOpts(): CheckerLauncherOptions getApi(): Api - getLayer(): Layer hideInteractInput(): void triggerSuccess(): void cancel(): void diff --git a/ui/packages/artalk/src/layer/index.ts b/ui/packages/artalk/src/layer/index.ts index 4a63ef926..02aa1e46d 100644 --- a/ui/packages/artalk/src/layer/index.ts +++ b/ui/packages/artalk/src/layer/index.ts @@ -1,4 +1,4 @@ -import Layer, { getLayerWrap } from './layer' - -export default Layer -export { getLayerWrap } +export * from './layer' +export * from './wrap' +export * from './scrollbar-helper' +export * from './layer-manager' diff --git a/ui/packages/artalk/src/layer/layer-manager.ts b/ui/packages/artalk/src/layer/layer-manager.ts new file mode 100644 index 000000000..9893b47b5 --- /dev/null +++ b/ui/packages/artalk/src/layer/layer-manager.ts @@ -0,0 +1,32 @@ +import type { ContextApi } from '~/types' +import { getScrollbarHelper } from './scrollbar-helper' +import { LayerWrap } from './wrap' +import { Layer } from './layer' + +export class LayerManager { + private wrap: LayerWrap + private ctx: ContextApi + + constructor(ctx: ContextApi) { + this.ctx = ctx + + this.wrap = new LayerWrap() + document.body.appendChild(this.wrap.getWrap()) + + ctx.on('destroy', () => { + this.wrap.getWrap().remove() + }) + + // 记录页面原始 CSS 属性 + getScrollbarHelper().init() + } + + getEl() { + return this.wrap.getWrap() + } + + create(name: string, el?: HTMLElement | undefined) { + const layer = new Layer(this.wrap, name, el) + return layer + } +} diff --git a/ui/packages/artalk/src/layer/layer.ts b/ui/packages/artalk/src/layer/layer.ts index 84b7c2868..4c2de6f1b 100644 --- a/ui/packages/artalk/src/layer/layer.ts +++ b/ui/packages/artalk/src/layer/layer.ts @@ -1,49 +1,17 @@ -import * as Utils from '../lib/utils' -import * as Ui from '../lib/ui' +import type { LayerWrap } from './wrap' -export default class Layer { +export class Layer { private $el: HTMLElement + private wrap: LayerWrap + private onAfterHide?: () => void - private name: string - private $wrap: HTMLElement - private $mask: HTMLElement - - private maskClickHideEnable: boolean = true - - public static BodyOrgOverflow: string - public static BodyOrgPaddingRight: string - - public afterHide?: Function - - constructor(name: string, el?: HTMLElement) { - this.name = name - const { $wrap, $mask } = getLayerWrap() - this.$wrap = $wrap - this.$mask = $mask - - this.$el = this.$wrap.querySelector(`[data-layer-name="${name}"].atk-layer-item`)! - if (this.$el === null) { - // 若传递 layer 元素为空 - if (!el) { - this.$el = Utils.createElement() - this.$el.classList.add('atk-layer-item') - } else { - this.$el = el - } - } - this.$el.setAttribute('data-layer-name', name) - this.$el.style.display = 'none' - - // 添加到 layers wrap 中 - this.$wrap.append(this.$el) - } - - getName() { - return this.name + constructor(wrap: LayerWrap, name: string, el?: HTMLElement) { + this.wrap = wrap + this.$el = this.wrap.createItem(name, el) } - getWrapEl() { - return this.$wrap + setOnAfterHide(func: () => void) { + this.onAfterHide = func } getEl() { @@ -51,111 +19,21 @@ export default class Layer { } show() { - this.fireAllActionTimer() - - this.$wrap.style.display = 'block' - this.$mask.style.display = 'block' - this.$mask.classList.add('atk-fade-in') this.$el.style.display = '' - - this.$mask.onclick = () => { - if (this.maskClickHideEnable) this.hide() - } - - // body style 禁止滚动 + 防抖 - this.pageBodyScrollBarHide() + this.wrap.show() } hide() { - if (this.afterHide) this.afterHide() - this.$wrap.classList.add('atk-fade-out') - this.$el.style.display = 'none' - - // body style 禁止滚动解除 - this.pageBodyScrollBarShow() - - this.newActionTimer(() => { - this.$wrap.style.display = 'none' - this.checkCleanLayer() - }, 450) - this.newActionTimer(() => { - this.$wrap.style.display = 'none' - this.$wrap.classList.remove('atk-fade-out') - }, 200) - } - - setMaskClickHide(enable: boolean) { - this.maskClickHideEnable = enable - } - - // 页面滚动条隐藏 - pageBodyScrollBarHide() { - document.body.style.overflow = 'hidden' - - const bpr = parseInt(window.getComputedStyle(document.body, null).getPropertyValue('padding-right'), 10) - document.body.style.paddingRight = `${Ui.getScrollBarWidth() + bpr || 0}px` - } - - // 页面滚动条显示 - pageBodyScrollBarShow() { - document.body.style.overflow = Layer.BodyOrgOverflow - document.body.style.paddingRight = Layer.BodyOrgPaddingRight - } - - // Timers - private static actionTimers: {act: Function, tid: number}[] = [] - - private newActionTimer(func: Function, delay: number) { - const act = () => { - func() // 执行 - Layer.actionTimers = Layer.actionTimers.filter(o => o.act !== act) // 删除 - } - - const tid = window.setTimeout(() => act(), delay) - - Layer.actionTimers.push({ act, tid }) - } - - private fireAllActionTimer() { - Layer.actionTimers.forEach(item => { - clearTimeout(item.tid) - item.act() // 立即执行 + this.wrap.hide(() => { + this.$el.style.display = 'none' + this.onAfterHide && this.onAfterHide() }) } - /** 销毁 - 无动画 */ - disposeNow() { - this.$el.remove() - this.pageBodyScrollBarShow() - // this.$el dispose - this.checkCleanLayer() - } - - /** 销毁 */ - dispose() { - this.hide() - this.$el.remove() - // this.$el dispose - this.checkCleanLayer() - } - - checkCleanLayer() { - if (this.getWrapEl().querySelectorAll('.atk-layer-item').length === 0) { - this.$wrap.style.display = 'none' - } - } -} - -export function getLayerWrap(): { $wrap: HTMLElement, $mask: HTMLElement } { - let $wrap = document.querySelector(`.atk-layer-wrap`) - if (!$wrap) { - $wrap = Utils.createElement( - `` - ) - document.body.appendChild($wrap) + destroy() { + this.wrap.hide(() => { + this.$el.remove() + this.onAfterHide && this.onAfterHide() + }) } - - const $mask = $wrap.querySelector('.atk-layer-mask')! - - return { $wrap, $mask } } diff --git a/ui/packages/artalk/src/layer/scrollbar-helper.ts b/ui/packages/artalk/src/layer/scrollbar-helper.ts new file mode 100644 index 000000000..bba74bc12 --- /dev/null +++ b/ui/packages/artalk/src/layer/scrollbar-helper.ts @@ -0,0 +1,24 @@ +import * as Ui from '@/lib/ui' + +let bodyOrgOverflow: string +let bodyOrgPaddingRight: string + +export function getScrollbarHelper() { + return { + init() { + bodyOrgOverflow = document.body.style.overflow + bodyOrgPaddingRight = document.body.style.paddingRight + }, + + unlock() { + document.body.style.overflow = bodyOrgOverflow + document.body.style.paddingRight = bodyOrgPaddingRight + }, + + lock() { + document.body.style.overflow = 'hidden' + const barPaddingRight = parseInt(window.getComputedStyle(document.body, null).getPropertyValue('padding-right'), 10) + document.body.style.paddingRight = `${Ui.getScrollBarWidth() + barPaddingRight || 0}px` + } + } +} diff --git a/ui/packages/artalk/src/layer/sidebar-layer.ts b/ui/packages/artalk/src/layer/sidebar-layer.ts index 52637ab87..a2d98810a 100644 --- a/ui/packages/artalk/src/layer/sidebar-layer.ts +++ b/ui/packages/artalk/src/layer/sidebar-layer.ts @@ -4,7 +4,7 @@ import * as Utils from '@/lib/utils' import * as Ui from '@/lib/ui' import SidebarHTML from './sidebar-layer.html?raw' import User from '../lib/user' -import Layer from './layer' +import type { Layer } from './layer' export default class SidebarLayer extends Component { public layer?: Layer @@ -36,73 +36,31 @@ export default class SidebarLayer extends Component { this.$el.style.transform = '' // 动画清除,防止二次打开失效 // 获取 Layer - if (this.layer == null) { - this.layer = new Layer('sidebar', this.$el) - this.layer.afterHide = () => { - // 防止评论框被吞 - this.ctx.editorResetState() - } - } - this.layer.show() - - // viewWrap 滚动条归位 - // this.$viewWrap.scrollTo(0, 0) + this.initLayer() + this.layer!.show() // 管理员身份验证 (若身份失效,弹出验证窗口) - ;(async () => { - const resp = await this.ctx.getApi().user.loginStatus() - if (resp.is_admin && !resp.is_login) { - this.layer?.hide() - this.firstShow = true - - this.ctx.checkAdmin({ - onSuccess: () => { - setTimeout(() => { - this.show(conf) - }, 500) - }, - onCancel: () => {} - }) - } - })() + this.authCheck({ + onSuccess: () => this.show(conf) // retry show after auth check + }) // 第一次加载 if (this.firstShow) { this.$iframeWrap.innerHTML = '' - this.$iframe = Utils.createElement('') - - // 准备 Iframe 参数 - const baseURL = (import.meta.env.DEV) ? 'http://localhost:23367/' - : Utils.getURLBasedOnApi({ - base: this.ctx.conf.server, - path: '/sidebar/', - }) - - const query: any = { - pageKey: this.conf.pageKey, - site: this.conf.site || '', - user: JSON.stringify(User.data), - time: +new Date() - } - - if (conf.view) query.view = conf.view - if (this.conf.darkMode) query.darkMode = '1' - if (typeof this.conf.locale === 'string') query.locale = this.conf.locale - - const urlParams = new URLSearchParams(query); - this.iframeLoad(`${baseURL}?${urlParams.toString()}`) - + this.$iframe = this.createIframe(conf.view) this.$iframeWrap.append(this.$iframe) this.firstShow = false } else { + const $iframe = this.$iframe! + // 夜间模式 - const isIframeSrcDarkMode = this.$iframe!.src.includes('darkMode=1') + const isIframeSrcDarkMode = $iframe.src.includes('darkMode=1') if (this.conf.darkMode && !isIframeSrcDarkMode) - this.iframeLoad(`${this.$iframe!.src}&darkMode=1`) + this.iframeLoad($iframe, `${this.$iframe!.src}&darkMode=1`) if (!this.conf.darkMode && isIframeSrcDarkMode) - this.iframeLoad(this.$iframe!.src.replace('&darkMode=1', '')) + this.iframeLoad($iframe, this.$iframe!.src.replace('&darkMode=1', '')) } // 执行滑动显示动画 @@ -128,14 +86,69 @@ export default class SidebarLayer extends Component { this.ctx.trigger('sidebar-hide') } - private iframeLoad(src: string) { - if (!this.$iframe) return + // -------------------------------------------------- + + private async authCheck(opts: { onSuccess: () => void }) { + const resp = await this.ctx.getApi().user.loginStatus() + if (resp.is_admin && !resp.is_login) { + this.firstShow = true + + this.ctx.checkAdmin({ + onSuccess: () => { + setTimeout(() => { + opts.onSuccess() + }, 500) + }, + onCancel: () => { + this.hide() + } + }) + } + } + + private initLayer() { + if (this.layer) return + + this.layer = this.ctx.get('layerManager').create('sidebar', this.$el) + this.layer.setOnAfterHide(() => { + // 防止评论框被吞 + this.ctx.editorResetState() + }) + } + + private createIframe(view?: string) { + const $iframe = Utils.createElement('') + + // 准备 Iframe 参数 + const baseURL = (import.meta.env.DEV) ? 'http://localhost:23367/' + : Utils.getURLBasedOnApi({ + base: this.ctx.conf.server, + path: '/sidebar/', + }) + + const query: any = { + pageKey: this.conf.pageKey, + site: this.conf.site || '', + user: JSON.stringify(User.data), + time: +new Date() + } + + if (view) query.view = view + if (this.conf.darkMode) query.darkMode = '1' + if (typeof this.conf.locale === 'string') query.locale = this.conf.locale + + const urlParams = new URLSearchParams(query); + this.iframeLoad($iframe, `${baseURL}?${urlParams.toString()}`) + + return $iframe + } - this.$iframe.src = src + private iframeLoad($iframe: HTMLIFrameElement, src: string) { + $iframe.src = src // 加载动画 Ui.showLoading(this.$iframeWrap) - this.$iframe.onload = () => { + $iframe.onload = () => { Ui.hideLoading(this.$iframeWrap) } } diff --git a/ui/packages/artalk/src/layer/wrap.ts b/ui/packages/artalk/src/layer/wrap.ts new file mode 100644 index 000000000..60461c021 --- /dev/null +++ b/ui/packages/artalk/src/layer/wrap.ts @@ -0,0 +1,77 @@ +import * as Utils from '@/lib/utils' +import { getScrollbarHelper } from './scrollbar-helper' + +export class LayerWrap { + private $wrap: HTMLElement + private $mask: HTMLElement + private allowMaskClose: boolean = true + private items: HTMLElement[] = [] + + constructor() { + this.$wrap = Utils.createElement( + `` + ) + this.$mask = this.$wrap.querySelector('.atk-layer-mask')! + } + + createItem(name: string, el?: HTMLElement) { + if (!el) { + el = document.createElement('div') + el.classList.add('atk-layer-item') + el.setAttribute('data-layer-name', name) + el.style.display = 'none' + } + this.$wrap.appendChild(el) + this.items.push(el) + return el + } + + getWrap() { + return this.$wrap + } + + getMask() { + return this.$mask + } + + setMaskClose(enabled: boolean) { + this.allowMaskClose = enabled + } + + show() { + this.$wrap.style.display = 'block' + this.$mask.style.display = 'block' + this.$mask.classList.add('atk-fade-in') + this.$mask.onclick = () => { + if (this.allowMaskClose) this.hide() + } + getScrollbarHelper().lock() + } + + hide(callback?: () => void) { + // if wrap contains more than one item, do not hide entire wrap + if (this.items.filter(e => e.isConnected && e.style.display !== 'none').length > 1) { + callback && callback() + return + } + + const onAfterHide = () => { + this.$wrap.style.display = 'none' + this.$wrap.classList.remove('atk-fade-out') + + callback && callback() + + getScrollbarHelper().unlock() + + this.$wrap.onanimationend = null + } + + // perform animation + this.$wrap.classList.add('atk-fade-out') + if (window.getComputedStyle(this.$wrap)['animation-name'] !== 'none') { + this.$wrap.onanimationend = () => onAfterHide() + } else { + onAfterHide() + } + } +} diff --git a/ui/packages/artalk/src/plugins/admin-only-elem.ts b/ui/packages/artalk/src/plugins/admin-only-elem.ts index e4f91ebae..969529e89 100644 --- a/ui/packages/artalk/src/plugins/admin-only-elem.ts +++ b/ui/packages/artalk/src/plugins/admin-only-elem.ts @@ -1,5 +1,4 @@ import type { ArtalkPlugin } from '~/types' -import { getLayerWrap } from '@/layer' export const AdminOnlyElem: ArtalkPlugin = (ctx) => { const scanApply = () => { @@ -23,10 +22,6 @@ function getAdminOnlyEls(opts: { $root: HTMLElement }): HTMLElement[] { // elements in $root opts.$root.querySelectorAll(`[atk-only-admin-show]`).forEach(item => els.push(item)) - // elements in layer - const { $wrap: $layerWrap } = getLayerWrap() - if ($layerWrap) $layerWrap.querySelectorAll(`[atk-only-admin-show]`).forEach(item => els.push(item)) - // TODO provide a Artalk.conf hook to set whitelist of admin-only elements, // and move following code to that hook (move into packages/artalk-sidebar) diff --git a/ui/packages/artalk/src/service.ts b/ui/packages/artalk/src/service.ts index 4f8fb36df..e0deacfe6 100644 --- a/ui/packages/artalk/src/service.ts +++ b/ui/packages/artalk/src/service.ts @@ -1,13 +1,14 @@ import type { ContextApi } from '~/types' import CheckerLauncher from './components/checker' import Editor from './editor/editor' -import Layer from './layer' import SidebarLayer from './layer/sidebar-layer' + import User from './lib/user' import List from './list/list' import * as I18n from './i18n' import { PlugManager } from './plugins/editor-kit' +import { LayerManager } from './layer/layer-manager' /** * Services @@ -32,9 +33,15 @@ const services = { return User }, + // 弹出层 + layerManager(ctx: ContextApi) { + return new LayerManager(ctx) + }, + // CheckerLauncher checkerLauncher(ctx: ContextApi) { const checkerLauncher = new CheckerLauncher({ + getCtx: () => ctx, getApi: () => ctx.getApi(), getIframeURLBase: () => ctx.conf.server, onReload: () => ctx.reload() @@ -56,13 +63,6 @@ const services = { return list }, - // 弹出层 - layer(ctx: ContextApi) { - // 记录页面原始 CSS 属性 - Layer.BodyOrgOverflow = document.body.style.overflow - Layer.BodyOrgPaddingRight = document.body.style.paddingRight - }, - // 侧边栏 Layer sidebarLayer(ctx: ContextApi) { const sidebarLayer = new SidebarLayer(ctx)